From 412fbb397ae0f8087369d5673a735f5b60778091 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Thu, 29 Oct 2026 00:50:10 -0400 Subject: [PATCH 01/63] fix(forms): clearing a field's length limit leaves it with none, not its parent field's /MaxLen is inheritable (ISO 32000-2 12.7.5.3). Clearing a kid's limit removed its own key, so the parent's limit came back. When an ancestor sets /MaxLen, write /MaxLen 0, which read.rs already reads as no limit and which overrides the inherited one (#3323). Follow-up to #4991. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/tests/it/automation.rs | 25 ++++++++++++++++++++++++ crates/forms/src/author/edit.rs | 3 +++ crates/forms/src/tests.rs | 23 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 01d0d1eb24..1613236ba5 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9433,6 +9433,31 @@ fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_ assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1"); } +/// Clearing a field's length limit through `form_set_props` (`"max_length": 0`) leaves it with +/// none, even when its parent field has one it would otherwise inherit. +#[test] +fn clearing_a_kids_length_limit_through_tools_does_not_bring_back_its_parents() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /T (address) /FT /Tx /MaxLen 3 /Kids [6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /MaxLen 8 /Rect [10 250 150 268] /P 3 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("kid-max-len"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "max_length": 0 })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Absalom" } })); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone(); + assert!(city.get("max_length").is_none(), "no limit, not the parent's 3: {city}"); + assert_eq!(city["value"], "Absalom", "the value is not cut to the parent's 3 characters"); +} + /// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none, /// even when its parent field has one it would otherwise inherit. #[test] diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 54a05f3db5..df97c7841a 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -189,6 +189,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), + // An absent key would inherit the parent's limit; zero is "no limit" (`read.rs`). + None if parent_limit => d.set(b"MaxLen".to_vec(), Object::Int(0)), None => { d.remove(b"MaxLen"); } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ac414af2ae..a326c0032a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -5271,6 +5271,29 @@ fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() { assert_eq!(selection_indices(&doc, &list), Some(vec![2])); } +#[test] +fn clearing_a_kids_length_limit_does_not_bring_back_its_parents() { + let mut doc = fixture(); + let city = field(&fields(&doc), "address.city").clone(); + let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap(); + set_props(&mut doc, "address.city", &FieldProps { max_len: Some(Some(8)), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").max_len, Some(8)); + set_props(&mut doc, "address.city", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let f = field(&fields(&doc), "address.city").clone(); + assert_eq!(f.max_len, None, "no limit, not the parent's 3"); + let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"MaxLen")).and_then(Object::as_int); + assert_eq!(own, Some(0), "the kid holds an explicit unlimited of its own"); + set_value(&mut doc, "address.city", &FieldValue::Text("Absalom".into())).unwrap(); + assert_eq!(field(&fields(&reopen(&doc)), "address.city").value, ["Absalom"], "not cut to 3 characters"); + // Without a parent limit, clearing still removes the key. + set_props(&mut doc, "name", &FieldProps { max_len: Some(Some(4)), ..FieldProps::default() }).unwrap(); + set_props(&mut doc, "name", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap(); + let name = field(&fields(&doc), "name").clone(); + assert!(doc.get(name.obj).as_dict().unwrap().get(b"MaxLen").is_none()); +} + #[test] fn clearing_a_kids_default_does_not_bring_back_its_parents() { let mut doc = fixture(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..eb55829838 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5003,8 +5003,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::each_widget_with_its_own_top_row_scrolls_by_itself", "crates/forms/src/tests.rs::a_list_boxs_top_row_follows_a_font_size_changed_with_its_items", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items", "crates/forms/src/tests.rs::clearing_a_kids_default_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off", "crates/automation/tests/it/automation.rs::clearing_a_kids_default_through_tools_does_not_bring_back_its_parents"] -notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default (clearing it leaves none, not a parent field's), multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items (the selected indices /I follow their items when the list is edited or sorted), default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" +evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::each_widget_with_its_own_top_row_scrolls_by_itself", "crates/forms/src/tests.rs::a_list_boxs_top_row_follows_a_font_size_changed_with_its_items", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items", "crates/forms/src/tests.rs::clearing_a_kids_default_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_kids_length_limit_does_not_bring_back_its_parents", "crates/automation/tests/it/automation.rs::clearing_a_kids_length_limit_through_tools_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off", "crates/automation/tests/it/automation.rs::clearing_a_kids_default_through_tools_does_not_bring_back_its_parents"] +notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default (clearing it leaves none, not a parent field's), multi-line, scroll, rich text flag, limit (clearing it leaves none, not a parent field's), password, file selection, spell check, comb; choices: items (the selected indices /I follow their items when the list is edited or sorted), default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" [[feature]] id = "form.props-actions" From cc9d95e1fbd6f9bff0ef93e4a194e9ac1ebf3cbd Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 00:29:23 -0400 Subject: [PATCH 02/63] fix(edit): an edited OCR phrase under its scan image is drawn on top (#3973) Some OCR tools write the invisible text layer first and the opaque page image after it. Edit text rewrote such a phrase in the text's place, so the cover and the new visible text were painted under the scan: the edit was saved (text extraction finds it) but nothing showed. When the rewritten paragraph was invisible and an XObject, inline image or shading is painted after its place, the cover and the new text now go on top of the page in their own q/Q (place_tagged, tagged Edited), drawn from the initial graphics state with the paragraph's CTM. Only the state the following text expects stays in place. Both rewrite paths (single style and styled rows) do this. An OCR layer drawn over its scan, the common case, is rewritten in place as before, so reading order is kept. The test builds a scan whose OCR word comes before the image and checks the cover and new text show, for one line and for a typed line break. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/lib.rs | 2 +- crates/edit/src/place.rs | 36 ++++++++++++ crates/edit/src/text/blocks.rs | 3 +- crates/edit/src/text/interp.rs | 20 +++++++ crates/edit/src/text/rewrite.rs | 26 +++++++-- crates/edit/src/text_regions/styled.rs | 12 +++- crates/engine/tests/it/ocr_edit.rs | 81 ++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 8 files changed, 174 insertions(+), 10 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 7c8556332b..1ca43d20a8 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -68,7 +68,7 @@ use marks::{matrix_number, n}; pub(crate) use place::undrawable; #[cfg(test)] use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_close, page_fonts, stream_bytes, stream_tag, tagged, wrapper_role}; -use place::{check, check_on_top, contents, page_list, place_tagged}; +use place::{check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint}; pub use place::{stamp, undrawable_message}; #[cfg(test)] diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 2f2c24c862..3056d3f71b 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -346,6 +346,42 @@ pub(crate) fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: Ok(mark_ref) } +/// Draw `ops`, written for the space whose CTM is `ctm`, on top of everything on `page`, tagged +/// `Edited`. A rewritten invisible paragraph goes here when [`revealed_under_paint`]: an OCR +/// layer written before the scan image it lies under would keep the revealed text hidden (#3973). +pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: [f64; 6], ops: &[pdfcraft_content::Op]) -> Result<(), EditError> { + if ops.is_empty() { + return Ok(()); + } + use pdfcraft_content::Op; + let mut all = vec![Op::new("q", vec![]), Op::new("cm", ctm.iter().map(|v| pdfcraft_content::num(*v)).collect())]; + all.extend_from_slice(ops); + all.push(Op::new("Q", vec![])); + place_tagged(doc, page, "Edited", pdfcraft_content::serialize_ops(&all), false).map(drop) +} + +/// Whether a rewritten paragraph that was `invisible` goes on top of the page: anything after +/// operator `at` can paint over it there (an XObject, an inline image or a shading), as the scan +/// image does over an OCR layer written before it (#3973). +pub(crate) fn revealed_under_paint(invisible: bool, ops: &[pdfcraft_content::Op], at: usize) -> bool { + invisible && ops.get(at..).unwrap_or_default().iter().any(|op| op.is("Do") || op.is("BI") || op.is("sh")) +} + +/// The operators to draw on top of `page` ([`draw_on_top`]): all of `paragraph` when `over`, +/// after checking the page takes them, so a refusal leaves the document as it was. +pub(crate) fn lift_on_top( + doc: &Document, + page: &pdfcraft_model::Page, + over: bool, + paragraph: &mut Vec, +) -> Result, EditError> { + if !over { + return Ok(Vec::new()); + } + check_on_top(doc, page)?; + Ok(std::mem::take(paragraph)) +} + fn too_deep_on_top() -> EditError { EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into()) } diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 615e7699a2..d1cb997c89 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -28,7 +28,8 @@ pub struct TextBlock { /// Byte ranges in `editor_text` and their source line indexes. pub spans: Vec<(std::ops::Range, usize)>, /// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose - /// pixels show the words. A rewrite draws the new text visibly (see [`BlockStyle::cover`]). + /// pixels show the words. A rewrite draws the new text visibly, on top of the page when + /// the scan is drawn after it (see [`BlockStyle::cover`]). pub invisible: bool, } diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 16b04b406f..08e9b2c5d7 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -25,6 +25,26 @@ pub(super) struct TextState { } impl TextState { + /// The state a page's content starts in (ISO 32000-2 §8.4.1, §9.3.1): what is in force on + /// top of the page, after its content wrapped in `q`/`Q`. + pub(crate) fn initial() -> Self { + TextState { + font: None, + char_spacing: 0.0, + word_spacing: 0.0, + scale: 1.0, + leading: 0.0, + rise: 0.0, + fill: Vec::new(), + render_mode: 0, + stroke: Vec::new(), + line_width: None, + ext_gstate: Vec::new(), + dash: (Vec::new(), 0.0), + line_cap: 0, + } + } + pub(super) fn of(ts: &Ts) -> Self { TextState { font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)), diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 513077a027..f03174885a 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -865,9 +865,26 @@ pub(crate) fn rewrite_block_from( char_spacing: o_state.char_spacing, lead, }; - let plan = - BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) }; + let at = if split { start } else { emit.bt_op }; + // Revealed OCR text that something is painted over goes on top of the page, where it starts + // from the initial state. + let over = crate::revealed_under_paint(b.invisible, &ops, at); + let initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; + let plan = BlockPlan { + block: &b, + lines: &lines, + style, + emit, + after: drawn_from, + movement, + state: o_state, + tm, + size, + underline: style.underline == Some(true), + }; let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; + let on_top = crate::lift_on_top(doc, &p, over, &mut block_ops)?; let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { @@ -875,7 +892,6 @@ pub(crate) fn rewrite_block_from( block_ops.push(Op::new("BT", vec![])); block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; (insert, !dropped.contains(&i)) @@ -892,12 +908,14 @@ pub(crate) fn rewrite_block_from( d.set(b"Resources".to_vec(), Object::Dict(res)); } })?; + crate::draw_on_top(doc, &p, emit.ctm, &on_top)?; Ok(LineEdit { substituted: font.substituted }) } /// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a /// rewritten invisible paragraph, drawn in the space whose CTM is `ctm`. Nothing for visible text. -/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it. +/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it; one +/// painted over where it was is drawn on top of the page instead ([`crate::draw_on_top`]). pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result, EditError> { if !block.invisible { return Ok(Vec::new()); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index e8b3b683d0..c03b75d3f4 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1,3 +1,4 @@ +use super::super::interp::TextState; use super::grouping::{across, is_centred, row_rect}; use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, @@ -783,6 +784,12 @@ pub(crate) fn rewrite_styled_block( let o = &lead.origin; // What is in force where the rewrite is drawn, and what the text after it expects. let after = if split { &o.state } else { &o.bt_state }; + let at = if split { start } else { o.bt_op }; + // Revealed OCR text that something is painted over goes on top of the page, where it starts + // from the initial state. + let over = crate::revealed_under_paint(block.invisible, &ops, at); + let initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; validate_contextual_rows(&rows, &kept)?; let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; @@ -869,7 +876,7 @@ pub(crate) fn rewrite_styled_block( // object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. block_ops.push(Op::new("q", vec![])); - block_ops.extend(state.paint_ops(after)); + block_ops.extend(state.paint_ops(drawn_from)); if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; let mut properties = Dict::new(); @@ -967,13 +974,13 @@ pub(crate) fn rewrite_styled_block( }; } block_ops.push(Op::new("Q", vec![])); + let on_top = crate::lift_on_top(doc, &p, over, &mut block_ops)?; block_ops.extend(after.ops()); if split { block_ops.insert(0, Op::new("ET", vec![])); block_ops.push(Op::new("BT", vec![])); block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { o.bt_op }; let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i))); let contents = rewritten_contents(doc, &streams, data); res.set(b"Font".to_vec(), Object::Dict(fonts)); @@ -981,5 +988,6 @@ pub(crate) fn rewrite_styled_block( d.set(b"Contents".to_vec(), contents); d.set(b"Resources".to_vec(), Object::Dict(res)); })?; + crate::draw_on_top(doc, &p, o.ctm, &on_top)?; Ok(LineEdit { substituted }) } diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 8009ef1bbf..86fc1c9df2 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -207,3 +207,84 @@ fn visible_stretched_text_rewrites_unchanged() { let i = ((y * out.width + x) * 4) as usize; assert_eq!(&out.rgba[i..i + 3], &[255, 255, 255]); } + +/// A scan whose OCR layer is drawn first and the opaque page image over it, as some OCR tools +/// write it (#3973): invisible Helvetica `text` on the scanned word, then the image. +fn ocr_under_scan(text: &str) -> std::sync::Arc> { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; + let (w, h) = (200u32, 100u32); + let mut rgb = Vec::new(); + for y in 0..h { + for x in 0..w { + let ink = (WORD[0]..WORD[2]).contains(&x) && (WORD[1]..WORD[3]).contains(&y); + rgb.extend(if ink { INK } else { PAPER }); + } + } + let mut d = Document::new_empty(); + let pages = d.add(Object::Dict(Dict::new())); + let content = format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET q 200 0 0 100 0 0 cm /Im Do Q"); + let contents = d.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + let mut image = Dict::new(); + image.set(b"Type".to_vec(), Object::name("XObject")); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(i64::from(w))); + image.set(b"Height".to_vec(), Object::Int(i64::from(h))); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + let image = d.add(Object::Stream(Stream::flate(image, &rgb))); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Dict(font)); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec())); + let page = d.add(Object::Dict(p)); + d.update_dict(pages, |p| { + p.set(b"Type".to_vec(), Object::name("Pages")); + p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + p.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + let mut root = Dict::new(); + root.set(b"Type".to_vec(), Object::name("Catalog")); + root.set(b"Pages".to_vec(), Object::Ref(pages)); + let root = d.add(Object::Dict(root)); + d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap()) +} + +/// The OCR layer is under the scan image: the rewritten paragraph is drawn on top of the image, +/// not in the OCR text's place where the image would hide it (#3973). One line takes the +/// single-style rewrite, a typed line break the styled one. +#[test] +fn an_ocr_layer_under_the_scan_image_shows_its_edit() { + for edit in ["Jane", "Jane\nDoe"] { + let mut s = Session::new(); + let id = s.open("ocr-under-scan.pdf", None, ocr_under_scan("John"), None).unwrap(); + let blocks = s.get(id).unwrap().text_blocks(0); + assert_eq!(blocks.len(), 1); + assert!(blocks[0].invisible, "the OCR layer is invisible text"); + assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box"); + + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: edit.into(), style: Default::default() }).unwrap(); + + let blocks = s.get(id).unwrap().text_blocks(0); + assert_eq!(blocks.iter().map(|b| b.text.replace('\n', " ")).collect::>(), [edit.replace('\n', " ")], "{edit:?}"); + let px = word_pixels(&s, id); + let paper = px.iter().filter(|p| near(**p, PAPER)).count(); + let ink = px.iter().filter(|p| near(**p, INK)).count(); + assert!(paper * 10 > px.len() * 4, "{edit:?}: the cover is drawn over the scan: {paper} of {} paper", px.len()); + assert!(ink * 100 > px.len() * 3, "{edit:?}: the new text shows: {ink} of {} ink", px.len()); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..aaaf789f39 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6628,8 +6628,8 @@ milestone = "M10" status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." +evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the new text is drawn on top of the page, so it shows also when the OCR layer was written under the scan image (#3973); the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." [[feature]] id = "ocr.ocr-downsample" From 270a11e699f8f224b67a5e8d45cc85596797fd5f Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 00:43:25 -0400 Subject: [PATCH 03/63] fix(edit): refuse before changing anything; cover undo, reopen, re-edit and the split case An OCR paragraph that may go on top of the page is checked (`check_on_top`) before either rewrite path changes the document, so a page whose marked content nests too deep is refused with nothing changed. `lift_on_top` no longer checks. Tests: undo restores the hidden layer and the scan, redo, save and reopen, and a second edit of the moved text all show the edit; a phrase split from visible text in the same text object shows its edit; a refused rewrite on a too-deep page leaves the saved bytes unchanged. Each new test was seen to fail with its part of the fix reverted. Parity: the note says when the edit is drawn on top and lists what is still missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/place.rs | 17 ++---- crates/edit/src/tests.rs | 15 ++++++ crates/edit/src/text/rewrite.rs | 7 ++- crates/edit/src/text_regions/styled.rs | 2 +- crates/engine/tests/it/ocr_edit.rs | 73 ++++++++++++++++++++++---- parity/acrobat-features.toml | 4 +- 6 files changed, 92 insertions(+), 26 deletions(-) diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 3056d3f71b..f1acb6bdca 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -367,19 +367,10 @@ pub(crate) fn revealed_under_paint(invisible: bool, ops: &[pdfcraft_content::Op] invisible && ops.get(at..).unwrap_or_default().iter().any(|op| op.is("Do") || op.is("BI") || op.is("sh")) } -/// The operators to draw on top of `page` ([`draw_on_top`]): all of `paragraph` when `over`, -/// after checking the page takes them, so a refusal leaves the document as it was. -pub(crate) fn lift_on_top( - doc: &Document, - page: &pdfcraft_model::Page, - over: bool, - paragraph: &mut Vec, -) -> Result, EditError> { - if !over { - return Ok(Vec::new()); - } - check_on_top(doc, page)?; - Ok(std::mem::take(paragraph)) +/// The operators to draw on top of the page ([`draw_on_top`]): all of `paragraph` when `over`, +/// none otherwise. The page was checked to take them ([`check_on_top`]) before the edit began. +pub(crate) fn lift_on_top(over: bool, paragraph: &mut Vec) -> Vec { + if over { std::mem::take(paragraph) } else { Vec::new() } } fn too_deep_on_top() -> EditError { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 359155015f..2d3ba5301f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4550,6 +4550,21 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused"); } +/// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep +/// for that is refused before either rewrite path changes anything. +#[test] +fn an_ocr_rewrite_refused_on_top_leaves_the_document_as_it_was() { + let content = format!("{} BT /F1 20 Tf 3 Tr 42 44 Td (John) Tj ET /Im Do {}", "/X BMC ".repeat(10_001), "EMC ".repeat(10_001)); + let mut doc = text_page(&content); + assert!(text::text_blocks(&doc, 0).unwrap()[0].invisible); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + for edit in ["Jane", "Jane\nDoe"] { + let error = text::rewrite_block(&mut doc, 0, 0, Some(edit), &text::BlockStyle::default()).unwrap_err(); + assert!(error.to_string().contains("nested too deeply"), "{edit:?}: {error}"); + assert!(write_full(&doc, &SaveOptions::default()).unwrap() == before, "{edit:?}: a refused rewrite changes nothing"); + } +} + #[test] fn group_move_budget_refusals_keep_the_original_page_selectable() { let text = text_page(&format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(99_995))); diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index f03174885a..42db5c4af3 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -755,6 +755,11 @@ pub(crate) fn rewrite_block_from( let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; members.iter().try_for_each(|line| line.ensure_editable())?; let p = page_dict(doc, page)?; + // Revealed OCR text may go on top of the page (see `revealed_under_paint`): a page that can't + // take that is refused here, before either rewrite path changes anything. + if b.invisible { + crate::check_on_top(doc, &p)?; + } let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?; @@ -884,7 +889,7 @@ pub(crate) fn rewrite_block_from( underline: style.underline == Some(true), }; let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; - let on_top = crate::lift_on_top(doc, &p, over, &mut block_ops)?; + let on_top = crate::lift_on_top(over, &mut block_ops); let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index c03b75d3f4..8ff4b0b62e 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -974,7 +974,7 @@ pub(crate) fn rewrite_styled_block( }; } block_ops.push(Op::new("Q", vec![])); - let on_top = crate::lift_on_top(doc, &p, over, &mut block_ops)?; + let on_top = crate::lift_on_top(over, &mut block_ops); block_ops.extend(after.ops()); if split { block_ops.insert(0, Op::new("ET", vec![])); diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 86fc1c9df2..a51ce8ddcb 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -211,6 +211,11 @@ fn visible_stretched_text_rewrites_unchanged() { /// A scan whose OCR layer is drawn first and the opaque page image over it, as some OCR tools /// write it (#3973): invisible Helvetica `text` on the scanned word, then the image. fn ocr_under_scan(text: &str) -> std::sync::Arc> { + under_scan(&format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET")) +} + +/// A page whose `text` content is followed by the opaque 200 × 100 scan image. +fn under_scan(text: &str) -> std::sync::Arc> { use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; let (w, h) = (200u32, 100u32); let mut rgb = Vec::new(); @@ -222,7 +227,7 @@ fn ocr_under_scan(text: &str) -> std::sync::Arc> { } let mut d = Document::new_empty(); let pages = d.add(Object::Dict(Dict::new())); - let content = format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET q 200 0 0 100 0 0 cm /Im Do Q"); + let content = format!("{text} q 200 0 0 100 0 0 cm /Im Do Q"); let contents = d.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); let mut image = Dict::new(); image.set(b"Type".to_vec(), Object::name("XObject")); @@ -264,9 +269,27 @@ fn ocr_under_scan(text: &str) -> std::sync::Arc> { std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap()) } +/// The scanned word's box shows the cover and new ink, not the scan. +fn assert_edit_shows(s: &Session, id: pdfcraft_engine::DocId, label: &str) { + let px = word_pixels(s, id); + let paper = px.iter().filter(|p| near(**p, PAPER)).count(); + let ink = px.iter().filter(|p| near(**p, INK)).count(); + assert!(paper * 10 > px.len() * 4, "{label}: the cover is drawn over the scan: {paper} of {} paper", px.len()); + assert!(ink * 100 > px.len() * 3, "{label}: the new text shows: {ink} of {} ink", px.len()); +} + +fn texts(s: &Session, id: pdfcraft_engine::DocId) -> Vec { + s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.replace('\n', " ")).collect() +} + +fn edit_block(s: &mut Session, id: pdfcraft_engine::DocId, block: usize, text: &str) { + s.apply(id, Edit::EditTextBlock { page: 0, block, text: text.into(), style: Default::default() }).unwrap(); +} + /// The OCR layer is under the scan image: the rewritten paragraph is drawn on top of the image, /// not in the OCR text's place where the image would hide it (#3973). One line takes the -/// single-style rewrite, a typed line break the styled one. +/// single-style rewrite, a typed line break the styled one. Undo restores the hidden layer, and +/// the edit survives redo, a save and reopen, and being edited again. #[test] fn an_ocr_layer_under_the_scan_image_shows_its_edit() { for edit in ["Jane", "Jane\nDoe"] { @@ -277,14 +300,46 @@ fn an_ocr_layer_under_the_scan_image_shows_its_edit() { assert!(blocks[0].invisible, "the OCR layer is invisible text"); assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box"); - s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: edit.into(), style: Default::default() }).unwrap(); + edit_block(&mut s, id, 0, edit); + assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}"); + assert_edit_shows(&s, id, edit); + s.undo(id).unwrap(); + assert_eq!(texts(&s, id), ["John"], "{edit:?}: undo"); + assert!(s.get(id).unwrap().text_blocks(0)[0].invisible, "{edit:?}: undo hides the layer again"); + assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "{edit:?}: undo shows the scan again"); + s.redo(id).unwrap(); + assert_edit_shows(&s, id, &format!("{edit:?} redo")); + + let saved = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let id = reopened.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(texts(&reopened, id), [edit.replace('\n', " ")], "{edit:?}: reopened"); + assert_edit_shows(&reopened, id, &format!("{edit:?} reopened")); + + edit_block(&mut reopened, id, 0, "Ann"); + assert_eq!(texts(&reopened, id), ["Ann"], "{edit:?}: edited again"); + assert_edit_shows(&reopened, id, &format!("{edit:?} edited again")); + } +} + +/// Visible text earlier in the same text object makes the rewrite split it; the OCR phrase still +/// goes on top of the scan, and the visible text stays. +#[test] +fn an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit() { + for edit in ["Jane", "Jane\nDoe"] { + let mut s = Session::new(); + let page = under_scan("BT /F1 20 Tf 10 80 Td (Head) Tj 3 Tr 32 -36 Td (John) Tj ET"); + let id = s.open("split.pdf", None, page, None).unwrap(); let blocks = s.get(id).unwrap().text_blocks(0); - assert_eq!(blocks.iter().map(|b| b.text.replace('\n', " ")).collect::>(), [edit.replace('\n', " ")], "{edit:?}"); - let px = word_pixels(&s, id); - let paper = px.iter().filter(|p| near(**p, PAPER)).count(); - let ink = px.iter().filter(|p| near(**p, INK)).count(); - assert!(paper * 10 > px.len() * 4, "{edit:?}: the cover is drawn over the scan: {paper} of {} paper", px.len()); - assert!(ink * 100 > px.len() * 3, "{edit:?}: the new text shows: {ink} of {} ink", px.len()); + let john = blocks.iter().position(|b| b.text == "John").unwrap(); + assert!(blocks[john].invisible && blocks.iter().any(|b| b.text == "Head" && !b.invisible), "{blocks:?}"); + edit_block(&mut s, id, john, edit); + let mut after = texts(&s, id); + after.sort(); + let mut want = vec!["Head".to_string(), edit.replace('\n', " ")]; + want.sort(); + assert_eq!(after, want, "{edit:?}"); + assert_edit_shows(&s, id, &format!("{edit:?} split")); } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index aaaf789f39..68716cf5fd 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6628,8 +6628,8 @@ milestone = "M10" status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the new text is drawn on top of the page, so it shows also when the OCR layer was written under the scan image (#3973); the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." +evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image, inline image or shading is painted after the OCR layer (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." [[feature]] id = "ocr.ocr-downsample" From 5a2f9273a39de3c529b9108fd41735478ff43f46 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 00:46:27 -0400 Subject: [PATCH 04/63] test(edit): pin when revealed OCR text moves on top A table test for `revealed_under_paint`: only invisible text with an XObject, inline image or shading after its place moves; a filled path, visible text or nothing after it does not. Seen to fail with the check reduced to `invisible`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/tests.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 2d3ba5301f..a51612106b 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4550,6 +4550,22 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused"); } +/// Only invisible text with an XObject, inline image or shading painted after its place moves on top. +#[test] +fn revealed_text_moves_on_top_only_when_painted_over() { + let ops = pdfcraft_content::parse(b"BT (a) Tj ET q /Im Do Q BT (b) Tj ET BI /W 1 ID x EI /Sh sh").ops; + let at = |name: &str, n: usize| ops.iter().enumerate().filter(|(_, o)| o.is(name)).nth(n).map(|(i, _)| i).unwrap(); + let after_bi = at("BI", 0) + 1; + assert!(revealed_under_paint(true, &ops, at("BT", 0))); + assert!(revealed_under_paint(true, &ops, at("BT", 1)), "inline image and shading after it"); + assert!(revealed_under_paint(true, &ops, after_bi), "a shading after it"); + assert!(!revealed_under_paint(true, &ops, ops.len()), "nothing after it"); + assert!(!revealed_under_paint(true, &ops, ops.len() + 5), "past the end"); + assert!(!revealed_under_paint(false, &ops, 0), "visible text stays where it is"); + let text_only = pdfcraft_content::parse(b"BT (a) Tj ET 0 0 10 10 re f").ops; + assert!(!revealed_under_paint(true, &text_only, 0), "a filled path does not move it"); +} + /// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep /// for that is refused before either rewrite path changes anything. #[test] From 3c11186dbd9d919aefedb3f74f6f808570dd353a Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 01:22:41 -0400 Subject: [PATCH 05/63] fix(edit): move revealed OCR text on top only when an XObject covers it An unrelated image later on the page no longer moves the paragraph and its cover on top, where the cover could hide visible content inside the paragraph's box. An XObject now counts only when it is drawn at or after the paragraph and its visible box overlaps the paragraph's (`images::painted_over`, from `page_images`). An inline image or a shading after the paragraph still counts anywhere, and so does any XObject when the page's images cannot be listed. The trigger table now covers an image elsewhere, an image drawn first, visible text, a filled path, a shading and an inline image. It fails with the old any-`Do` trigger. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/images.rs | 6 ++++++ crates/edit/src/place.rs | 16 +++++++++----- crates/edit/src/tests.rs | 29 +++++++++++++++----------- crates/edit/src/text/rewrite.rs | 2 +- crates/edit/src/text_regions/styled.rs | 2 +- parity/acrobat-features.toml | 2 +- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 5180e76018..7322b8f9d6 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -536,6 +536,12 @@ pub(crate) fn selectable_images( .collect()) } +/// Whether an XObject drawn at or after operator `from` of the page's joined operators shows +/// inside `rect` (user space). +pub(crate) fn painted_over(doc: &Document, page: usize, rect: [f64; 4], from: usize) -> Result { + Ok(page_images(doc, page)?.iter().any(|i| i.op >= from && i.visible_rect.and_then(|v| intersection(v, rect)).is_some())) +} + /// Find the saved graphics-state scope containing just this image and its crop. Applying /// the transform before that scope moves the actual clipping path as well as the pixels. /// A shared clip must never be moved along with unrelated artwork or text. diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index f1acb6bdca..e040acf1f3 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -360,11 +360,17 @@ pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: place_tagged(doc, page, "Edited", pdfcraft_content::serialize_ops(&all), false).map(drop) } -/// Whether a rewritten paragraph that was `invisible` goes on top of the page: anything after -/// operator `at` can paint over it there (an XObject, an inline image or a shading), as the scan -/// image does over an OCR layer written before it (#3973). -pub(crate) fn revealed_under_paint(invisible: bool, ops: &[pdfcraft_content::Op], at: usize) -> bool { - invisible && ops.get(at..).unwrap_or_default().iter().any(|op| op.is("Do") || op.is("BI") || op.is("sh")) +/// Whether a rewritten paragraph that was `invisible` goes on top of page `page`: something after +/// operator `at` of the page's joined `ops` paints over its `rect` (user space), as the scan image +/// does over an OCR layer written before it (#3973). XObjects count where they are drawn; an inline +/// image or a shading after it counts anywhere, and so does any XObject when the page's images +/// cannot be listed. +pub(crate) fn revealed_under_paint(doc: &Document, page: usize, invisible: bool, rect: [f64; 4], ops: &[pdfcraft_content::Op], at: usize) -> bool { + let later = ops.get(at..).unwrap_or_default(); + if !invisible || later.iter().any(|op| op.is("BI") || op.is("sh")) { + return invisible; + } + later.iter().any(|op| op.is("Do")) && crate::images::painted_over(doc, page, rect, at).unwrap_or(true) } /// The operators to draw on top of the page ([`draw_on_top`]): all of `paragraph` when `over`, diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a51612106b..4349ce7ad6 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4550,20 +4550,25 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused"); } -/// Only invisible text with an XObject, inline image or shading painted after its place moves on top. +/// Invisible text moves on top only when something painted after it covers it: an XObject drawn +/// over its box, or any inline image or shading. #[test] fn revealed_text_moves_on_top_only_when_painted_over() { - let ops = pdfcraft_content::parse(b"BT (a) Tj ET q /Im Do Q BT (b) Tj ET BI /W 1 ID x EI /Sh sh").ops; - let at = |name: &str, n: usize| ops.iter().enumerate().filter(|(_, o)| o.is(name)).nth(n).map(|(i, _)| i).unwrap(); - let after_bi = at("BI", 0) + 1; - assert!(revealed_under_paint(true, &ops, at("BT", 0))); - assert!(revealed_under_paint(true, &ops, at("BT", 1)), "inline image and shading after it"); - assert!(revealed_under_paint(true, &ops, after_bi), "a shading after it"); - assert!(!revealed_under_paint(true, &ops, ops.len()), "nothing after it"); - assert!(!revealed_under_paint(true, &ops, ops.len() + 5), "past the end"); - assert!(!revealed_under_paint(false, &ops, 0), "visible text stays where it is"); - let text_only = pdfcraft_content::parse(b"BT (a) Tj ET 0 0 10 10 re f").ops; - assert!(!revealed_under_paint(true, &text_only, 0), "a filled path does not move it"); + let on_top = |content: &str| { + let doc = image_page_with(content); + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let at = ops.iter().position(|o| o.is("BT")).unwrap(); + revealed_under_paint(&doc, 0, block.invisible, block.rect, &ops, at) + }; + let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + assert!(on_top(&format!("BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET {scan}")), "the scan covers it"); + assert!(!on_top(&format!("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET {scan}")), "an image elsewhere"); + assert!(!on_top(&format!("{scan} BT /F1 12 Tf 3 Tr 110 150 Td (Over) Tj ET")), "the scan is drawn first"); + assert!(!on_top(&format!("BT /F1 12 Tf 0 Tr 110 150 Td (Shown) Tj ET {scan}")), "visible text stays where it is"); + assert!(!on_top("BT /F1 12 Tf 3 Tr 110 150 Td (Path) Tj ET 0 0 600 800 re f"), "a filled path does not move it"); + assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Shade) Tj ET /Sh sh"), "a shading after it"); + assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Inline) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 ID x EI"), "an inline image after it"); } /// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 42db5c4af3..768693e70a 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -873,7 +873,7 @@ pub(crate) fn rewrite_block_from( let at = if split { start } else { emit.bt_op }; // Revealed OCR text that something is painted over goes on top of the page, where it starts // from the initial state. - let over = crate::revealed_under_paint(b.invisible, &ops, at); + let over = crate::revealed_under_paint(doc, page, b.invisible, b.rect, &ops, at); let initial = TextState::initial(); let drawn_from = if over { &initial } else { after }; let plan = BlockPlan { diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 8ff4b0b62e..6ea69b4dc8 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -787,7 +787,7 @@ pub(crate) fn rewrite_styled_block( let at = if split { start } else { o.bt_op }; // Revealed OCR text that something is painted over goes on top of the page, where it starts // from the initial state. - let over = crate::revealed_under_paint(block.invisible, &ops, at); + let over = crate::revealed_under_paint(doc, page, block.invisible, block.rect, &ops, at); let initial = TextState::initial(); let drawn_from = if over { &initial } else { after }; validate_contextual_rows(&rows, &kept)?; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 68716cf5fd..5b5927d91d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6629,7 +6629,7 @@ status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image, inline image or shading is painted after the OCR layer (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn over the OCR layer's box after it, or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." [[feature]] id = "ocr.ocr-downsample" From 705d862328fb40e2aeda77874d3e41d420fed4b6 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 02:08:08 -0400 Subject: [PATCH 06/63] fix(edit): check revealed OCR text where the rewrite lands The on-top trigger compared later paint with the paragraph's old box. A rewrite that is moved (BlockStyle::offset), rewrapped wider or grown to more lines can land under an image the old box is clear of, and stayed underneath it. Both rewrite paths now test a Reach: the old box (where the cover goes) plus the moved box grown to the wrap width either way and down to the drawn line count (the widest measured line when wider), and up by one line pitch for a larger size; turned text grows by the lines' diagonal; any non-finite number makes it unbounded. Over-reaching is harmless: the cover still fills only the old box. The parity notes say so. Tests: an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands (move, width and line growth, both paths) and a_rewrites_reach_covers_its_move_ width_and_lines. Seen failing with each path reverted to the old box and with growth zeroed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/lib.rs | 2 +- crates/edit/src/place.rs | 48 ++++++++++++++++++++++++-- crates/edit/src/tests.rs | 40 +++++++++++++++++++++ crates/edit/src/text/rewrite.rs | 4 ++- crates/edit/src/text_regions/styled.rs | 15 +++++--- parity/acrobat-features.toml | 2 +- 6 files changed, 100 insertions(+), 11 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 1ca43d20a8..059e94d927 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -68,7 +68,7 @@ use marks::{matrix_number, n}; pub(crate) use place::undrawable; #[cfg(test)] use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_close, page_fonts, stream_bytes, stream_tag, tagged, wrapper_role}; -use place::{check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint}; +use place::{Reach, check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint}; pub use place::{stamp, undrawable_message}; #[cfg(test)] diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index e040acf1f3..0d2f138a41 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -360,17 +360,59 @@ pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: place_tagged(doc, page, "Edited", pdfcraft_content::serialize_ops(&all), false).map(drop) } +/// Where a rewritten paragraph can be drawn, in user space ([`revealed_under_paint`]). +pub(crate) struct Reach { + /// The paragraph's old box, where its cover goes. + pub rect: [f64; 4], + /// The move asked for (`BlockStyle::offset`). + pub offset: [f64; 2], + /// The widest line the rewrite may draw, how many lines it has, and how far apart they are. + pub width: f64, + pub rows: usize, + pub pitch: f64, + pub upright: bool, +} + +impl Reach { + /// The old box and the moved one, grown to `width` either way (alignment may grow a line left + /// or right), down to `rows` lines and up to one `pitch` above its bottom (a larger size); turned text grows by the lines' diagonal every way. + /// Unbounded when a number is not finite. + pub(crate) fn bounds(&self) -> [f64; 4] { + const UNBOUNDED: [f64; 4] = [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY]; + let ([dx, dy], r) = (self.offset, self.rect); + if !r.iter().chain(&self.offset).chain([&self.width, &self.pitch]).all(|v| v.is_finite()) { + return UNBOUNDED; + } + let moved = [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]; + let rows = f64::from(u32::try_from(self.rows).unwrap_or(u32::MAX)); + let (width, height) = (self.width.abs(), self.pitch.abs() * rows); + let grown = if self.upright { + [ + moved[0].min(moved[2] - width), + moved[1].min(moved[3] - height), + moved[2].max(moved[0] + width), + moved[3].max(moved[1] + self.pitch.abs()), + ] + } else { + let d = width.hypot(height); + [moved[0] - d, moved[1] - d, moved[2] + d, moved[3] + d] + }; + let b = [r[0].min(grown[0]), r[1].min(grown[1]), r[2].max(grown[2]), r[3].max(grown[3])]; + if b.iter().all(|v| v.is_finite()) { b } else { UNBOUNDED } + } +} + /// Whether a rewritten paragraph that was `invisible` goes on top of page `page`: something after -/// operator `at` of the page's joined `ops` paints over its `rect` (user space), as the scan image +/// operator `at` of the page's joined `ops` paints over `reach` (user space, [`Reach::bounds`]), as the scan image /// does over an OCR layer written before it (#3973). XObjects count where they are drawn; an inline /// image or a shading after it counts anywhere, and so does any XObject when the page's images /// cannot be listed. -pub(crate) fn revealed_under_paint(doc: &Document, page: usize, invisible: bool, rect: [f64; 4], ops: &[pdfcraft_content::Op], at: usize) -> bool { +pub(crate) fn revealed_under_paint(doc: &Document, page: usize, invisible: bool, reach: [f64; 4], ops: &[pdfcraft_content::Op], at: usize) -> bool { let later = ops.get(at..).unwrap_or_default(); if !invisible || later.iter().any(|op| op.is("BI") || op.is("sh")) { return invisible; } - later.iter().any(|op| op.is("Do")) && crate::images::painted_over(doc, page, rect, at).unwrap_or(true) + later.iter().any(|op| op.is("Do")) && crate::images::painted_over(doc, page, reach, at).unwrap_or(true) } /// The operators to draw on top of the page ([`draw_on_top`]): all of `paragraph` when `over`, diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 4349ce7ad6..95119c802e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4571,6 +4571,46 @@ fn revealed_text_moves_on_top_only_when_painted_over() { assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Inline) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 ID x EI"), "an inline image after it"); } +/// Revealed OCR text is checked where the rewrite lands, not only where it was: an image drawn +/// after it over just the moved or grown text still lifts the text on top, on both rewrite paths. +#[test] +fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { + let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let moved = text::BlockStyle { offset: Some([-290.0, -450.0]), ..Default::default() }; + let cases = [ + ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved", &moved), + ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved\nTwice", &moved), + ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there, this line now runs well past the scan's left edge", &text::BlockStyle::default()), + ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there,\nthis line now runs well past the scan's left edge", &text::BlockStyle::default()), + ("BT /F1 12 Tf 3 Tr 110 230 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &text::BlockStyle::default()), + ]; + for (content, edit, style) in cases { + let mut doc = image_page_with(&format!("{content} {scan}")); + let before = text::text_blocks(&doc, 0).unwrap().remove(0); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let at = ops.iter().position(|o| o.is("BT")).unwrap(); + assert!(!revealed_under_paint(&doc, 0, true, before.rect, &ops, at), "{edit:?}: the old box alone is clear of the scan"); + text::rewrite_block(&mut doc, 0, 0, Some(edit), style).unwrap(); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let scan_at = ops.iter().position(|o| o.is("Do")).unwrap(); + let shows = ["Tj", "TJ", "'", "\""]; + assert!(ops.iter().skip(scan_at).any(|o| shows.iter().any(|s| o.is(s))), "{edit:?}: the rewrite is drawn over the scan"); + } +} + +/// [`crate::Reach`] grows the old box to the move, the width either way and the lines below. +#[test] +fn a_rewrites_reach_covers_its_move_width_and_lines() { + let reach = |offset, width, rows, upright| crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset, width, rows, pitch: 10.0, upright }.bounds(); + assert_eq!(reach([0.0, 0.0], 20.0, 1, true), [10.0, 90.0, 30.0, 100.0]); + assert_eq!(reach([5.0, -50.0], 20.0, 1, true), [10.0, 40.0, 35.0, 100.0]); + assert_eq!(reach([0.0, 0.0], 50.0, 3, true), [-20.0, 70.0, 60.0, 100.0]); + assert_eq!(reach([0.0, 0.0], 30.0, 4, false), [-40.0, 40.0, 80.0, 150.0]); + assert_eq!(reach([f64::NAN, 0.0], 20.0, 1, true)[0], f64::NEG_INFINITY); + let larger = crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset: [0.0, 0.0], width: 20.0, rows: 1, pitch: 24.0, upright: true }; + assert_eq!(larger.bounds(), [10.0, 76.0, 30.0, 114.0], "a larger size grows the line up as well as down"); +} + /// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep /// for that is refused before either rewrite path changes anything. #[test] diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 768693e70a..e08d54ca4e 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -873,7 +873,9 @@ pub(crate) fn rewrite_block_from( let at = if split { start } else { emit.bt_op }; // Revealed OCR text that something is painted over goes on top of the page, where it starts // from the initial state. - let over = crate::revealed_under_paint(doc, page, b.invisible, b.rect, &ops, at); + let widest = wrapped.iter().map(|l| advance.of(l)).fold(width, f64::max); + let reach = crate::Reach { rect: b.rect, offset: [dx, dy], width: widest, rows: wrapped.len(), pitch: lead * k, upright }; + let over = crate::revealed_under_paint(doc, page, b.invisible, reach.bounds(), &ops, at); let initial = TextState::initial(); let drawn_from = if over { &initial } else { after }; let plan = BlockPlan { diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 6ea69b4dc8..2b19a00108 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -785,11 +785,6 @@ pub(crate) fn rewrite_styled_block( // What is in force where the rewrite is drawn, and what the text after it expects. let after = if split { &o.state } else { &o.bt_state }; let at = if split { start } else { o.bt_op }; - // Revealed OCR text that something is painted over goes on top of the page, where it starts - // from the initial state. - let over = crate::revealed_under_paint(doc, page, block.invisible, block.rect, &ops, at); - let initial = TextState::initial(); - let drawn_from = if over { &initial } else { after }; validate_contextual_rows(&rows, &kept)?; let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; @@ -815,6 +810,16 @@ pub(crate) fn rewrite_styled_block( }) .rfind(|v| *v > 0.0) .unwrap_or(first.size * 1.5); + // Revealed OCR text that something is painted over goes on top of the page, where it starts + // from the initial state. + let largest = style.ranges.iter().filter_map(|r| r.size).chain(style.size).fold(first.size, f64::max); + let pitch = source_leading.max(largest * style.line_spacing.unwrap_or(1.2)); + let widest = measured.iter().copied().fold(width, f64::max); + // This path lays rows out along user-space baselines, so the paragraph is upright here. + let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true }; + let over = crate::revealed_under_paint(doc, page, block.invisible, reach.bounds(), &ops, at); + let initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; let mut baseline = first.origin.baseline; for (row_index, row) in rows.iter().enumerate() { let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i)); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5b5927d91d..ef7f0f53fb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6629,7 +6629,7 @@ status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn over the OCR layer's box after it, or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn after the OCR layer over where the edit lands (its old box, moved, rewrapped or grown to more lines), or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." [[feature]] id = "ocr.ocr-downsample" From 8fd151ff79ec2f8b35449bf6f1c24dc32a644095 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 02:49:28 -0400 Subject: [PATCH 07/63] fix(edit): reach the styled rewrite's rows at the step they are written The styled path writes rows `line_spacing` (1.5 by default) times the size apart when a size or spacing is set, but its Reach assumed 1.2, so an image over only the lower rows of a larger rewrite was missed. The pitch now comes from the row writer's own step (row_step, shared by both), at the largest size among the paragraph's lines and ranges. The landing test gains a six-row rewrite at twice the size over an image covering only its last row; it fails with the 1.2 step. A case with only line_spacing set covers the other branch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/tests.rs | 9 ++++++++- crates/edit/src/text_regions/styled.rs | 21 ++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 95119c802e..e728a90393 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4577,6 +4577,8 @@ fn revealed_text_moves_on_top_only_when_painted_over() { fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; let moved = text::BlockStyle { offset: Some([-290.0, -450.0]), ..Default::default() }; + let sized = text::BlockStyle { size: Some(24.0), ..Default::default() }; + let spaced = text::BlockStyle { line_spacing: Some(3.0), ..Default::default() }; let cases = [ ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved", &moved), ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved\nTwice", &moved), @@ -4584,7 +4586,12 @@ fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there,\nthis line now runs well past the scan's left edge", &text::BlockStyle::default()), ("BT /F1 12 Tf 3 Tr 110 230 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &text::BlockStyle::default()), ]; - for (content, edit, style) in cases { + let low = "q 200 0 0 30 100 300 cm /Im0 Do Q"; + let cases = cases.into_iter().map(|(c, e, s)| (c, e, s, scan)).chain([ + ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &sized, low), + ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &spaced, low), + ]); + for (content, edit, style, scan) in cases { let mut doc = image_page_with(&format!("{content} {scan}")); let before = text::text_blocks(&doc, 0).unwrap().remove(0); let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 2b19a00108..a606952233 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -402,6 +402,16 @@ fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextB Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0))) } +/// How far below a row of `size` the next one is written: the source's leading unless a size +/// or line spacing is chosen. +fn row_step(style: &BlockStyle, size: f64, source_leading: f64) -> f64 { + if style.line_spacing.is_none() && style.size.is_none() { + source_leading + } else { + style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(size) + } +} + fn wrap_styled_rows( chars: &[StyledChar], width: f64, @@ -812,8 +822,9 @@ pub(crate) fn rewrite_styled_block( .unwrap_or(first.size * 1.5); // Revealed OCR text that something is painted over goes on top of the page, where it starts // from the initial state. - let largest = style.ranges.iter().filter_map(|r| r.size).chain(style.size).fold(first.size, f64::max); - let pitch = source_leading.max(largest * style.line_spacing.unwrap_or(1.2)); + let largest = + block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size).chain(style.ranges.iter().filter_map(|r| r.size)).fold(first.size, f64::max); + let pitch = source_leading.max(row_step(style, largest, source_leading)); let widest = measured.iter().copied().fold(width, f64::max); // This path lays rows out along user-space baselines, so the paragraph is upright here. let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true }; @@ -972,11 +983,7 @@ pub(crate) fn rewrite_styled_block( x += segment_width; pos = end; } - baseline -= if style.line_spacing.is_none() && style.size.is_none() { - source_leading - } else { - style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) - }; + baseline -= row_step(style, anchor.size, source_leading); } block_ops.push(Op::new("Q", vec![])); let on_top = crate::lift_on_top(over, &mut block_ops); From 8e5f423d6d777380db7807a4e1c9dafb098368c9 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 03:31:03 -0400 Subject: [PATCH 08/63] fix(edit): reach as high as a larger range's glyphs With only a range enlarged, row_step is the source leading, so the styled rewrite's reach rose one source line above its old box while a 120-point range draws far higher, and an image there was missed. The pitch is now at least 1.2 times the largest size. The landing test gains a 120-point range on a 12-point OCR line under an image above the old box; it fails without the floor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/tests.rs | 13 +++++++++++++ crates/edit/src/text_regions/styled.rs | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index e728a90393..dead2dcef0 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4579,6 +4579,18 @@ fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { let moved = text::BlockStyle { offset: Some([-290.0, -450.0]), ..Default::default() }; let sized = text::BlockStyle { size: Some(24.0), ..Default::default() }; let spaced = text::BlockStyle { line_spacing: Some(3.0), ..Default::default() }; + let range = text::RangeStyle { + range: 0..2, + family: None, + resource: None, + size: Some(120.0), + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let enlarged = text::BlockStyle { ranges: vec![range], ..Default::default() }; + let above = "q 200 0 0 60 100 200 cm /Im0 Do Q"; let cases = [ ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved", &moved), ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved\nTwice", &moved), @@ -4590,6 +4602,7 @@ fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { let cases = cases.into_iter().map(|(c, e, s)| (c, e, s, scan)).chain([ ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &sized, low), ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &spaced, low), + ("BT /F1 12 Tf 3 Tr 110 150 Td (Hi there) Tj ET", "Hi there", &enlarged, above), ]); for (content, edit, style, scan) in cases { let mut doc = image_page_with(&format!("{content} {scan}")); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index a606952233..7fe7a6a066 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -824,7 +824,8 @@ pub(crate) fn rewrite_styled_block( // from the initial state. let largest = block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size).chain(style.ranges.iter().filter_map(|r| r.size)).fold(first.size, f64::max); - let pitch = source_leading.max(row_step(style, largest, source_leading)); + // At least one line of the largest glyphs, so a larger range reaches up as far as it draws. + let pitch = source_leading.max(row_step(style, largest, source_leading)).max(largest * 1.2); let widest = measured.iter().copied().fold(width, f64::max); // This path lays rows out along user-space baselines, so the paragraph is upright here. let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true }; From ffa08fa00d664898222ba63fd04ffc45703fe634 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 03:37:21 -0400 Subject: [PATCH 09/63] fix(edit): reach one line above the paragraph's top (#3973) A larger range on the first line of a multi-line paragraph rises from the first line's baseline, not from the old box's bottom, so the reach now extends one pitch above the moved box's top. Name the 1.2 em line floor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/place.rs | 9 ++------- crates/edit/src/tests.rs | 6 +++--- crates/edit/src/text_regions/styled.rs | 3 ++- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 0d2f138a41..b55e71a6ff 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -375,7 +375,7 @@ pub(crate) struct Reach { impl Reach { /// The old box and the moved one, grown to `width` either way (alignment may grow a line left - /// or right), down to `rows` lines and up to one `pitch` above its bottom (a larger size); turned text grows by the lines' diagonal every way. + /// or right), down to `rows` lines and up one `pitch` above its top (a larger size on the first line, however many lines the old box had); turned text grows by the lines' diagonal every way. /// Unbounded when a number is not finite. pub(crate) fn bounds(&self) -> [f64; 4] { const UNBOUNDED: [f64; 4] = [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY]; @@ -387,12 +387,7 @@ impl Reach { let rows = f64::from(u32::try_from(self.rows).unwrap_or(u32::MAX)); let (width, height) = (self.width.abs(), self.pitch.abs() * rows); let grown = if self.upright { - [ - moved[0].min(moved[2] - width), - moved[1].min(moved[3] - height), - moved[2].max(moved[0] + width), - moved[3].max(moved[1] + self.pitch.abs()), - ] + [moved[0].min(moved[2] - width), moved[1].min(moved[3] - height), moved[2].max(moved[0] + width), moved[3] + self.pitch.abs()] } else { let d = width.hypot(height); [moved[0] - d, moved[1] - d, moved[2] + d, moved[3] + d] diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index dead2dcef0..7ee78e882b 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4622,13 +4622,13 @@ fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { #[test] fn a_rewrites_reach_covers_its_move_width_and_lines() { let reach = |offset, width, rows, upright| crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset, width, rows, pitch: 10.0, upright }.bounds(); - assert_eq!(reach([0.0, 0.0], 20.0, 1, true), [10.0, 90.0, 30.0, 100.0]); + assert_eq!(reach([0.0, 0.0], 20.0, 1, true), [10.0, 90.0, 30.0, 110.0]); assert_eq!(reach([5.0, -50.0], 20.0, 1, true), [10.0, 40.0, 35.0, 100.0]); - assert_eq!(reach([0.0, 0.0], 50.0, 3, true), [-20.0, 70.0, 60.0, 100.0]); + assert_eq!(reach([0.0, 0.0], 50.0, 3, true), [-20.0, 70.0, 60.0, 110.0]); assert_eq!(reach([0.0, 0.0], 30.0, 4, false), [-40.0, 40.0, 80.0, 150.0]); assert_eq!(reach([f64::NAN, 0.0], 20.0, 1, true)[0], f64::NEG_INFINITY); let larger = crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset: [0.0, 0.0], width: 20.0, rows: 1, pitch: 24.0, upright: true }; - assert_eq!(larger.bounds(), [10.0, 76.0, 30.0, 114.0], "a larger size grows the line up as well as down"); + assert_eq!(larger.bounds(), [10.0, 76.0, 30.0, 124.0], "a larger size grows the line up as well as down"); } /// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 7fe7a6a066..943bece7af 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -825,7 +825,8 @@ pub(crate) fn rewrite_styled_block( let largest = block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size).chain(style.ranges.iter().filter_map(|r| r.size)).fold(first.size, f64::max); // At least one line of the largest glyphs, so a larger range reaches up as far as it draws. - let pitch = source_leading.max(row_step(style, largest, source_leading)).max(largest * 1.2); + const LINE_EM: f64 = 1.2; + let pitch = source_leading.max(row_step(style, largest, source_leading)).max(largest * LINE_EM); let widest = measured.iter().copied().fold(width, f64::max); // This path lays rows out along user-space baselines, so the paragraph is upright here. let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true }; From f84290b294a6f40b11b90c683c4226f3d73aa346 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 08:23:56 -0400 Subject: [PATCH 10/63] perf(render): undo PNG predictors a byte row at a time (#5430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendored hayro-syntax ran PNG predictors through the bit-level BitChunks/BitWriter machinery TIFF predictors need, although PNG filters are defined on bytes (ISO 32000-2 §7.4.4.4, PNG specification §9). A 993 x 936 RGB image took about 63 ms to unfilter whatever its row filters; undone on byte slices it takes about 3 ms (Up) to 16 ms (Paeth) in a release build. It also fixes a row that ends part-way through a pixel (three 4-bit samples at an odd width): the bit-level path dropped its last byte, each later row started a byte early and the picture came out sheared. TIFF predictor 2 keeps the old path. Vendored hayro-syntax patch 29. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/render/src/raster/tests.rs | 3 + .../src/raster/tests/png_predicted_images.rs | 69 ++++ vendor/README.md | 2 +- vendor/hayro-syntax/src/filter/lzw_flate.rs | 336 ++++++++++++------ 4 files changed, 308 insertions(+), 102 deletions(-) create mode 100644 crates/render/src/raster/tests/png_predicted_images.rs diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index a4e4404e6b..68b0c0cb08 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -7004,6 +7004,9 @@ mod default_colour_spaces; // (vendored hayro-syntax patch 28, hayro-interpret patch 55). mod devicen_inks; +// #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 29). +mod png_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/png_predicted_images.rs b/crates/render/src/raster/tests/png_predicted_images.rs new file mode 100644 index 0000000000..14675adab0 --- /dev/null +++ b/crates/render/src/raster/tests/png_predicted_images.rs @@ -0,0 +1,69 @@ +//! #5430: Flate images with PNG predictors (ISO 32000-2 §7.4.4.4) are undone a byte row at a +//! time (vendored hayro-syntax patch 29). The bit-level decoder they went through before dropped +//! the last byte of each filtered row that ends part-way through a pixel, so every later row +//! started a byte early and the picture came out sheared. + +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; the image fills it. +const WIDTH: usize = 150; +const HEIGHT: usize = 200; + +/// The colour of each of the image's four rows, top first, as 4-bit samples. +const ROWS: [[u8; 3]; 4] = [[15, 0, 0], [0, 15, 0], [0, 0, 15], [15, 15, 0]]; + +#[test] +fn a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared() { + // Three 4-bit RGB pixels are 4.5 bytes, so each row is five bytes and ends half-way through a + // two-byte pixel. + let rows: Vec> = ROWS + .iter() + .map(|rgb| { + let nibbles: Vec = (0..3).flat_map(|_| rgb.iter().copied()).chain([0]).collect(); + nibbles.chunks(2).map(|pair| (pair[0] << 4) | pair[1]).collect() + }) + .collect(); + let mut predicted = Vec::new(); + let mut above = vec![0u8; 5]; + for row in &rows { + predicted.push(2); + predicted.extend(row.iter().zip(&above).map(|(b, a)| b.wrapping_sub(*a))); + above.clone_from(row); + } + 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 /DeviceRGB /BitsPerComponent 4 \ + /Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors 3 /BitsPerComponent 4 /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)); + + // Each image pixel is 50 pt square; look at the middle of each. + for (row, rgb) in ROWS.iter().enumerate() { + let expected = rgb.map(|sample| sample * 17); + for column in 0..3 { + let (x, y) = (column * 50 + 25, row * 50 + 25); + assert_eq!(rgb_at(&page.rgba, WIDTH, x, y), expected, "image row {row}, column {column}"); + } + } +} diff --git a/vendor/README.md b/vendor/README.md index 84b2546bf3..dfba101330 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (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) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_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`) | | 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) | (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` | +| 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) 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) `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` | | 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 f1ab1aae73..5a0027597b 100644 --- a/vendor/hayro-syntax/src/filter/lzw_flate.rs +++ b/vendor/hayro-syntax/src/filter/lzw_flate.rs @@ -1008,15 +1008,16 @@ fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> { return None; } - let (bit_size, chunk_len) = if is_png_predictor { - ( - 8, - // PdfCraft patch: in usize (the u8 product overflowed). - (usize::from(params.colors) * usize::from(params.bits_per_component)).div_ceil(8), - ) - } else { - (params.bits_per_component, params.colors as usize) - }; + if is_png_predictor { + // PdfCraft patch: PNG predictors work on bytes whatever the bit depth, so they are + // undone a row at a time on byte slices instead of through the bit-level readers. + // In usize (the u8 product overflowed). + let bpp = (usize::from(params.colors) * usize::from(params.bits_per_component)) + .div_ceil(8); + return Some(unfilter_png(&data, row_len, bpp)); + } + + 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)?; let zero_col = BitChunk::new(0, chunk_len); @@ -1024,58 +1025,7 @@ fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> { let mut writer = BitWriter::new(&mut out, bit_size)?; for in_row in data.chunks_exact(total_row_len) { - if is_png_predictor { - let predictor = in_row[0]; - let in_data = &in_row[1..]; - let in_data_chunks = BitChunks::new(in_data, bit_size, chunk_len)?; - - match predictor { - 1 => apply::( - prev_row, - zero_col.clone(), - zero_col.clone(), - in_data_chunks, - &mut writer, - chunk_len, - bit_size, - )?, - 2 => apply::( - prev_row, - zero_col.clone(), - zero_col.clone(), - in_data_chunks, - &mut writer, - chunk_len, - bit_size, - )?, - 3 => apply::( - prev_row, - zero_col.clone(), - zero_col.clone(), - in_data_chunks, - &mut writer, - chunk_len, - bit_size, - )?, - 4 => apply::( - prev_row, - zero_col.clone(), - zero_col.clone(), - in_data_chunks, - &mut writer, - chunk_len, - bit_size, - )?, - _ => { - // Just copy the data. - let mut reader = BitReader::new(in_data); - - while let Some(data) = reader.read(bit_size) { - writer.write(data); - } - } - } - } else if i == 2 { + if i == 2 { apply::( prev_row, zero_col.clone(), @@ -1104,6 +1054,98 @@ fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> { } } +/// 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 +/// row are dropped. +fn unfilter_png(data: &[u8], row_len: usize, bpp: usize) -> Vec { + let bpp = bpp.max(1); + let in_row_len = row_len.saturating_add(1); + let num_rows = data.len() / in_row_len; + let mut out = vec![0; num_rows.saturating_mul(row_len)]; + let zero_row = vec![0; row_len]; + let mut prev: &[u8] = &zero_row; + + for (cur, in_row) in out + .chunks_exact_mut(row_len) + .zip(data.chunks_exact(in_row_len)) + { + let Some((&filter, src)) = in_row.split_first() else { + break; + }; + + match filter { + 1 => { + let mut left: &[u8] = &zero_row; + for (px, src) in cur.chunks_mut(bpp).zip(src.chunks(bpp)) { + for ((c, s), l) in px.iter_mut().zip(src).zip(left) { + *c = s.wrapping_add(*l); + } + left = px; + } + } + 2 => { + for ((c, s), u) in cur.iter_mut().zip(src).zip(prev) { + *c = s.wrapping_add(*u); + } + } + 3 => { + let mut left: &[u8] = &zero_row; + for ((px, src), up) in cur + .chunks_mut(bpp) + .zip(src.chunks(bpp)) + .zip(prev.chunks(bpp)) + { + for (((c, s), l), u) in px.iter_mut().zip(src).zip(left).zip(up) { + // The sum is taken before halving, without wrapping. + let avg = (u16::from(*l) + u16::from(*u)) / 2; + *c = s.wrapping_add(avg as u8); + } + left = px; + } + } + 4 => { + let mut left: &[u8] = &zero_row; + let mut up_left: &[u8] = &zero_row; + for ((px, src), up) in cur + .chunks_mut(bpp) + .zip(src.chunks(bpp)) + .zip(prev.chunks(bpp)) + { + for ((((c, s), l), u), ul) in + px.iter_mut().zip(src).zip(left).zip(up).zip(up_left) + { + *c = s.wrapping_add(paeth_byte(*l, *u, *ul)); + } + left = px; + up_left = up; + } + } + _ => { + for (c, s) in cur.iter_mut().zip(src) { + *c = *s; + } + } + } + + prev = cur; + } + + out +} + +/// The Paeth predictor on bytes: the neighbour nearest `left + up - up_left`, preferring left, +/// then up. +fn paeth_byte(left: u8, up: u8, up_left: u8) -> u8 { + let (a, b, c) = (i16::from(left), i16::from(up), i16::from(up_left)); + let pa = (b - c).abs(); + let pb = (a - c).abs(); + let pc = (a + b - c - c).abs(); + + let not_left = if pb <= pc { up } else { up_left }; + if pa <= pb && pa <= pc { left } else { not_left } +} + fn apply<'a, T: Predictor>( prev_row: BitChunks<'a>, mut prev_col: BitChunk, @@ -1153,46 +1195,6 @@ impl Predictor for Sub { } } -struct Up; -impl Predictor for Up { - fn predict(cur_row: u16, prev_row: u16, _: u16, _: u16) -> u16 { - cur_row.wrapping_add(prev_row) - } -} - -struct Avg; -impl Predictor for Avg { - fn predict(cur_row: u16, prev_row: u16, prev_col: u16, _: u16) -> u16 { - cur_row.wrapping_add(((prev_col as u32 + prev_row as u32) / 2) as u16) - } -} - -struct Paeth; -impl Predictor for Paeth { - fn predict(cur_row: u16, prev_row: u16, prev_col: u16, top_left: u16) -> u16 { - fn paeth(a: u16, b: u16, c: u16) -> u16 { - let a = a as i32; - let b = b as i32; - let c = c as i32; - - let p = a + b - c; - let pa = (p - a).abs(); - let pb = (p - b).abs(); - let pc = (p - c).abs(); - - if pa <= pb && pa <= pc { - a as u16 - } else if pb <= pc { - b as u16 - } else { - c as u16 - } - } - - cur_row.wrapping_add(paeth(prev_col, prev_row, top_left)) - } -} - #[cfg(test)] #[rustfmt::skip] mod tests { @@ -1357,3 +1359,135 @@ mod tests { ); } } + +/// PdfCraft patch tests: PNG predictors are undone byte by byte (`unfilter_png`). +#[cfg(test)] +mod pdfcraft_png_predictor_tests { + use crate::filter::lzw_flate::{PredictorParams, apply_predictor}; + + /// PNG unfiltering written out from the PNG specification (§9.2–9.4), one byte at a time. + fn reference(data: &[u8], row_len: usize, bpp: usize) -> Vec { + let mut out: Vec = Vec::new(); + for (r, row) in data.chunks_exact(row_len + 1).enumerate() { + let start = r * row_len; + for x in 0..row_len { + let left = if x >= bpp { out[start + x - bpp] } else { 0 }; + let up = if r > 0 { out[start + x - row_len] } else { 0 }; + let up_left = if r > 0 && x >= bpp { + out[start + x - row_len - bpp] + } else { + 0 + }; + let predicted = match row[0] { + 1 => left, + 2 => up, + 3 => ((u16::from(left) + u16::from(up)) / 2) as u8, + 4 => { + let p = i16::from(left) + i16::from(up) - i16::from(up_left); + let (pa, pb, pc) = ( + (p - i16::from(left)).abs(), + (p - i16::from(up)).abs(), + (p - i16::from(up_left)).abs(), + ); + if pa <= pb && pa <= pc { + left + } else if pb <= pc { + up + } else { + up_left + } + } + _ => 0, + }; + out.push(row[1 + x].wrapping_add(predicted)); + } + } + out + } + + #[test] + fn png_predictors_match_the_specification_at_every_depth_and_pixel_size() { + let mut seed: u32 = 0x2545_f491; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + for case in 0..400 { + let colors = 1 + (next() % 4) as u8; + let bits_per_component = [1u8, 2, 4, 8, 16][(next() % 5) as usize]; + let columns = 1 + (next() % 40) as usize; + let bpp = (usize::from(colors) * usize::from(bits_per_component)).div_ceil(8); + let row_len = + (columns * usize::from(colors) * usize::from(bits_per_component)).div_ceil(8); + let rows = 1 + (next() % 6) as usize; + let mut data = Vec::new(); + for _ in 0..rows { + // Filter types 0–4, and 5–7, which are not PNG filters and copy the row. + data.push((next() % 8) as u8); + data.extend((0..row_len).map(|_| next() as u8)); + } + // A partial row at the end is not decoded. + let tail = (next() % 3) as usize; + data.extend((0..tail.min(row_len)).map(|_| next() as u8)); + + let params = PredictorParams { + predictor: 10 + (next() % 6) as u8, + colors, + bits_per_component, + columns, + early_change: false, + }; + let got = apply_predictor(data.clone(), ¶ms).unwrap(); + assert_eq!( + got, + reference(&data, row_len, bpp), + "case {case}: {colors} colours at {bits_per_component} bits, {columns} columns" + ); + } + } + + #[test] + fn a_row_ending_part_way_through_a_pixel_keeps_its_last_byte() { + // Three 4-bit samples are a byte and a half, so a three-column row is five bytes and ends + // half-way through a two-byte pixel. Each row is filtered Up. + let data = [ + 2, 10, 11, 12, 13, 14, // + 2, 20, 21, 22, 23, 24, // + 2, 30, 31, 32, 33, 34, + ]; + let params = PredictorParams { + predictor: 15, + colors: 3, + bits_per_component: 4, + columns: 3, + early_change: false, + }; + assert_eq!( + apply_predictor(data.to_vec(), ¶ms).unwrap(), + [10, 11, 12, 13, 14, 30, 32, 34, 36, 38, 60, 63, 66, 69, 72] + ); + } + + #[test] + fn png_average_and_paeth_carry_the_left_pixel_across_a_wide_row() { + // Three 16-bit samples (6 bytes) per pixel: the left neighbour is six bytes back. + let row_len = 6 * 3; + let mut data = vec![3u8]; + data.extend((0..row_len).map(|i| (i * 37 + 200) as u8)); + data.push(4); + data.extend((0..row_len).map(|i| (255 - i * 11) as u8)); + let params = PredictorParams { + predictor: 15, + colors: 3, + bits_per_component: 16, + columns: 3, + early_change: false, + }; + assert_eq!( + apply_predictor(data.clone(), ¶ms).unwrap(), + reference(&data, row_len, 6) + ); + } +} From bb4a8accdd8df8cc8f4658a3e6fa3a68bde5106b Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 14:28:05 -0400 Subject: [PATCH 11/63] M12: an XFA check box without a width is as wide as its box and caption A field with no `w` is growable in width (XFA 3.3 Ch.8, growable objects): it takes its content's width, clamped to minW/maxW. The layouter gave every non-button field without a width all the room left in its container, so a positioned radio group whose choices set only minW stretched the first choice to the group's right edge. Its box (drawn at the field's right end) then sat on the last choice's box, and the group showed one circle where it had two (#5442's Yes/No groups). A check button without a width is now its box and widget margins plus its caption beside it (the caption's reserve, else its text), or the wider of the two when the caption is above or below, within what is available and clamped by minW/maxW. Other fields keep taking the available width. Test: a_radio_choice_without_a_width_is_as_wide_as_its_content, two choices with minW and no w in a positioned exclGroup; with the change reverted the Yes box lands at 105.2pt, on top of the No box. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/xfa/src/layout/measure.rs | 19 ++++++++++++++++++- crates/xfa/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 2fffb3dfa0..6532f6511e 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -51,7 +51,8 @@ impl Layouter<'_> { let som = ctx.som.clone(); // A container without a width is as wide as its content (a radio group beside its // question, say), and so is a text label or a button (as wide as its caption), within - // what is available; any other field without one takes what is available. + // what is available, and so is a check box (its box and caption); any other field without + // one takes what is available. let w = match c.w { Some(w) => w, None => match node { @@ -65,6 +66,7 @@ impl Layouter<'_> { .as_ref() .and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para))) .map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)), + Node::Field(f) if f.ui == Ui::CheckButton => (self.check_width(f, &ctx) + c.margin.horizontal()).min(avail_w), Node::Field(_) => avail_w, }, }; @@ -149,6 +151,21 @@ impl Layouter<'_> { } } + /// How wide check box `f` is without a width of its own (XFA 3.3 Ch.8, growable fields): its + /// box and widget margins, plus its caption beside it (its reserve, else its text), or the + /// wider of the two when the caption is above or below. + fn check_width(&self, f: &Field, ctx: &Ctx) -> f64 { + let check = f.check_size.unwrap_or(10.0) + f.ui_margin.horizontal(); + let Some(cap) = f.caption.as_ref().filter(|c| c.presence.occupies() && c.placement != Placement::Inline) else { + return check; + }; + let text = || self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para)).map_or(0.0, |t| t + 2.0); + match cap.placement { + Placement::Top | Placement::Bottom => check.max(text()), + _ => check + cap.reserve.unwrap_or_else(text), + } + } + /// How wide text `v` is unwrapped, in `font` and `para`, with a hair to spare so that laying /// it out at that width keeps it on its lines; `None` for what isn't text, or text that draws /// nothing. diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e424be5441..ff7c43fdbc 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2997,6 +2997,29 @@ fn a_radio_button_sits_where_a_check_box_would() { } } +/// A check box without a width is as wide as its box and caption, at least its minW, not the rest +/// of its group: two choices side by side in a positioned radio group keep their own boxes. +#[test] +fn a_radio_choice_without_a_width_is_as_wide_as_its_content() { + let choice = |name: &str, x: &str, item: &str| { + format!( + r#"{name}{item}"# + ) + }; + let body = format!( + r#"{}{}"#, + choice("Yes", "0mm", "Y"), + choice("No", "20.308mm", "N"), + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let laid = widgets(&form.pages[0]); + let x = |name: &str| laid.iter().find(|w| w.name == name).map(|w| w.rect.x - 36.0).unwrap_or_else(|| panic!("no radio button {name}")); + let mm = 72.0 / 25.4; + // Each box sits at the right end of its own 20.32mm-wide choice. + assert!((x("Yes") - (20.32 * mm - 10.0)).abs() < 0.01, "Yes box at {} pt", x("Yes")); + assert!((x("No") - ((20.308 + 20.32) * mm - 10.0)).abs() < 0.01, "No box at {} pt", x("No")); +} + /// A caption without a reserve leaves room for the box at the size it is drawn, a box larger than /// its field shrinking to fit; and a field without a height is as tall as that caption drawn in /// all the box leaves, not at some narrower width. From 56ebc5f9264001d4d8d135c1a2293d4411a4530c Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 14:40:19 -0400 Subject: [PATCH 12/63] M12: pin a check box's content width and flowed packing Review follow-up. The radio-choice test only reached the minW clamp, so a wrong content formula would still pass. A check box with a reserved caption and no width or minW now must be exactly reserve + box, both positioned (its right-aligned box ends the field) and flowed (two pack side by side in an lr-tb row). Each assertion fails with the measure.rs change reverted: the box lands at 530pt, and the second flowed check box drops to the next row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/xfa/src/layout/measure.rs | 1 + crates/xfa/src/tests.rs | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 6532f6511e..e9405e0518 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -159,6 +159,7 @@ impl Layouter<'_> { let Some(cap) = f.caption.as_ref().filter(|c| c.presence.occupies() && c.placement != Placement::Inline) else { return check; }; + // The 2pt matches `caption_reserve`'s room for a caption without a reserve. let text = || self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para)).map_or(0.0, |t| t + 2.0); match cap.placement { Placement::Top | Placement::Bottom => check.max(text()), diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index ff7c43fdbc..d382bbe644 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3015,11 +3015,34 @@ fn a_radio_choice_without_a_width_is_as_wide_as_its_content() { let laid = widgets(&form.pages[0]); let x = |name: &str| laid.iter().find(|w| w.name == name).map(|w| w.rect.x - 36.0).unwrap_or_else(|| panic!("no radio button {name}")); let mm = 72.0 / 25.4; - // Each box sits at the right end of its own 20.32mm-wide choice. + // A negative reserve (as Designer writes) sizes the caption to its text, so content is + // narrower than minW and each box sits at the right end of its own 20.32mm-wide choice. assert!((x("Yes") - (20.32 * mm - 10.0)).abs() < 0.01, "Yes box at {} pt", x("Yes")); assert!((x("No") - ((20.308 + 20.32) * mm - 10.0)).abs() < 0.01, "No box at {} pt", x("No")); } +/// Without a width or minW, a check box is exactly its caption's reserve plus its box, whether +/// placed (its right-aligned box ends there) or flowed (two fit side by side in an lr-tb row). +#[test] +fn a_check_box_without_a_width_is_its_caption_and_box() { + let check = |name: &str, at: &str| { + format!( + r#"{name}10"# + ) + }; + let box_at = |body: String, name: &str| { + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + widgets(&form.pages[0]).iter().find(|w| w.name == name).map(|w| (w.rect.x - 36.0, w.rect.y - 36.0)).unwrap() + }; + let placed = format!(r#"{}"#, check("placed", r#"x="0in" y="0in""#)); + let (x, _) = box_at(placed, "placed"); + assert!((x - 36.0).abs() < 0.01, "the box ends the 46pt field, after the 36pt caption: {x}"); + + let flowed = format!(r#"{}{}"#, check("first", ""), check("second", "")); + let (x, y) = box_at(flowed, "second"); + assert!((x - 82.0).abs() < 0.01 && y.abs() < 0.01, "the second check box follows the first on its row: ({x}, {y})"); +} + /// A caption without a reserve leaves room for the box at the size it is drawn, a box larger than /// its field shrinking to fit; and a field without a height is as tall as that caption drawn in /// all the box leaves, not at some narrower width. From 9ca051ac063ae708a2a6fbc480ca610dcf054840 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 18:07:38 -0400 Subject: [PATCH 13/63] fix(ui): list X.PDF in file pickers on Linux and the BSDs (#5481) rfd 0.17 gives each filter extension to the XDG portal, GTK and Zenity as the glob `*.`, which those match case-sensitively, so a "PDF" filter of `pdf` hid `Report.PDF`. `AddFilterAnyCase::add_filter_any_case` passes each letter as a bracket class there (`pdf` -> `[pP][dD][fF]`) and the extension unchanged on Windows, macOS and the web, which already ignore case. Every picker filter in ui-egui now goes through it; the imports used only by native pickers are gated off wasm. Fixes #5481 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/ui-egui/src/app/files.rs | 13 ++++-- crates/ui-egui/src/dialogs/marks_ui.rs | 4 +- crates/ui-egui/src/editing/save.rs | 4 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/platform.rs | 1 + crates/ui-egui/src/platform/file_filter.rs | 50 ++++++++++++++++++++++ crates/ui-egui/src/tools/content_ui.rs | 4 +- crates/ui-egui/src/tools/fill_sign.rs | 3 +- crates/ui-egui/src/tools/measure_ui.rs | 4 +- crates/ui-egui/src/tools/sign_ui.rs | 6 ++- crates/ui-egui/src/tools/sign_ui/dialog.rs | 4 +- crates/ui-egui/src/tools/stamps_ui.rs | 4 +- crates/ui-egui/src/workflows/actions_ui.rs | 4 +- crates/ui-egui/src/workflows/create_ui.rs | 6 ++- crates/ui-egui/src/workflows/files.rs | 41 +++++++++--------- crates/ui-egui/src/workflows/print_ui.rs | 4 +- 16 files changed, 116 insertions(+), 37 deletions(-) create mode 100644 crates/ui-egui/src/platform/file_filter.rs diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 3c68dc5619..c24e9d9037 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,5 @@ use super::*; +use crate::file_filter::AddFilterAnyCase as _; /// 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`). @@ -607,7 +608,9 @@ impl PdfCraftApp { /// The File ▸ Open picker: PDFs, and images and text that become PDFs. #[cfg(not(target_arch = "wasm32"))] pub(super) fn open_picker() -> rfd::AsyncFileDialog { - rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE) + rfd::AsyncFileDialog::new() + .add_filter_any_case("PDF", &["pdf"]) + .add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE) } pub fn open_dialog(&mut self) { @@ -615,7 +618,9 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] self.pick( pickers::PickFor::Open, - rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE), + rfd::AsyncFileDialog::new() + .add_filter_any_case("PDF", &["pdf"]) + .add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE), true, ); // Browsers pick files asynchronously; the bytes arrive through `inbox`. @@ -625,8 +630,8 @@ impl PdfCraftApp { let ctx = self.ctx.clone(); wasm_bindgen_futures::spawn_local(async move { if let Some(h) = rfd::AsyncFileDialog::new() - .add_filter("PDF", &["pdf"]) - .add_filter(tl!("Images and text"), &create_ui::CONVERTIBLE) + .add_filter_any_case("PDF", &["pdf"]) + .add_filter_any_case(tl!("Images and text"), &create_ui::CONVERTIBLE) .pick_file() .await { diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 085c581119..46dd1eb3a5 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -3,6 +3,8 @@ //! page the range selects, with the marks laid out on it. "Update" opens the same dialog in //! replace mode. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use std::collections::{HashMap, HashSet}; use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; @@ -777,7 +779,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M #[cfg(not(target_arch = "wasm32"))] if browse { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]); + .add_filter_any_case(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]); let epoch = app.dialog_epoch(); app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| { // Only into the dialog that asked: not one closed meanwhile, or opened again since. diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index f685247c9d..b6c04dd9e0 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -1,4 +1,6 @@ use super::{PdfCraftApp, StagingName, staging_suffixes}; +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; /// A save held back by [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged): the file /// on disk is another version than the document came from (#2166). @@ -58,7 +60,7 @@ impl PdfCraftApp { (SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) => p, _ => { let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") }; - let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(name); let dialog = in_document_folder(dialog, doc_path.as_deref()); // The bytes are taken once the user has chosen, so edits made meanwhile are saved too. self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 222bb988e4..ef04581e2d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -119,6 +119,7 @@ pub use persist::units; #[cfg(not(target_arch = "wasm32"))] pub use persist::window_state; use platform::drag_pointer; +use platform::file_filter; #[cfg(not(target_arch = "wasm32"))] pub use platform::file_watch; #[cfg(target_os = "macos")] diff --git a/crates/ui-egui/src/platform.rs b/crates/ui-egui/src/platform.rs index 129f622f52..47196409f9 100644 --- a/crates/ui-egui/src/platform.rs +++ b/crates/ui-egui/src/platform.rs @@ -1,4 +1,5 @@ pub(super) mod drag_pointer; +pub(super) mod file_filter; #[cfg(not(target_arch = "wasm32"))] pub mod file_watch; #[cfg(target_os = "macos")] diff --git a/crates/ui-egui/src/platform/file_filter.rs b/crates/ui-egui/src/platform/file_filter.rs new file mode 100644 index 0000000000..47e11bae72 --- /dev/null +++ b/crates/ui-egui/src/platform/file_filter.rs @@ -0,0 +1,50 @@ +//! Picker filters that list a file whatever the letter case of its extension. +//! +//! On Linux and the BSDs, rfd gives each extension to the XDG portal, GTK or Zenity as the glob +//! `*.`, and those match it case-sensitively: a "PDF" filter of `pdf` hid `Report.PDF`. +//! Windows, macOS and the browser already ignore case, so there the extension passes unchanged. + +/// Whether the platform's pickers match filter globs case-sensitively. +const CASE_SENSITIVE_GLOBS: bool = cfg!(all(unix, not(target_os = "macos"))); + +/// [`rfd::AsyncFileDialog::add_filter`], with each extension matched in any letter case. +pub(crate) trait AddFilterAnyCase { + #[must_use] + fn add_filter_any_case(self, name: impl Into, extensions: &[impl AsRef]) -> Self; +} + +impl AddFilterAnyCase for rfd::AsyncFileDialog { + fn add_filter_any_case(self, name: impl Into, extensions: &[impl AsRef]) -> Self { + let globs: Vec = extensions.iter().map(|e| any_case(e.as_ref(), CASE_SENSITIVE_GLOBS)).collect(); + self.add_filter(name, &globs) + } +} + +/// `ext` as a glob fragment matching any letter case: `pdf` becomes `[pP][dD][fF]`. +fn any_case(ext: &str, case_sensitive: bool) -> String { + if !case_sensitive { + return ext.to_owned(); + } + ext.chars() + .map(|c| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::any_case; + + #[test] + fn a_case_sensitive_picker_gets_a_glob_matching_every_case() { + assert_eq!(any_case("pdf", true), "[pP][dD][fF]"); + assert_eq!(any_case("PDF", true), "[pP][dD][fF]"); + assert_eq!(any_case("jp2", true), "[jJ][pP]2"); + assert_eq!(any_case("*", true), "*"); + } + + #[test] + fn a_case_blind_picker_gets_the_extension_unchanged() { + assert_eq!(any_case("pdf", false), "pdf"); + assert_eq!(any_case("*", false), "*"); + } +} diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index e28f20e232..993c6f953b 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -3,6 +3,8 @@ //! (images keep their proportions), double-click text to retype it, format it from the panel, //! Delete to remove it. Each change is one undoable engine edit. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_engine::{Added, AddedContent, AddedText, Edit}; use pdfcraft_render::DocInfo; @@ -849,7 +851,7 @@ impl crate::PdfCraftApp { Some(_) => {} None => { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .add_filter_any_case(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .set_title(title); let target = self.active_ids().map(|(_, id)| id); self.ask_one(crate::pickers::Ask::File(dialog), target, read); diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..0555655660 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -3,6 +3,7 @@ //! signature. Everything is an annotation (typewriter text, PdfCraft-drawn stamps, ink), so it //! can be moved, deleted and undone like any comment. +use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError}; use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, SignatureImage, SignaturePhoto, Style, Turn}; @@ -780,7 +781,7 @@ impl crate::PdfCraftApp { let epoch = self.dialog_epoch(); let initials = self.signature_draft.initials; let scan = self.signature_draft.scan_mode; - let dialog = rfd::AsyncFileDialog::new().add_filter(tl!(if scan { "Photo" } else { "Image" }), &["png", "jpg", "jpeg"]); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!(if scan { "Photo" } else { "Image" }), &["png", "jpg", "jpeg"]); #[cfg(not(target_arch = "wasm32"))] self.ask_one(crate::pickers::Ask::File(dialog), None, move |app, path| { if app.dialog_epoch() != epoch diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 5008464ad9..76fa91cdf4 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -1,4 +1,6 @@ //! Measure objects: page-space clicks, live readings and persistent drawing scales. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, page_cursor::Layer, theme::Tokens, widgets}; use egui::{Color32, Pos2, Stroke}; use pdfcraft_engine::{ @@ -613,7 +615,7 @@ fn export_csv(app: &mut PdfCraftApp) { match app.export_dir_override.as_ref().map(|d| std::path::PathBuf::from(d).join("measurements.csv")) { Some(path) => write(app, path), None => { - let dialog = rfd::AsyncFileDialog::new().set_file_name("measurements.csv").add_filter("CSV", &["csv"]); + let dialog = rfd::AsyncFileDialog::new().set_file_name("measurements.csv").add_filter_any_case("CSV", &["csv"]); app.ask_one(crate::pickers::Ask::Save(dialog), None, write); } } diff --git a/crates/ui-egui/src/tools/sign_ui.rs b/crates/ui-egui/src/tools/sign_ui.rs index b00d07279a..08ea0b15a3 100644 --- a/crates/ui-egui/src/tools/sign_ui.rs +++ b/crates/ui-egui/src/tools/sign_ui.rs @@ -2,6 +2,8 @@ //! click an empty signature field), the Sign with a Digital ID / Configure New Digital ID / //! Sign as dialogs, the Signatures panel, and the signature message bar. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use std::path::PathBuf; use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2}; @@ -501,7 +503,7 @@ impl PdfCraftApp { Some(p) => sign_and_save(self, PathBuf::from(p)), #[cfg(not(target_arch = "wasm32"))] None => { - let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem}_signed.pdf")); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(format!("{stem}_signed.pdf")); let dialog = crate::editing::in_document_folder(dialog, folder.as_deref()); // The signature's page, rectangle and field refer to the document as it is now: // the pick is dropped (with a notice) if it is edited or switched away from @@ -533,7 +535,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn pick_sign_graphic_image(&mut self) { let epoch = self.dialog_epoch(); - let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Image"), &["png", "jpg", "jpeg"]); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!("Image"), &["png", "jpg", "jpeg"]); self.ask_one(crate::pickers::Ask::File(dialog), None, move |app, path| { if app.dialog_epoch() != epoch || app.dialog != Some(crate::Dialog::Sign) { return; diff --git a/crates/ui-egui/src/tools/sign_ui/dialog.rs b/crates/ui-egui/src/tools/sign_ui/dialog.rs index feb4fdd207..e627f9a9f8 100644 --- a/crates/ui-egui/src/tools/sign_ui/dialog.rs +++ b/crates/ui-egui/src/tools/sign_ui/dialog.rs @@ -2,6 +2,8 @@ use super::{ Align, Appearance, AppearanceLabels, Color32, CornerRadius, KEY_ALGORITHMS, Layout, NewIdDraft, PdfCraftApp, Rect, SavedSig, SignGraphic, SignStep, Stroke, Tokens, icons, sign, theme, vec2, widgets, }; +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; /// Draw the signing dialogs; returns `true` when the dialog should close. pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { @@ -233,7 +235,7 @@ fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { // The picker answers on a later frame, into the draft if the same dialog is still open. #[cfg(not(target_arch = "wasm32"))] if browse { - let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Digital ID"), &["p12", "pfx"]); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!("Digital ID"), &["p12", "pfx"]); let epoch = app.dialog_epoch(); app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| { // Not one closed meanwhile, or opened again since. diff --git a/crates/ui-egui/src/tools/stamps_ui.rs b/crates/ui-egui/src/tools/stamps_ui.rs index 9cddb31d23..3db8d23ad5 100644 --- a/crates/ui-egui/src/tools/stamps_ui.rs +++ b/crates/ui-egui/src/tools/stamps_ui.rs @@ -1,6 +1,8 @@ //! Custom stamps (Add a stamp ▸ Custom stamps ▸ Create): a PDF page or an image saved in the //! user's stamp library under a category and a name, then placed like any stamp. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use std::sync::Arc; use egui::{Align, Layout}; @@ -145,7 +147,7 @@ impl PdfCraftApp { Some(_) => {} None => { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .add_filter_any_case(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .set_title(tl!("Select a file for the stamp")); self.ask_one(crate::pickers::Ask::File(dialog), None, read); } diff --git a/crates/ui-egui/src/workflows/actions_ui.rs b/crates/ui-egui/src/workflows/actions_ui.rs index 5ef299102f..313d55c5f2 100644 --- a/crates/ui-egui/src/workflows/actions_ui.rs +++ b/crates/ui-egui/src/workflows/actions_ui.rs @@ -1,6 +1,8 @@ //! Action Wizard: the actions (PdfCraft's and your own), running one over files in the //! background, and creating or editing an action's steps. Your actions are remembered. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use std::sync::{Arc, Mutex}; use egui::{Align, Layout}; @@ -186,7 +188,7 @@ impl PdfCraftApp { match self.action_files_override.clone() { Some(p) => run(self, p.into_iter().map(std::path::PathBuf::from).collect()), None => { - let dialog = rfd::AsyncFileDialog::new().set_title(title).add_filter("PDF", &["pdf"]); + let dialog = rfd::AsyncFileDialog::new().set_title(title).add_filter_any_case("PDF", &["pdf"]); self.ask(crate::pickers::Ask::Files(dialog), None, run); } } diff --git a/crates/ui-egui/src/workflows/create_ui.rs b/crates/ui-egui/src/workflows/create_ui.rs index 6dceb50657..177ccf6140 100644 --- a/crates/ui-egui/src/workflows/create_ui.rs +++ b/crates/ui-egui/src/workflows/create_ui.rs @@ -1,6 +1,8 @@ //! Create a PDF (blank, from images, from text) and Reduce File Size (execution plan M10.2, //! M11.1). Opening an image or a text file converts it to a new, unsaved PDF, as Acrobat does. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use std::sync::Arc; use crate::PdfCraftApp; @@ -154,7 +156,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .add_filter_any_case(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .set_title(tl!("Choose images")); self.ask(crate::pickers::Ask::Files(dialog), None, |app, files| { let mut images = Vec::new(); @@ -285,7 +287,7 @@ impl PdfCraftApp { match self.save_override.clone() { Some(p) => write(self, p), None => { - let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(&name); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(&name); let dialog = crate::editing::in_document_folder(dialog, folder.as_deref()); self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, p| write(app, p.to_string_lossy().into_owned())); } diff --git a/crates/ui-egui/src/workflows/files.rs b/crates/ui-egui/src/workflows/files.rs index a9313a4714..b33dd32cd2 100644 --- a/crates/ui-egui/src/workflows/files.rs +++ b/crates/ui-egui/src/workflows/files.rs @@ -3,6 +3,7 @@ //! Files are picked asynchronously and used on a later frame: desktop builds through `pickers` //! (a blocking picker crashes the app on macOS), browsers through `requests`. +use crate::file_filter::AddFilterAnyCase as _; use std::sync::Arc; use pdfcraft_engine::{DocId, Edit, SplitBy}; @@ -61,16 +62,16 @@ pub(crate) fn installed_font_files(ctx: &egui::Context) -> Option rfd::AsyncFileDialog { let dialog = rfd::AsyncFileDialog::new(); if matches!(purpose, FilePurpose::TextFont { .. }) { - return dialog.add_filter("TrueType / OpenType font", &["ttf", "otf"]); + return dialog.add_filter_any_case("TrueType / OpenType font", &["ttf", "otf"]); } if purpose == FilePurpose::RedactWords { - return dialog.add_filter(tl!("Text"), &["txt"]); + return dialog.add_filter_any_case(tl!("Text"), &["txt"]); } if !matches!(purpose, FilePurpose::CreateMultiple | FilePurpose::CreateMultipleSeparate | FilePurpose::InsertPages | FilePurpose::Combine) { - return dialog.add_filter("PDF", &["pdf"]); + return dialog.add_filter_any_case("PDF", &["pdf"]); } let all: Vec<&str> = std::iter::once("pdf").chain(pdfcraft_engine::CONVERTIBLE).collect(); - dialog.add_filter(tl!("PDF, images and text"), &all).add_filter("PDF", &["pdf"]) + dialog.add_filter_any_case(tl!("PDF, images and text"), &all).add_filter_any_case("PDF", &["pdf"]) } /// The Replace Pages dialog: the chosen file and the ranges (1-based, inclusive). @@ -898,7 +899,7 @@ impl PdfCraftApp { Some(_) => {} None => { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("Comment and form data").to_string(), &extensions) + .add_filter_any_case(tl!("Comment and form data").to_string(), &extensions) .set_title(tl!("Import data").to_string()); let target = self.active_ids().map(|(_, id)| id); self.ask_one(crate::pickers::Ask::File(dialog), target, import); @@ -939,7 +940,7 @@ impl PdfCraftApp { None => { let dialog = rfd::AsyncFileDialog::new() .set_title(tl!("Export").to_string()) - .add_filter(ext.to_uppercase(), &[ext]) + .add_filter_any_case(ext.to_uppercase(), &[ext]) .set_file_name(format!("{stem}.{ext}")); let dialog = crate::editing::in_document_folder(dialog, folder.as_deref()); self.ask_one(crate::pickers::Ask::Save(dialog), None, write); @@ -959,7 +960,7 @@ impl PdfCraftApp { { let dialog = rfd::AsyncFileDialog::new() .set_title(tl!("Select data files to merge").to_string()) - .add_filter(tl!("Form data and PDF forms").to_string(), &["fdf", "xfdf", "pdf"]); + .add_filter_any_case(tl!("Form data and PDF forms").to_string(), &["fdf", "xfdf", "pdf"]); self.ask(crate::pickers::Ask::Files(dialog), None, |app, paths| { let mut files = Vec::new(); for p in paths { @@ -1004,7 +1005,7 @@ impl PdfCraftApp { None => { let dialog = rfd::AsyncFileDialog::new() .set_title(tl!("Save the spreadsheet").to_string()) - .add_filter("CSV", &["csv"]) + .add_filter_any_case("CSV", &["csv"]) .set_file_name("report.csv"); self.ask_one(crate::pickers::Ask::Save(dialog), None, write); } @@ -1047,22 +1048,22 @@ impl PdfCraftApp { let title = if comments { tl!("Export comments") } else { tl!("Export form data") }; let d = rfd::AsyncFileDialog::new().set_title(title).set_file_name(format!("{stem}.xfdf")); let d = if comments { - d.add_filter("XFDF", &["xfdf"]).add_filter("FDF", &["fdf"]) + d.add_filter_any_case("XFDF", &["xfdf"]).add_filter_any_case("FDF", &["fdf"]) } else if xfa { // An XFA form's data, as Acrobat exports it, first. d.set_file_name(format!("{stem}.xml")) - .add_filter("XML", &["xml"]) - .add_filter("XDP", &["xdp"]) - .add_filter("XFDF", &["xfdf"]) - .add_filter("FDF", &["fdf"]) - .add_filter("CSV", &["csv"]) - .add_filter(tl!("Text").to_string(), &["txt"]) + .add_filter_any_case("XML", &["xml"]) + .add_filter_any_case("XDP", &["xdp"]) + .add_filter_any_case("XFDF", &["xfdf"]) + .add_filter_any_case("FDF", &["fdf"]) + .add_filter_any_case("CSV", &["csv"]) + .add_filter_any_case(tl!("Text").to_string(), &["txt"]) } else { - d.add_filter("XFDF", &["xfdf"]) - .add_filter("FDF", &["fdf"]) - .add_filter("XML", &["xml"]) - .add_filter("CSV", &["csv"]) - .add_filter(tl!("Text").to_string(), &["txt"]) + d.add_filter_any_case("XFDF", &["xfdf"]) + .add_filter_any_case("FDF", &["fdf"]) + .add_filter_any_case("XML", &["xml"]) + .add_filter_any_case("CSV", &["csv"]) + .add_filter_any_case(tl!("Text").to_string(), &["txt"]) }; let d = crate::editing::in_document_folder(d, folder.as_deref()); self.ask_one(crate::pickers::Ask::Save(d), None, write); diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 9145256297..7ea7a18602 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -3,6 +3,8 @@ //! (Size, Poster, Multiple, Booklet); orientation; comments & forms; and a live preview of the //! sheets. Printing sends the print-ready PDF to the system spooler; "Save as PDF" writes it. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2}; use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool}; @@ -551,7 +553,7 @@ impl PdfCraftApp { Some(p) => write(self, p.into()), None => { let stem = name.trim_end_matches(".pdf").trim_end_matches(".PDF"); - let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem} (print).pdf")); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(format!("{stem} (print).pdf")); self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, path| { write(app, path); }) From e93e8d50ba7082c4c6e60c4dd41791280bbb2506 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 13:15:43 -0500 Subject: [PATCH 14/63] fix(edit): keep a soft mask anchored when the edit is lifted over the scan (#3973) A soft mask is placed by the CTM in force when its `gs` runs (ISO 32000-2 11.6.5.2). The paragraph lifted over the scan re-selects the graphics state after its own `cm`, so a mask selected before a translation landed translated with it and could hide the new text. A re-emitted state that carries a dictionary /SMask now records its anchor CTM and is selected there: `cm` to the anchor, `gs`, `cm` back. The test edits the same paragraph with and without a mask and requires the same ink; it fails with the re-anchoring disabled (the mask lands 100 pt right and the text vanishes). Both rewrite paths. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/text/interp.rs | 54 ++++++++++++++++++++------ crates/edit/src/text/rewrite.rs | 4 +- crates/edit/src/text_regions/styled.rs | 3 +- crates/engine/tests/it/ocr_edit.rs | 53 +++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 13 deletions(-) diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index a9eb8a0c5f..61f82509b1 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -18,7 +18,7 @@ pub(super) struct TextState { /// graphics state the text was painted with that [`TextState::ops`] doesn't restate. pub(super) stroke: Vec, pub(super) line_width: Option, - ext_gstate: Vec>, + ext_gstate: Vec, /// The dash pattern and line cap in force (see [`Ts`]): compared, not restated. pub(super) dash: Dash, pub(super) line_cap: i64, @@ -74,13 +74,17 @@ impl TextState { /// /// A `gs` has no inverse, so a selection `current` has and this state doesn't stays in force: /// run these inside a `q`/`Q` that starts from `current`, one per state drawn. - pub(crate) fn paint_ops(&self, current: &TextState) -> Vec { + /// + /// `ctm` is the CTM where they run. A soft mask is placed by the CTM its `gs` ran at + /// (ISO 32000-2 §11.6.5.2), so one selected at another CTM is selected there and the CTM + /// set back. + pub(crate) fn paint_ops(&self, current: &TextState, ctm: [f64; 6]) -> Vec { let n = pdfcraft_content::num; let mut v = Vec::new(); // The selections both share stay in force; the ones after them are selected again in order. let shared = self.ext_gstate.iter().zip(¤t.ext_gstate).take_while(|(a, b)| a == b).count(); - for name in self.ext_gstate.iter().skip(shared) { - v.push(Op::new("gs", vec![Object::Name(name.clone())])); + for sel in self.ext_gstate.iter().skip(shared) { + sel.push_ops(&mut v, ctm); } // A reselected ExtGState may set `/LW`, so the width (already the effective one) follows it. let reselected = !v.is_empty(); @@ -242,7 +246,7 @@ pub(super) struct Ts { /// The line width a `w` or an ExtGState's `/LW` set (`None`: the initial 1.0). pub(super) line_width: Option, /// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]). - pub(super) ext_gstate: Vec>, + pub(super) ext_gstate: Vec, /// The dash pattern a `d` or an ExtGState's `/D` set (initially solid: `[] 0`). pub(super) dash: Dash, /// The line cap a `J` or an ExtGState's `/LC` set (initially 0, butt). @@ -275,12 +279,38 @@ const MAX_GS: usize = 32; /// Record `gs /name` in `list`. A `gs` sets every parameter its dictionary names to an absolute /// value, so a later selection of the same dictionary overrides the earlier one completely: the /// earlier entry goes, which keeps replaying the list equivalent to replaying the stream. -fn select_gs(list: &mut Vec>, name: &[u8]) { - list.retain(|n| n != name); +fn select_gs(list: &mut Vec, name: &[u8], anchor: Option<[f64; 6]>) { + list.retain(|s| s.name != name); if list.len() >= MAX_GS { list.remove(0); } - list.push(name.to_vec()); + list.push(GsSelection { name: name.to_vec(), anchor }); +} + +/// One `gs` selection: the ExtGState's resource name, and the CTM it ran at when the dictionary +/// has a soft mask, which that CTM places (ISO 32000-2 §11.6.5.2). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GsSelection { + name: Vec, + anchor: Option<[f64; 6]>, +} + +impl GsSelection { + /// Select this ExtGState from where the CTM is `ctm`: at its anchor, then back to `ctm`. Both + /// moves need an invertible matrix; without one it is selected where it runs. + fn push_ops(&self, v: &mut Vec, ctm: [f64; 6]) { + let gs = Op::new("gs", vec![Object::Name(self.name.clone())]); + let moves = self.anchor.filter(|a| *a != ctm).and_then(|a| { + let (anchor, here) = (Matrix(a), Matrix(ctm)); + Some((anchor.then(&here.invert()?), here.then(&anchor.invert()?))) + }); + let Some((to, back)) = moves else { + v.push(gs); + return; + }; + let cm = |m: Matrix| Op::new("cm", m.0.iter().map(|x| pdfcraft_content::num(*x)).collect()); + v.extend([cm(to), gs, cm(back)]); + } } /// The text-showing operators of one stream with their text, font, box and baseline. @@ -898,10 +928,12 @@ pub(super) fn interpret( b"Ts" => ts.rise = op.num(0).unwrap_or(0.0), b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1), b"gs" => { - if let Some(name) = op.name(0) { - select_gs(&mut ts.ext_gstate, name); - } let params = op.name(0).and_then(|name| states.get(name)).map(|p| doc.resolve(p)); + if let Some(name) = op.name(0) { + let masked = + params.as_deref().and_then(Object::as_dict).and_then(|p| p.get(b"SMask")).is_some_and(|m| doc.resolve(m).as_dict().is_some()); + select_gs(&mut ts.ext_gstate, name, masked.then_some(ts.ctm.0)); + } if let Some(params) = params.as_deref().and_then(Object::as_dict) { ts.gs.apply(doc, params); // `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5). diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 6bf3d89784..d27d23e0b5 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -702,11 +702,13 @@ fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, let mut ops = vec![Op::new("q", vec![])]; ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); // Moved: a translation inside that state. + let mut ctm = Matrix(plan.emit.ctm); if let Some((ax, ay)) = plan.movement { ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); + ctm = Matrix([1.0, 0.0, 0.0, 1.0, ax, ay]).then(&ctm); } // Painted as the paragraph was, including what its text object set before the first show. - ops.extend(plan.state.paint_ops(plan.after)); + ops.extend(plan.state.paint_ops(plan.after, ctm.0)); ops.push(Op::new("BT", vec![])); let mut state = plan.state.clone(); state.word_spacing = 0.0; diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 5ddb1064fb..7a9204a8c0 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1001,6 +1001,7 @@ pub(crate) fn rewrite_styled_block( .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; let (tx, ty) = back.apply(dx, dy); block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)])); + let run_ctm = Matrix([1.0, 0.0, 0.0, 1.0, tx, ty]).then(&Matrix(o.ctm)).0; // A new row can land one leading above the next paragraph and look like the same // paragraph. When every row is in this rewrite, a paragraph marker keeps them in one // scope so the neighbour stays out (#2517). A row left in the source stream cannot share @@ -1098,7 +1099,7 @@ pub(crate) fn rewrite_styled_block( // object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. block_ops.push(Op::new("q", vec![])); - block_ops.extend(state.paint_ops(drawn_from)); + block_ops.extend(state.paint_ops(drawn_from, run_ctm)); if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; let mut properties = Dict::new(); diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index a51ce8ddcb..53ca309326 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -216,6 +216,11 @@ fn ocr_under_scan(text: &str) -> std::sync::Arc> { /// A page whose `text` content is followed by the opaque 200 × 100 scan image. fn under_scan(text: &str) -> std::sync::Arc> { + under_scan_with(text, |_| pdfcraft_cos::Dict::new()) +} + +/// As [`under_scan`], with the page's ExtGState resources made by `ext_gstates`. +fn under_scan_with(text: &str, ext_gstates: impl FnOnce(&mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict) -> std::sync::Arc> { use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; let (w, h) = (200u32, 100u32); let mut rgb = Vec::new(); @@ -248,6 +253,7 @@ fn under_scan(text: &str) -> std::sync::Arc> { let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + res.set(b"ExtGState".to_vec(), Object::Dict(ext_gstates(&mut d))); let mut p = Dict::new(); p.set(b"Type".to_vec(), Object::name("Page")); p.set(b"Parent".to_vec(), Object::Ref(pages)); @@ -343,3 +349,50 @@ fn an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit() { assert_edit_shows(&s, id, &format!("{edit:?} split")); } } + +/// `/SM`: a luminosity soft mask that is white over 30..110 × 30..70 in the space it is selected +/// in, black elsewhere. +fn soft_mask_state(d: &mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict { + use pdfcraft_cos::{Dict, Object, Stream}; + let mut group = Dict::new(); + group.set(b"S".to_vec(), Object::name("Transparency")); + group.set(b"CS".to_vec(), Object::name("DeviceRGB")); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec())); + form.set(b"Group".to_vec(), Object::Dict(group)); + let mut mask = Dict::new(); + mask.set(b"Type".to_vec(), Object::name("Mask")); + mask.set(b"S".to_vec(), Object::name("Luminosity")); + mask.set(b"G".to_vec(), Object::Ref(d.add(Object::Stream(Stream::flate(form, b"1 g 30 30 80 40 re f"))))); + let mut state = Dict::new(); + state.set(b"Type".to_vec(), Object::name("ExtGState")); + state.set(b"SMask".to_vec(), Object::Dict(mask)); + let mut states = Dict::new(); + states.set(b"SM".to_vec(), Object::Dict(state)); + states +} + +/// The soft mask is selected before a translation, so it is placed by the CTM before it. The +/// paragraph lifted over the scan selects it again after its own `cm`: there it must be placed +/// as it was, not 100 pt to the right where it would hide the new text, so the edit renders +/// exactly as the same edit on the page without the mask. Both rewrite paths. +#[test] +fn a_soft_mask_selected_before_a_cm_stays_put_when_the_edit_is_lifted() { + for edit in ["Jane", "Jane\nDoe"] { + let mut plain = Session::new(); + let page = under_scan("q 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q"); + let plain_id = plain.open("plain.pdf", None, page, None).unwrap(); + edit_block(&mut plain, plain_id, 0, edit); + let mut s = Session::new(); + let page = under_scan_with("q /SM gs 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q", soft_mask_state); + let id = s.open("masked.pdf", None, page, None).unwrap(); + assert_eq!(texts(&s, id), ["John"]); + edit_block(&mut s, id, 0, edit); + assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}"); + assert_edit_shows(&s, id, &format!("{edit:?} masked")); + let ink = |s: &Session, id| word_pixels(s, id).iter().filter(|p| near(**p, INK)).count(); + assert_eq!(ink(&s, id), ink(&plain, plain_id), "{edit:?}: the mask does not hide the new text"); + } +} From 56578a6006ebce754bac22b13389ea1f8319620c Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 13:42:16 -0500 Subject: [PATCH 15/63] M3: test what moves an OCR edit on top: clipped, two images, form (#3973) Review asked for text under and above a scan (already pinned), an image clipped away entirely, two images, and a form holding the image. The clipped case fails when the hit test uses the image's box instead of its visible box; the negative cases fail when any later XObject counts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/tests.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index deb4d67a7c..2b78b07113 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6214,16 +6214,17 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { } /// Invisible text moves on top only when something painted after it covers it: an XObject drawn -/// over its box, or any inline image or shading. +/// over its box (an image, or a form holding one), or any inline image or shading. An image +/// clipped away entirely, or drawn elsewhere, does not move it. #[test] fn revealed_text_moves_on_top_only_when_painted_over() { - let on_top = |content: &str| { - let doc = image_page_with(content); + let on_top_of = |doc: Document| { let block = text::text_blocks(&doc, 0).unwrap().remove(0); let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; let at = ops.iter().position(|o| o.is("BT")).unwrap(); revealed_under_paint(&doc, 0, block.invisible, block.rect, &ops, at) }; + let on_top = |content: &str| on_top_of(image_page_with(content)); let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; assert!(on_top(&format!("BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET {scan}")), "the scan covers it"); assert!(!on_top(&format!("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET {scan}")), "an image elsewhere"); @@ -6232,6 +6233,29 @@ fn revealed_text_moves_on_top_only_when_painted_over() { assert!(!on_top("BT /F1 12 Tf 3 Tr 110 150 Td (Path) Tj ET 0 0 600 800 re f"), "a filled path does not move it"); assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Shade) Tj ET /Sh sh"), "a shading after it"); assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Inline) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 ID x EI"), "an inline image after it"); + let under = "BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET"; + let elsewhere = "q 50 0 0 50 400 600 cm /Im0 Do Q"; + assert!(!on_top(&format!("{under} q 0 0 10 10 re W n {scan} Q")), "the scan is clipped away entirely"); + assert!(on_top(&format!("{under} {elsewhere} {scan}")), "two images, the second over it"); + assert!(on_top(&format!("{under} {scan} {elsewhere}")), "two images, the first over it"); + assert!(!on_top(&format!("{under} {elsewhere} {elsewhere}")), "two images, neither over it"); + let in_form = |content: &str| { + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> >> >>"; + let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"); + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im0 6 0 R >> >>", "/Im0 Do"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + page.into(), + stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + image, + form, + ]; + on_top_of(build(&objs.iter().map(String::as_str).collect::>())) + }; + assert!(in_form(&format!("{under} q 200 0 0 100 100 100 cm /Fm0 Do Q")), "a form holding the scan, over it"); + assert!(!in_form(&format!("{under} q 50 0 0 50 400 600 cm /Fm0 Do Q")), "a form holding an image elsewhere"); } /// Revealed OCR text is checked where the rewrite lands, not only where it was: an image drawn From 12e17a1f648e31fbcc831536d144639b0279f503 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:52:29 +0100 Subject: [PATCH 16/63] refactor(ui): crop mode's hit is a hit test of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crop mode owns every press on its page, the controls beside its frame included (0595d4d39). `PageHits::at` made that hit itself. A `Hit` comes from a module's hit test, named `hit` or `…_hit`, so the cursor, the tip and the press agree, and the Page presses guard holds the page modules to it. The picture module's `crop_hit` makes it now, and `PageHits::at` asks it first, as before. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 12 +++--------- crates/ui-egui/src/tools/edit_text_ui.rs | 3 ++- crates/ui-egui/src/tools/edit_text_ui/image.rs | 8 ++++++++ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56d2674213..b281d044a5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1804,18 +1804,12 @@ impl PageHits<'_> { images, clip, } = *self; - // Crop mode owns presses on its page, including the controls beside its - // frame. Underlying paragraphs and the empty-page marquee must not take - // a crop button's press before the image module receives it. + // Crop mode owns every press on its page (`edit_text_ui::crop_hit`). if tool == QuickTool::EditText && can_modify - && let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some()) + && let Some(crop) = crate::edit_text_ui::crop_hit(view, page) { - return vec![crate::page_router::Hit::new( - crate::page_router::Owner::Picture { index: selection.index, part: crate::page_router::PicturePart::Body }, - crate::page_cursor::Layer::Handle, - egui::CursorIcon::Default, - )]; + return vec![crop]; } let fields = if preparing { crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 19a165c152..9100c6d603 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -23,7 +23,8 @@ mod layout; pub(crate) use background::TextBackground; pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id}; pub(crate) use image::{ - ImageCx, Matrix, body_hit as picture_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, user_to_screen, + ImageCx, Matrix, body_hit as picture_hit, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, + user_to_screen, }; #[cfg(test)] use image::{rotate_knob, turn_degrees, turn_degrees_delta}; diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index 281f7de80e..53d3fb1483 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -519,6 +519,14 @@ pub(crate) fn handle_hit( Some(Hit::new(picture(PicturePart::Corner(grip)), Layer::Handle, obb.handle_cursor(handle))) } +/// The picture being cropped on `page`: crop mode owns every press there, the controls beside its +/// frame included, so the paragraphs under it and the empty page's marquee never take a crop +/// button's press before the picture does. `None` when no picture on `page` is being cropped. +pub(crate) fn crop_hit(view: &DocView, page: usize) -> Option { + let selection = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())?; + Some(Hit::new(Owner::Picture { index: selection.index, part: PicturePart::Body }, Layer::Handle, egui::CursorIcon::Default)) +} + /// The picture under `p` (its shape, not only its box): a click selects it, a drag moves the /// selected one. On the text layer, asked before the paragraphs: editable text on top of a /// picture takes the press. From 0cbd61d611568ba176b148369e9efac2632689cd Mon Sep 17 00:00:00 2001 From: alehel Date: Sat, 31 Oct 2026 08:03:36 +0100 Subject: [PATCH 17/63] test(ui): one source-scan guard for the Page presses rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5426 took the page pointer router from #5300 at 11bc8c56, before #5300 added its guard. The guard on main checks seven named press handlers, eight hit tests called by path and one `router.press(` caller. Planted inside `#[cfg(any())]`, it caught one of five violations: a second `link_hit` in `page_tool_input`, a bare `hit(` in comments' `page_after_text`, `view.router.cancel()` and `press_cancelled = true` in redact_ui all passed. This brings in #5300's rules beside main's press-handler check, in one test, `page_modules_act_on_a_press_only_through_the_router` (from aef301d82): - only `page_tool_input`, `draw_pages` and `cancel_pointer_gestures` press, settle or cancel the router; - only `cancel_on_escape` ignores a press until the button is up; - every `hit` and `…_hit` call, bare or by path, is in a function allowed to make one, with why; - a `Hit` is made only in a hit test; - an allowlist entry that is no longer used fails the test. It caught all five planted violations, and a stale ROUTER_WRITERS entry. CLAUDE.md's "Page presses" line names the test. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 146 +++++++++++++++--- 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e9328f8d3..7b1d7f5023 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. -- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 2cbde2b995..e92d4c01df 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -263,13 +263,11 @@ fn uses(line: &str, name: &str) -> bool { line.match_indices(name).any(|(at, _)| name.starts_with('.') || !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) } -/// Each use of one of `names` in the page modules' code (their tests left out) outside the -/// functions `allowed` to, as "src/file:line: `name` why"; then each allowed function that uses -/// none of them any more, as "src/file: fn function unused". -fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec { +/// Each name `find` finds in a line of the page modules' code (their tests left out), as (file, +/// the top-level function or `impl` method it is in, line, name). +fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, Option, usize, String)> { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut problems = Vec::new(); - let mut needed = vec![false; allowed.len()]; + let mut found = Vec::new(); for file in PAGE_MODULES { let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); // Tests drive egui's input and read its output themselves. @@ -287,14 +285,36 @@ fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &s if line.trim_start().starts_with("//") { continue; } - for name in names.iter().filter(|name| uses(line, name)) { - match allowed.iter().position(|(f, function_name, _)| *f == file && function == Some(*function_name)) { - Some(k) => needed[k] = true, - None => problems.push(format!("src/{file}:{}: `{name}` {why}", n + 1)), - } + for name in find(line) { + found.push((file, function.map(str::to_owned), n + 1, name)); } } } + found +} + +/// Each use of one of `names` in the page modules' code (their tests left out). +fn page_module_uses(names: &[&str]) -> Vec<(&'static str, Option, usize, String)> { + page_module_matches(|line| names.iter().filter(|name| uses(line, name)).map(|name| (*name).to_owned()).collect()) +} + +/// Each use of one of `names` in the page modules' code (their tests left out) outside the +/// functions `allowed` to, as "src/file:line: `name` why"; then each allowed function that uses +/// none of them any more, as "src/file: fn function unused". +fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec { + misuses_in(page_module_uses(names), allowed, why, unused) +} + +/// [`misuses`], of what was `found`. +fn misuses_in(found: Vec<(&str, Option, usize, String)>, allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec { + let mut problems = Vec::new(); + let mut needed = vec![false; allowed.len()]; + for (file, function, n, name) in found { + match allowed.iter().position(|(f, function_name, _)| *f == file && function.as_deref() == Some(*function_name)) { + Some(k) => needed[k] = true, + None => problems.push(format!("src/{file}:{n}: `{name}` {why}")), + } + } for ((file, name, _), needed) in allowed.iter().zip(needed) { if !needed { problems.push(format!("src/{file}: fn {name} {unused}")); @@ -362,18 +382,68 @@ fn page_modules_claim_the_cursor_and_never_set_it() { assert!(problems.is_empty(), "{}", problems.join("\n")); } -/// Hit producers are called only by the central routing table, not again by an input handler. -const PAGE_HIT_CALLS: [&str; 8] = [ +/// What decides, keeps or drops the press the pages hold. +const ROUTER_WRITES: [&str; 3] = ["router.press(", "router.settle(", "router.cancel("]; + +/// The functions that decide, keep or drop the press the pages hold: (file, function, why). +const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ + ("canvas.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), + ("canvas.rs", "draw_pages", "the press is over once the button is up and its release seen, and a new one drops the last owner"), + ("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, a dialog, automation's cancel"), +]; + +/// Ways to make a `Hit`. +const HIT_BUILDS: [&str; 2] = ["Hit::new(", "Hit {"]; + +/// Whether `name` is a hit test's: `hit`, or `…_hit`. +fn is_hit_test(name: &str) -> bool { + name == "hit" || name.ends_with("_hit") +} + +/// The hit tests `line` calls, by path or bare. A method, after its `.`, is left out: +/// `PageCx::hit` finds the comment under a point, not a `Hit`. +fn hit_test_calls(line: &str) -> Vec { + line.match_indices("hit(") + .filter_map(|(at, _)| { + let end = at + "hit".len(); + let start = line[..end].char_indices().rev().find(|(_, c)| !(c.is_alphanumeric() || *c == '_')).map_or(0, |(i, c)| i + c.len_utf8()); + let (before, name) = (&line[..start], &line[start..end]); + (is_hit_test(name) && !before.ends_with('.') && !before.ends_with("fn ")).then(|| name.to_owned()) + }) + .collect() +} + +/// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the +/// press point; the others ask where the pointer is. +const HIT_TESTERS: [(&str, &str, &str); 4] = [ + ("canvas.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), + ("canvas.rs", "page_overlays", "a link's click counts only released over the link its press was on"), + ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), + ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), +]; + +/// What ignores the press the pages hold until the button is up. +const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; + +/// The one function that ignores a press until the button is up: (file, function, why). +const PRESS_IGNORERS: [(&str, &str, &str); 1] = [("canvas.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")]; + +/// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who +/// may). +const PAGE_HIT_CALLS: [&str; 9] = [ "prepare::hit(", "forms_ui::hit(", "comments::hit(", "edit_text_ui::hit(", "edit_text_ui::picture_hit(", "edit_text_ui::picture_handle_hit(", + "edit_text_ui::crop_hit(", "object_ui::hit(", "content_ui::hit(", ]; +/// Whether a press handler's `code` acts on the owner the router captured, and calls no hit test +/// of its own to choose one. fn routed_press_handler(code: &str) -> bool { let code = code.lines().filter(|line| !line.trim_start().starts_with("//")).collect::>().join("\n"); code.contains("router.owner(") @@ -383,21 +453,30 @@ fn routed_press_handler(code: &str) -> bool { && !uses(&code, "handle_hit(") } -/// Source-level guard for the Page presses rule, alongside the raw-pointer and cursor guards. -/// Runtime routing, capture lifetime and Escape behavior have separate behavioral tests. +/// Page modules act on a press only when the router says it is theirs. The hits at the press +/// point decide its owner once, in one place, and a `Hit` comes only from a module's hit test, +/// so what the cursor promised is what the press does. Where each module tested the press +/// point again, or asked another whether it wanted the press, the answers disagreed: a press on +/// one check box released over the next ticked the second, and a drag could be taken over +/// halfway. Only `PageHits::at` asks the hit tests at the press point. Escape is the one way to +/// drop a press and ignore it until the button is up. #[test] -fn page_modules_take_presses_from_the_router_without_repeating_hit_tests() { +fn page_modules_act_on_a_press_only_through_the_router() { let mut problems = misuses( - &PAGE_HIT_CALLS, - &[("canvas.rs", "at", "the central PageHits table")], - "repeats a module hit test outside PageHits::at; use the captured router owner", - "no longer collects module hits", + &ROUTER_WRITES, + &ROUTER_WRITERS, + "decides, keeps or drops the press the pages hold. Only the hits at the press point decide its owner, once \ + (src/canvas/page_router.rs): a page module reads `view.router.owner(page)`. Drop a press with \ + `DocView::cancel_pointer_gestures`. A new place that must? Add the function to ROUTER_WRITERS in \ + src/canvas/page_pointer/tests.rs, with why.", + "no longer decides, keeps or drops the press: remove it from ROUTER_WRITERS.", ); problems.extend(misuses( - &["router.press("], - &[("canvas.rs", "page_tool_input", "the central press capture")], - "captures a page press outside the central routing path", - "no longer captures page presses", + &PRESS_IGNORES, + &PRESS_IGNORERS, + "ignores the press the pages hold. Escape cancels a press, in `cancel_on_escape`: drop a gesture with \ + `DocView::cancel_pointer_gestures` instead.", + "no longer ignores a press: remove it from PRESS_IGNORERS.", )); let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); for (file, function) in [ @@ -425,6 +504,25 @@ fn page_modules_take_presses_from_the_router_without_repeating_hit_tests() { problems.push(format!("src/{file}: {function} must consume the captured router owner without repeating a hit test")); } } + // A hit test is a function named `hit` or `…_hit`: one answer to "what of mine is here?", + // which gives the module's cursor, its tip and its press. + problems.extend(misuses_in( + page_module_matches(hit_test_calls), + &HIT_TESTERS, + "calls a hit test. The hits at the press point decide its owner once, in `PageHits::at`: act on the press when \ + `view.router.owner(page)` says it is yours, rather than testing its point again. Testing where the pointer \ + is (a tooltip, a click released over what it was pressed on)? Add the function to HIT_TESTERS in \ + src/canvas/page_pointer/tests.rs, with why.", + "calls no hit test any more: remove it from HIT_TESTERS.", + )); + let builds = page_module_uses(&HIT_BUILDS); + assert!(!builds.is_empty(), "no page module makes a `Hit` any more: update HIT_BUILDS"); + problems.extend(builds.into_iter().filter(|(_, function, _, _)| !function.as_deref().is_some_and(is_hit_test)).map(|(file, _, n, name)| { + format!( + "src/{file}:{n}: `{name}` makes a hit outside a hit test. A page module answers \"what of mine is \ + here?\" in one function, named `hit` or `…_hit`, so its cursor, its tip and its press agree." + ) + })); assert!(problems.is_empty(), "{}", problems.join("\n")); } From 71d222cacf118eeb343c608d93668f618bff8fbb Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 11:01:16 +0100 Subject: [PATCH 18/63] refactor(ui): move page input out of canvas.rs into canvas/page_input.rs What the tools do with the pointer on one page now lives in its own file: `PageInput`, `PageToolContext` and `page_tool_input`; `PageHits`, `edit_inventory`, `link_hit` and `cancel_on_escape`; the text layer, `text_layer` to `finish_text_pointer_gesture`; and `page_overlays`, which follows links and picks images. `cached_edit_images` stays in canvas.rs, where painting and the menus use it too. No change in behaviour. canvas.rs goes from 4,073 to 3,265 lines. The guard's page modules gain `canvas/page_input.rs`, and its allowlist entries move with the functions. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 810 +---------------- crates/ui-egui/src/canvas/page_input.rs | 815 ++++++++++++++++++ .../ui-egui/src/canvas/page_pointer/tests.rs | 12 +- 3 files changed, 826 insertions(+), 811 deletions(-) create mode 100644 crates/ui-egui/src/canvas/page_input.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b281d044a5..289edf811d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,7 +12,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand use pdfcraft_engine::{DocId, Edit}; use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; -use crate::page_cursor::{Layer, Subject}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; @@ -28,6 +28,7 @@ pub(super) mod image_pick; mod navigation; mod organize; pub(super) mod page_cursor; +mod page_input; pub(super) mod page_pointer; pub(super) mod page_router; pub mod scroll_speed; @@ -63,6 +64,8 @@ pub(crate) use organize::grid_page_rect; use organize::organize_grid; #[cfg(test)] use organize::sharp_enough; +pub(crate) use page_input::PageToolContext; +use page_input::{PageInput, cancel_on_escape, page_overlays, page_tool_input, text_layer}; use selection::{Selection, Span, click_unit, selection_actions}; pub use xform::PageXform; use xform::snap_to_pixels; @@ -1444,308 +1447,6 @@ fn paint_page_tiles( } } -/// What the page's tools did with the pointer this frame. -struct PageInput { - /// A tool, a field or a comment took the pointer: text selection must leave it alone. - consumed: bool, - /// Redact or Highlight draws boxes off text. - area_tool: bool, -} - -/// Common geometry and pointer state for a tool acting on one page. -#[derive(Clone, Copy)] -pub(crate) struct PageToolContext<'a> { - pub ui: &'a egui::Ui, - pub resp: &'a egui::Response, - pub xf: &'a PageXform, - pub ptr: &'a PagePointer, - pub page: usize, - pub doc: &'a pdfcraft_engine::Document, - pub info: &'a DocInfo, -} - -/// Comments: tools, selection, moving and resizing come before text selection. Form fields take -/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then -/// comments; then text selection. -fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { - let PageEnv { - added, - allowed, - author, - by_line, - can_crop, - can_fill, - can_modify, - comments_hidden, - custom_stamp, - date_text, - doc, - doc_links, - editing_content, - form, - info, - initials, - prefs, - preparing, - redact_mark, - signature, - table_preview, - text_style, - tool, - units, - fill_text_size, - fill_text_color, - .. - } = frame.env; - let PageSlot { i, xf, .. } = *slot; - let PageFrame { resp, ptr, pointer, .. } = *frame; - let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - let (lines, images) = - if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; - let field_tool = match tool { - QuickTool::Field(f) => Some(f), - _ => None, - }; - // While a table is being set up, nothing else is drawn or moved. - let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); - let hits = PageHits { - page: i, - xf: &xf, - info, - tool, - pcx: &pcx, - form, - can_fill, - can_modify, - editing_content, - preparing, - field_tool, - prepare_allowed, - added, - lines: &lines, - images: &images, - clip: ui.clip_rect(), - }; - if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { - let at = hits.at(view, o); - view.router.press(i, at, tool); - } - if let QuickTool::Measure(measure_tool) = tool { - crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); - } - - // Form fields take clicks first with the Select tool (as Acrobat fills fields in - // every viewing mode); then comments; then text selection. - if let QuickTool::Stamp(kind) = tool - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { - // Centred on the click, upright as the page is shown. - let (vx, vy) = xf.screen_to_view(p); - let (w, h) = kind.size(); - let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; - let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); - let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; - let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); - let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; - view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { - page: i, - style: pdfcraft_engine::Style::default_for(&shape), - shape, - contents: String::new(), - author: author.clone(), - })); - frame.out.stamp_placed = true; - } - } - if let Some(cs) = custom_stamp.as_ref() - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { - // Centred on the click at its natural size (the engine sizes it). - let (vx, vy) = xf.screen_to_view(p); - let u = info.pages[i].view_to_user(vx, vy); - let (x, y) = (u[0] as f64, u[1] as f64); - view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { - page: i, - rect: [x, y, x, y], - name: cs.name.clone(), - file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, - author: author.clone(), - }); - frame.out.stamp_placed = true; - } - } - // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). - if let QuickTool::Fill(ft) = tool - && allowed - && !comments::fill_grabs(&pcx, view) - { - match crate::fill_sign::page_input( - cx, - crate::fill_sign::PageInput { - tool: ft, - text_size: fill_text_size, - text_color: fill_text_color, - signature: signature.as_ref(), - initials: initials.as_ref(), - preview: frame.signature_preview, - author, - date_text, - }, - view, - ) { - Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), - Some(crate::fill_sign::FillAction::Signature(e)) => { - view.pending_edit = Some(*e); - view.fill_signature_page = Some(i); - } - Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, - Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, - Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), - None => {} - } - } - if matches!(tool, QuickTool::SignArea { .. }) { - crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view); - } - if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { - crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify); - } - if tool == QuickTool::Crop - && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop }) - { - view.current = i; - frame.out.open_boxes = true; - } - let on_field = if preparing { - let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); - frame.out.field_props |= o.properties; - frame.out.field_placed |= o.placed; - frame.out.open_table = frame.out.open_table.or(o.table); - o.consumed || field_tool.is_some() - } else { - tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view) - }; - // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). - let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; - let boxing = area_tool && { - let text = view.page_text(i); - let over_text = |p: Pos2| { - let (vx, vy) = xf.screen_to_view(p); - text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) - }; - crate::redact_ui::page_input(cx, over_text, view) - }; - let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); - // Add text acts on the page's own paragraphs over them, and on the one its press was on until - // the button is up: a drag that leaves the box still ends, and no new box takes it over. - let over_source = tool == QuickTool::AddText - && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) - || pointer.is_some_and(|p| { - !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p) - })); - let source_blocked = over_source && view.content.hold_blocked(); - if over_source && resp.clicked() && !source_blocked { - if let Some(edit) = view.content.finish_draft(added) { - view.pending_edit = Some(edit); - } - view.content.selected = None; - } - let on_content = !on_objects && editing_content && can_modify && { - source_blocked - || crate::content_ui::page_input( - cx, - added, - tool == QuickTool::AddText && !over_source, - matches!(tool, QuickTool::AddText | QuickTool::EditText), - text_style, - view, - ) - || tool == QuickTool::AddText && !over_source - }; - // Added content owns its gesture; its source text must not queue a second edit. - // Edit text & images boxes every paragraph and image whenever the tool is on. Only the - // input waits while selected objects or added content own the pointer, or while page - // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn - // instead of vanishing until the pointer comes back. - let others_own_pointer = on_objects || on_content; - let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { - // Editable text takes precedence over background/overlapping artwork. Both - // inventories are painted every frame, even when text owns the pointer. - let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer); - let image_cx = crate::edit_text_ui::ImageCx { - ui, - resp, - xf: &xf, - ptr, - page: i, - info, - allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), - }; - let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); - !others_own_pointer && (text_used || image_used) - }; - if tool == QuickTool::EditText { - if others_own_pointer { - if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() { - view.image_selection = None; - } - } else { - crate::content_ui::sync_image_selection(view, i, &images, added); - } - } - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view); - if tool == QuickTool::Select - && resp.secondary_clicked() - && can_modify - && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) - && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) - { - view.links.context = Some((i, l.index)); - } - let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); - // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the - // last claim on a layer stands). - if frame.env.unobstructed - && let Some(p) = pointer - { - for h in hits.at(view, p) { - view.cursor.claim_with_tip(h.layer, h.icon, h.tip); - } - } - PageInput { consumed, area_tool } -} - -/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. -fn edit_inventory( - view: &mut DocView, - doc: &pdfcraft_engine::Document, - i: usize, -) -> (Vec, Vec) { - let generation = doc.edit_generation(); - let lines = match view.edit_lines.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.text_blocks(i); - view.edit_lines.insert(i, (generation, l.clone())); - l - } - }; - let images = cached_edit_images(view, doc, i).to_vec(); - (lines, images) -} - /// Images parsed once per page and document edit generation, shared by painting and menus. fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document, page: usize) -> &'a [pdfcraft_engine::PageImage] { let generation = doc.edit_generation(); @@ -1756,472 +1457,6 @@ fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document &entry.1 } -/// What the page modules have under a point on one page, for the page router. -struct PageHits<'a> { - page: usize, - xf: &'a PageXform, - info: &'a DocInfo, - tool: QuickTool, - pcx: &'a comments::PageCx<'a>, - form: &'a [pdfcraft_engine::FormField], - can_fill: bool, - can_modify: bool, - /// Edit a PDF is open: added text and pictures can be picked up. - editing_content: bool, - preparing: bool, - field_tool: Option, - /// Prepare may draw and move fields (not while a table is being set up). - prepare_allowed: bool, - added: &'a [pdfcraft_engine::Added], - lines: &'a [pdfcraft_engine::TextBlock], - images: &'a [pdfcraft_engine::PageImage], - /// The pages' visible area (the picture's rotate knob stays inside it). - clip: Rect, -} - -impl PageHits<'_> { - /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text - /// on top of artwork takes the press), added content, the objects selected together and the - /// open paragraph's frame, the selected picture's grips over them all; then form fields - /// (Prepare's, being moved, over the comments; filled ones under added content and the - /// comments that move). - fn at(&self, view: &DocView, p: Pos2) -> Vec { - let PageHits { - page, - xf, - info, - tool, - pcx, - form, - can_fill, - can_modify, - editing_content, - preparing, - field_tool, - prepare_allowed, - added, - lines, - images, - clip, - } = *self; - // Crop mode owns every press on its page (`edit_text_ui::crop_hit`). - if tool == QuickTool::EditText - && can_modify - && let Some(crop) = crate::edit_text_ui::crop_hit(view, page) - { - return vec![crop]; - } - let fields = if preparing { - crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) - } else if tool == QuickTool::Select { - crate::forms_ui::hit(xf, info, page, form, can_fill, p) - } else { - None - }; - // With Add text, only the paragraphs and pictures not under added content. - let editing = - can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p)); - let (pictures, paragraphs, objects, grips) = if editing { - let open = view.line_editor.as_ref().filter(|e| e.page == page); - let frame = open - .filter(|e| !e.has_changes()) - .and_then(|e| lines.get(e.block)) - .map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)); - // Add text acts on the page's paragraphs only in their boxes, edges included (it - // finishes the box being typed there, `over_source`): not on pictures, nor beside a - // paragraph, where a click starts a new box. - let edit_text = tool == QuickTool::EditText; - let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); - ( - edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(), - on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(), - edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(), - edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(), - ) - } else { - (None, None, None, None) - }; - // Added text and pictures, over the page's own paragraphs and under the objects selected - // together. - let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); - let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); - [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] - .into_iter() - .flatten() - .collect() - } -} - -/// Text layer: find matches, selection, I-beam and drag-to-select. -fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { - let PageEnv { info, selects_text, tool, .. } = frame.env; - let PageSlot { i, r, xf } = *slot; - let PageFrame { resp, ptr, pointer, .. } = *frame; - if let Some(text) = view.texts.get(&i).cloned() { - paint_text_marks(ui, view, &text, i, xf, resp.dragged()); - if selects_text - && !input.consumed - && let Some(p) = pointer.filter(|p| r.contains(*p)) - { - text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p }); - } - } -} - -fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) { - let painter = ui.painter(); - let to_screen = |g: [f32; 4]| xf.view_rect(g); - if let Some(find) = &view.find { - for (index, (match_page, range)) in find.matches.iter().enumerate() { - if *match_page != page { - continue; - } - let fill = if find.current == Some(index) { - Color32::from_rgba_unmultiplied(255, 140, 0, 110) - } else { - Color32::from_rgba_unmultiplied(255, 214, 0, 90) - }; - for line_rect in text.line_rects(range.clone()) { - painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill); - } - } - } - if view.all_text { - for line_rect in text.line_rects(0..text.glyphs.len()) { - painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - } - if let Some(selection) = view.selection.filter(|selection| selection.page == page) { - for line_rect in selection.rects(text) { - painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - if let Span::Column { rect } = selection.span - && dragging - { - let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); - painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); - } - } -} - -struct TextPointer<'a> { - page: usize, - xf: PageXform, - rect: Rect, - info: &'a DocInfo, - tool: QuickTool, - resp: &'a egui::Response, - ptr: PagePointer, - pointer: Pos2, -} - -struct TextGesture<'a> { - page: usize, - xf: PageXform, - rect: Rect, - tool: QuickTool, - ptr: PagePointer, - resp: &'a egui::Response, - pointer: Pos2, - vx: f32, - vy: f32, - over_text: bool, - over_link: bool, - column: bool, -} - -fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) { - let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer; - let (vx, vy) = xf.screen_to_view(screen_pointer); - let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]); - let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer)); - let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt); - let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column }; - update_text_cursor(view, &gesture); - update_selection_on_press(ui, view, text, &gesture); - update_dragged_selection(view, text, &gesture); - finish_text_pointer_gesture(ui, view, text, &gesture); -} - -fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) { - // A press on a link selects nothing (it is the link's), so no cursor here says it would. - if gesture.column && !gesture.over_link { - view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair); - } else if gesture.over_text && !gesture.over_link { - view.cursor.claim(Layer::Text, egui::CursorIcon::Text); - } -} - -fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { - // A press held by what keeps its drag (a link, a comment that moves) selects no text; one on - // text markup lets its drag select the text underneath. - let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); - if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() { - let now = ui.input(|input| input.time); - let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist)); - let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist); - view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 }; - view.last_press_time = now; - view.last_press_pos = Some(gesture.pointer); - let clicks = selection_click_count(view.clicks, gesture.tool); - if clicks >= 2 - && gesture.over_text - && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) - { - view.selection = Some(Selection::run(gesture.page, first, last)); - } - } - if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() { - view.actions_dismissed = None; - view.selection_drag = true; - let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); - let (ox, oy) = gesture.xf.screen_to_view(origin); - if view.clicks < 2 { - view.selection = if gesture.column { - Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } }) - } else { - text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor)) - }; - } - } -} - -fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 { - if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks } -} - -fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { - let clicks = selection_click_count(view.clicks, gesture.tool); - if gesture.resp.dragged() - && let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page) - { - match &mut selection.span { - Span::Run { anchor, head } if clicks >= 2 => { - if let Some(hit) = text.nearest(gesture.vx, gesture.vy) - && let Some((first, last)) = click_unit(text, clicks, *anchor) - { - let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit)); - (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; - } - } - Span::Run { head, .. } => { - if let Some(hit) = text.nearest(gesture.vx, gesture.vy) { - *head = hit; - } - } - Span::Column { rect } => { - rect[2] = gesture.vx; - rect[3] = gesture.vy; - } - } - } - if gesture.resp.drag_stopped() - && view.selection.is_some_and(|selection| { - selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty() - }) - { - view.selection = None; - } -} - -fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { - let release_clicks = if gesture.resp.triple_clicked() { - view.clicks.max(2).saturating_add(1) - } else if gesture.resp.double_clicked() { - view.clicks.max(2) - } else { - 0 - }; - if release_clicks > 0 { - view.clicks = release_clicks; - } - let clicks = selection_click_count(view.clicks, gesture.tool); - if release_clicks > 0 - && clicks >= 2 - && gesture.over_text - && view.selection.is_none() - && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) - { - view.selection = Some(Selection::run(gesture.page, first, last)); - } else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() { - let shift = ui.input(|input| input.modifiers.shift); - match ( - view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span), - text.nearest(gesture.vx, gesture.vy), - ) { - (Some(Span::Run { head, .. }), Some(hit)) => *head = hit, - _ => view.selection = None, - } - } -} - -/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks -/// and badges. -fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { - let PageEnv { - added, - allowed, - calc_order, - can_modify, - comments_hidden, - doc, - doc_links, - editing_content, - form, - generation, - info, - prefs, - preparing, - redact_mark, - table_preview, - tool, - unobstructed, - .. - } = frame.env; - let PageSlot { i, xf, .. } = *slot; - let PageFrame { resp, ptr, pointer, .. } = *frame; - let painter = ui.painter(); - let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - if tool == QuickTool::Select && !input.consumed { - crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); - } - crate::image_pick::drop_stale(doc, view); - crate::image_pick::paint(painter, &xf, i, info, view); - // A cancelled press ends no selection: no markup or redaction is made from it. - if !view.press_cancelled { - comments::page_after_text(resp, &pcx, view); - if tool == QuickTool::Redact && can_modify { - crate::redact_ui::after_text(resp, i, info, view); - } - } - if input.area_tool { - crate::redact_ui::paint(painter, i, view, pointer); - } - comments::paint_page(ui, painter, &pcx, view); - if editing_content { - crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer); - } - if tool == QuickTool::EditText { - crate::object_ui::paint(painter, &xf, i, info, &view.objects); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::paint(painter, &xf, i, view); - } - if tool == QuickTool::Link { - crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); - } - if preparing { - crate::prepare::paint_page(tool_cx, form, view); - if let Some(preview) = &table_preview { - crate::field_table_ui::paint_preview(painter, &xf, i, preview); - } - if calc_order { - let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { - frame.calc_hover = Some(field); - } - } - } else { - crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer); - } - - // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a - // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as - // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a - // radio button's own) is left as it is drawn. - paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields); - // Links: the hand and the link's target, which count only for the tools that follow or - // edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is - // open, even beside it. A click follows the link its press was on, released over it: one that - // slips off the link first follows nothing, as a button's does. - if unobstructed - && resp.clicked() - && tool == QuickTool::Select - && !input.consumed - && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) - && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) - && let Some(l) = info.links.get(index) - { - frame.out.clicked_link = Some(l.target.clone()); - } - // Comments say what they are with any tool, beside its cursor, as Acrobat's do. - comments::describe(&pcx, view); - for (mp, mr, mc) in &view.compare_marks { - if *mp == i { - let sr = xf.user_rect(info, i, *mr).expand(1.5); - painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); - } - } - if let Some((fp, fr, t0)) = view.flash - && fp == i - { - let now = ui.input(|inp| inp.time); - let t0 = if t0 == 0.0 { now } else { t0 }; - view.flash = Some((fp, fr, t0)); - let age = (now - t0) as f32; - if age < 1.6 { - let a = ((1.6 - age) / 1.6 * 255.0) as u8; - let sr = xf.user_rect(info, i, fr).expand(4.0); - painter.rect_stroke( - sr, - CornerRadius::same(3), - Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), - egui::StrokeKind::Outside, - ); - ui.ctx().request_repaint(); - } else { - view.flash = None; - } - } - // Validation is viewer chrome over the signer's original artwork, which - // may still say "Signature Not Verified". These badges never enter document - // streams, image/text exports, vector printing or Print as Image. - for badge in signature_badges(&doc.signatures, i) { - let [x0, y0, x1, y1] = badge.rect; - let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); - let (icon, color) = crate::sign_ui::signature_status_style(badge.status); - painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); - // A widget too small for the mark keeps its outline: the verdict still shows. - if sr.width() < 4.0 || sr.height() < 4.0 { - continue; - } - // Big enough to read at a glance, small enough to leave the signer's own - // text visible around it. - let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); - let mark = Rect::from_center_size(sr.center(), vec2(side, side)); - icons::paint(ui, mark, icon, side * 0.64, color); - // Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with - // any tool; a link's or a comment's tip wins over any badge. - if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) { - view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string()); - } - } -} - -fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) { - if !enabled { - return; - } - for field in form.iter() { - let required = field.has(pdfcraft_engine::field_flags::REQUIRED); - for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) { - let rect = widget.rect; - let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]); - let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); - let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; - if widget.round { - let radius = screen_rect.width().min(screen_rect.height()) / 2.0; - painter.circle_filled(screen_rect.center(), radius, tint); - painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color)); - } else { - painter.rect_filled(screen_rect, CornerRadius::same(1), tint); - painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); - } - } - } -} - fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) { let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env; let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame; @@ -2283,43 +1518,6 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i page_overlays(ui, view, frame, &slot, &input); } -/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows -/// links and the Link tool edits them (`Layer::reached_by`). -fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { - let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; - let label = match &l.target { - LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), - LinkTarget::Uri(u) => u.clone(), - LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), - LinkTarget::RemoteGoTo { file, .. } => file.clone(), - LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), - }; - let owner = crate::page_router::Owner::Link { index }; - Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) -} - -/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is -/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The -/// frame of the release still belongs to the cancelled press. -fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) { - // Over once the button is up and its release seen, or when a new press comes (its release and - // the next press can share a frame). - let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_))); - if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) { - view.press_cancelled = false; - } - if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) - { - // A text selection being dragged goes with its drag: the markup, redaction or Replace - // Text that its end would make is not made. - if view.selection_drag { - view.clear_selection(); - } - view.cancel_pointer_gestures(); - view.press_cancelled = true; - } -} - #[allow(clippy::cognitive_complexity)] fn draw_pages( ui: &mut egui::Ui, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs new file mode 100644 index 0000000000..32500d7154 --- /dev/null +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -0,0 +1,815 @@ +//! Page input: what the tools do with the pointer on one page, what lies under it for the page +//! router, the text layer, and what is drawn over the page after its text. + +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; +use pdfcraft_render::{DocInfo, LinkTarget, PageText}; + +use super::{DocView, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, signature_badges}; +use crate::page_cursor::{Layer, Subject}; +use crate::page_pointer::PagePointer; +use crate::{QuickTool, comments, icons}; + +/// What the page's tools did with the pointer this frame. +pub(super) struct PageInput { + /// A tool, a field or a comment took the pointer: text selection must leave it alone. + pub(super) consumed: bool, + /// Redact or Highlight draws boxes off text. + pub(super) area_tool: bool, +} + +/// Common geometry and pointer state for a tool acting on one page. +#[derive(Clone, Copy)] +pub(crate) struct PageToolContext<'a> { + pub ui: &'a egui::Ui, + pub resp: &'a egui::Response, + pub xf: &'a PageXform, + pub ptr: &'a PagePointer, + pub page: usize, + pub doc: &'a pdfcraft_engine::Document, + pub info: &'a DocInfo, +} + +/// Comments: tools, selection, moving and resizing come before text selection. Form fields take +/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then +/// comments; then text selection. +pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { + let PageEnv { + added, + allowed, + author, + by_line, + can_crop, + can_fill, + can_modify, + comments_hidden, + custom_stamp, + date_text, + doc, + doc_links, + editing_content, + form, + info, + initials, + prefs, + preparing, + redact_mark, + signature, + table_preview, + text_style, + tool, + units, + fill_text_size, + fill_text_color, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let (lines, images) = + if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; + let field_tool = match tool { + QuickTool::Field(f) => Some(f), + _ => None, + }; + // While a table is being set up, nothing else is drawn or moved. + let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + let hits = PageHits { + page: i, + xf: &xf, + info, + tool, + pcx: &pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + lines: &lines, + images: &images, + clip: ui.clip_rect(), + }; + if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { + let at = hits.at(view, o); + view.router.press(i, at, tool); + } + if let QuickTool::Measure(measure_tool) = tool { + crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); + } + + // Form fields take clicks first with the Select tool (as Acrobat fills fields in + // every viewing mode); then comments; then text selection. + if let QuickTool::Stamp(kind) = tool + && allowed + && let Some(p) = ptr.within(xf.rect) + { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // The tool's own click: nothing drawn over the page here holds the press. + if resp.clicked() && view.router.owner(i).is_none() { + // Centred on the click, upright as the page is shown. + let (vx, vy) = xf.screen_to_view(p); + let (w, h) = kind.size(); + let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; + let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); + let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; + let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); + let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; + view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: i, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: String::new(), + author: author.clone(), + })); + frame.out.stamp_placed = true; + } + } + if let Some(cs) = custom_stamp.as_ref() + && allowed + && let Some(p) = ptr.within(xf.rect) + { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // The tool's own click: nothing drawn over the page here holds the press. + if resp.clicked() && view.router.owner(i).is_none() { + // Centred on the click at its natural size (the engine sizes it). + let (vx, vy) = xf.screen_to_view(p); + let u = info.pages[i].view_to_user(vx, vy); + let (x, y) = (u[0] as f64, u[1] as f64); + view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { + page: i, + rect: [x, y, x, y], + name: cs.name.clone(), + file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, + author: author.clone(), + }); + frame.out.stamp_placed = true; + } + } + // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). + if let QuickTool::Fill(ft) = tool + && allowed + && !comments::fill_grabs(&pcx, view) + { + match crate::fill_sign::page_input( + cx, + crate::fill_sign::PageInput { + tool: ft, + text_size: fill_text_size, + text_color: fill_text_color, + signature: signature.as_ref(), + initials: initials.as_ref(), + preview: frame.signature_preview, + author, + date_text, + }, + view, + ) { + Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), + Some(crate::fill_sign::FillAction::Signature(e)) => { + view.pending_edit = Some(*e); + view.fill_signature_page = Some(i); + } + Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, + Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, + Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), + None => {} + } + } + if matches!(tool, QuickTool::SignArea { .. }) { + crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view); + } + if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { + crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify); + } + if tool == QuickTool::Crop + && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop }) + { + view.current = i; + frame.out.open_boxes = true; + } + let on_field = if preparing { + let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); + frame.out.field_props |= o.properties; + frame.out.field_placed |= o.placed; + frame.out.open_table = frame.out.open_table.or(o.table); + o.consumed || field_tool.is_some() + } else { + tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view) + }; + // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). + let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; + let boxing = area_tool && { + let text = view.page_text(i); + let over_text = |p: Pos2| { + let (vx, vy) = xf.screen_to_view(p); + text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) + }; + crate::redact_ui::page_input(cx, over_text, view) + }; + let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); + // Add text acts on the page's own paragraphs over them, and on the one its press was on until + // the button is up: a drag that leaves the box still ends, and no new box takes it over. + let over_source = tool == QuickTool::AddText + && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) + || pointer.is_some_and(|p| { + !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p) + })); + let source_blocked = over_source && view.content.hold_blocked(); + if over_source && resp.clicked() && !source_blocked { + if let Some(edit) = view.content.finish_draft(added) { + view.pending_edit = Some(edit); + } + view.content.selected = None; + } + let on_content = !on_objects && editing_content && can_modify && { + source_blocked + || crate::content_ui::page_input( + cx, + added, + tool == QuickTool::AddText && !over_source, + matches!(tool, QuickTool::AddText | QuickTool::EditText), + text_style, + view, + ) + || tool == QuickTool::AddText && !over_source + }; + // Added content owns its gesture; its source text must not queue a second edit. + // Edit text & images boxes every paragraph and image whenever the tool is on. Only the + // input waits while selected objects or added content own the pointer, or while page + // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn + // instead of vanishing until the pointer comes back. + let others_own_pointer = on_objects || on_content; + let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { + // Editable text takes precedence over background/overlapping artwork. Both + // inventories are painted every frame, even when text owns the pointer. + let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer); + let image_cx = crate::edit_text_ui::ImageCx { + ui, + resp, + xf: &xf, + ptr, + page: i, + info, + allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), + }; + let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); + !others_own_pointer && (text_used || image_used) + }; + if tool == QuickTool::EditText { + if others_own_pointer { + if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() { + view.image_selection = None; + } + } else { + crate::content_ui::sync_image_selection(view, i, &images, added); + } + } + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view); + if tool == QuickTool::Select + && resp.secondary_clicked() + && can_modify + && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) + && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) + { + view.links.context = Some((i, l.index)); + } + let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); + // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the + // last claim on a layer stands). + if frame.env.unobstructed + && let Some(p) = pointer + { + for h in hits.at(view, p) { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip); + } + } + PageInput { consumed, area_tool } +} + +/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. +fn edit_inventory( + view: &mut DocView, + doc: &pdfcraft_engine::Document, + i: usize, +) -> (Vec, Vec) { + let generation = doc.edit_generation(); + let lines = match view.edit_lines.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.text_blocks(i); + view.edit_lines.insert(i, (generation, l.clone())); + l + } + }; + let images = cached_edit_images(view, doc, i).to_vec(); + (lines, images) +} + +/// What the page modules have under a point on one page, for the page router. +struct PageHits<'a> { + page: usize, + xf: &'a PageXform, + info: &'a DocInfo, + tool: QuickTool, + pcx: &'a comments::PageCx<'a>, + form: &'a [pdfcraft_engine::FormField], + can_fill: bool, + can_modify: bool, + /// Edit a PDF is open: added text and pictures can be picked up. + editing_content: bool, + preparing: bool, + field_tool: Option, + /// Prepare may draw and move fields (not while a table is being set up). + prepare_allowed: bool, + added: &'a [pdfcraft_engine::Added], + lines: &'a [pdfcraft_engine::TextBlock], + images: &'a [pdfcraft_engine::PageImage], + /// The pages' visible area (the picture's rotate knob stays inside it). + clip: Rect, +} + +impl PageHits<'_> { + /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text + /// on top of artwork takes the press), added content, the objects selected together and the + /// open paragraph's frame, the selected picture's grips over them all; then form fields + /// (Prepare's, being moved, over the comments; filled ones under added content and the + /// comments that move). + fn at(&self, view: &DocView, p: Pos2) -> Vec { + let PageHits { + page, + xf, + info, + tool, + pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + lines, + images, + clip, + } = *self; + // Crop mode owns every press on its page (`edit_text_ui::crop_hit`). + if tool == QuickTool::EditText + && can_modify + && let Some(crop) = crate::edit_text_ui::crop_hit(view, page) + { + return vec![crop]; + } + let fields = if preparing { + crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) + } else if tool == QuickTool::Select { + crate::forms_ui::hit(xf, info, page, form, can_fill, p) + } else { + None + }; + // With Add text, only the paragraphs and pictures not under added content. + let editing = + can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p)); + let (pictures, paragraphs, objects, grips) = if editing { + let open = view.line_editor.as_ref().filter(|e| e.page == page); + let frame = open + .filter(|e| !e.has_changes()) + .and_then(|e| lines.get(e.block)) + .map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)); + // Add text acts on the page's paragraphs only in their boxes, edges included (it + // finishes the box being typed there, `over_source`): not on pictures, nor beside a + // paragraph, where a click starts a new box. + let edit_text = tool == QuickTool::EditText; + let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); + ( + edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(), + on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(), + edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(), + edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(), + ) + } else { + (None, None, None, None) + }; + // Added text and pictures, over the page's own paragraphs and under the objects selected + // together. + let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); + let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); + [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] + .into_iter() + .flatten() + .collect() + } +} + +/// Text layer: find matches, selection, I-beam and drag-to-select. +pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { + let PageEnv { info, selects_text, tool, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + if let Some(text) = view.texts.get(&i).cloned() { + paint_text_marks(ui, view, &text, i, xf, resp.dragged()); + if selects_text + && !input.consumed + && let Some(p) = pointer.filter(|p| r.contains(*p)) + { + text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p }); + } + } +} + +fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) { + let painter = ui.painter(); + let to_screen = |g: [f32; 4]| xf.view_rect(g); + if let Some(find) = &view.find { + for (index, (match_page, range)) in find.matches.iter().enumerate() { + if *match_page != page { + continue; + } + let fill = if find.current == Some(index) { + Color32::from_rgba_unmultiplied(255, 140, 0, 110) + } else { + Color32::from_rgba_unmultiplied(255, 214, 0, 90) + }; + for line_rect in text.line_rects(range.clone()) { + painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill); + } + } + } + if view.all_text { + for line_rect in text.line_rects(0..text.glyphs.len()) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + } + if let Some(selection) = view.selection.filter(|selection| selection.page == page) { + for line_rect in selection.rects(text) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + if let Span::Column { rect } = selection.span + && dragging + { + let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); + painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); + } + } +} + +struct TextPointer<'a> { + page: usize, + xf: PageXform, + rect: Rect, + info: &'a DocInfo, + tool: QuickTool, + resp: &'a egui::Response, + ptr: PagePointer, + pointer: Pos2, +} + +struct TextGesture<'a> { + page: usize, + xf: PageXform, + rect: Rect, + tool: QuickTool, + ptr: PagePointer, + resp: &'a egui::Response, + pointer: Pos2, + vx: f32, + vy: f32, + over_text: bool, + over_link: bool, + column: bool, +} + +fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) { + let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer; + let (vx, vy) = xf.screen_to_view(screen_pointer); + let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]); + let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer)); + let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt); + let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column }; + update_text_cursor(view, &gesture); + update_selection_on_press(ui, view, text, &gesture); + update_dragged_selection(view, text, &gesture); + finish_text_pointer_gesture(ui, view, text, &gesture); +} + +fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) { + // A press on a link selects nothing (it is the link's), so no cursor here says it would. + if gesture.column && !gesture.over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair); + } else if gesture.over_text && !gesture.over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Text); + } +} + +fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + // A press held by what keeps its drag (a link, a comment that moves) selects no text; one on + // text markup lets its drag select the text underneath. + let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); + if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() { + let now = ui.input(|input| input.time); + let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist)); + let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist); + view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 }; + view.last_press_time = now; + view.last_press_pos = Some(gesture.pointer); + let clicks = selection_click_count(view.clicks, gesture.tool); + if clicks >= 2 + && gesture.over_text + && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) + { + view.selection = Some(Selection::run(gesture.page, first, last)); + } + } + if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() { + view.actions_dismissed = None; + view.selection_drag = true; + let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); + let (ox, oy) = gesture.xf.screen_to_view(origin); + if view.clicks < 2 { + view.selection = if gesture.column { + Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } }) + } else { + text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor)) + }; + } + } +} + +fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 { + if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks } +} + +fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let clicks = selection_click_count(view.clicks, gesture.tool); + if gesture.resp.dragged() + && let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page) + { + match &mut selection.span { + Span::Run { anchor, head } if clicks >= 2 => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) + && let Some((first, last)) = click_unit(text, clicks, *anchor) + { + let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit)); + (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; + } + } + Span::Run { head, .. } => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) { + *head = hit; + } + } + Span::Column { rect } => { + rect[2] = gesture.vx; + rect[3] = gesture.vy; + } + } + } + if gesture.resp.drag_stopped() + && view.selection.is_some_and(|selection| { + selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty() + }) + { + view.selection = None; + } +} + +fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let release_clicks = if gesture.resp.triple_clicked() { + view.clicks.max(2).saturating_add(1) + } else if gesture.resp.double_clicked() { + view.clicks.max(2) + } else { + 0 + }; + if release_clicks > 0 { + view.clicks = release_clicks; + } + let clicks = selection_click_count(view.clicks, gesture.tool); + if release_clicks > 0 + && clicks >= 2 + && gesture.over_text + && view.selection.is_none() + && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) + { + view.selection = Some(Selection::run(gesture.page, first, last)); + } else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() { + let shift = ui.input(|input| input.modifiers.shift); + match ( + view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span), + text.nearest(gesture.vx, gesture.vy), + ) { + (Some(Span::Run { head, .. }), Some(hit)) => *head = hit, + _ => view.selection = None, + } + } +} + +/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks +/// and badges. +pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { + let PageEnv { + added, + allowed, + calc_order, + can_modify, + comments_hidden, + doc, + doc_links, + editing_content, + form, + generation, + info, + prefs, + preparing, + redact_mark, + table_preview, + tool, + unobstructed, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let painter = ui.painter(); + let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + if tool == QuickTool::Select && !input.consumed { + crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); + } + crate::image_pick::drop_stale(doc, view); + crate::image_pick::paint(painter, &xf, i, info, view); + // A cancelled press ends no selection: no markup or redaction is made from it. + if !view.press_cancelled { + comments::page_after_text(resp, &pcx, view); + if tool == QuickTool::Redact && can_modify { + crate::redact_ui::after_text(resp, i, info, view); + } + } + if input.area_tool { + crate::redact_ui::paint(painter, i, view, pointer); + } + comments::paint_page(ui, painter, &pcx, view); + if editing_content { + crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer); + } + if tool == QuickTool::EditText { + crate::object_ui::paint(painter, &xf, i, info, &view.objects); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::paint(painter, &xf, i, view); + } + if tool == QuickTool::Link { + crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); + } + if preparing { + crate::prepare::paint_page(tool_cx, form, view); + if let Some(preview) = &table_preview { + crate::field_table_ui::paint_preview(painter, &xf, i, preview); + } + if calc_order { + let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); + if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { + frame.calc_hover = Some(field); + } + } + } else { + crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer); + } + + // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a + // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as + // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a + // radio button's own) is left as it is drawn. + paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields); + // Links: the hand and the link's target, which count only for the tools that follow or + // edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is + // open, even beside it. A click follows the link its press was on, released over it: one that + // slips off the link first follows nothing, as a button's does. + if unobstructed + && resp.clicked() + && tool == QuickTool::Select + && !input.consumed + && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) + && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) + && let Some(l) = info.links.get(index) + { + frame.out.clicked_link = Some(l.target.clone()); + } + // Comments say what they are with any tool, beside its cursor, as Acrobat's do. + comments::describe(&pcx, view); + for (mp, mr, mc) in &view.compare_marks { + if *mp == i { + let sr = xf.user_rect(info, i, *mr).expand(1.5); + painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); + } + } + if let Some((fp, fr, t0)) = view.flash + && fp == i + { + let now = ui.input(|inp| inp.time); + let t0 = if t0 == 0.0 { now } else { t0 }; + view.flash = Some((fp, fr, t0)); + let age = (now - t0) as f32; + if age < 1.6 { + let a = ((1.6 - age) / 1.6 * 255.0) as u8; + let sr = xf.user_rect(info, i, fr).expand(4.0); + painter.rect_stroke( + sr, + CornerRadius::same(3), + Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), + egui::StrokeKind::Outside, + ); + ui.ctx().request_repaint(); + } else { + view.flash = None; + } + } + // Validation is viewer chrome over the signer's original artwork, which + // may still say "Signature Not Verified". These badges never enter document + // streams, image/text exports, vector printing or Print as Image. + for badge in signature_badges(&doc.signatures, i) { + let [x0, y0, x1, y1] = badge.rect; + let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); + let (icon, color) = crate::sign_ui::signature_status_style(badge.status); + painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); + // A widget too small for the mark keeps its outline: the verdict still shows. + if sr.width() < 4.0 || sr.height() < 4.0 { + continue; + } + // Big enough to read at a glance, small enough to leave the signer's own + // text visible around it. + let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); + let mark = Rect::from_center_size(sr.center(), vec2(side, side)); + icons::paint(ui, mark, icon, side * 0.64, color); + // Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with + // any tool; a link's or a comment's tip wins over any badge. + if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) { + view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string()); + } + } +} + +fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) { + if !enabled { + return; + } + for field in form.iter() { + let required = field.has(pdfcraft_engine::field_flags::REQUIRED); + for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) { + let rect = widget.rect; + let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]); + let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); + let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; + if widget.round { + let radius = screen_rect.width().min(screen_rect.height()) / 2.0; + painter.circle_filled(screen_rect.center(), radius, tint); + painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color)); + } else { + painter.rect_filled(screen_rect, CornerRadius::same(1), tint); + painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); + } + } + } +} + +/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows +/// links and the Link tool edits them (`Layer::reached_by`). +fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { + let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; + let label = match &l.target { + LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), + LinkTarget::Uri(u) => u.clone(), + LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), + LinkTarget::RemoteGoTo { file, .. } => file.clone(), + LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), + }; + let owner = crate::page_router::Owner::Link { index }; + Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) +} + +/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is +/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The +/// frame of the release still belongs to the cancelled press. +pub(super) fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) { + // Over once the button is up and its release seen, or when a new press comes (its release and + // the next press can share a frame). + let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_))); + if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) { + view.press_cancelled = false; + } + if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + // A text selection being dragged goes with its drag: the markup, redaction or Replace + // Text that its end would make is not made. + if view.selection_drag { + view.clear_selection(); + } + view.cancel_pointer_gestures(); + view.press_cancelled = true; + } +} diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index e92d4c01df..248b627298 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -150,8 +150,9 @@ fn edges_are_primary_finite_in_order_and_capped() { } /// The page modules: the per-page chain in `canvas::document_area` and what it calls. -const PAGE_MODULES: [&str; 35] = [ +const PAGE_MODULES: [&str; 36] = [ "canvas.rs", + "canvas/page_input.rs", "canvas/area.rs", "canvas/bars.rs", "canvas/cache.rs", @@ -387,7 +388,7 @@ const ROUTER_WRITES: [&str; 3] = ["router.press(", "router.settle(", "router.can /// The functions that decide, keep or drop the press the pages hold: (file, function, why). const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ - ("canvas.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), + ("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), ("canvas.rs", "draw_pages", "the press is over once the button is up and its release seen, and a new one drops the last owner"), ("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, a dialog, automation's cancel"), ]; @@ -416,8 +417,8 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. const HIT_TESTERS: [(&str, &str, &str); 4] = [ - ("canvas.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), - ("canvas.rs", "page_overlays", "a link's click counts only released over the link its press was on"), + ("canvas/page_input.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), + ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), ]; @@ -426,7 +427,8 @@ const HIT_TESTERS: [(&str, &str, &str); 4] = [ const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; /// The one function that ignores a press until the button is up: (file, function, why). -const PRESS_IGNORERS: [(&str, &str, &str); 1] = [("canvas.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")]; +const PRESS_IGNORERS: [(&str, &str, &str); 1] = + [("canvas/page_input.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")]; /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). From 2b256ab0c79eff8c97b54efdf143685faab4b65c Mon Sep 17 00:00:00 2001 From: alehel Date: Sat, 31 Oct 2026 08:31:01 +0100 Subject: [PATCH 19/63] refactor(ui): one link test for the Select tool, the Link tool and right-clicks Three link tests disagreed. The Select tool's hand and click (`link_hit`) took the topmost link the renderer lists, with no margin, by its place in that list. The Link tool's selection and Select's right-click took the bottom one of every link the engine lists, 2 px around each, by `/Annots` index. Comments had a fourth (`on_link`). Now `link_ui::hit` is the one test, and `Owner::Link` carries the `/Annots` index: - the Link tool finds every link the engine lists, 2 px around each, and takes the topmost; a drag from a link still draws a new one (the hit declines the drag); - every other tool finds the links the renderer can follow, with no margin (so Select shows no hand over a link with no action, as before), and takes the topmost; - following a link looks its target up by that index; - `comments::hit` is told whether a link is under the point. The Link tool is told the link its press is on, and the one a right-click is on, instead of testing the pointer itself. A right-click goes to the top hit under the pointer, so with the Select tool a right-click on a note over a link selects the note and opens its menu, and one just beside a link no longer opens the link's. Behaviour changes (the user's rulings, 2026-10-31): the Link tool takes the topmost of stacked links; Select's right-click has no margin; a right-click on a note over a link is the note's. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 54 ++++--- .../ui-egui/src/canvas/page_pointer/tests.rs | 3 +- crates/ui-egui/src/canvas/page_router.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 15 +- crates/ui-egui/src/tools/link_ui.rs | 66 ++++++--- crates/ui-egui/tests/it/pointer.rs | 134 +++++++++++++++++- 6 files changed, 215 insertions(+), 59 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 32500d7154..5ca60f8684 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -2,11 +2,12 @@ //! router, the text layer, and what is drawn over the page after its text. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; -use pdfcraft_render::{DocInfo, LinkTarget, PageText}; +use pdfcraft_render::{DocInfo, PageText}; use super::{DocView, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, signature_badges}; use crate::page_cursor::{Layer, Subject}; use crate::page_pointer::PagePointer; +use crate::page_router::{Owner, top}; use crate::{QuickTool, comments, icons}; /// What the page's tools did with the pointer this frame. @@ -88,6 +89,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut field_tool, prepare_allowed, added, + doc_links, lines: &lines, images: &images, clip: ui.clip_rect(), @@ -96,6 +98,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let at = hits.at(view, o); view.router.press(i, at, tool); } + // A right-click goes to what a press there would go to: the top hit under the pointer. + let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| top(hits.at(view, p), tool)).map(|h| h.owner); if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); } @@ -270,14 +274,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut crate::content_ui::sync_image_selection(view, i, &images, added); } } - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view); + let (link_owner, link_context) = (link_of(view.router.owner(i)), link_of(context)); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, link_owner, link_context, view); if tool == QuickTool::Select - && resp.secondary_clicked() && can_modify - && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) - && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) + && let Some(index) = link_context { - view.links.context = Some((i, l.index)); + view.links.context = Some((i, index)); } let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the @@ -292,6 +295,14 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageInput { consumed, area_tool } } +/// The link `owner` is, by its `/Annots` index. +fn link_of(owner: Option) -> Option { + match owner { + Some(Owner::Link { index }) => Some(index), + _ => None, + } +} + /// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. fn edit_inventory( view: &mut DocView, @@ -328,6 +339,8 @@ struct PageHits<'a> { /// Prepare may draw and move fields (not while a table is being set up). prepare_allowed: bool, added: &'a [pdfcraft_engine::Added], + /// The document's links, as the engine lists them (the Link tool edits every one). + doc_links: &'a [pdfcraft_engine::LinkItem], lines: &'a [pdfcraft_engine::TextBlock], images: &'a [pdfcraft_engine::PageImage], /// The pages' visible area (the picture's rotate knob stays inside it). @@ -355,6 +368,7 @@ impl PageHits<'_> { field_tool, prepare_allowed, added, + doc_links, lines, images, clip, @@ -400,10 +414,9 @@ impl PageHits<'_> { // together. let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); - [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] - .into_iter() - .flatten() - .collect() + let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); + let comment = comments::hit(pcx, view, p, link.is_some()); + [link, comment, pictures, paragraphs, added, objects, grips, fields].into_iter().flatten().collect() } } @@ -694,9 +707,9 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P && resp.clicked() && tool == QuickTool::Select && !input.consumed - && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) - && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) - && let Some(l) = info.links.get(index) + && let Some(owner @ Owner::Link { index }) = view.router.owner(i) + && pointer.and_then(|p| crate::link_ui::hit(&xf, info, i, doc_links, tool, p)).is_some_and(|h| h.owner == owner) + && let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index) { frame.out.clicked_link = Some(l.target.clone()); } @@ -777,21 +790,6 @@ fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInf } } -/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows -/// links and the Link tool edits them (`Layer::reached_by`). -fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { - let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; - let label = match &l.target { - LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), - LinkTarget::Uri(u) => u.clone(), - LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), - LinkTarget::RemoteGoTo { file, .. } => file.clone(), - LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), - }; - let owner = crate::page_router::Owner::Link { index }; - Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) -} - /// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is /// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The /// frame of the release still belongs to the cancelled press. diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 248b627298..4f9398be85 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -432,7 +432,8 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 9] = [ +const PAGE_HIT_CALLS: [&str; 10] = [ + "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", "comments::hit(", diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 8b91cc865c..a9f7500189 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -18,7 +18,7 @@ use crate::page_cursor::Layer; pub(crate) enum Owner { /// A comment (or a Fill & Sign mark), by its `/Annots` index, and the part of it. Comment { index: usize, part: CommentPart }, - /// A link, by its index in the document's links. + /// A link, by its index in its page's `/Annots`. Link { index: usize }, /// A form field's widget, by the field's index in the form and the widget's in the field. Field { field: usize, widget: usize }, diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index a7ef6c92e1..527df65d01 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -54,8 +54,9 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, /// only, as Acrobat picks them up: a press anywhere else places the tool's mark. With a shape /// tool (rectangle, oval, line, polygon…), only the selected comment's handles, so the shape just /// drawn can be resized straight away (#2338); a press anywhere else, the shape itself included, -/// draws. `None` with other tools, off the page, or with comments hidden. -pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { +/// draws. `None` with other tools, off the page, or with comments hidden. `on_link`: a link lies +/// under `p` ([`crate::link_ui::hit`]). +pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Option { if cx.hidden || !cx.xf.rect.contains(p) { return None; } @@ -93,7 +94,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { } let a = cx.hit(p).filter(|a| bodies && pick(a))?; // A click on a highlight over a link follows the link, so the link's hand and tip show there. - if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link(cx, p) { + if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link { return None; } let markup = is_markup(&a.subtype); @@ -116,7 +117,8 @@ fn grabs(cx: &PageCx<'_>, view: &DocView) -> bool { if cx.ptr.any_down { return matches!(view.router.owner(cx.page), Some(Owner::Comment { .. })) && view.router.keeps_drag(); } - cx.ptr.pos.and_then(|p| hit(cx, view, p)).is_some_and(|h| !h.declines_drag) + // Only the Select tool lets a link through a highlight, and it doesn't come here. + cx.ptr.pos.and_then(|p| hit(cx, view, p, false)).is_some_and(|h| !h.declines_drag) } /// With a Fill & Sign tool, a Fill & Sign mark already on the page is picked up as with the Select @@ -130,11 +132,6 @@ fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { matches!(cx.tool, QuickTool::Comment(_)) && grabs(cx, view) } -/// Whether `p` is on one of this page's links. -fn on_link(cx: &PageCx<'_>, p: Pos2) -> bool { - cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p)) -} - /// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat /// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays /// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index a0840e1b89..699383cbfe 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -4,14 +4,17 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle}; -use pdfcraft_render::DocInfo; +use pdfcraft_render::{DocInfo, LinkTarget}; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; +use crate::page_router::{Hit, Owner}; use crate::theme::{self, Tokens}; -use crate::{PdfCraftApp, widgets}; +use crate::{PdfCraftApp, QuickTool, widgets}; const LINK_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +/// How far around a link (screen points) the Link tool finds it, so a thin one can be picked. +const LINK_TOOL_MARGIN: f32 = 2.0; #[derive(Clone, Debug, Default, PartialEq)] pub struct LinkView { @@ -56,8 +59,38 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { [u[0] as f64, u[1] as f64] } -/// Link tool input on one page. Returns `true` when the gesture belongs to the tool. -pub(crate) fn page_input(cx: PageToolContext<'_>, links: &[LinkItem], view: &mut DocView) -> bool { +/// The link under `p` on `page`: the one hit test of links, for the hand, the tip and the press. +/// Of stacked links, the topmost (the last in `/Annots`, drawn on top). Only the Select tool +/// follows links and the Link tool edits them (`Layer::reached_by`). The Link tool finds every link +/// the document has (`links`, the engine's), with a margin around each so a thin one can be +/// picked, and a drag from one draws a new link. The other tools find the links the renderer can +/// follow, which have a target (`info.links`). The tip is the link's target. +pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, links: &[LinkItem], tool: QuickTool, p: Pos2) -> Option { + let index = if tool == QuickTool::Link { + links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index + } else { + info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?.index + }; + let tip = info.links.iter().find(|l| l.page == page && l.index == index).map(|l| target_label(info, &l.target)); + let declines_drag = tool == QuickTool::Link; + Some(Hit { tip, declines_drag, ..Hit::new(Owner::Link { index }, Layer::Link, egui::CursorIcon::PointingHand) }) +} + +/// What a link's tip says it goes to. +fn target_label(info: &DocInfo, target: &LinkTarget) -> String { + match target { + LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), + LinkTarget::Uri(u) => u.clone(), + LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), + LinkTarget::RemoteGoTo { file, .. } => file.clone(), + LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), + } +} + +/// Link tool input on one page: `owner` is the link that holds the press (by its `/Annots` +/// index), and `context` the one a right-click is on. Returns `true` when the gesture belongs to +/// the tool. +pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option, context: Option, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; let lv = &mut view.links; @@ -76,26 +109,21 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, links: &[LinkItem], view: &mut return true; } let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; - let hit = links.iter().filter(|l| l.page == page).find(|l| screen_rect(xf, info, page, l.rect).expand(2.0).contains(p)); - match hit { - Some(_) => view.cursor.claim(Layer::Link, egui::CursorIcon::PointingHand), - None => view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair), - } - if resp.secondary_clicked() - && let Some(l) = hit - { - lv.selected = Some((page, l.index)); - lv.context = Some((page, l.index)); + // Over a link, its hit claims the hand. + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if let Some(index) = context { + lv.selected = Some((page, index)); + lv.context = Some((page, index)); return true; } if resp.clicked() || resp.double_clicked() { let now = ui.input(|i| i.time); // A second click on the same link soon after the first is a double-click. - let double = resp.double_clicked() || hit.is_some_and(|l| lv.last_click.is_some_and(|(t, p, k)| p == page && k == l.index && now - t < 0.5)); - lv.last_click = hit.map(|l| (now, page, l.index)); - lv.selected = hit.map(|l| (page, l.index)); - if double && let Some(l) = hit { - lv.open_existing = Some((page, l.index)); + let double = resp.double_clicked() || owner.is_some_and(|k| lv.last_click.is_some_and(|(t, p, l)| p == page && l == k && now - t < 0.5)); + lv.last_click = owner.map(|k| (now, page, k)); + lv.selected = owner.map(|k| (page, k)); + if double && let Some(k) = owner { + lv.open_existing = Some((page, k)); lv.last_click = None; } return true; diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 889caf4c1c..1ee9676ad2 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -11,7 +11,7 @@ use egui_kittest::kittest::Queryable; use pdfcraft_engine::StampKind; use pdfcraft_ui_egui::comments::CommentTool; use pdfcraft_ui_egui::fill_sign::FillTool; -use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool, UnlockPrompt}; use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap}; @@ -957,3 +957,135 @@ fn escape_cancels_a_crop_rectangle() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "no page box was set"); } + +// --------------------------------------------------------------------------------------------- +// One link test: what the Select tool follows, the Link tool edits and a right-click opens is +// the same link. + +/// A 300×400 page with two web links overlapping at x 150..200, y 300..350: `first` first in +/// `/Annots`, then `second` drawn over it. +fn stacked_links_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 200 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/first) >> >>", + "<< /Type /Annot /Subtype /Link /Rect [150 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/second) >> >>", + ]) +} + +/// A 300×400 page with a link that does nothing (no action) at x 10..40, y 300..350, then a web +/// link at x 50..250 beside it: second in `/Annots`, and the first the renderer can follow. +fn dead_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [10 300 40 350] /Border [0 0 0] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + ]) +} + +/// [`link_pdf`], with a rectangle comment drawn over the middle of its link. +fn note_over_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + "<< /Type /Annot /Subtype /Square /Rect [100 310 200 340] /C [1 0 0] /F 4 /T (Ana) /Contents (A note) >>", + ]) +} + +fn link_tool(h: &mut Harness<'_, PdfCraftApp>) { + h.state_mut().quick_tool = QuickTool::Link; + h.run_steps(4); +} + +/// A right-click at `p`: press and release in frames of their own. +fn right_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(3); +} + +fn pending_url(h: &Harness<'_, PdfCraftApp>) -> Option { + h.state().pending_link.as_ref().map(|l| l.url.clone()) +} + +/// Of two stacked links the Link tool selects the one drawn on top, the one the Select tool +/// follows. It used to select the one underneath. +#[test] +fn the_link_tool_selects_the_topmost_of_stacked_links() { + let mut h = open(stacked_links_pdf(), "150", false); + let both = at(&h, 175.0, 325.0); + mouse_click(&mut h, both); + assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/second"), "the Select tool follows the link on top"); + h.state_mut().pending_link = None; + link_tool(&mut h); + mouse_click(&mut h, both); + assert_eq!(h.state().views[0].links.selected, Some((0, 1)), "the Link tool selects it too"); +} + +/// The Link tool edits every link, one that does nothing included. The Select tool, which +/// follows links, shows no hand over it, and follows the link beside it by its own `/Annots` +/// place, not by its place among the links the renderer can follow. +#[test] +fn the_link_tool_selects_a_link_with_no_action() { + let mut h = open(dead_link_pdf(), "150", false); + let dead = at(&h, 25.0, 325.0); + assert_ne!(cursor_at(&mut h, dead), egui::CursorIcon::PointingHand, "the Select tool has nothing to follow there"); + let web = at(&h, 150.0, 325.0); + mouse_click(&mut h, web); + assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/"), "the web link is followed"); + h.state_mut().pending_link = None; + link_tool(&mut h); + assert_eq!(cursor_at(&mut h, dead), egui::CursorIcon::PointingHand); + mouse_click(&mut h, dead); + assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "the Link tool selects the link that does nothing"); +} + +/// With the Link tool a drag that starts on a link draws a new link: the press is the link's, its +/// drag is not. +#[test] +fn a_drag_with_the_link_tool_from_a_link_draws_a_new_link() { + let mut h = open(link_pdf(), "150", false); + link_tool(&mut h); + let on_link = at(&h, 150.0, 325.0); + mouse_drag(&mut h, on_link, egui::vec2(80.0, 120.0)); + let s = h.state(); + assert_eq!(s.dialog, Some(Dialog::LinkProps)); + assert_eq!(s.link_draft.as_ref().map(|d| d.index), Some(None), "Link Properties opens for a new link"); +} + +/// With the Select tool a right-click finds a link only where a click follows it: just beside the +/// link it opens the page's menu. The Link tool's margin around links used to reach there. +#[test] +fn a_right_click_beside_a_link_with_the_select_tool_opens_no_link_menu() { + let mut h = open(link_pdf(), "150", false); + // The link ends at x 250. + let beside = at(&h, 250.0, 325.0) + egui::vec2(1.5, 0.0); + right_click(&mut h, beside); + assert_eq!(h.state().views[0].links.context, None, "no link under the right-click"); + assert!(h.query_by_label("Remove all links").is_none(), "the page's menu has no link items"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + let on_link = at(&h, 245.0, 325.0); + right_click(&mut h, on_link); + assert_eq!(h.state().views[0].links.context, Some((0, 0)), "on the link, its menu"); +} + +/// A right-click goes to what a press there would go to: on a note over a link, the note. It used +/// to open the link's menu. +#[test] +fn a_right_click_on_a_note_over_a_link_opens_the_notes_menu() { + let mut h = open(note_over_link_pdf(), "150", false); + let on_note = at(&h, 150.0, 325.0); + right_click(&mut h, on_note); + let v = &h.state().views[0]; + assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the note is selected, not the link"); + assert!(h.query_by_label("Remove all links").is_none(), "the menu is the note's"); +} From bab615fed13e2204cbb6baf3119f5f8d0c2ffc37 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:15:32 +0100 Subject: [PATCH 20/63] refactor(ui): route right-clicks by what lies on top under the pointer Each right-click handler tested the pointer itself, and the chain's order decided which ran: comments only when no earlier module took the frame, Prepare by its own widget scan. A right-click now goes to what lies on top under the pointer (`PageHits::context_at`), by the layer ranking a press uses, with two differences. Comments count with every tool, as a comment's menu opens whatever tool is active (`comments::context_hit`). A highlight over a link is the highlight's: a left click there follows the link, a right-click opens the highlight's menu. On every right-click the page's menu is anchored at the pointer, and the comment it is on is selected, or none (`comments::context`); a Prepare field is selected (`prepare::context`); links take theirs from the same answer. Edit text's own paragraphs and pictures, and a Prepare field tool, keep their right-clicks as before. Behaviour change (the user's ruling, 2026-10-31): a right-click on a highlight over a link opens the highlight's menu, not the link's. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 41 +++++++++++++++++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 6 ++- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 25 ++++++++--- crates/ui-egui/src/tools/prepare.rs | 36 ++++++++-------- crates/ui-egui/tests/it/pointer.rs | 40 ++++++++++++++++++ 6 files changed, 120 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 5ca60f8684..5d6dcb8054 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -98,8 +98,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let at = hits.at(view, o); view.router.press(i, at, tool); } - // A right-click goes to what a press there would go to: the top hit under the pointer. - let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| top(hits.at(view, p), tool)).map(|h| h.owner); + let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); } @@ -303,6 +302,27 @@ fn link_of(owner: Option) -> Option { } } +/// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the +/// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is +/// selected; links take theirs from what this returns. +fn right_click(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { + let context = hits.context_at(view, p); + // Edit text's own paragraphs and pictures keep the right-click as they take it today, and a + // Prepare field tool the whole page. + let editing_here = matches!(hits.tool, QuickTool::AddText | QuickTool::EditText) && top(hits.at(view, p), hits.tool).is_some(); + if !editing_here && hits.field_tool.is_none() { + let comment = match context { + Some(Owner::Comment { index, .. }) => Some(index), + _ => None, + }; + comments::context(hits.pcx, p, comment, view); + } + if let Some(Owner::PrepareField { field, widget }) = context { + crate::prepare::context(hits.form, field, widget, view); + } + context +} + /// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. fn edit_inventory( view: &mut DocView, @@ -348,12 +368,25 @@ struct PageHits<'a> { } impl PageHits<'_> { + /// The hits at `p`, bottom up ([`Self::hits`]). + fn at(&self, view: &DocView, p: Pos2) -> Vec { + self.hits(view, p, false) + } + + /// What a right-click at `p` is on: what lies on top there, as for a press, but with every + /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight + /// over a link is the highlight's ([`comments::context_hit`]). + fn context_at(&self, view: &DocView, p: Pos2) -> Option { + top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner) + } + /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text /// on top of artwork takes the press), added content, the objects selected together and the /// open paragraph's frame, the selected picture's grips over them all; then form fields /// (Prepare's, being moved, over the comments; filled ones under added content and the /// comments that move). - fn at(&self, view: &DocView, p: Pos2) -> Vec { + /// For a right-click (`context`), comments by [`comments::context_hit`]. + fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec { let PageHits { page, xf, @@ -415,7 +448,7 @@ impl PageHits<'_> { let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); - let comment = comments::hit(pcx, view, p, link.is_some()); + let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) }; [link, comment, pictures, paragraphs, added, objects, grips, fields].into_iter().flatten().collect() } } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 4f9398be85..f9fc72bbcd 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -417,7 +417,11 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. const HIT_TESTERS: [(&str, &str, &str); 4] = [ - ("canvas/page_input.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), + ( + "canvas/page_input.rs", + "hits", + "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", + ), ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index cb8bafe642..d0c46d0453 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized}; pub use geometry::{drawn_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; -pub(crate) use input::{describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input}; +pub(crate) use input::{context, context_hit, describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 527df65d01..a12d971f5c 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -27,12 +27,6 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, if cv.cancelled_drag { return true; } - if resp.secondary_clicked() - && let Some(p) = pointer.filter(|p| page_rect.contains(*p)) - { - cv.context_at = Some((cx.page, cx.to_user(p))); - cv.selected = cx.hit(p).map(|a| (cx.page, a.index)); - } let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; match cx.tool { QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin), @@ -106,6 +100,25 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, icon) }) } +/// The comment a right-click at `p` is on: the topmost, with every tool, as a comment's menu shows +/// whatever tool is active. A highlight over a link is the highlight's here (a left click on it +/// follows the link, [`hit`]). `None` off the page or with comments hidden. +pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option { + if cx.hidden || !cx.xf.rect.contains(p) { + return None; + } + let a = cx.hit(p)?; + let layer = if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment }; + Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, egui::CursorIcon::Default)) +} + +/// A right-click at `p` on this page: the page's menu is anchored there (Copy Image), and the +/// comment it is on ([`context_hit`], when it wins there) is selected, or none. +pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option, view: &mut DocView) { + view.comments.context_at = Some((cx.page, cx.to_user(p))); + view.comments.selected = index.map(|i| (cx.page, i)); +} + /// Whether the comments take the press on this page (or, with no button down, would take one /// under the pointer), so the tools beneath leave it: a live move, resize or reshape, or a press /// held by a comment that keeps its drag. diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index cc4b81441e..78568f46db 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -398,24 +398,6 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti return out; } - // Right-click a field: select it (the context menu acts on it). - if resp.secondary_clicked() - && let Some((f, wi)) = form.iter().find_map(|f| { - f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i)) - }) - { - let key = (f.name.clone(), wi); - if let Some(i) = prep.also.iter().position(|k| *k == key) { - // Part of the selection: it becomes the anchor the others line up with. - prep.also.remove(i); - if let Some(old) = prep.selected.replace(key) { - prep.also.push(old); - } - } else if prep.selected.as_ref() != Some(&key) { - prep.selected = Some(key); - prep.also.clear(); - } - } // The Select tool: what the press was on ([`hit`]), a corner handle or a field; elsewhere a // drag draws a selection rectangle. A press held by something drawn over the fields (a // comment) is not Prepare's. @@ -469,6 +451,24 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti out } +/// A right-click on widget `widget` of field `field` (the top hit there, [`hit`]): the field is +/// selected, so the menu acts on it. Part of a selection of several, it becomes the anchor the +/// others line up with. +pub(crate) fn context(form: &[FormField], field: usize, widget: usize, view: &mut DocView) { + let Some(f) = form.get(field) else { return }; + let key = (f.name.clone(), widget); + let prep = &mut view.prepare; + if let Some(i) = prep.also.iter().position(|k| *k == key) { + prep.also.remove(i); + if let Some(old) = prep.selected.replace(key) { + prep.also.push(old); + } + } else if prep.selected.as_ref() != Some(&key) { + prep.selected = Some(key); + prep.also.clear(); + } +} + /// Align, Center, Distribute and Set Fields to Same Size (Prepare a form, several fields /// selected). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 1ee9676ad2..c868dc8079 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1089,3 +1089,43 @@ fn a_right_click_on_a_note_over_a_link_opens_the_notes_menu() { assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the note is selected, not the link"); assert!(h.query_by_label("Remove all links").is_none(), "the menu is the note's"); } + +/// [`link_pdf`], with a highlight drawn over the middle of its link. +fn highlight_over_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + "<< /Type /Annot /Subtype /Highlight /Rect [100 310 200 340] /QuadPoints [100 340 200 340 100 310 200 310] /C [1 1 0] /F 4 /T (Ana) >>", + ]) +} + +/// A click on a highlight over a link follows the link, but a right-click there is the +/// highlight's: it is selected and its menu opens. The link's menu used to open, with the +/// highlight selected underneath. +#[test] +fn a_right_click_on_a_highlight_over_a_link_opens_the_highlights_menu() { + let mut h = open(highlight_over_link_pdf(), "150", false); + let on_both = at(&h, 150.0, 325.0); + right_click(&mut h, on_both); + let v = &h.state().views[0]; + assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the highlight is selected, not the link"); + assert!(h.query_by_label("Remove all links").is_none(), "the menu is the highlight's"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + mouse_click(&mut h, on_both); + assert!(h.state().pending_link.is_some(), "a click there still follows the link"); +} + +/// A comment's menu opens on a right-click whatever the tool, though a press there is the tool's: +/// with the Rectangle tool, a right-click on a comment selects it. +#[test] +fn a_right_click_on_a_comment_with_a_drawing_tool_opens_its_menu() { + let mut h = open(comment_on_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); + h.run_steps(4); + let on_comment = at(&h, 100.0, 305.0); + right_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu"); +} From 1780f33350b1b7dc521ecc25bea3fba4e1d02509 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:23:07 +0100 Subject: [PATCH 21/63] refactor(ui): tell each page whether the press is its own Each module asked the router `owner(page).is_none()` to learn whether a press was the active tool's. That reads a press held on another page, or begun between the pages, as the tool's too. The router now remembers the page a press began on (`begin` on every new press, then `press` on the page it lands on), and `press_for(page)` answers what a page has to do with it: - `Mine(owner)`: the press began on this page and the owner's hit won it; - `Free`: no button is down, or the press began on this page and no hit won it, so it is the active tool's; - `Elsewhere`: the press began on another page, or between the pages. The page view asks once per page, and the stamps and the Link tool use the answer. The other modules move to it in the next commits. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas/page_input.rs | 10 +-- .../ui-egui/src/canvas/page_pointer/tests.rs | 2 +- crates/ui-egui/src/canvas/page_router.rs | 68 ++++++++++++++++++- 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 289edf811d..428f71c186 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1604,9 +1604,9 @@ fn draw_pages( cancel_on_escape(ui.ctx(), view, &ptr); view.router.settle(ptr.any_down, !ptr.edges.is_empty()); // A new press is no longer the last one's, wherever it lands (the page it is on gives it - // its owner; in the gap between pages it has none). + // its owner; in the gap between pages it is no page's). if ptr.pressed().is_some() { - view.router.cancel(); + view.router.begin(); // The page widget is not focusable. Explicitly return keyboard ownership from Pages // when the user presses the document, without stealing focus through a modal. if let Some(id) = view.pages_focus_id { diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 5d6dcb8054..00edfe54cc 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -7,7 +7,7 @@ use pdfcraft_render::{DocInfo, PageText}; use super::{DocView, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, signature_badges}; use crate::page_cursor::{Layer, Subject}; use crate::page_pointer::PagePointer; -use crate::page_router::{Owner, top}; +use crate::page_router::{Owner, PagePress, top}; use crate::{QuickTool, comments, icons}; /// What the page's tools did with the pointer this frame. @@ -98,6 +98,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let at = hits.at(view, o); view.router.press(i, at, tool); } + // What this page has to do with the press: its owner's, the active tool's, or another page's. + let press = view.router.press_for(i); let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); @@ -111,7 +113,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { + if resp.clicked() && press == PagePress::Free { // Centred on the click, upright as the page is shown. let (vx, vy) = xf.screen_to_view(p); let (w, h) = kind.size(); @@ -136,7 +138,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { + if resp.clicked() && press == PagePress::Free { // Centred on the click at its natural size (the engine sizes it). let (vx, vy) = xf.screen_to_view(p); let u = info.pages[i].view_to_user(vx, vy); @@ -273,7 +275,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut crate::content_ui::sync_image_selection(view, i, &images, added); } } - let (link_owner, link_context) = (link_of(view.router.owner(i)), link_of(context)); + let (link_owner, link_context) = (link_of(press.owner()), link_of(context)); let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, link_owner, link_context, view); if tool == QuickTool::Select && can_modify diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index f9fc72bbcd..2e79b7481e 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -384,7 +384,7 @@ fn page_modules_claim_the_cursor_and_never_set_it() { } /// What decides, keeps or drops the press the pages hold. -const ROUTER_WRITES: [&str; 3] = ["router.press(", "router.settle(", "router.cancel("]; +const ROUTER_WRITES: [&str; 4] = ["router.begin(", "router.press(", "router.settle(", "router.cancel("]; /// The functions that decide, keep or drop the press the pages hold: (file, function, why). const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index a9f7500189..f1aa199c67 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -6,7 +6,8 @@ //! press point decide who captures it ([`PageRouter::press`]), by the same table: the highest layer //! the active tool reaches. Until the button is up only the owner acts on the press, its drag and //! its release, so what the cursor promised is what the press does, and nothing takes a gesture -//! back halfway. +//! back halfway. Each page is told what it has to do with the press ([`PagePress`]): its owner's, +//! the active tool's, or none of its business. use egui::CursorIcon; @@ -106,6 +107,28 @@ pub(crate) fn top(hits: impl IntoIterator, tool: QuickTool) -> Optio }) } +/// What one page has to do with the press the pages hold. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PagePress { + /// The press began on this page, and the owner's hit won it: only its module acts on it. + Mine(Owner), + /// No button is down, or the press began on this page and nobody's hit won it: the active + /// tool's. + Free, + /// The press began on another page, or between the pages. + Elsewhere, +} + +impl PagePress { + /// Who holds the press on this page, if anyone. + pub(crate) fn owner(self) -> Option { + match self { + PagePress::Mine(owner) => Some(owner), + PagePress::Free | PagePress::Elsewhere => None, + } + } +} + /// A press the pages hold, and who it belongs to. #[derive(Clone, Debug, PartialEq)] pub(crate) struct Capture { @@ -118,13 +141,33 @@ pub(crate) struct Capture { #[derive(Debug, Default)] pub(crate) struct PageRouter { capture: Option, + /// A press is held, and the page it began on (`None`: between the pages). + began: Option>, } impl PageRouter { + /// A new press on the pages: the last one's owner goes, and until a page claims it the press is + /// between the pages. + pub(crate) fn begin(&mut self) { + self.capture = None; + self.began = Some(None); + } + /// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's /// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page). pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { self.capture = top(hits, tool).map(|h| Capture { page, owner: h.owner, declines_drag: h.declines_drag }); + self.began = Some(Some(page)); + } + + /// What `page` has to do with the press the pages hold. + pub(crate) fn press_for(&self, page: usize) -> PagePress { + match (self.began, &self.capture) { + (None, _) => PagePress::Free, + (Some(Some(p)), Some(c)) if p == page => PagePress::Mine(c.owner), + (Some(Some(p)), None) if p == page => PagePress::Free, + _ => PagePress::Elsewhere, + } } /// Who holds the press on `page`. @@ -142,12 +185,14 @@ impl PageRouter { pub(crate) fn settle(&mut self, any_down: bool, edges: bool) { if !any_down && !edges { self.capture = None; + self.began = None; } } /// Drops the press (Escape, a dialog, automation's cancel). pub(crate) fn cancel(&mut self) { self.capture = None; + self.began = None; } } @@ -185,6 +230,27 @@ mod tests { assert!(!r.keeps_drag()); } + /// A page is told whether the press is its owner's, the active tool's, or another page's. + #[test] + fn each_page_is_told_whether_the_press_is_its_own() { + let mut r = PageRouter::default(); + assert_eq!(r.press_for(3), PagePress::Free, "no button down: every page is the tool's"); + r.begin(); + assert_eq!(r.press_for(0), PagePress::Elsewhere, "a press between the pages is no page's"); + r.press(2, [], QuickTool::Select); + assert_eq!((r.press_for(2), r.press_for(1)), (PagePress::Free, PagePress::Elsewhere), "nobody's, on page 2"); + r.begin(); + r.press(1, [hit(Layer::Comment, 4)], QuickTool::Select); + assert_eq!((r.press_for(1), r.press_for(2)), (PagePress::Mine(hit(Layer::Comment, 4).owner), PagePress::Elsewhere)); + r.settle(false, true); + assert_eq!(r.press_for(2), PagePress::Elsewhere, "until its release has been seen"); + r.settle(false, false); + assert_eq!((r.press_for(1), r.press_for(2)), (PagePress::Free, PagePress::Free)); + r.begin(); + r.cancel(); + assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press is nobody's"); + } + #[test] fn a_highlight_lets_its_drag_go_to_the_text() { let mut r = PageRouter::default(); From 84fe5e54b31ad62ea86feb360aaeaecfbda23b37 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:28:48 +0100 Subject: [PATCH 22/63] refactor(ui): tell comments and Fill & Sign whether the press is theirs Comments ran last in the page view's chain of `||`, so only when no earlier module had taken the frame, and asked the router themselves (`grabs`, `fill_grabs`, `draw_grabs`), with a hit test of their own for hover. A link, a field or added text taking the frame first hid a comment's gesture end, its Escape and the click that unselects it. Comments now run on every frame and are told the page's `PagePress`: - a press on a comment (its body, a handle or a point) goes to `select_input` with the part, with the Select tool, a shape tool (the selected shape's handles) or Fill & Sign (its marks); - the drawing tools act only on a press nobody holds (`Gestures::free`); - a click on anything but a comment leaves comments unselected. `grabs`, `fill_grabs`, `draw_grabs` and `cancelled_drag` (with the raw button read in `keys` it needed) are gone; `grabs_points` is `reaches_points`. Fill & Sign leaves the press to a mark when the press is someone's, or, with no button down, when a mark is the top hit under the pointer. The page's hover is that top hit (`PageInput.hover`), and the comments' hover outline follows it, so it no longer shows under a field or added text that would take a press. Behaviour change (the user's ruling, 2026-10-31): a click on a form field, or on added text, leaves the selected comment unselected, as a click on a link or the page already did. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/canvas/page_input.rs | 37 ++++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 7 +- crates/ui-egui/src/canvas/signature_drag.rs | 3 +- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/geometry.rs | 4 +- crates/ui-egui/src/tools/comments/input.rs | 115 ++++++++---------- crates/ui-egui/src/tools/comments/paint.rs | 28 +++-- crates/ui-egui/src/tools/comments/state.rs | 2 - crates/ui-egui/src/tools/comments/tests.rs | 36 ++++++ crates/ui-egui/tests/it/pointer.rs | 26 ++++ 11 files changed, 164 insertions(+), 99 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 428f71c186..861833d25a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1496,7 +1496,8 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i let slot = PageSlot { i, r, xf }; // A cancelled press takes nothing until the button is up. - let input = if view.press_cancelled { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) }; + let input = + if view.press_cancelled { PageInput { consumed: true, area_tool: false, hover: None } } else { page_tool_input(ui, view, frame, &slot) }; let painter = ui.painter(); let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 00edfe54cc..05a302926d 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -16,6 +16,8 @@ pub(super) struct PageInput { pub(super) consumed: bool, /// Redact or Highlight draws boxes off text. pub(super) area_tool: bool, + /// What a press under the pointer would take (the top hit there), for hover painting. + pub(super) hover: Option, } /// Common geometry and pointer state for a tool acting on one page. @@ -153,10 +155,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut frame.out.stamp_placed = true; } } - // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). + // A mark already placed takes its own press (comments, below), and with no button down the + // tool's preview hides over one, where a press would pick it up. + let over_mark = matches!(tool, QuickTool::Fill(_)) + && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); if let QuickTool::Fill(ft) = tool && allowed - && !comments::fill_grabs(&pcx, view) + && !over_mark { match crate::fill_sign::page_input( cx, @@ -283,17 +288,23 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut { view.links.context = Some((i, index)); } - let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); - // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the - // last claim on a layer stands). - if frame.env.unobstructed - && let Some(p) = pointer - { - for h in hits.at(view, p) { - view.cursor.claim_with_tip(h.layer, h.icon, h.tip); - } + // Comments, every frame: their own presses, a commenting tool's, and a click elsewhere that + // leaves them unselected. + let on_comment = comments::page_input(ui, resp, &pcx, press, view); + let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; + let hover = pointer.filter(|_| frame.env.unobstructed).and_then(|p| claim_under(&hits, view, p)); + PageInput { consumed, area_tool, hover } +} + +/// The cursor and the tip from what lies under the pointer at `p`, after the tools claimed theirs +/// (the last claim on a layer stands). Returns the top of it, what a press there would take, for +/// hover painting. +fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { + let under = hits.at(view, p); + for h in &under { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); } - PageInput { consumed, area_tool } + top(under, hits.tool) } /// The link `owner` is, by its `/Annots` index. @@ -701,7 +712,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P if input.area_tool { crate::redact_ui::paint(painter, i, view, pointer); } - comments::paint_page(ui, painter, &pcx, view); + comments::paint_page(ui, painter, &pcx, view, input.hover.as_ref()); if editing_content { crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer); } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 2e79b7481e..865f5051b0 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -217,14 +217,13 @@ const RAW_READS: [&str; 21] = [ /// Functions in the page modules that are not page input and read egui's pointer state /// themselves: (file, function, why). -const NOT_PAGE_INPUT: [(&str, &str, &str); 11] = [ +const NOT_PAGE_INPUT: [(&str, &str, &str); 10] = [ ("canvas/area.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"), ("canvas/area.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"), ("canvas/area.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"), ("canvas/area.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"), ("canvas/bars.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"), ("canvas/organize.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"), - ("tools/comments/input.rs", "keys", "keyboard handling after the pages: a drag cancelled with Escape ends once no button is down"), ("tools/forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"), ("tools/content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"), ("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"), @@ -416,7 +415,7 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. -const HIT_TESTERS: [(&str, &str, &str); 4] = [ +const HIT_TESTERS: [(&str, &str, &str); 3] = [ ( "canvas/page_input.rs", "hits", @@ -424,7 +423,6 @@ const HIT_TESTERS: [(&str, &str, &str); 4] = [ ), ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), - ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), ]; /// What ignores the press the pages hold until the button is up. @@ -493,7 +491,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { ("tools/edit_text_ui.rs", "page_input"), ("tools/edit_text_ui/image.rs", "image_input"), ("tools/object_ui.rs", "input"), - ("tools/comments/input.rs", "select_input"), ] { let source = std::fs::read_to_string(src.join(file)).expect("page input module"); let body = source.lines().skip_while(|line| declares(line) != Some(function)).take_while(|line| *line != "}").collect::>().join("\n"); diff --git a/crates/ui-egui/src/canvas/signature_drag.rs b/crates/ui-egui/src/canvas/signature_drag.rs index c706b8a113..9f7a071ac7 100644 --- a/crates/ui-egui/src/canvas/signature_drag.rs +++ b/crates/ui-egui/src/canvas/signature_drag.rs @@ -402,8 +402,7 @@ mod tests { let doc = session.get(id).expect("open"); let index = doc.info.annotations.iter().find(|a| a.subtype == "Sound").expect("listed").index; let ctx = egui::Context::default(); - let mut cv = CommentView::default(); - cv.gesture = Some(Gesture::Move { page: 0, index, from: pos2(0.0, 0.0) }); + let mut cv = CommentView { gesture: Some(Gesture::Move { page: 0, index, from: pos2(0.0, 0.0) }), ..CommentView::default() }; let mut drag = SignatureDrag::default(); let key = Some((doc.edit_generation(), 0, index)); drag.prepare_move(&ctx, doc, &cv, None, false, 1.0); diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index d0c46d0453..83ee620ae1 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized}; pub use geometry::{drawn_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; -pub(crate) use input::{context, context_hit, describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input}; +pub(crate) use input::{context, context_hit, describe, hit, keys, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index 051cf09dba..ee67b92bb0 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -20,10 +20,10 @@ pub(super) fn reshapable(a: &Annotation) -> bool { && !a.intent.as_deref().is_some_and(|i| i.ends_with("Dimension")) } -/// Whether `tool` picks up a line's end and corner handles: the Select and Fill & Sign tools. +/// Whether `tool` reaches a line's end and corner handles: the Select and Fill & Sign tools. /// The drawing tools draw from where they are pressed, so a new line starts at the end of the /// one just drawn rather than dragging it. -pub(super) fn grabs_points(tool: QuickTool) -> bool { +pub(super) fn reaches_points(tool: QuickTool) -> bool { !matches!(tool, QuickTool::Comment(t) if t.draws() || t.clicks_points()) } diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index a12d971f5c..7ada11bc7f 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -1,43 +1,47 @@ use super::geometry::{ - CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, grabs_points, handle_pos, is_markup, point_handle, reshapable, reshaped, resizable, + CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, is_markup, point_handle, reaches_points, reshapable, reshaped, resizable, resized, snapped_caret, }; use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; -use crate::page_router::{CommentPart, Hit, Owner}; +use crate::page_router::{CommentPart, Hit, Owner, PagePress}; -/// Comment input on one page, before text selection runs. Returns `true` when the pointer -/// gesture belongs to commenting (text selection must ignore it). -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) -> bool { +/// Comment input on one page, before text selection runs. `press` is what this page has to do with +/// the press the pages hold: a press on a comment ([`hit`]) is that comment's, with any tool, and +/// one nobody holds is a commenting tool's. Returns `true` when the pointer gesture belongs to +/// commenting (text selection must ignore it). +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); let page_rect = cx.xf.rect; - let pressed_here = origin.is_some_and(|o| page_rect.contains(o)); + let free = press == PagePress::Free; + let pressed_here = free && origin.is_some_and(|o| page_rect.contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); - let fill_grab = fill_grabs(cx, view); - let draw_grab = draw_grabs(cx, view); + let owned = match press { + PagePress::Mine(Owner::Comment { index, part }) => Some((index, part)), + _ => None, + }; let caret_text = if cx.tool == QuickTool::Comment(CommentTool::Caret) { view.page_text(cx.page) } else { None }; let cv = &mut view.comments; if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { cv.gesture = None; - cv.cancelled_drag = true; return true; } - if cv.cancelled_drag { - return true; - } - let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; + // A comment being moved, resized or reshaped on this page keeps the frame until it ends. + let moving = + matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page); + let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free }; match cx.tool { - QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin), + // The selected shape's handles, and Fill & Sign's marks, take their own presses. + QuickTool::Comment(_) | QuickTool::Fill(_) if owned.is_some() || moving => select_input(ui, resp, cx, view, owned, pointer, origin), QuickTool::Comment(tool) if tool.draws() => gestures.draw_shape(view, tool), QuickTool::Comment(tool) if tool.clicks_points() => gestures.click_points(view, tool), QuickTool::Comment(CommentTool::Callout) => gestures.callout(view), QuickTool::Comment(CommentTool::Attach) => gestures.attach(view), QuickTool::Comment(CommentTool::Caret) => gestures.caret(view, caret_text.as_deref()), QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => gestures.note_or_box(view, tool), - QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin), - QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin), + QuickTool::Select => select_input(ui, resp, cx, view, owned, pointer, origin), _ => false, } } @@ -68,7 +72,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op }; let bodies = matches!(cx.tool, QuickTool::Select | QuickTool::Fill(_)); if let Some(a) = view.comments.selected.filter(|(page, _)| *page == cx.page).and_then(|(_, i)| cx.get(i)).filter(|a| pick(a) && cx.allowed) { - if grabs_points(cx.tool) + if reaches_points(cx.tool) && reshapable(a) && let Some(point) = point_handle(cx, a, p) { @@ -119,32 +123,6 @@ pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option, view: &mut view.comments.selected = index.map(|i| (cx.page, i)); } -/// Whether the comments take the press on this page (or, with no button down, would take one -/// under the pointer), so the tools beneath leave it: a live move, resize or reshape, or a press -/// held by a comment that keeps its drag. -fn grabs(cx: &PageCx<'_>, view: &DocView) -> bool { - if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) - { - return true; - } - if cx.ptr.any_down { - return matches!(view.router.owner(cx.page), Some(Owner::Comment { .. })) && view.router.keeps_drag(); - } - // Only the Select tool lets a link through a highlight, and it doesn't come here. - cx.ptr.pos.and_then(|p| hit(cx, view, p, false)).is_some_and(|h| !h.declines_drag) -} - -/// With a Fill & Sign tool, a Fill & Sign mark already on the page is picked up as with the Select -/// tool ([`hit`]), so the tool does not place a mark there. -pub(crate) fn fill_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { - matches!(cx.tool, QuickTool::Fill(_)) && grabs(cx, view) -} - -/// With a shape tool, the selected comment's handles are picked up ([`hit`]). -fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { - matches!(cx.tool, QuickTool::Comment(_)) && grabs(cx, view) -} - /// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat /// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays /// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a @@ -180,13 +158,19 @@ pub(super) fn hover_tip(a: &Annotation, prefs: &CommentPrefs) -> Option Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) } -/// The Select tool: select, move, resize, edit, delete, on the comment the press was on (its -/// owner, from [`hit`]). The cursor comes from [`hit`] too. -fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView, pointer: Option, origin: Option) -> bool { - let owner = match view.router.owner(cx.page) { - Some(Owner::Comment { index, part }) => cx.get(index).map(|a| (a, part)), - _ => None, - }; +/// The Select tool: select, move, resize, edit, delete, on the comment the press is on and the +/// part of it (`owned`, from [`hit`]). The cursor comes from [`hit`] too. A click on anything else +/// (a link, a field, added text, the page) leaves comments unselected. +fn select_input( + ui: &egui::Ui, + resp: &egui::Response, + cx: &PageCx<'_>, + view: &mut DocView, + owned: Option<(usize, CommentPart)>, + pointer: Option, + origin: Option, +) -> bool { + let owner = owned.and_then(|(index, part)| cx.get(index).map(|a| (a, part))); let cv = &mut view.comments; let mut consumed = false; if resp.drag_started() @@ -262,8 +246,8 @@ fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mu // (egui clears the press origin on release, so clicks are located by the pointer.) let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); if resp.clicked() && on_page { - // A click elsewhere (a link beneath text highlighting included: it follows the link) - // leaves comments unselected. + // A click elsewhere (a link beneath text highlighting included: it follows the link; a + // form field; added text) leaves comments unselected. match owner { Some((a, _)) => { cv.selected = Some((cx.page, a.index)); @@ -326,13 +310,21 @@ struct Gestures<'a> { pointer: Option, origin: Option, page_rect: Rect, + /// The press began on this page, and nobody holds it: the tool's. pressed_here: bool, over_page: bool, + /// Nobody holds the press, and it began on this page, or no button is down. + free: bool, } impl Gestures<'_> { + /// The tool's own click: one nobody holds. + fn clicked(&self) -> bool { + self.free && self.resp.clicked() + } + fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool { - let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { return false; @@ -380,7 +372,7 @@ impl Gestures<'_> { // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a // single PDF point is visible and remains editable like any other drawing. if matches!(tool, CommentTool::Ink | CommentTool::FreehandHighlight) - && resp.clicked() + && self.clicked() && over_page && let Some(p) = pointer && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) @@ -412,7 +404,7 @@ impl Gestures<'_> { cv.undo_point(); return true; } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -492,7 +484,7 @@ impl Gestures<'_> { } fn attach(&self, view: &mut DocView) -> bool { - let Self { resp, cx, pointer, over_page, .. } = *self; + let Self { cx, pointer, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { return false; @@ -500,7 +492,7 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -511,7 +503,7 @@ impl Gestures<'_> { } fn caret(&self, view: &mut DocView, caret_text: Option<&pdfcraft_render::PageText>) -> bool { - let Self { resp, cx, pointer, over_page, .. } = *self; + let Self { cx, pointer, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { return false; @@ -519,7 +511,7 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text); } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -573,7 +565,7 @@ impl Gestures<'_> { } } } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -587,9 +579,6 @@ impl Gestures<'_> { /// Delete / Escape handling for comments (only while no text field has focus). pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) { - if !ctx.input(|i| i.pointer.any_down()) { - view.comments.cancelled_drag = false; - } if ctx.egui_wants_keyboard_input() { return; } diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 59b5ca773a..e784c332ae 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,12 +1,24 @@ -use super::geometry::{HANDLES, grabs_points, handle_pos, is_markup, reshapable, reshaped, resizable}; -use super::state::{Gesture, PageCx, SETTLE_SECS}; +use super::geometry::{HANDLES, handle_pos, is_markup, reaches_points, reshapable, reshaped, resizable}; +use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, Stroke, color32, vec2, }; +use crate::page_router::{CommentPart, Hit, Owner}; -/// Paint comment selection, hover and gesture previews on a page. -pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { +/// The comment outlined under the pointer: the one a press there would take (`hover`, the top hit), +/// with the Select tool or, over its own marks, Fill & Sign. Not the selected comment, nor while a +/// comment is moved, resized or drawn, nor one under a form field or added text that would take +/// the press. +pub(super) fn hover_outline<'c>(cx: &'c PageCx<'_>, cv: &CommentView, hover: Option<&Hit>) -> Option<&'c Annotation> { + let Some(Owner::Comment { index, part: CommentPart::Body }) = hover.map(|h| h.owner) else { return None }; + let tool_outlines = matches!(cx.tool, QuickTool::Select | QuickTool::Fill(_)); + (cv.gesture.is_none() && tool_outlines && cv.selected != Some((cx.page, index))).then(|| cx.get(index)).flatten() +} + +/// Paint comment selection, hover and gesture previews on a page. `hover` is the top hit under the +/// pointer. +pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, hover: Option<&Hit>) { let cv = &view.comments; let pointer = cx.ptr.pos; // A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the @@ -21,11 +33,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> painter.rect_filled(rect, CornerRadius::ZERO, fill); } } - if cv.gesture.is_none() - && let Some(a) = pointer.and_then(|p| cx.hit(p)) - && (cx.tool == QuickTool::Select || matches!(cx.tool, QuickTool::Fill(_)) && a.fill_sign) - && cv.selected != Some((cx.page, a.index)) - { + if let Some(a) = hover_outline(cx, cv, hover) { for r in cx.screen_rects(a) { painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside); } @@ -45,7 +53,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> return paint_gesture(painter, cx, view); } let shift = ui.input(|i| i.modifiers.shift); - if cx.allowed && grabs_points(cx.tool) && reshapable(a) { + if cx.allowed && reaches_points(cx.tool) && reshapable(a) { return paint_points(painter, cx, view, a, pointer, shift); } let r = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref()).expand(2.0); diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 6cac912210..07452f5b49 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -133,8 +133,6 @@ pub struct CommentView { /// Drawings just released: shown from their points until the page raster that includes the /// committed annotation arrives, so the stroke never vanishes in between (#1858). pub(crate) settling: Vec, - /// Escape ends egui's drag too; ignore its synthetic release until the mouse is up. - pub(super) cancelled_drag: bool, pub composer: Option, /// Reply being typed under the selected card. pub reply: String, diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 6110e8a6fc..0f9edd6c22 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -499,3 +499,39 @@ fn text_boxes_fit_their_text() { assert_eq!(long[2], 300.0); assert!(long[3] - long[1] > 50.0); } + +/// The hover outline goes round the comment a press under the pointer would take (the top hit +/// there): not a highlight under a form field, which takes the press. +#[test] +fn the_hover_outline_goes_round_what_a_press_would_take() { + use super::paint::hover_outline; + use crate::page_cursor::Layer; + use crate::page_router::{CommentPart, Hit, Owner}; + let page = PageInfo { width: 200.0, height: 200.0, label: String::new(), crop: [0.0, 0.0, 200.0, 200.0], rotation: 0 }; + let xf = PageXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), rot: 0, pw: 200.0, ph: 200.0 }; + let highlight = Annotation { rect: [50.0, 50.0, 150.0, 150.0], ..annotation("Highlight", None, false) }; + let info = DocInfo { pages: vec![page], annotations: vec![highlight], ..Default::default() }; + let prefs = CommentPrefs::default(); + // The pointer is over the highlight. + let ptr = PagePointer { pos: Some(pos2(100.0, 100.0)), ..PagePointer::default() }; + let cx = PageCx { + page: 0, + xf: &xf, + ptr: &ptr, + info: &info, + tool: QuickTool::Select, + prefs: &prefs, + allowed: true, + hidden: false, + redact_mark: ([0.0; 3], 1.0), + }; + let on = |owner, layer| Hit::new(owner, layer, egui::CursorIcon::PointingHand); + let comment = on(Owner::Comment { index: 0, part: CommentPart::Body }, Layer::Comment); + let field = on(Owner::Field { field: 0, widget: 0 }, Layer::Field); + let cv = CommentView::default(); + assert!(hover_outline(&cx, &cv, Some(&comment)).is_some(), "the highlight a press there selects"); + assert!(hover_outline(&cx, &cv, Some(&field)).is_none(), "not under a field that takes the press"); + assert!(hover_outline(&cx, &cv, None).is_none(), "nor where nothing would take it"); + let selected = CommentView { selected: Some((0, 0)), ..CommentView::default() }; + assert!(hover_outline(&cx, &selected, Some(&comment)).is_none(), "the selected comment has its own frame"); +} diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index c868dc8079..b63d9937fb 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1129,3 +1129,29 @@ fn a_right_click_on_a_comment_with_a_drawing_tool_opens_its_menu() { right_click(&mut h, on_comment); assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu"); } + +/// The check box `agree` of [`checkbox_pdf`], with a rectangle comment at x 150..250, y 100..150. +fn comment_and_checkbox_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 5 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [100 200 120 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + "<< /Type /Annot /Subtype /Square /Rect [150 100 250 150] /C [1 0 0] /F 4 /T (Ana) >>", + ]) +} + +/// A click on anything but the selected comment leaves it unselected, a form field included, as +/// a click on a link or on the page does. A field's click used to keep it selected. +#[test] +fn a_click_on_a_form_field_deselects_the_selected_comment() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + let comment = at(&h, 200.0, 125.0); + mouse_click(&mut h, comment); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the comment is selected"); + let check_box = at(&h, 110.0, 210.0); + mouse_click(&mut h, check_box); + assert_eq!(h.state().views[0].comments.selected, None, "a click on the check box leaves it unselected"); + assert_eq!(agree(&h).len(), 1, "and ticks the box"); +} From 951ef7e3627288484499d73a7dd9e1ff533283ce Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:34:49 +0100 Subject: [PATCH 23/63] refactor(ui): one gate for the presses nobody holds The tools that act wherever on the page each decided for themselves whether a press was theirs: Sign area, zoom and snapshot, crop, redact and the stamps asked the router, and Measure, Fill & Sign, the camera and the Link tool's rectangle acted on any press. Stamp placement lived inline in the page view. `tool_input` is now the one gate. Measure, the Stamp tool and custom stamps, Fill & Sign, Sign area, zoom and snapshot, the camera and crop are each told whether the press is the tool's (it began on their page and no hit won it), and act only then; their cursors and gestures run as before. Redact's boxes and the Link tool's rectangle are told the same where they are called. Stamp placement moves to `stamps_ui::place` and `place_custom`, beside custom stamps. Measure is told its clicks (`measure_ui::Clicks`), Sign area takes a `PageToolContext`. The camera's corners are hits (`camera_ui::hit`, `Owner::CameraCorner` on the handle layer), so a corner's drag is an owned press like any other: it starts on the corner's own press, ends on `drag_stopped() || !primary_down`, and `cancel_pointer_gestures` drops it. Until now only the order of two key handlers kept Escape mid-drag from leaving a corner following the pointer: Escape leaves the camera tool before the pages see it. The kittest for Escape mid-drag can't fail with that order; a unit test of the cancel comes later in this series. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/canvas/crop.rs | 8 +- crates/ui-egui/src/canvas/page_input.rs | 210 +++++++++--------- .../ui-egui/src/canvas/page_pointer/tests.rs | 6 +- crates/ui-egui/src/canvas/page_router.rs | 2 + crates/ui-egui/src/canvas/zoom_snap.rs | 11 +- crates/ui-egui/src/tools/camera_ui.rs | 57 +++-- crates/ui-egui/src/tools/fill_sign.rs | 5 +- crates/ui-egui/src/tools/link_ui.rs | 10 +- crates/ui-egui/src/tools/measure_ui.rs | 20 +- crates/ui-egui/src/tools/redact_ui.rs | 10 +- crates/ui-egui/src/tools/sign_ui.rs | 21 +- crates/ui-egui/src/tools/stamps_ui.rs | 50 +++++ crates/ui-egui/tests/it/pointer.rs | 37 +++ 14 files changed, 281 insertions(+), 169 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 861833d25a..30d2799f68 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -863,6 +863,9 @@ impl DocView { self.sign.drag = None; self.redact_drag = None; self.crop_drag = None; + if let Some(q) = &mut self.camera { + q.grab = None; + } self.block_drag = None; self.org_drag = None; self.panel_drag = None; diff --git a/crates/ui-egui/src/canvas/crop.rs b/crates/ui-egui/src/canvas/crop.rs index bdd1e9e135..7858b00dd6 100644 --- a/crates/ui-egui/src/canvas/crop.rs +++ b/crates/ui-egui/src/canvas/crop.rs @@ -27,10 +27,12 @@ pub(crate) struct CropPageInput<'a> { pub(crate) info: &'a DocInfo, pub(crate) view: &'a mut DocView, pub(crate) allowed: bool, + /// Nobody holds the press, and it began on this page: the tool's. + pub(crate) free: bool, } pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { - let CropPageInput { resp, xf, ptr, page, info, view, allowed } = input; + let CropPageInput { resp, xf, ptr, page, info, view, allowed, free } = input; let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); @@ -38,9 +40,9 @@ pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { if !allowed { return false; } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.crop_drag = Some((page, o)); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 05a302926d..40552fc1ec 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -39,30 +39,20 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let PageEnv { added, allowed, - author, - by_line, - can_crop, can_fill, can_modify, comments_hidden, - custom_stamp, - date_text, doc, doc_links, editing_content, form, info, - initials, prefs, preparing, redact_mark, - signature, table_preview, text_style, tool, - units, - fill_text_size, - fill_text_color, .. } = frame.env; let PageSlot { i, xf, .. } = *slot; @@ -103,106 +93,11 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // What this page has to do with the press: its owner's, the active tool's, or another page's. let press = view.router.press_for(i); let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); - if let QuickTool::Measure(measure_tool) = tool { - crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); - } - - // Form fields take clicks first with the Select tool (as Acrobat fills fields in - // every viewing mode); then comments; then text selection. - if let QuickTool::Stamp(kind) = tool - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && press == PagePress::Free { - // Centred on the click, upright as the page is shown. - let (vx, vy) = xf.screen_to_view(p); - let (w, h) = kind.size(); - let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; - let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); - let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; - let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); - let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; - view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { - page: i, - style: pdfcraft_engine::Style::default_for(&shape), - shape, - contents: String::new(), - author: author.clone(), - })); - frame.out.stamp_placed = true; - } - } - if let Some(cs) = custom_stamp.as_ref() - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && press == PagePress::Free { - // Centred on the click at its natural size (the engine sizes it). - let (vx, vy) = xf.screen_to_view(p); - let u = info.pages[i].view_to_user(vx, vy); - let (x, y) = (u[0] as f64, u[1] as f64); - view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { - page: i, - rect: [x, y, x, y], - name: cs.name.clone(), - file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, - author: author.clone(), - }); - frame.out.stamp_placed = true; - } - } // A mark already placed takes its own press (comments, below), and with no button down the - // tool's preview hides over one, where a press would pick it up. + // Fill & Sign tool's preview hides over one, where a press would pick it up. let over_mark = matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); - if let QuickTool::Fill(ft) = tool - && allowed - && !over_mark - { - match crate::fill_sign::page_input( - cx, - crate::fill_sign::PageInput { - tool: ft, - text_size: fill_text_size, - text_color: fill_text_color, - signature: signature.as_ref(), - initials: initials.as_ref(), - preview: frame.signature_preview, - author, - date_text, - }, - view, - ) { - Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), - Some(crate::fill_sign::FillAction::Signature(e)) => { - view.pending_edit = Some(*e); - view.fill_signature_page = Some(i); - } - Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, - Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, - Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), - None => {} - } - } - if matches!(tool, QuickTool::SignArea { .. }) { - crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view); - } - if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { - crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify); - } - if tool == QuickTool::Crop - && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop }) - { - view.current = i; - frame.out.open_boxes = true; - } + tool_input(ui, view, frame, slot, &pcx, press, over_mark); let on_field = if preparing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); frame.out.field_props |= o.properties; @@ -220,7 +115,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let (vx, vy) = xf.screen_to_view(p); text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) }; - crate::redact_ui::page_input(cx, over_text, view) + crate::redact_ui::page_input(cx, over_text, press == PagePress::Free, view) }; let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); // Add text acts on the page's own paragraphs over them, and on the one its press was on until @@ -281,7 +176,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } } let (link_owner, link_context) = (link_of(press.owner()), link_of(context)); - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, link_owner, link_context, view); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, press == PagePress::Free, link_owner, link_context, view); if tool == QuickTool::Select && can_modify && let Some(index) = link_context @@ -315,6 +210,100 @@ fn link_of(owner: Option) -> Option { } } +/// The active tool's own input on page `i`: the one gate for presses nobody holds. Measure, the +/// stamps, Fill & Sign, Sign area, zoom and snapshot, the camera's quadrilateral and crop act only +/// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor +/// and gestures. `over_mark`: a Fill & Sign mark takes the press, or would take one under the +/// pointer. +fn tool_input( + ui: &egui::Ui, + view: &mut DocView, + frame: &mut PageFrame<'_>, + slot: &PageSlot, + pcx: &comments::PageCx<'_>, + press: PagePress, + over_mark: bool, +) { + let PageEnv { + allowed, + author, + by_line, + can_crop, + can_modify, + custom_stamp, + date_text, + doc, + info, + initials, + signature, + tool, + units, + fill_text_size, + fill_text_color, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, .. } = *frame; + let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let free = press == PagePress::Free; + if let QuickTool::Measure(measure_tool) = tool { + crate::measure_ui::page_input(ui, crate::measure_ui::Clicks::of(resp, free), doc, view, pcx, measure_tool, units); + } + if let QuickTool::Stamp(kind) = tool + && allowed + && crate::stamps_ui::place(cx, kind, by_line, author, free, view) + { + frame.out.stamp_placed = true; + } + if let Some(cs) = custom_stamp.as_ref() + && allowed + && crate::stamps_ui::place_custom(cx, cs, author, free, view) + { + frame.out.stamp_placed = true; + } + if let QuickTool::Fill(ft) = tool + && allowed + && !over_mark + { + let input = crate::fill_sign::PageInput { + tool: ft, + text_size: fill_text_size, + text_color: fill_text_color, + signature: signature.as_ref(), + initials: initials.as_ref(), + preview: frame.signature_preview, + author, + date_text, + }; + match crate::fill_sign::page_input(cx, input, free, view) { + Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), + Some(crate::fill_sign::FillAction::Signature(e)) => { + view.pending_edit = Some(*e); + view.fill_signature_page = Some(i); + } + Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, + Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, + Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), + None => {} + } + } + if matches!(tool, QuickTool::SignArea { .. }) { + crate::sign_ui::page_input(cx, free, view); + } + if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { + crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, free, view); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify, press); + } + if tool == QuickTool::Crop + && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop, free }) + { + view.current = i; + frame.out.open_boxes = true; + } +} + /// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the /// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is /// selected; links take theirs from what this returns. @@ -460,9 +449,10 @@ impl PageHits<'_> { // together. let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); + let corner = (tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten(); let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) }; - [link, comment, pictures, paragraphs, added, objects, grips, fields].into_iter().flatten().collect() + [link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() } } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 865f5051b0..c2d214d0d7 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -150,7 +150,7 @@ fn edges_are_primary_finite_in_order_and_capped() { } /// The page modules: the per-page chain in `canvas::document_area` and what it calls. -const PAGE_MODULES: [&str; 36] = [ +const PAGE_MODULES: [&str; 37] = [ "canvas.rs", "canvas/page_input.rs", "canvas/area.rs", @@ -187,6 +187,7 @@ const PAGE_MODULES: [&str; 36] = [ "canvas/image_pick.rs", "canvas/signature_drag.rs", "tools/calc_order_ui.rs", + "tools/stamps_ui.rs", ]; /// egui's raw pointer state: where the pointer is, and which buttons are down or went down. @@ -434,11 +435,12 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 10] = [ +const PAGE_HIT_CALLS: [&str; 11] = [ "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", "comments::hit(", + "camera_ui::hit(", "edit_text_ui::hit(", "edit_text_ui::picture_hit(", "edit_text_ui::picture_handle_hit(", diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index f1aa199c67..23eae443e9 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -36,6 +36,8 @@ pub(crate) enum Owner { /// Added text or a picture placed on the page, by its index among the page's, and a corner of /// the selected one (left, top) or the item itself. Added { index: usize, corner: Option<(bool, bool)> }, + /// Enhance ▸ Camera image: a corner of the quadrilateral (0 left top, then clockwise). + CameraCorner { corner: usize }, } /// The part of a picture a hit is on. diff --git a/crates/ui-egui/src/canvas/zoom_snap.rs b/crates/ui-egui/src/canvas/zoom_snap.rs index 159adf2b7e..a422907b89 100644 --- a/crates/ui-egui/src/canvas/zoom_snap.rs +++ b/crates/ui-egui/src/canvas/zoom_snap.rs @@ -13,15 +13,15 @@ use crate::{PdfCraftApp, QuickTool}; /// [x0, y0, x1, y1]); a plain click has an empty rectangle. pub type Marquee = (usize, Rect, [f32; 4]); -/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot). -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) { +/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot), with a press nobody holds (`free`). +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, free: bool, view: &mut DocView) { let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.marquee = Some((page, o)); @@ -40,7 +40,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p view.marquee_done = Some((page, r, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)])); } } - if resp.clicked() + if free + && resp.clicked() && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) { let v = xf.screen_to_view(p); diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index abaad1ed5c..a7e7d6c934 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -7,6 +7,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2}; use crate::canvas::{DocView, PageXform, ViewAction}; use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; +use crate::page_router::{Hit, Owner, PagePress}; use crate::theme::Tokens; pub use pdfcraft_engine::camera_clean::Look; @@ -59,8 +60,28 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { xf.norm_to_screen(c[0] / xf.pw.max(1e-3), c[1] / xf.ph.max(1e-3)) } -/// Corner dragging on one page. A press on a page without the quadrilateral moves it there. -pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) { +/// The corner of the quadrilateral under `p` on `page`, the nearest within reach: the one hit test +/// of the camera tool, for the cursor and the press. `None` once the page is enhanced, or on a page +/// without the quadrilateral. +pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option { + if view.camera_done.is_some() { + return None; + } + let q = view.camera.filter(|q| q.page == page)?; + let corner = q + .corners + .iter() + .enumerate() + .map(|(i, &c)| (i, screen(xf, c).distance(p))) + .filter(|(_, d)| *d <= GRAB) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i)?; + Some(Hit::new(Owner::CameraCorner { corner }, Layer::Handle, egui::CursorIcon::Grab)) +} + +/// Corner dragging on one page: a corner's own press ([`hit`]) drags it, and a press nobody holds +/// on a page without the quadrilateral moves it there. +pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool, press: PagePress) { let (pointer, origin) = (ptr.pos, ptr.origin); if !allowed { if pointer.is_some_and(|p| xf.rect.contains(p)) { @@ -76,23 +97,21 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe if view.camera.is_none() && page == view.current { view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); } - if resp.drag_started() || resp.clicked() { - let pressed = origin.filter(|o| xf.rect.contains(*o)); - if pressed.is_some() && view.camera.is_none_or(|q| q.page != page) { - view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); - } - if resp.drag_started() - && let (Some(o), Some(q)) = (pressed, view.camera.as_mut()) - { - q.grab = (0..4) - .map(|i| (i, screen(xf, q.corners[i]).distance(o))) - .filter(|(_, d)| *d <= GRAB) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(i, _)| i); - } + if (resp.drag_started() || resp.clicked()) + && press == PagePress::Free + && origin.is_some_and(|o| xf.rect.contains(o)) + && view.camera.is_none_or(|q| q.page != page) + { + view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); + } + if resp.drag_started() + && let PagePress::Mine(Owner::CameraCorner { corner }) = press + && let Some(q) = view.camera.as_mut().filter(|q| q.page == page) + { + q.grab = Some(corner); } let Some(q) = view.camera.as_mut().filter(|q| q.page == page) else { return }; - let near = pointer.is_some_and(|p| q.corners.iter().any(|&c| screen(xf, c).distance(p) <= GRAB)); + // Over a corner, its hit claims the grab cursor. if let Some(i) = q.grab { view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); if let Some(p) = pointer { @@ -101,11 +120,9 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe *c = [x.clamp(0.0, xf.pw), y.clamp(0.0, xf.ph)]; } } - if resp.drag_stopped() { + if resp.drag_stopped() || !ptr.primary_down { q.grab = None; } - } else if near { - view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); } } diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..416be8867e 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -455,7 +455,7 @@ pub(crate) struct PageInput<'a> { } /// Preview and clicks with a Fill & Sign tool on one page. -pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &mut DocView) -> Option { +pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bool, view: &mut DocView) -> Option { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let PageInput { tool, text_size, text_color, signature, initials, preview, author, date_text } = input; let pointer = ptr.within(xf.rect)?; @@ -475,7 +475,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &m } else { view.cursor.claim(Layer::ToolArea, if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair }); } - if !resp.clicked() { + // The tool's own click: one nobody holds. + if !(free && resp.clicked()) { return None; } // Typed text starts a little above the click, as displayed. diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 699383cbfe..d380d1b71b 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -87,10 +87,10 @@ fn target_label(info: &DocInfo, target: &LinkTarget) -> String { } } -/// Link tool input on one page: `owner` is the link that holds the press (by its `/Annots` -/// index), and `context` the one a right-click is on. Returns `true` when the gesture belongs to -/// the tool. -pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option, context: Option, view: &mut DocView) -> bool { +/// Link tool input on one page: `free` when nobody holds the press and it began on this page, +/// `owner` the link that holds it (by its `/Annots` index), and `context` the link a right-click is +/// on. A drag from either draws a new link. Returns `true` when the gesture belongs to the tool. +pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option, context: Option, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; let lv = &mut view.links; @@ -128,7 +128,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option, context: } return true; } - if resp.drag_started() { + if resp.drag_started() && (free || owner.is_some()) { let origin = ptr.origin.unwrap_or(p); lv.drag = Some((page, origin)); return true; diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 5008464ad9..a81f01b0b2 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -202,9 +202,23 @@ fn snap_to_content( None } +/// The Measure tools' own clicks this frame: those of a press nobody holds. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct Clicks { + pub single: bool, + pub double: bool, +} + +impl Clicks { + /// `resp`'s clicks, when the press is the tool's (`free`). + pub(crate) fn of(resp: &egui::Response, free: bool) -> Self { + Clicks { single: free && resp.clicked(), double: free && resp.double_clicked() } + } +} + pub(crate) fn page_input( ui: &egui::Ui, - resp: &egui::Response, + clicks: Clicks, doc: &Document, view: &mut DocView, cx: &crate::comments::PageCx<'_>, @@ -254,7 +268,7 @@ pub(crate) fn page_input( point = measure::snap::constrain(*last, point); } hover = Some(point); - if resp.clicked() { + if clicks.single { if state.page != Some(page) { state.cancel(); state.page = Some(page); @@ -284,7 +298,7 @@ pub(crate) fn page_input( } } if state.page == Some(page) && !state.points.is_empty() { - if (resp.double_clicked() || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) { + if (clicks.double || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) { finish(state, &mut view.pending_edit, doc, page, tool, author, units); } if !typing && ui.input(|i| i.key_pressed(egui::Key::Backspace)) { diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index e55dd8c5c3..0cca746bf8 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -233,9 +233,9 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { } /// Redact tool input on a page. Presses on text are left to text selection (the selection is -/// marked when it ends, see [`after_text`]); presses elsewhere draw a box. Returns `true` when -/// the gesture is a box. -pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bool, view: &mut DocView) -> bool { +/// marked when it ends, see [`after_text`]); presses nobody holds (`free`) elsewhere draw a box. +/// Returns `true` when the gesture is a box. +pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bool, free: bool, view: &mut DocView) -> bool { let PageToolContext { resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; if let Some((dp, start)) = view.redact_drag @@ -256,8 +256,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo if !over_text(p) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. - if resp.drag_started_by(egui::PointerButton::Primary) && view.router.owner(page).is_none() { + // The tool's own press, of the primary button: one nobody holds. + if resp.drag_started_by(egui::PointerButton::Primary) && free { let origin = ptr.origin.unwrap_or(p); if !over_text(origin) { view.redact_drag = Some((page, origin)); diff --git a/crates/ui-egui/src/tools/sign_ui.rs b/crates/ui-egui/src/tools/sign_ui.rs index b00d07279a..581fd23694 100644 --- a/crates/ui-egui/src/tools/sign_ui.rs +++ b/crates/ui-egui/src/tools/sign_ui.rs @@ -8,10 +8,9 @@ use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2}; use pdfcraft_engine::sign::{self, Appearance, AppearanceLabels, Certificate, DigitalId, Name, PrivateKey}; use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatus}; -use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind}; +use crate::canvas::{DocView, PageToolContext, Snapshot, SnapshotKind}; use crate::fill_sign::SavedSig; use crate::page_cursor::Layer; -use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -162,23 +161,17 @@ pub struct SignView { pub field: Option, } -/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)). -pub(crate) fn page_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &pdfcraft_render::DocInfo, - view: &mut DocView, -) { +/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)), from a +/// press nobody holds (`free`). +pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, view: &mut DocView) { + let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.sign.drag = Some((page, o)); diff --git a/crates/ui-egui/src/tools/stamps_ui.rs b/crates/ui-egui/src/tools/stamps_ui.rs index 9cddb31d23..484c7eb938 100644 --- a/crates/ui-egui/src/tools/stamps_ui.rs +++ b/crates/ui-egui/src/tools/stamps_ui.rs @@ -6,6 +6,8 @@ use std::sync::Arc; use egui::{Align, Layout}; use serde::{Deserialize, Serialize}; +use crate::canvas::{DocView, PageToolContext}; +use crate::page_cursor::Layer; use crate::theme::{self, Tokens}; use crate::{Dialog, PdfCraftApp, QuickTool, widgets}; @@ -41,6 +43,54 @@ mod b64 { } } +/// The Stamp tool on one page: a click that nobody holds (`free`) places stamp `kind` centred on +/// it, upright as the page is shown. `true` once one is placed. +pub(crate) fn place(cx: PageToolContext<'_>, kind: pdfcraft_engine::StampKind, by_line: &str, author: &str, free: bool, view: &mut DocView) -> bool { + let PageToolContext { resp, xf, ptr, page, info, .. } = cx; + let (Some(p), Some(page_info)) = (ptr.within(xf.rect), info.pages.get(page)) else { return false }; + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if !(free && resp.clicked()) { + return false; + } + let (vx, vy) = xf.screen_to_view(p); + let (w, h) = kind.size(); + let a = page_info.view_to_user((f64::from(vx) - w / 2.0) as f32, (f64::from(vy) - h / 2.0) as f32); + let b = page_info.view_to_user((f64::from(vx) + w / 2.0) as f32, (f64::from(vy) + h / 2.0) as f32); + let rect = [f64::from(a[0].min(b[0])), f64::from(a[1].min(b[1])), f64::from(a[0].max(b[0])), f64::from(a[1].max(b[1]))]; + let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.to_owned()); + let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; + view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: String::new(), + author: author.to_owned(), + })); + true +} + +/// A custom stamp on one page: a click that nobody holds (`free`) places `stamp` centred on it, at +/// its natural size (the engine sizes it). `true` once one is placed. +pub(crate) fn place_custom(cx: PageToolContext<'_>, stamp: &CustomStamp, author: &str, free: bool, view: &mut DocView) -> bool { + let PageToolContext { resp, xf, ptr, page, info, .. } = cx; + let (Some(p), Some(page_info)) = (ptr.within(xf.rect), info.pages.get(page)) else { return false }; + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if !(free && resp.clicked()) { + return false; + } + let (vx, vy) = xf.screen_to_view(p); + let u = page_info.view_to_user(vx, vy); + let (x, y) = (f64::from(u[0]), f64::from(u[1])); + view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { + page, + rect: [x, y, x, y], + name: stamp.name.clone(), + file: pdfcraft_engine::MarkFile { name: stamp.file.clone(), bytes: stamp.data.clone(), page: stamp.page }, + author: author.to_owned(), + }); + true +} + /// The Create Custom Stamp dialog: the chosen file, its category and name. #[derive(Clone, Debug, Default, PartialEq)] pub struct StampDraft { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index b63d9937fb..9ecc955604 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1155,3 +1155,40 @@ fn a_click_on_a_form_field_deselects_the_selected_comment() { assert_eq!(h.state().views[0].comments.selected, None, "a click on the check box leaves it unselected"); assert_eq!(agree(&h).len(), 1, "and ticks the box"); } + +/// The camera tool's quadrilateral, corner by corner, in page view points. +fn camera_corners(h: &Harness<'_, PdfCraftApp>) -> Option<[[f32; 2]; 4]> { + h.state().views[0].camera.map(|q| q.corners) +} + +/// A corner of the camera tool's quadrilateral is a press of its own: Escape in the middle of its +/// drag cancels it, and once the button is up no corner follows the pointer. Today Escape also +/// leaves the camera tool before the pages see it; this holds whatever the order of the keys. +#[test] +fn escape_mid_corner_drag_leaves_no_corner_following_the_pointer() { + let mut h = open(text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EnhanceCamera; + h.run_steps(4); + // The left top corner starts 5% in from the page's edges: view (15, 20) on the 300×400 page. + let corner = at(&h, 15.0, 380.0); + let start = camera_corners(&h); + h.hover_at(corner); + h.run_steps(1); + h.drag_at(corner); + h.run_steps(1); + h.hover_at(corner + egui::vec2(30.0, 30.0)); + h.run_steps(2); + assert_ne!(camera_corners(&h), start, "the corner follows its drag"); + h.key_press(egui::Key::Escape); + h.run_steps(1); + let end = corner + egui::vec2(60.0, 60.0); + h.hover_at(end); + h.run_steps(1); + h.drop_at(end); + h.run_steps(2); + let after = camera_corners(&h); + h.hover_at(corner + egui::vec2(120.0, 90.0)); + h.run_steps(3); + assert_eq!(camera_corners(&h), after, "no corner follows the pointer once the press is cancelled"); + assert!(h.state().views[0].camera.is_none_or(|q| q.grab.is_none()), "and none is held"); +} From ed8a10e83e8c933c945aa2437a9a8e31ed41411e Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:41:07 +0100 Subject: [PATCH 24/63] refactor(ui): tell forms and Prepare whether the press is theirs Forms and Prepare read the router themselves, and forms tested the pointer twice more: for a field's tooltip, and for whether a click came up over the widget it was pressed on. Following a link tested the pointer again too. Both are now told the page's `PagePress`. Prepare's Select tool draws a selection rectangle, or unselects, only on a press nobody holds, and a field tool draws only on such a press. Whether a click came up over what its press began on is answered once, from the hits under the pointer (`released_over`), for fields and for following links. The field tooltip (`forms_ui::tooltip`) and the field hover outline come from the page's hover, the top hit under the pointer. `PageHits::hits` is now the only function that calls a hit test, and the press-handler check no longer lists forms or Prepare. Behaviour changes: a field's tooltip shows only where a press would take the field, not under a comment drawn over it (the user's ruling, 2026-10-31); a field's hover outline follows the same rule, so it shows only with the Select tool, which fills fields. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 7 ++- crates/ui-egui/src/canvas/page_input.rs | 20 +++++--- .../ui-egui/src/canvas/page_pointer/tests.rs | 26 ++-------- crates/ui-egui/src/tools/forms_ui.rs | 47 ++++++++++++------- crates/ui-egui/src/tools/prepare.rs | 35 +++++++++----- crates/ui-egui/tests/it/pointer.rs | 34 ++++++++++++++ 6 files changed, 111 insertions(+), 58 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 30d2799f68..6cb3211c7c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1499,8 +1499,11 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i let slot = PageSlot { i, r, xf }; // A cancelled press takes nothing until the button is up. - let input = - if view.press_cancelled { PageInput { consumed: true, area_tool: false, hover: None } } else { page_tool_input(ui, view, frame, &slot) }; + let input = if view.press_cancelled { + PageInput { consumed: true, area_tool: false, hover: None, released_over: false } + } else { + page_tool_input(ui, view, frame, &slot) + }; let painter = ui.painter(); let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 40552fc1ec..38c20196d9 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -18,6 +18,9 @@ pub(super) struct PageInput { pub(super) area_tool: bool, /// What a press under the pointer would take (the top hit there), for hover painting. pub(super) hover: Option, + /// The press's click came up over what the press began on: a link's or a field's click counts + /// only then, as a button's does. + pub(super) released_over: bool, } /// Common geometry and pointer state for a tool acting on one page. @@ -92,6 +95,10 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } // What this page has to do with the press: its owner's, the active tool's, or another page's. let press = view.router.press_for(i); + let released_over = match press { + PagePress::Mine(owner) if resp.clicked() => pointer.is_some_and(|p| hits.at(view, p).iter().any(|h| h.owner == owner)), + _ => false, + }; let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); // A mark already placed takes its own press (comments, below), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. @@ -99,13 +106,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); let on_field = if preparing { - let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); + let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view); frame.out.field_props |= o.properties; frame.out.field_placed |= o.placed; frame.out.open_table = frame.out.open_table.or(o.table); o.consumed || field_tool.is_some() } else { - tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view) + tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view) }; // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; @@ -188,7 +195,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let on_comment = comments::page_input(ui, resp, &pcx, press, view); let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; let hover = pointer.filter(|_| frame.env.unobstructed).and_then(|p| claim_under(&hits, view, p)); - PageInput { consumed, area_tool, hover } + PageInput { consumed, area_tool, hover, released_over } } /// The cursor and the tip from what lies under the pointer at `p`, after the tools claimed theirs @@ -727,7 +734,8 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P } } } else { - crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer); + crate::forms_ui::paint_page(painter, &xf, i, info, form, view, input.hover.as_ref()); + crate::forms_ui::tooltip(resp, form, input.hover.as_ref()); } // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a @@ -743,8 +751,8 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P && resp.clicked() && tool == QuickTool::Select && !input.consumed - && let Some(owner @ Owner::Link { index }) = view.router.owner(i) - && pointer.and_then(|p| crate::link_ui::hit(&xf, info, i, doc_links, tool, p)).is_some_and(|h| h.owner == owner) + && let Some(Owner::Link { index }) = view.router.owner(i) + && input.released_over && let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index) { frame.out.clicked_link = Some(l.target.clone()); diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index c2d214d0d7..48bb408bad 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -416,15 +416,11 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. -const HIT_TESTERS: [(&str, &str, &str); 3] = [ - ( - "canvas/page_input.rs", - "hits", - "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", - ), - ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), - ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), -]; +const HIT_TESTERS: [(&str, &str, &str); 1] = [( + "canvas/page_input.rs", + "hits", + "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", +)]; /// What ignores the press the pages hold until the button is up. const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; @@ -487,8 +483,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { )); let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); for (file, function) in [ - ("tools/forms_ui.rs", "page_input"), - ("tools/prepare.rs", "page_input"), ("tools/content_ui.rs", "page_input"), ("tools/edit_text_ui.rs", "page_input"), ("tools/edit_text_ui/image.rs", "image_input"), @@ -496,16 +490,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { ] { let source = std::fs::read_to_string(src.join(file)).expect("page input module"); let body = source.lines().skip_while(|line| declares(line) != Some(function)).take_while(|line| *line != "}").collect::>().join("\n"); - // Forms reuse their hit test for hover text and to require release over the captured - // widget. Neither chooses the press owner. Pin both calls to the current pointer `p`, - // not the press origin, and reject an additional call or a changed argument. - let body = if file == "tools/forms_ui.rs" { - let call = "hit(xf, info, page, form, allowed, p)"; - assert_eq!(body.matches(call).count(), 2, "only the existing hover and release checks are exempt"); - body.replace(call, "hover_or_release_check") - } else { - body - }; if !routed_press_handler(&body) { problems.push(format!("src/{file}: {function} must consume the captured router owner without repeating a hit test")); } diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 7232b1169b..1d31b81fbc 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -15,7 +15,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; -use crate::page_router::{Hit, Owner}; +use crate::page_router::{Hit, Owner, PagePress}; use crate::theme::Tokens; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -182,19 +182,30 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField Some(Hit::new(Owner::Field { field, widget }, Layer::Field, icon)) } -/// Presses and clicks on form widgets of one page, on the field the press was on ([`hit`]). -/// `true` while a field holds the press. -pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: bool, view: &mut DocView) -> bool { - let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; - // The field's tooltip (`/TU`) under the pointer, as Acrobat shows it on hover (#3854). ISO - // 32000-2 Table 226 makes it the name the user interface identifies the field by. - if let Some(p) = ptr.within(xf.rect) - && let Some(Owner::Field { field, .. }) = hit(xf, info, page, form, allowed, p).map(|h| h.owner) +/// The tooltip (`/TU`) of the field under the pointer, as Acrobat shows it on hover (#3854): only +/// where the field is what a press would take (`hover`, the top hit there). ISO 32000-2 Table 226 +/// makes it the name the user interface identifies the field by. +pub(crate) fn tooltip(resp: &egui::Response, form: &[FormField], hover: Option<&Hit>) { + if let Some(Owner::Field { field, .. }) = hover.map(|h| h.owner) && let Some(tip) = form.get(field).and_then(|f| f.tooltip.as_deref()).filter(|t| !t.trim().is_empty()) { resp.clone().on_hover_text_at_pointer(tip); } - let Some(Owner::Field { field, widget: wi }) = view.router.owner(page) else { return false }; +} + +/// Presses and clicks on form widgets of one page, on the field the press is on (`press`, from +/// [`hit`]). `released_over`: the press's click came up over the widget it began on. `true` while a +/// field holds the press. +pub(crate) fn page_input( + cx: PageToolContext<'_>, + form: &[FormField], + allowed: bool, + press: PagePress, + released_over: bool, + view: &mut DocView, +) -> bool { + let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; + let PagePress::Mine(Owner::Field { field, widget: wi }) = press else { return false }; let Some((f, w)) = form.get(field).and_then(|f| f.widgets.get(wi).map(|w| (f, w))) else { return false }; let usable = usable(f, w, allowed); // A text field opens on the press rather than the click, so a drag that starts on it @@ -210,8 +221,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b } // A click counts released over the widget its press was on, as a button's does: one that slips // off it first does nothing (a check box's overlay closes on it too). - if !resp.clicked() || !ptr.pos.and_then(|p| hit(xf, info, page, form, allowed, p)).is_some_and(|h| h.owner == Owner::Field { field, widget: wi }) - { + if !resp.clicked() || !released_over { return true; } if !allowed { @@ -489,7 +499,8 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st picked } -/// Paint focus and hover frames on a page, the pages' `pointer` over it. +/// Paint focus and hover frames on a page: the hover frame round the widget a press under the +/// pointer would take (`hover`, the top hit there). pub(crate) fn paint_page( painter: &egui::Painter, xf: &PageXform, @@ -497,9 +508,13 @@ pub(crate) fn paint_page( info: &DocInfo, form: &[FormField], view: &DocView, - pointer: Option, + hover: Option<&Hit>, ) { - for f in form { + let hovered = match hover.map(|h| h.owner) { + Some(Owner::Field { field, widget }) => Some((field, widget)), + _ => None, + }; + for (fi, f) in form.iter().enumerate() { for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page) && !w.hidden) { let r = widget_rect(xf, info, page, w.rect); let focused = view.forms.focus.as_ref().is_some_and(|x| x.name == f.name && x.widget == wi); @@ -508,7 +523,7 @@ pub(crate) fn paint_page( } if view.forms.unbaked.contains(&(f.name.clone(), page)) { paint_unbaked_widget(painter, xf, f, w, r); - } else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) { + } else if fillable(f) && hovered == Some((fi, wi)) { painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside); } } diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 78568f46db..ffd716c056 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -13,7 +13,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::comments; use crate::page_cursor::Layer; -use crate::page_router::{Hit, Owner}; +use crate::page_router::{Hit, Owner, PagePress}; use crate::theme; use crate::widgets; @@ -285,8 +285,17 @@ pub(crate) fn hit( Some(Hit::new(Owner::PrepareField { field, widget }, Layer::Placed, egui::CursorIcon::Move)) } -/// Pointer input on one page while preparing a form. -pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Option, allowed: bool, view: &mut DocView) -> Outcome { +/// Pointer input on one page while preparing a form: `press` says whether the press is a field's +/// or a corner's ([`hit`]), nobody's (a field tool draws, the Select tool draws a selection +/// rectangle), or another page's. +pub(crate) fn page_input( + cx: PageToolContext<'_>, + form: &[FormField], + tool: Option, + allowed: bool, + press: PagePress, + view: &mut DocView, +) -> Outcome { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let mut out = Outcome { consumed: false, properties: false, placed: false, table: None }; let pointer = ptr.pos; @@ -373,9 +382,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti if !allowed { return out; } + let free = press == PagePress::Free; if let Some(tool) = tool { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - if resp.drag_started() || resp.clicked() { + if free && (resp.drag_started() || resp.clicked()) { let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p); prep.grab = Some((page, Grab::Draw(origin), origin)); out.consumed = true; @@ -398,18 +408,17 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti return out; } - // The Select tool: what the press was on ([`hit`]), a corner handle or a field; elsewhere a - // drag draws a selection rectangle. A press held by something drawn over the fields (a + // The Select tool: what the press is on ([`hit`]), a corner handle or a field; a press nobody + // holds draws a selection rectangle. A press held by something drawn over the fields (a // comment) is not Prepare's. - let owner = view.router.owner(page); - let held = matches!(owner, Some(Owner::PrepareField { .. } | Owner::PrepareCorner { .. })); - let hit = match owner { - Some(Owner::PrepareField { field, widget }) => form.get(field).map(|f| (f, widget)), + let held = matches!(press, PagePress::Mine(Owner::PrepareField { .. } | Owner::PrepareCorner { .. })); + let hit = match press { + PagePress::Mine(Owner::PrepareField { field, widget }) => form.get(field).map(|f| (f, widget)), _ => None, }; - if resp.drag_started() && (held || owner.is_none()) { + if resp.drag_started() && (held || free) { let origin = ptr.origin.unwrap_or(p); - if let Some(Owner::PrepareCorner { left, top }) = owner { + if let PagePress::Mine(Owner::PrepareCorner { left, top }) = press { prep.grab = Some((page, Grab::Corner(left, top), origin)); out.consumed = true; } else if let Some((f, wi)) = hit { @@ -432,7 +441,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti out.properties = true; return out; } - if resp.clicked() && (held || owner.is_none()) { + if resp.clicked() && (held || free) { let extend = ui.input(|i| i.modifiers.shift || i.modifiers.command); match hit { // Shift/⌘-click adds or removes a field from the selection. diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 9ecc955604..6975a12a04 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1192,3 +1192,37 @@ fn escape_mid_corner_drag_leaves_no_corner_following_the_pointer() { assert_eq!(camera_corners(&h), after, "no corner follows the pointer once the press is cancelled"); assert!(h.state().views[0].camera.is_none_or(|q| q.grab.is_none()), "and none is held"); } + +/// A 300×400 page with a text field `name` (tooltip "Your name") at x 50..250, y 200..220, and a +/// rectangle comment drawn over its right end, x 150..260, y 190..230. +fn commented_text_field_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /TU (Your name) /Rect [50 200 250 220] /P 3 0 R /F 4 /DA (/Helv 12 Tf 0 g) >>", + "<< /Type /Annot /Subtype /Square /Rect [150 190 260 230] /C [1 0 0] /F 4 /T (Ana) >>", + ]) +} + +/// Rests the pointer at `p` long enough for a tooltip. +fn rest_at(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + for _ in 0..40 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(15)); + } +} + +/// A field's tooltip (`/TU`) shows only where a press would take the field: not under a comment +/// drawn over it. It used to show over the whole field. +#[test] +fn a_fields_tooltip_shows_only_where_the_field_takes_the_press() { + let mut h = open(commented_text_field_pdf(), "150", false); + let under_comment = at(&h, 200.0, 210.0); + rest_at(&mut h, under_comment); + assert!(h.query_by_label("Your name").is_none(), "no field tooltip where the comment takes the press"); + let on_field = at(&h, 100.0, 210.0); + rest_at(&mut h, on_field); + assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip where it takes the press"); +} From 734cb4c13a4aa2e2ba1ae201f9fcf905d227d3ee Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 12:26:10 +0100 Subject: [PATCH 25/63] test(ui): the page guards read past a test module in the middle of a file The page guards (pointer reads, cursor writes, and presses) left a file's tests out by cutting it at its first `#[cfg(test)] mod`. `edit_text_ui/image.rs` has a test module at line 231, so its lines 245-1116 were never read: `crop_input`, `image_input` and the picture hit tests among them. A hit test called there, a router write or a raw pointer read passed every guard. `outside_test_modules` now leaves out each test module up to its closing brace, and the guards read the rest. Nothing in the newly read code breaks a rule. A unit test pins the scanner, and a bare `crop_hit(` planted in `image_input` now fails the press guard (it passed before). The guard's messages and docs, CLAUDE.md's "Page presses" line, and the docs of `page_tool_input`, `PageHits::hits` and `PageRouter::cancel` now say what the code does: modules are told what a press is to their page (`PagePress`), and `PageHits::hits` is the one function that asks the hit tests. `comments::context_hit(` joins the hit tests a press handler must not call. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- crates/ui-egui/src/canvas/page_input.rs | 12 ++--- .../ui-egui/src/canvas/page_pointer/tests.rs | 50 +++++++++++++++---- crates/ui-egui/src/canvas/page_router.rs | 6 ++- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7b1d7f5023..47a80b86bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. -- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own: the page view tells it what the press is to its page (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 38c20196d9..a2f71d97c8 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -35,9 +35,9 @@ pub(crate) struct PageToolContext<'a> { pub info: &'a DocInfo, } -/// Comments: tools, selection, moving and resizing come before text selection. Form fields take -/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then -/// comments; then text selection. +/// The tools' input on one page. The hits at the press point decide who holds the press, and each +/// module is told what the press is to this page (`PagePress`): its own, nobody's (the active tool's, +/// through `tool_input`), or another page's. Text selection runs after, on what nobody took. pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { let PageEnv { added, @@ -391,9 +391,9 @@ impl PageHits<'_> { /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text /// on top of artwork takes the press), added content, the objects selected together and the - /// open paragraph's frame, the selected picture's grips over them all; then form fields - /// (Prepare's, being moved, over the comments; filled ones under added content and the - /// comments that move). + /// open paragraph's frame, the selected picture's grips over them all; the camera tool's corners; + /// then form fields (Prepare's, being moved, over the comments; filled ones under added content + /// and the comments that move). In crop mode, only the picture being cropped. /// For a right-click (`context`), comments by [`comments::context_hit`]. fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec { let PageHits { diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 48bb408bad..69abae12cf 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -264,6 +264,25 @@ fn uses(line: &str, name: &str) -> bool { line.match_indices(name).any(|(at, _)| name.starts_with('.') || !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) } +/// The lines of `source` outside its test modules, numbered from 1. Tests drive egui's input and +/// read its output themselves. A test module (`#[cfg(test)]` then `mod name {`) ends at the first +/// line that is a lone `}`, as rustfmt lays a top-level item out, so the code after a test module +/// in the middle of a file is still read; `#[cfg(test)]` then `mod name;` is one line. +fn outside_test_modules(source: &str) -> Vec<(usize, &str)> { + let mut kept = Vec::new(); + let mut lines = source.lines().enumerate().map(|(n, line)| (n + 1, line)).peekable(); + while let Some((n, line)) = lines.next() { + if line == "#[cfg(test)]" && lines.peek().is_some_and(|(_, next)| next.starts_with("mod ")) { + if lines.next().is_some_and(|(_, module)| module.ends_with('{')) { + lines.by_ref().find(|(_, line)| *line == "}"); + } + continue; + } + kept.push((n, line)); + } + kept +} + /// Each name `find` finds in a line of the page modules' code (their tests left out), as (file, /// the top-level function or `impl` method it is in, line, name). fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, Option, usize, String)> { @@ -271,11 +290,9 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, let mut found = Vec::new(); for file in PAGE_MODULES { let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); - // Tests drive egui's input and read its output themselves. - let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); // The top-level function, or method of an `impl`, that each line is in. let (mut function, mut in_impl) = (None, false); - for (n, line) in code.lines().enumerate() { + for (n, line) in outside_test_modules(&source) { if line.starts_with("impl") { (function, in_impl) = (None, true); } else if line == "}" { @@ -287,7 +304,7 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, continue; } for name in find(line) { - found.push((file, function.map(str::to_owned), n + 1, name)); + found.push((file, function.map(str::to_owned), n, name)); } } } @@ -414,8 +431,9 @@ fn hit_test_calls(line: &str) -> Vec { .collect() } -/// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the -/// press point; the others ask where the pointer is. +/// The functions that call a hit test: (file, function, why). `PageHits::hits` is the one: the hits +/// at the press point decide its owner, and those under the pointer give its cursor, its tip, hover +/// painting and a right-click. const HIT_TESTERS: [(&str, &str, &str); 1] = [( "canvas/page_input.rs", "hits", @@ -431,11 +449,12 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 11] = [ +const PAGE_HIT_CALLS: [&str; 12] = [ "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", "comments::hit(", + "comments::context_hit(", "camera_ui::hit(", "edit_text_ui::hit(", "edit_text_ui::picture_hit(", @@ -461,7 +480,7 @@ fn routed_press_handler(code: &str) -> bool { /// so what the cursor promised is what the press does. Where each module tested the press /// point again, or asked another whether it wanted the press, the answers disagreed: a press on /// one check box released over the next ticked the second, and a drag could be taken over -/// halfway. Only `PageHits::at` asks the hit tests at the press point. Escape is the one way to +/// halfway. Only `PageHits::hits` asks the hit tests. Escape is the one way to /// drop a press and ignore it until the button is up. #[test] fn page_modules_act_on_a_press_only_through_the_router() { @@ -469,7 +488,7 @@ fn page_modules_act_on_a_press_only_through_the_router() { &ROUTER_WRITES, &ROUTER_WRITERS, "decides, keeps or drops the press the pages hold. Only the hits at the press point decide its owner, once \ - (src/canvas/page_router.rs): a page module reads `view.router.owner(page)`. Drop a press with \ + (src/canvas/page_router.rs), and the page view tells each module what the press is to its page (`PagePress`). Drop a press with \ `DocView::cancel_pointer_gestures`. A new place that must? Add the function to ROUTER_WRITERS in \ src/canvas/page_pointer/tests.rs, with why.", "no longer decides, keeps or drops the press: remove it from ROUTER_WRITERS.", @@ -499,8 +518,8 @@ fn page_modules_act_on_a_press_only_through_the_router() { problems.extend(misuses_in( page_module_matches(hit_test_calls), &HIT_TESTERS, - "calls a hit test. The hits at the press point decide its owner once, in `PageHits::at`: act on the press when \ - `view.router.owner(page)` says it is yours, rather than testing its point again. Testing where the pointer \ + "calls a hit test. The hits at the press point decide its owner once, in `PageHits::hits`: act on the press when \ + the page view tells you it is yours (`PagePress::Mine`), rather than testing its point again. Testing where the pointer \ is (a tooltip, a click released over what it was pressed on)? Add the function to HIT_TESTERS in \ src/canvas/page_pointer/tests.rs, with why.", "calls no hit test any more: remove it from HIT_TESTERS.", @@ -516,6 +535,15 @@ fn page_modules_act_on_a_press_only_through_the_router() { assert!(problems.is_empty(), "{}", problems.join("\n")); } +/// A test module in the middle of a file hides only itself from the page guards: the code after +/// it is read too. The guards used to stop at the first test module. +#[test] +fn the_page_guards_read_past_a_test_module_in_the_middle_of_a_file() { + let source = "fn before() {}\n#[cfg(test)]\nmod tests {\n fn inside() {}\n}\n\nfn after() {}\n#[cfg(test)]\nmod more;\nfn last() {}\n"; + let kept: Vec<(usize, &str)> = outside_test_modules(source); + assert_eq!(kept, [(1, "fn before() {}"), (6, ""), (7, "fn after() {}"), (10, "fn last() {}")]); +} + #[test] fn the_page_press_guard_rejects_missing_owners_and_second_hit_tests() { assert!(routed_press_handler("let owner = view.router.owner(page);")); diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 23eae443e9..447289ccd0 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -191,7 +191,8 @@ impl PageRouter { } } - /// Drops the press (Escape, a dialog, automation's cancel). + /// Drops the press (Escape, a dialog, automation's cancel): every page is the active tool's again + /// ([`PagePress::Free`]). Page input skips a press Escape cancelled until the button is up. pub(crate) fn cancel(&mut self) { self.capture = None; self.began = None; @@ -250,7 +251,8 @@ mod tests { assert_eq!((r.press_for(1), r.press_for(2)), (PagePress::Free, PagePress::Free)); r.begin(); r.cancel(); - assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press is nobody's"); + // Page input skips a press Escape cancelled until the button is up (`press_cancelled`). + assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press leaves every page to the tool"); } #[test] From b1a0657c426076ca7576dfbf15853463c02f846d Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 12:29:46 +0100 Subject: [PATCH 26/63] fix(ui): right-clicks with Prepare, the Link tool and Add text as before Routing right-clicks by what lies on top under the pointer changed three cases beyond the rulings: - **Prepare.** A right-click while a field was dragged selected the field under the pointer, and the release then moved that one. Prepare used to ignore right-clicks during a drag; it does again (`prepare::context` returns while a grab is held). - **The Link tool.** A note over a link took the right-click, though the Link tool's press there is the link's. The Link tool's right-click on a link is the link's again, whatever lies over it. - **Add text.** Comments took right-clicks away from paragraphs and added items. Add text used to take every right-click on a page it can change; it does again. Tests: those three, and two that pin ruling 6's right-click half with the Select tool: no link menu on a link with no action, and none on a form field over a link. Each was seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 20 +++++++-- crates/ui-egui/src/tools/prepare.rs | 6 ++- crates/ui-egui/tests/it/pointer.rs | 58 +++++++++++++++++++++++++ crates/ui-egui/tests/it/prepare.rs | 37 ++++++++++++++++ 4 files changed, 116 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index a2f71d97c8..7f5f9dd154 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -316,9 +316,13 @@ fn tool_input( /// selected; links take theirs from what this returns. fn right_click(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { let context = hits.context_at(view, p); - // Edit text's own paragraphs and pictures keep the right-click as they take it today, and a - // Prepare field tool the whole page. - let editing_here = matches!(hits.tool, QuickTool::AddText | QuickTool::EditText) && top(hits.at(view, p), hits.tool).is_some(); + // Add text takes every right-click on a page it can change, Edit text those on its own + // paragraphs and pictures, and a Prepare field tool the whole page, as before. + let editing_here = match hits.tool { + QuickTool::AddText => hits.can_modify || top(hits.at(view, p), hits.tool).is_some(), + QuickTool::EditText => top(hits.at(view, p), hits.tool).is_some(), + _ => false, + }; if !editing_here && hits.field_tool.is_none() { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), @@ -385,8 +389,16 @@ impl PageHits<'_> { /// What a right-click at `p` is on: what lies on top there, as for a press, but with every /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight /// over a link is the highlight's ([`comments::context_hit`]). + /// The Link tool edits links, so its right-click on one is the link's whatever lies over it, as + /// its press there is. fn context_at(&self, view: &DocView, p: Pos2) -> Option { - top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner) + let hits = self.hits(view, p, true); + if self.tool == QuickTool::Link + && let Some(link) = hits.iter().rev().find(|h| matches!(h.owner, Owner::Link { .. })) + { + return Some(link.owner); + } + top(hits, QuickTool::Select).map(|h| h.owner) } /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index ffd716c056..9c659352f9 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -462,11 +462,15 @@ pub(crate) fn page_input( /// A right-click on widget `widget` of field `field` (the top hit there, [`hit`]): the field is /// selected, so the menu acts on it. Part of a selection of several, it becomes the anchor the -/// others line up with. +/// others line up with. Not while a field is dragged or a rectangle drawn: the release acts on +/// the selection the drag began with. pub(crate) fn context(form: &[FormField], field: usize, widget: usize, view: &mut DocView) { let Some(f) = form.get(field) else { return }; let key = (f.name.clone(), widget); let prep = &mut view.prepare; + if prep.grab.is_some() || prep.ignore_release { + return; + } if let Some(i) = prep.also.iter().position(|k| *k == key) { prep.also.remove(i); if let Some(old) = prep.selected.replace(key) { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 6975a12a04..306f98accc 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1226,3 +1226,61 @@ fn a_fields_tooltip_shows_only_where_the_field_takes_the_press() { rest_at(&mut h, on_field); assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip where it takes the press"); } + +/// With the Link tool a right-click on a link is the link's, as its press there is, though a note +/// lies over it: the Link tool edits links and reaches no comment. +#[test] +fn a_right_click_with_the_link_tool_on_a_note_over_a_link_selects_the_link() { + let mut h = open(note_over_link_pdf(), "150", false); + link_tool(&mut h); + let on_both = at(&h, 150.0, 325.0); + right_click(&mut h, on_both); + let v = &h.state().views[0]; + assert_eq!((v.links.selected, v.links.context, v.comments.selected), (Some((0, 0)), Some((0, 0)), None), "the link, not the note"); +} + +/// With Add text a right-click leaves comments alone, as before: Add text takes the page's +/// right-clicks. +#[test] +fn a_right_click_with_add_text_selects_no_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::AddText; + h.run_steps(4); + // Above the line of text the comment covers, where no paragraph is. + let on_comment = at(&h, 100.0, 317.0); + right_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, None, "the comment under the pointer is not selected"); +} + +/// With the Select tool a right-click finds a link only where a click follows it: a link that +/// does nothing has no menu. The Link tool's own test used to give it one. +#[test] +fn a_right_click_with_the_select_tool_on_a_link_with_no_action_opens_no_link_menu() { + let mut h = open(dead_link_pdf(), "150", false); + let dead = at(&h, 25.0, 325.0); + right_click(&mut h, dead); + assert_eq!(h.state().views[0].links.context, None); + assert!(h.query_by_label("Remove all links").is_none(), "the page's menu has no link items"); +} + +/// [`link_pdf`], with a check box over the middle of its link. +fn field_over_link_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 6 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [140 315 160 335] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + ]) +} + +/// A right-click on a form field over a link is the field's, as a press there is: no link menu. +#[test] +fn a_right_click_on_a_field_over_a_link_opens_no_link_menu() { + let mut h = open(field_over_link_pdf(), "150", false); + let on_field = at(&h, 150.0, 325.0); + right_click(&mut h, on_field); + assert_eq!(h.state().views[0].links.context, None, "the field is on top"); + assert!(h.query_by_label("Remove all links").is_none()); +} diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 82e33b29de..281e3d9e18 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1562,3 +1562,40 @@ fn button_export_properties_apply_cancel_and_undo() { assert_eq!(f.export_of(0), Some(original.as_str())); } } + +/// The screen rectangle of widget 0 of field `name` on the first view. +fn field_rect(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Rect { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") +} + +/// A right-click while a field is dragged leaves the dragged field selected, so the drag moves it +/// and not the field right-clicked. The right-click used to select the other field, and the release +/// moved that one. +#[test] +fn a_right_click_during_a_field_drag_moves_the_dragged_field() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + let (name, city) = (field_rect(&h, "name"), field_rect(&h, "city")); + let a = name.center(); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + h.hover_at(a + egui::vec2(0.0, 30.0)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: city.center(), button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + } + h.run_steps(1); + let end = a + egui::vec2(0.0, 60.0); + h.hover_at(end); + h.run_steps(1); + h.drop_at(end); + h.run_steps(3); + assert_eq!(field_rect(&h, "city"), city, "the field right-clicked stays where it was"); + assert_ne!(field_rect(&h, "name"), name, "the dragged field moved"); +} From b12d091496348088bde8bd592dd3d9e3fb3cc91f Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 12:35:25 +0100 Subject: [PATCH 27/63] fix(ui): hover painting beside the open palette, and one hit test under the pointer The page's hover (the top hit under the pointer) was only worked out when nothing was drawn over the pages, so with the palette open a field beside it lost its tooltip and its hover outline, and comments their outline. The palette keeps the page's cursor and tips away, which is what that gate is for; hover now ignores it, as the outlines and the field tooltip did before. The hits under the pointer were also asked up to four times a page each frame, each pass running every module's hit test: for a click's release, for Fill & Sign's preview, for a right-click, and for the cursor. A click's release, Fill & Sign's preview and a right-click's tool checks now share one pass, taken before the tools act and only on frames that need it. A right-click's own pass (comments by `context_hit`) stays, and the cursor and hover keep theirs, after the tools act. A unit test pins that cancelling the pages' gestures lets go of a camera corner. The kittest for Escape mid corner drag passes on `main` too, because Escape leaves the camera tool first. Tests: `a_fields_tooltip_shows_beside_the_open_palette`, `cancelling_pointer_gestures_lets_go_of_a_camera_corner`; each seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 35 +++++++++++++++---------- crates/ui-egui/src/canvas/tests.rs | 10 +++++++ crates/ui-egui/tests/it/pointer.rs | 14 ++++++++++ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 7f5f9dd154..f1d458257e 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -95,15 +95,19 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } // What this page has to do with the press: its owner's, the active tool's, or another page's. let press = view.router.press_for(i); + // What lies under the pointer before the tools act, asked once: for a click's release, a + // right-click, and Fill & Sign's preview. + let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; + let under = pointer.filter(|_| wants_under).map(|p| hits.at(view, p)).unwrap_or_default(); let released_over = match press { - PagePress::Mine(owner) if resp.clicked() => pointer.is_some_and(|p| hits.at(view, p).iter().any(|h| h.owner == owner)), + PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, }; - let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); + let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p)); // A mark already placed takes its own press (comments, below), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. - let over_mark = matches!(tool, QuickTool::Fill(_)) - && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); + let over_mark = + matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); let on_field = if preparing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view); @@ -194,17 +198,19 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // leaves them unselected. let on_comment = comments::page_input(ui, resp, &pcx, press, view); let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; - let hover = pointer.filter(|_| frame.env.unobstructed).and_then(|p| claim_under(&hits, view, p)); + let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed)); PageInput { consumed, area_tool, hover, released_over } } -/// The cursor and the tip from what lies under the pointer at `p`, after the tools claimed theirs -/// (the last claim on a layer stands). Returns the top of it, what a press there would take, for -/// hover painting. -fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { +/// What lies under the pointer at `p`, after the tools act: its top, what a press there would take, +/// for hover painting. With `claim` (nothing drawn over the pages, such as the palette), its cursor +/// and tip too, after the tools claimed theirs (the last claim on a layer stands). +fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2, claim: bool) -> Option { let under = hits.at(view, p); - for h in &under { - view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); + if claim { + for h in &under { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); + } } top(under, hits.tool) } @@ -314,13 +320,14 @@ fn tool_input( /// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the /// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is /// selected; links take theirs from what this returns. -fn right_click(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { +/// `under`: the hits at `p` before the tools act. +fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { let context = hits.context_at(view, p); // Add text takes every right-click on a page it can change, Edit text those on its own // paragraphs and pictures, and a Prepare field tool the whole page, as before. let editing_here = match hits.tool { - QuickTool::AddText => hits.can_modify || top(hits.at(view, p), hits.tool).is_some(), - QuickTool::EditText => top(hits.at(view, p), hits.tool).is_some(), + QuickTool::AddText => hits.can_modify || top(under.iter().cloned(), hits.tool).is_some(), + QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), _ => false, }; if !editing_here && hits.field_tool.is_none() { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4761584735..02aeeacd09 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2037,3 +2037,13 @@ fn image_handles_account_for_document_and_view_rotation() { } } } + +/// Cancelling the pages' pointer gestures (Escape, a dialog, automation's cancel) lets go of a +/// camera corner being dragged: the quadrilateral stays, and no corner follows the pointer. +#[test] +fn cancelling_pointer_gestures_lets_go_of_a_camera_corner() { + let mut v = view(1, PageLayout::Single, true); + v.camera = Some(crate::camera_ui::CameraQuad { grab: Some(2), ..crate::camera_ui::CameraQuad::inset(0, 300.0, 400.0) }); + v.cancel_pointer_gestures(); + assert_eq!(v.camera.map(|q| q.grab), Some(None)); +} diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 306f98accc..0e5a7e3af1 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1284,3 +1284,17 @@ fn a_right_click_on_a_field_over_a_link_opens_no_link_menu() { assert_eq!(h.state().views[0].links.context, None, "the field is on top"); assert!(h.query_by_label("Remove all links").is_none()); } + +/// While the palette is open, a field beside it still shows its tooltip, as before: the palette +/// keeps the page's own tips away, not egui's tooltip of a field. +#[test] +fn a_fields_tooltip_shows_beside_the_open_palette() { + let mut h = open(commented_text_field_pdf(), "150", false); + h.state_mut().palette_open = true; + h.run_steps(4); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::unique("palette"))).expect("palette shown"); + let on_field = at(&h, 60.0, 210.0); + assert!(!palette.expand(20.0).contains(on_field), "the field is beside the palette {palette:?}"); + rest_at(&mut h, on_field); + assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip shows"); +} From ce90c81ffb065e2d4f48bcb0926732c92773e2e1 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:06:29 +0100 Subject: [PATCH 28/63] fix(ui): a drag seen past its page's edge still draws on that page egui reports a drag once the pointer has moved a few points from the press. A press near a page's bottom edge, flicked onto the next page in one frame, is seen as a drag only with the pointer on the next page. Redact (and the area Highlight), the Link tool and Prepare returned before their drag start unless the pointer was on their own page, and the next page is `Elsewhere`, so the press drew nothing anywhere. (On main the next page took it, with the box's start on the page before.) They now start the gesture from where the press began on their own page, as zoom, snapshot, crop and Sign area already did, so the box, link rectangle, marquee, field drawn or field moved belongs to the page the press began on. Tests: one per tool, each seen failing with the fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/link_ui.rs | 19 ++++--- crates/ui-egui/src/tools/prepare.rs | 73 +++++++++++++-------------- crates/ui-egui/src/tools/redact_ui.rs | 20 ++++---- crates/ui-egui/tests/it/pointer.rs | 58 +++++++++++++++++++++ 4 files changed, 117 insertions(+), 53 deletions(-) diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index d380d1b71b..9cd62d6969 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -108,7 +108,19 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option, free: bool, owner: Option form.get(field).map(|f| (f, widget)), _ => None, }; - if resp.drag_started() && (held || free) { - let origin = ptr.origin.unwrap_or(p); - if let PagePress::Mine(Owner::PrepareCorner { left, top }) = press { + // A drag starts from where its press began on this page: egui may see it only once the pointer + // is past the page's edge. + if allowed + && resp.drag_started() + && (held || free) + && let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o)) + { + out.consumed = true; + if tool.is_some() { + prep.grab = Some((page, Grab::Draw(origin), origin)); + } else if let PagePress::Mine(Owner::PrepareCorner { left, top }) = press { prep.grab = Some((page, Grab::Corner(left, top), origin)); - out.consumed = true; } else if let Some((f, wi)) = hit { prep.selected = Some((f.name.clone(), wi)); prep.grab = Some((page, Grab::Move, origin)); - out.consumed = true; } else { prep.marquee_before = Some(prep.selected.iter().chain(prep.also.iter()).cloned().collect()); if !ui.input(|i| i.modifiers.shift || i.modifiers.command) { @@ -432,7 +409,29 @@ pub(crate) fn page_input( prep.also.clear(); } prep.grab = Some((page, Grab::Select(origin), origin)); + } + return out; + } + let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return out }; + if !allowed { + return out; + } + if let Some(tool) = tool { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // A click without a drag places the field at once. + if free && resp.clicked() { out.consumed = true; + if tool == FieldTool::TextTable { + out.table = Some((page, table_rect(xf, Rect::from_min_size(p, egui::Vec2::ZERO)))); + out.placed = true; + return out; + } + let at = to_user(xf, info, page, p); + let (w, h) = tool.default_size(); + let join = prep.selected.as_ref().and_then(|(n, _)| form.iter().find(|f| &f.name == n)); + prep.select_added = Some(form.len()); + view.pending_edit = Some(Edit::AddField { page, rect: [at[0], at[1] - h, at[0] + w, at[1]], kind: tool.new_field(join), name: None }); + out.placed = true; } return out; } diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index 0cca746bf8..345e7e5d77 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -252,17 +252,17 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo } return true; } - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; - if !over_text(p) { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // The tool's own press, of the primary button: one nobody holds, which began on this page off + // its text. egui may see the drag only once the pointer is past the page's edge. + if resp.drag_started_by(egui::PointerButton::Primary) + && free + && let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o) && !over_text(*o)) + { + view.redact_drag = Some((page, origin)); + return true; } - // The tool's own press, of the primary button: one nobody holds. - if resp.drag_started_by(egui::PointerButton::Primary) && free { - let origin = ptr.origin.unwrap_or(p); - if !over_text(origin) { - view.redact_drag = Some((page, origin)); - return true; - } + if pointer.is_some_and(|p| xf.rect.contains(p) && !over_text(p)) { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } false } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 0e5a7e3af1..8a020cc4ea 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1298,3 +1298,61 @@ fn a_fields_tooltip_shows_beside_the_open_palette() { rest_at(&mut h, on_field); assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip shows"); } + +/// Two blank 300×400 pages. +fn two_pages_pdf() -> Vec { + pdf(&[ + CATALOG, + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Page /Parent 2 0 R >>", + ]) +} + +/// Sets a tool up (`tool`), presses above page 1's bottom edge and drags onto page 2 in one frame, +/// so egui sees the drag only once the pointer is on page 2, and returns the app after the release. +fn drag_seen_only_past_page_ones_edge(tool: impl FnOnce(&mut PdfCraftApp)) -> Harness<'static, PdfCraftApp> { + let mut h = open(two_pages_pdf(), "100", false); + tool(h.state_mut()); + h.run_steps(4); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + let press = egui::pos2(one.min.x + 50.0, one.max.y - 30.0); + let release = egui::pos2(one.min.x + 150.0, two.min.y + 20.0); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + h.hover_at(release); + h.run_steps(1); + h.drop_at(release); + h.run_steps(4); + h +} + +// A drag seen only past its page's edge still draws on the page its press began on, as zoom, crop +// and Sign area do. With these three it used to draw nothing anywhere. + +#[test] +fn a_redaction_box_dragged_off_its_page_is_drawn_on_it() { + let h = drag_seen_only_past_page_ones_edge(|app| app.quick_tool = QuickTool::Redact); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert!(doc.info.annotations.iter().any(|a| a.subtype == "Redact" && a.page == 0), "a redaction mark on page 1"); +} + +#[test] +fn a_link_rectangle_dragged_off_its_page_is_drawn_on_it() { + let h = drag_seen_only_past_page_ones_edge(|app| app.quick_tool = QuickTool::Link); + assert_eq!(h.state().link_draft.as_ref().map(|d| (d.page, d.index)), Some((0, None)), "a new link on page 1"); +} + +#[test] +fn a_field_dragged_off_its_page_is_drawn_on_it() { + let h = drag_seen_only_past_page_ones_edge(|app| assert!(app.execute("form.add.text"), "Prepare a form, with the text field tool")); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert!(doc.form.iter().any(|f| f.widgets.iter().any(|w| w.page == Some(0))), "a field on page 1"); +} From 6557d6edc5d8761dcc26394391f7f7e52f12a62e Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:12:47 +0100 Subject: [PATCH 29/63] test(ui): the page guard holds told modules to what they are told The modules that are now told what a press is to their page (forms, Prepare, comments, the Link tool, the camera, the stamps and the other free-press tools) left the press-handler list, whose check was only that each read `router.owner(` and called no hit test of its own. Nothing kept them from reading the router again, though CLAUDE.md said the guard enforced it. - Rule (e) now: `router.press_for(`, `router.owner(` and `router.keeps_drag(` are read only by `ROUTER_READERS`: the page view, and the Edit text group and text layer until 4b tells them too. An unused entry fails the test, so 4b removes them one by one. - A hit test called as a method counts as a call: `cx.hit(origin)` or `self.foo_hit(p)` in a press handler passed. `PageCx::hit`, which finds a comment and makes no `Hit`, is renamed `comment_at`. - An entry of `PAGE_HIT_CALLS` that no page module calls any more fails the test. Each was seen: a `view.router.owner(page)` and a `cx.hit(origin)` planted in forms' `page_input` pass the guard before and fail it after, and a stale `PAGE_HIT_CALLS` entry fails it. Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/canvas/page_pointer/tests.rs | 42 +++++++++++++++++-- crates/ui-egui/src/tools/comments/input.rs | 4 +- crates/ui-egui/src/tools/comments/paint.rs | 2 +- crates/ui-egui/src/tools/comments/state.rs | 2 +- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 69abae12cf..dd37e44373 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -418,15 +418,14 @@ fn is_hit_test(name: &str) -> bool { name == "hit" || name.ends_with("_hit") } -/// The hit tests `line` calls, by path or bare. A method, after its `.`, is left out: -/// `PageCx::hit` finds the comment under a point, not a `Hit`. +/// The hit tests `line` calls: by path, bare or as a method. fn hit_test_calls(line: &str) -> Vec { line.match_indices("hit(") .filter_map(|(at, _)| { let end = at + "hit".len(); let start = line[..end].char_indices().rev().find(|(_, c)| !(c.is_alphanumeric() || *c == '_')).map_or(0, |(i, c)| i + c.len_utf8()); let (before, name) = (&line[..start], &line[start..end]); - (is_hit_test(name) && !before.ends_with('.') && !before.ends_with("fn ")).then(|| name.to_owned()) + (is_hit_test(name) && !before.ends_with("fn ")).then(|| name.to_owned()) }) .collect() } @@ -440,6 +439,27 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", )]; +/// What reads who holds the press the pages hold. +const ROUTER_READS: [&str; 3] = ["router.press_for(", "router.owner(", "router.keeps_drag("]; + +/// The functions that read who holds the press: (file, function, why). The page view asks once per +/// page and tells each module what the press is to its page (`PagePress`); the Edit text group and +/// the text layer still ask, until they are told too (PR 4b), and an unused entry fails the test. +const ROUTER_READERS: [(&str, &str, &str); 8] = [ + ( + "canvas/page_input.rs", + "page_tool_input", + "the page view: what the press is to each page (`press_for`); Add text's paragraph and the pictures until 4b", + ), + ("canvas/page_input.rs", "update_selection_on_press", "the text layer selects no text under a press held by what keeps its drag (4b)"), + ("canvas/page_input.rs", "finish_text_pointer_gesture", "a click held by what keeps its drag leaves the text selection alone (4b)"), + ("canvas/page_input.rs", "page_overlays", "a click follows the link that holds its press (4b)"), + ("tools/content_ui.rs", "page_input", "added text and pictures, until they are told (4b)"), + ("tools/edit_text_ui.rs", "page_input", "Edit text's paragraphs, until they are told (4b)"), + ("tools/edit_text_ui/image.rs", "image_input", "Edit text's pictures, until they are told (4b)"), + ("tools/object_ui.rs", "input", "the objects selected together, until they are told (4b)"), +]; + /// What ignores the press the pages hold until the button is up. const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; @@ -493,6 +513,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { src/canvas/page_pointer/tests.rs, with why.", "no longer decides, keeps or drops the press: remove it from ROUTER_WRITERS.", ); + problems.extend(misuses( + &ROUTER_READS, + &ROUTER_READERS, + "reads who holds the press the pages hold. The page view asks the router once per page and tells each module \ + what the press is to its page: take a `PagePress` (src/canvas/page_router.rs) instead. A new place that must? \ + Add the function to ROUTER_READERS in src/canvas/page_pointer/tests.rs, with why.", + "no longer reads who holds the press: remove it from ROUTER_READERS.", + )); problems.extend(misuses( &PRESS_IGNORES, &PRESS_IGNORERS, @@ -524,6 +552,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { src/canvas/page_pointer/tests.rs, with why.", "calls no hit test any more: remove it from HIT_TESTERS.", )); + // An entry no page module calls any more no longer guards anything. + let called = page_module_uses(&PAGE_HIT_CALLS); + problems.extend( + PAGE_HIT_CALLS + .iter() + .filter(|call| !called.iter().any(|(_, _, _, name)| name == *call)) + .map(|call| format!("no page module calls `{call}` any more: remove it from PAGE_HIT_CALLS.")), + ); let builds = page_module_uses(&HIT_BUILDS); assert!(!builds.is_empty(), "no page module makes a `Hit` any more: update HIT_BUILDS"); problems.extend(builds.into_iter().filter(|(_, function, _, _)| !function.as_deref().is_some_and(is_hit_test)).map(|(file, _, n, name)| { diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 7ada11bc7f..396aea8b13 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -90,7 +90,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op return Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Handle(x, y) }, Layer::Handle, icon)); } } - let a = cx.hit(p).filter(|a| bodies && pick(a))?; + let a = cx.comment_at(p).filter(|a| bodies && pick(a))?; // A click on a highlight over a link follows the link, so the link's hand and tip show there. if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link { return None; @@ -111,7 +111,7 @@ pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option { if cx.hidden || !cx.xf.rect.contains(p) { return None; } - let a = cx.hit(p)?; + let a = cx.comment_at(p)?; let layer = if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment }; Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, egui::CursorIcon::Default)) } diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index e784c332ae..8faae3d02e 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -24,7 +24,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> // A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the // marked area reads before anything is applied. if cv.gesture.is_none() - && let Some(a) = pointer.and_then(|p| cx.hit(p)) + && let Some(a) = pointer.and_then(|p| cx.comment_at(p)) && a.subtype == "Redact" { let ([r, g, b], opacity) = cx.redact_mark; diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 07452f5b49..9c876d950f 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -384,7 +384,7 @@ impl PageCx<'_> { /// The topmost comment under `p`: the one drawn last, in `/Annots` order (inspection lists /// comments by position, not in paint order). - pub(super) fn hit(&self, p: Pos2) -> Option<&Annotation> { + 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) } From ae62b66304a3e4b089fe7d3da7138cc5d44b885c Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:16:14 +0100 Subject: [PATCH 30/63] fix(ui): right-clicks with the Link tool and the camera as before; true docs Routing right-clicks changed a few more cases beyond the rulings, found in review: - **The Link tool.** A right-click on a link also unselected the selected comment and moved the page menu's anchor; the Link tool used to take it whole. On a document it can't change, its right-click on a note over a link opened the plain page menu instead of the note's. Now the Link tool's right-click is what its press there would take, on a document it can change, and on a link it leaves comments alone. - **The camera.** A corner, on the handle layer, took right-clicks from a comment under it, though it has no menu. Corners are no longer right-click targets. - **The Link tool's hand** showed in the 2 px margin past the page's edge, where a press is no page's. `link_ui::hit` now finds nothing off the page. Docs made true: what a comment's press is (`comments::page_input`), what reaches the free-press gate (`page_tool_input`), and when every page is `PagePress::Free`. Tests: those three, and two that pin right-clicks kept as before: Edit text's on a paragraph under a comment, and a Prepare field tool's. Each seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 34 +++++---- crates/ui-egui/src/canvas/page_router.rs | 3 +- crates/ui-egui/src/tools/comments/input.rs | 5 +- crates/ui-egui/src/tools/link_ui.rs | 4 + crates/ui-egui/tests/it/pointer.rs | 85 ++++++++++++++++++++++ 5 files changed, 113 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index f1d458257e..eda260451d 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -35,9 +35,10 @@ pub(crate) struct PageToolContext<'a> { pub info: &'a DocInfo, } -/// The tools' input on one page. The hits at the press point decide who holds the press, and each -/// module is told what the press is to this page (`PagePress`): its own, nobody's (the active tool's, -/// through `tool_input`), or another page's. Text selection runs after, on what nobody took. +/// The tools' input on one page. The hits at the press point decide who holds the press, and the +/// modules are told what the press is to this page (`PagePress`): their own, nobody's (the active +/// tool's: `tool_input`, and the tools still called here), or another page's. The Edit text group +/// still asks the router, until it is told too. Text selection runs after, on what nobody took. pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { let PageEnv { added, @@ -324,13 +325,15 @@ fn tool_input( fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { let context = hits.context_at(view, p); // Add text takes every right-click on a page it can change, Edit text those on its own - // paragraphs and pictures, and a Prepare field tool the whole page, as before. - let editing_here = match hits.tool { + // paragraphs and pictures, the Link tool those on a link, and a Prepare field tool the whole + // page, as before: comments see none of them. + let tool_takes_it = match hits.tool { QuickTool::AddText => hits.can_modify || top(under.iter().cloned(), hits.tool).is_some(), QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), - _ => false, + QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), + _ => hits.field_tool.is_some(), }; - if !editing_here && hits.field_tool.is_none() { + if !tool_takes_it { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, @@ -396,16 +399,16 @@ impl PageHits<'_> { /// What a right-click at `p` is on: what lies on top there, as for a press, but with every /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight /// over a link is the highlight's ([`comments::context_hit`]). - /// The Link tool edits links, so its right-click on one is the link's whatever lies over it, as - /// its press there is. + /// The Link tool edits links and reaches no comment: on a document it can change, its + /// right-click is what its press there would take, when that is anything. fn context_at(&self, view: &DocView, p: Pos2) -> Option { - let hits = self.hits(view, p, true); if self.tool == QuickTool::Link - && let Some(link) = hits.iter().rev().find(|h| matches!(h.owner, Owner::Link { .. })) + && self.can_modify + && let Some(press) = top(self.hits(view, p, false), QuickTool::Link) { - return Some(link.owner); + return Some(press.owner); } - top(hits, QuickTool::Select).map(|h| h.owner) + top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner) } /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text @@ -413,7 +416,8 @@ impl PageHits<'_> { /// open paragraph's frame, the selected picture's grips over them all; the camera tool's corners; /// then form fields (Prepare's, being moved, over the comments; filled ones under added content /// and the comments that move). In crop mode, only the picture being cropped. - /// For a right-click (`context`), comments by [`comments::context_hit`]. + /// For a right-click (`context`), comments by [`comments::context_hit`], and no camera corner (it + /// has no menu). fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec { let PageHits { page, @@ -475,7 +479,7 @@ impl PageHits<'_> { // together. let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); - let corner = (tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten(); + let corner = (!context && tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten(); let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) }; [link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 447289ccd0..0b53038625 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -115,7 +115,8 @@ pub(crate) enum PagePress { /// The press began on this page, and the owner's hit won it: only its module acts on it. Mine(Owner), /// No button is down, or the press began on this page and nobody's hit won it: the active - /// tool's. + /// tool's. Also every page's while a press that began off the pages is held, and after the + /// press was dropped ([`PageRouter::cancel`]) until the button is up. Free, /// The press began on another page, or between the pages. Elsewhere, diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 396aea8b13..52319276da 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -9,8 +9,9 @@ use crate::page_cursor::{Layer, Subject}; use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// Comment input on one page, before text selection runs. `press` is what this page has to do with -/// the press the pages hold: a press on a comment ([`hit`]) is that comment's, with any tool, and -/// one nobody holds is a commenting tool's. Returns `true` when the pointer gesture belongs to +/// the press the pages hold: a press on a comment where the tool reaches it ([`hit`]: the Select +/// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that +/// comment's, and one nobody holds is a commenting tool's. Returns `true` when the pointer gesture belongs to /// commenting (text selection must ignore it). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 9cd62d6969..659bad41e4 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -66,6 +66,10 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { /// picked, and a drag from one draws a new link. The other tools find the links the renderer can /// follow, which have a target (`info.links`). The tip is the link's target. pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, links: &[LinkItem], tool: QuickTool, p: Pos2) -> Option { + // A press off the page is no page's, so nothing there is a link's, the margin included. + if !xf.rect.contains(p) { + return None; + } let index = if tool == QuickTool::Link { links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index } else { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 8a020cc4ea..04d98b9318 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1356,3 +1356,88 @@ fn a_field_dragged_off_its_page_is_drawn_on_it() { let doc = s.session.get(s.views[0].id).unwrap(); assert!(doc.form.iter().any(|f| f.widgets.iter().any(|w| w.page == Some(0))), "a field on page 1"); } + +/// With the Link tool a right-click on a link is the link's and leaves comments as they are, as +/// before: the selected comment stays selected. +#[test] +fn a_right_click_with_the_link_tool_on_a_link_keeps_the_selected_comment() { + let mut h = open(note_over_link_pdf(), "150", false); + let on_note = at(&h, 150.0, 325.0); + mouse_click(&mut h, on_note); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note is selected"); + link_tool(&mut h); + // On the link, away from the note. + let on_link = at(&h, 70.0, 325.0); + right_click(&mut h, on_link); + let v = &h.state().views[0]; + assert_eq!((v.links.context, v.comments.selected), (Some((0, 0)), Some((0, 1))), "the link's menu; the note stays selected"); +} + +/// A 300×400 page with a rectangle comment round the camera tool's first corner (view 15, 20). +fn comment_at_camera_corner_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Square /Rect [5 365 45 395] /C [1 0 0] /F 4 /T (Ana) >>", + ]) +} + +/// A camera corner has no menu: a right-click on a comment under it opens the comment's, as +/// before. +#[test] +fn a_right_click_on_a_comment_under_a_camera_corner_opens_the_comments_menu() { + let mut h = open(comment_at_camera_corner_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EnhanceCamera; + h.run_steps(4); + let corner = at(&h, 15.0, 380.0); + right_click(&mut h, corner); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu"); +} + +/// A 300×400 page with a web link along its left edge, x 0..100, y 300..350. +fn edge_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [0 300 100 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + ]) +} + +/// The Link tool's margin round a link stops at the page's edge: beside the page, where a press +/// does nothing, no hand promises one. +#[test] +fn the_link_tools_hand_stops_at_the_pages_edge() { + let mut h = open(edge_link_pdf(), "150", false); + link_tool(&mut h); + let on_link = at(&h, 10.0, 325.0); + assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand, "on the link"); + let beside = at(&h, 0.0, 325.0) - egui::vec2(1.0, 0.0); + assert_ne!(cursor_at(&mut h, beside), egui::CursorIcon::PointingHand, "just off the page"); +} + +/// With Edit text a right-click on a paragraph is Edit text's, as before, though a comment lies +/// over it: the comment is not selected. +#[test] +fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + let on_text = at(&h, 100.0, 303.0); + wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default); + right_click(&mut h, on_text); + assert_eq!(h.state().views[0].comments.selected, None); +} + +/// With a Prepare field tool a right-click is the tool's, as before: a comment under it is not +/// selected. +#[test] +fn a_right_click_with_a_field_tool_selects_no_comment() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.add.text")); + h.run_steps(4); + let on_comment = at(&h, 200.0, 125.0); + right_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, None); +} From 47ece03a5465dd22355c88c001b583d925b811fc Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:48:54 +0100 Subject: [PATCH 31/63] fix(ui): the Link tool's click told too; a moved comment keeps its selection Found in review: - **The Link tool's click** acted on any press: with Prepare open, a click on a field also unselected the selected link, and a click that began off the page did too. It now acts only on a press on a link, or one nobody holds. - **A right-click while a comment is moved** selected the comment under it mid-drag, though the release still moved the first. Comments now keep their selection during a move, resize or reshape, as Prepare does. - The hits under the pointer before the tools act are taken only on the pointer's page, and the Link tool's right-click reuses them. - The router has one source for the press's page (`Capture` no longer carries its own). - The guard lists which functions may ask which comment lies under a point (`comment_at`); the guard finds hit tests by name, and its docs say so. - Docs made true: what `PagePress::Mine` promises, what `tool_input` gates, and that a dialog does not cancel the pages' press (only Escape and automation's cancel do). Tests: the two fixes, and ruling 1 for added text (a click on it unselects the comment), each seen failing with its fix reverted; the drawing-tool right-click test now checks the comment's menu. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 19 ++++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 24 ++++++- crates/ui-egui/src/canvas/page_router.rs | 12 ++-- crates/ui-egui/src/canvas/tests.rs | 2 +- crates/ui-egui/src/tools/link_ui.rs | 3 +- crates/ui-egui/tests/it/pointer.rs | 64 ++++++++++++++++++- 6 files changed, 106 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index eda260451d..248b5a767f 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -99,7 +99,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // What lies under the pointer before the tools act, asked once: for a click's release, a // right-click, and Fill & Sign's preview. let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; - let under = pointer.filter(|_| wants_under).map(|p| hits.at(view, p)).unwrap_or_default(); + let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p)).unwrap_or_default(); let released_over = match press { PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, @@ -227,7 +227,8 @@ fn link_of(owner: Option) -> Option { /// The active tool's own input on page `i`: the one gate for presses nobody holds. Measure, the /// stamps, Fill & Sign, Sign area, zoom and snapshot, the camera's quadrilateral and crop act only /// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor -/// and gestures. `over_mark`: a Fill & Sign mark takes the press, or would take one under the +/// and gestures. The camera also drags a corner whose press it holds, and crop's double-click +/// opens Set Page Boxes wherever it is. `over_mark`: a Fill & Sign mark takes the press, or would take one under the /// pointer. fn tool_input( ui: &egui::Ui, @@ -323,7 +324,7 @@ fn tool_input( /// selected; links take theirs from what this returns. /// `under`: the hits at `p` before the tools act. fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { - let context = hits.context_at(view, p); + let context = hits.context_at(view, p, under); // Add text takes every right-click on a page it can change, Edit text those on its own // paragraphs and pictures, the Link tool those on a link, and a Prepare field tool the whole // page, as before: comments see none of them. @@ -333,7 +334,10 @@ fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mu QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), _ => hits.field_tool.is_some(), }; - if !tool_takes_it { + // A comment being moved, resized or reshaped keeps its selection until its release. + let comment_dragged = + matches!(view.comments.gesture, Some(comments::Gesture::Move { .. } | comments::Gesture::Resize { .. } | comments::Gesture::Reshape { .. })); + if !tool_takes_it && !comment_dragged { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, @@ -400,11 +404,12 @@ impl PageHits<'_> { /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight /// over a link is the highlight's ([`comments::context_hit`]). /// The Link tool edits links and reaches no comment: on a document it can change, its - /// right-click is what its press there would take, when that is anything. - fn context_at(&self, view: &DocView, p: Pos2) -> Option { + /// right-click is what its press there would take (`under`, the hits at `p`), when that is + /// anything. + fn context_at(&self, view: &DocView, p: Pos2, under: &[crate::page_router::Hit]) -> Option { if self.tool == QuickTool::Link && self.can_modify - && let Some(press) = top(self.hits(view, p, false), QuickTool::Link) + && let Some(press) = top(under.iter().cloned(), QuickTool::Link) { return Some(press.owner); } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index dd37e44373..5919fdae80 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -407,7 +407,7 @@ const ROUTER_WRITES: [&str; 4] = ["router.begin(", "router.press(", "router.sett const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ ("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), ("canvas.rs", "draw_pages", "the press is over once the button is up and its release seen, and a new one drops the last owner"), - ("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, a dialog, automation's cancel"), + ("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, automation's cancel"), ]; /// Ways to make a `Hit`. @@ -430,7 +430,9 @@ fn hit_test_calls(line: &str) -> Vec { .collect() } -/// The functions that call a hit test: (file, function, why). `PageHits::hits` is the one: the hits +/// The functions that call a hit test: (file, function, why). Hit tests are found by name (a +/// function named `hit` or `…_hit`, called by path, bare or as a method); a point test by another +/// name has a list of its own (`COMMENT_POINT_TESTERS`). `PageHits::hits` is the one: the hits /// at the press point decide its owner, and those under the pointer give its cursor, its tip, hover /// painting and a right-click. const HIT_TESTERS: [(&str, &str, &str); 1] = [( @@ -439,6 +441,16 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", )]; +/// The comments' point test, which their hit tests are built on: which comment lies under a point. +const COMMENT_POINT_TESTS: [&str; 1] = [".comment_at("]; + +/// The functions that ask which comment lies under a point: (file, function, why). +const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ + ("tools/comments/input.rs", "hit", "the comments' hit test"), + ("tools/comments/input.rs", "context_hit", "the comments' hit test for a right-click"), + ("tools/comments/paint.rs", "paint_page", "a redaction mark shows its fill under the pointer with every tool, as comments' tips show"), +]; + /// What reads who holds the press the pages hold. const ROUTER_READS: [&str; 3] = ["router.press_for(", "router.owner(", "router.keeps_drag("]; @@ -521,6 +533,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { Add the function to ROUTER_READERS in src/canvas/page_pointer/tests.rs, with why.", "no longer reads who holds the press: remove it from ROUTER_READERS.", )); + problems.extend(misuses( + &COMMENT_POINT_TESTS, + &COMMENT_POINT_TESTERS, + "asks which comment lies under a point. The comments' hit tests answer that for a press and a right-click: \ + act on what the page view tells you, rather than testing the point again. A new place that must? Add the \ + function to COMMENT_POINT_TESTERS in src/canvas/page_pointer/tests.rs, with why.", + "no longer asks which comment lies under a point: remove it from COMMENT_POINT_TESTERS.", + )); problems.extend(misuses( &PRESS_IGNORES, &PRESS_IGNORERS, diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 0b53038625..be83411072 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -112,7 +112,8 @@ pub(crate) fn top(hits: impl IntoIterator, tool: QuickTool) -> Optio /// What one page has to do with the press the pages hold. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum PagePress { - /// The press began on this page, and the owner's hit won it: only its module acts on it. + /// The press began on this page, and the owner's hit won it: its module acts on it. Others + /// only let go of their own state (a click on anything but the selected comment unselects it). Mine(Owner), /// No button is down, or the press began on this page and nobody's hit won it: the active /// tool's. Also every page's while a press that began off the pages is held, and after the @@ -135,7 +136,6 @@ impl PagePress { /// A press the pages hold, and who it belongs to. #[derive(Clone, Debug, PartialEq)] pub(crate) struct Capture { - pub page: usize, pub owner: Owner, pub declines_drag: bool, } @@ -159,7 +159,7 @@ impl PageRouter { /// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's /// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page). pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { - self.capture = top(hits, tool).map(|h| Capture { page, owner: h.owner, declines_drag: h.declines_drag }); + self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag }); self.began = Some(Some(page)); } @@ -167,7 +167,7 @@ impl PageRouter { pub(crate) fn press_for(&self, page: usize) -> PagePress { match (self.began, &self.capture) { (None, _) => PagePress::Free, - (Some(Some(p)), Some(c)) if p == page => PagePress::Mine(c.owner), + (Some(Some(p)), Some(capture)) if p == page => PagePress::Mine(capture.owner), (Some(Some(p)), None) if p == page => PagePress::Free, _ => PagePress::Elsewhere, } @@ -175,7 +175,7 @@ impl PageRouter { /// Who holds the press on `page`. pub(crate) fn owner(&self, page: usize) -> Option { - self.capture.as_ref().filter(|c| c.page == page).map(|c| c.owner) + self.press_for(page).owner() } /// Whether the press is held by someone who keeps its drag: then the page's text is not @@ -192,7 +192,7 @@ impl PageRouter { } } - /// Drops the press (Escape, a dialog, automation's cancel): every page is the active tool's again + /// Drops the press (Escape, automation's cancel): every page is the active tool's again /// ([`PagePress::Free`]). Page input skips a press Escape cancelled until the button is up. pub(crate) fn cancel(&mut self) { self.capture = None; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 02aeeacd09..c0a797fa67 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2038,7 +2038,7 @@ fn image_handles_account_for_document_and_view_rotation() { } } -/// Cancelling the pages' pointer gestures (Escape, a dialog, automation's cancel) lets go of a +/// Cancelling the pages' pointer gestures (Escape, automation's cancel) lets go of a /// camera corner being dragged: the quadrilateral stays, and no corner follows the pointer. #[test] fn cancelling_pointer_gestures_lets_go_of_a_camera_corner() { diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 659bad41e4..069564afff 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -132,7 +132,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option Vec { } /// The Link tool's margin round a link stops at the page's edge: beside the page, where a press -/// does nothing, no hand promises one. +/// does nothing, no hand promises one (as before the margin moved into the one link test). #[test] fn the_link_tools_hand_stops_at_the_pages_edge() { let mut h = open(edge_link_pdf(), "150", false); @@ -1441,3 +1442,64 @@ fn a_right_click_with_a_field_tool_selects_no_comment() { right_click(&mut h, on_comment); assert_eq!(h.state().views[0].comments.selected, None); } + +/// With the Link tool a click that began off the page (in the gutter beside it) is no page's: the +/// selected link stays selected. It used to unselect it. +#[test] +fn a_click_that_began_off_the_page_keeps_the_selected_link() { + let mut h = open(link_pdf(), "150", false); + link_tool(&mut h); + let on_link = at(&h, 150.0, 325.0); + mouse_click(&mut h, on_link); + assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "the link is selected"); + let left = page(&h).min.x; + slipping_click(&mut h, egui::pos2(left - 2.0, on_link.y), egui::pos2(left + 3.0, on_link.y)); + assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "a click that began off the page leaves it selected"); +} + +/// A right-click while a comment is moved leaves the moved comment selected: the move ends on +/// it. The right-click used to select the comment under it mid-drag. +#[test] +fn a_right_click_during_a_comment_move_keeps_the_moved_comment_selected() { + let mut h = open(stacked_comments_pdf(), "150", false); + // "Bottom note" alone, then "Top note" alone. + let (bottom, top) = (at(&h, 60.0, 160.0), at(&h, 190.0, 290.0)); + h.hover_at(bottom); + h.run_steps(1); + h.drag_at(bottom); + h.run_steps(1); + h.hover_at(bottom + egui::vec2(0.0, 20.0)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: top, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(1); + let end = bottom + egui::vec2(0.0, 40.0); + h.hover_at(end); + h.run_steps(1); + h.drop_at(end); + h.run_steps(3); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the moved comment stays selected"); +} + +/// A click on added text leaves the selected comment unselected, as a click on a form field does +/// (ruling 1). It used to keep it selected. +#[test] +fn a_click_on_added_text_deselects_the_selected_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() }, + })); + // The Edit panel open, with the Select tool: added text is picked up. + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(3); + let on_comment = at(&h, 150.0, 315.0); + mouse_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected"); + let on_added = at(&h, 90.0, 202.0); + mouse_click(&mut h, on_added); + assert_eq!(h.state().views[0].comments.selected, None, "a click on the added text leaves it unselected"); +} From aa415811d7462d17978a9f17f3b6190681cc8710 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 14:22:54 +0100 Subject: [PATCH 32/63] fix(ui): right-clicks leave a held press's selections alone; guard finds more page modules Found in review: - **Right-clicks during a press.** A comment being moved kept its selection only while its gesture state was live, so a gesture left over (a release the pages never saw) blocked every later right-click. Comments and Prepare now leave their selection alone while the primary button is held. egui ends the press's own interaction (and its drag) when another button goes down, so the button is what tells. A touch's long press, which egui reports as a right-click with the finger still down, still opens its menu. - **A click that began beside the page** unselected the selected comment, though the Link tool and Prepare now leave theirs alone for such a click. Comments do the same. - **The guard** took a file as a page module only if it named `PagePointer`; a file using `PageToolContext`, `PagePress` or the router's hits or owners is one too. Its message about tooltips and release checks now points at the page view's own hits, and its doc of the router's readers names link following. Tests: a right-click with a gesture left over, a click that began beside the page, and a long press on a comment. Each was seen failing with its fix reverted. A planted page-router name in a module the guard didn't list fails the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 28 ++++++++----- .../ui-egui/src/canvas/page_pointer/tests.rs | 24 +++++++---- crates/ui-egui/src/tools/comments/input.rs | 19 +++++---- crates/ui-egui/tests/it/pointer.rs | 41 +++++++++++++++++++ 4 files changed, 87 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 248b5a767f..4a9ed3a54e 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -104,7 +104,12 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, }; - let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p)); + // A right-click while the primary button is held (a comment moved, a box drawn) leaves the + // selections that press acts on alone; egui ends the press's own interaction when another button + // goes down, so the button is what tells. A touch's long press, which egui reports as a + // right-click with the finger still down, opens its menu. + let press_held = ptr.primary_down && !resp.long_touched(); + let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p, press_held)); // A mark already placed takes its own press (comments, below), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. let over_mark = @@ -322,29 +327,30 @@ fn tool_input( /// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the /// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is /// selected; links take theirs from what this returns. -/// `under`: the hits at `p` before the tools act. -fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { +/// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the +/// selections its press acts on are left alone until its release. +fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2, press_held: bool) -> Option { let context = hits.context_at(view, p, under); - // Add text takes every right-click on a page it can change, Edit text those on its own - // paragraphs and pictures, the Link tool those on a link, and a Prepare field tool the whole - // page, as before: comments see none of them. + // Add text takes every right-click on a page it can change, Edit text those on anything of its + // own (paragraphs, pictures, added content, the objects selected together and their grips), the + // Link tool those on a link, and a Prepare field tool the whole page, as before: comments see + // none of them. let tool_takes_it = match hits.tool { QuickTool::AddText => hits.can_modify || top(under.iter().cloned(), hits.tool).is_some(), QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), _ => hits.field_tool.is_some(), }; - // A comment being moved, resized or reshaped keeps its selection until its release. - let comment_dragged = - matches!(view.comments.gesture, Some(comments::Gesture::Move { .. } | comments::Gesture::Resize { .. } | comments::Gesture::Reshape { .. })); - if !tool_takes_it && !comment_dragged { + if !tool_takes_it && !press_held { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, }; comments::context(hits.pcx, p, comment, view); } - if let Some(Owner::PrepareField { field, widget }) = context { + if let Some(Owner::PrepareField { field, widget }) = context + && !press_held + { crate::prepare::context(hits.form, field, widget, view); } context diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 5919fdae80..1c41858651 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -190,6 +190,13 @@ const PAGE_MODULES: [&str; 37] = [ "tools/stamps_ui.rs", ]; +/// What a file that acts on the pages uses: the pointer, a tool's page context, what a press is to +/// a page, or the router's hits and owners. Such a file is a page module. +const PAGE_MODULE_MARKS: [&str; 4] = ["PagePointer", "PageToolContext", "PagePress", "page_router::"]; + +/// The router's own files, which define what page modules use. +const ROUTER_FILES: [&str; 3] = ["canvas/page_pointer.rs", "canvas/page_router.rs", "canvas/page_cursor.rs"]; + /// egui's raw pointer state: where the pointer is, and which buttons are down or went down. const RAW_READS: [&str; 21] = [ "hover_pos()", @@ -355,7 +362,7 @@ fn page_modules_read_the_pointer_only_through_page_pointer() { NOT_PAGE_INPUT there, with why.", "reads no pointer state any more: remove it from NOT_PAGE_INPUT.", ); - // A module that takes the page pointer is a page module, so it is checked too. + // A module that acts on the pages is a page module, so it is checked too. let mut dirs = vec![src.clone()]; while let Some(dir) = dirs.pop() { for entry in std::fs::read_dir(dir).expect("source directory") { @@ -375,8 +382,9 @@ fn page_modules_read_the_pointer_only_through_page_pointer() { let file = path.strip_prefix(&src).expect("under src").components().map(|c| c.as_os_str().to_string_lossy()).collect::>().join("/"); let source = std::fs::read_to_string(&path).expect("source file"); - if file != "canvas/page_pointer.rs" && source.contains("PagePointer") && !PAGE_MODULES.contains(&file.as_str()) { - problems.push(format!("src/{file} takes the page pointer: add it to PAGE_MODULES.")); + let marks = PAGE_MODULE_MARKS.iter().filter(|mark| source.contains(**mark)).collect::>(); + if !ROUTER_FILES.contains(&file.as_str()) && !marks.is_empty() && !PAGE_MODULES.contains(&file.as_str()) { + problems.push(format!("src/{file} uses {marks:?}, so it acts on the pages: add it to PAGE_MODULES.")); } } } @@ -455,8 +463,9 @@ const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ const ROUTER_READS: [&str; 3] = ["router.press_for(", "router.owner(", "router.keeps_drag("]; /// The functions that read who holds the press: (file, function, why). The page view asks once per -/// page and tells each module what the press is to its page (`PagePress`); the Edit text group and -/// the text layer still ask, until they are told too (PR 4b), and an unused entry fails the test. +/// page and tells each module what the press is to its page (`PagePress`); the Edit text group, the +/// text layer and link following still ask, until they are told too (PR 4b), and an unused entry +/// fails the test. const ROUTER_READERS: [(&str, &str, &str); 8] = [ ( "canvas/page_input.rs", @@ -567,8 +576,9 @@ fn page_modules_act_on_a_press_only_through_the_router() { page_module_matches(hit_test_calls), &HIT_TESTERS, "calls a hit test. The hits at the press point decide its owner once, in `PageHits::hits`: act on the press when \ - the page view tells you it is yours (`PagePress::Mine`), rather than testing its point again. Testing where the pointer \ - is (a tooltip, a click released over what it was pressed on)? Add the function to HIT_TESTERS in \ + the page view tells you it is yours (`PagePress::Mine`), rather than testing its point again. What lies under \ + the pointer comes from the page view's own hits: hover painting and tips (`PageInput.hover`), a click released \ + over what it was pressed on (`released_over`). A new place that must ask? Add the function to HIT_TESTERS in \ src/canvas/page_pointer/tests.rs, with why.", "calls no hit test any more: remove it from HIT_TESTERS.", )); diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 52319276da..4c026604ea 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -35,14 +35,14 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free }; match cx.tool { // The selected shape's handles, and Fill & Sign's marks, take their own presses. - QuickTool::Comment(_) | QuickTool::Fill(_) if owned.is_some() || moving => select_input(ui, resp, cx, view, owned, pointer, origin), + QuickTool::Comment(_) | QuickTool::Fill(_) if owned.is_some() || moving => select_input(ui, resp, cx, view, press, pointer, origin), QuickTool::Comment(tool) if tool.draws() => gestures.draw_shape(view, tool), QuickTool::Comment(tool) if tool.clicks_points() => gestures.click_points(view, tool), QuickTool::Comment(CommentTool::Callout) => gestures.callout(view), QuickTool::Comment(CommentTool::Attach) => gestures.attach(view), QuickTool::Comment(CommentTool::Caret) => gestures.caret(view, caret_text.as_deref()), QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => gestures.note_or_box(view, tool), - QuickTool::Select => select_input(ui, resp, cx, view, owned, pointer, origin), + QuickTool::Select => select_input(ui, resp, cx, view, press, pointer, origin), _ => false, } } @@ -160,18 +160,22 @@ pub(super) fn hover_tip(a: &Annotation, prefs: &CommentPrefs) -> Option } /// The Select tool: select, move, resize, edit, delete, on the comment the press is on and the -/// part of it (`owned`, from [`hit`]). The cursor comes from [`hit`] too. A click on anything else -/// (a link, a field, added text, the page) leaves comments unselected. +/// part of it (`press`, from [`hit`]). The cursor comes from [`hit`] too. A click on this page on +/// anything else (a link, a field, added text, the page) leaves comments unselected; one that +/// began off the pages is no page's. fn select_input( ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView, - owned: Option<(usize, CommentPart)>, + press: PagePress, pointer: Option, origin: Option, ) -> bool { - let owner = owned.and_then(|(index, part)| cx.get(index).map(|a| (a, part))); + let owner = match press { + PagePress::Mine(Owner::Comment { index, part }) => cx.get(index).map(|a| (a, part)), + _ => None, + }; let cv = &mut view.comments; let mut consumed = false; if resp.drag_started() @@ -255,7 +259,8 @@ fn select_input( cv.reveal = true; consumed = true; } - None => cv.selected = None, + None if press != PagePress::Elsewhere => cv.selected = None, + None => {} } } if resp.double_clicked() diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index d318818304..6685085614 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1503,3 +1503,44 @@ fn a_click_on_added_text_deselects_the_selected_comment() { mouse_click(&mut h, on_added); assert_eq!(h.state().views[0].comments.selected, None, "a click on the added text leaves it unselected"); } + +/// A comment gesture left over with no button down (a release the pages never saw) does not keep +/// a right-click from selecting the comment under it. +#[test] +fn a_right_click_selects_its_comment_though_a_gesture_was_left_over() { + let mut h = open(stacked_comments_pdf(), "150", false); + let bottom = at(&h, 60.0, 160.0); + h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: bottom }); + let top = at(&h, 190.0, 290.0); + right_click(&mut h, top); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the right-click is selected"); +} + +/// A click that began off the page (in the gutter beside it) is no page's: the selected comment +/// stays selected, as the Link tool's and Prepare's selections do. It used to unselect it. +#[test] +fn a_click_that_began_off_the_page_keeps_the_selected_comment() { + let mut h = open(stacked_comments_pdf(), "150", false); + let bottom = at(&h, 60.0, 160.0); + mouse_click(&mut h, bottom); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the note is selected"); + let left = page(&h).min.x; + slipping_click(&mut h, egui::pos2(left - 2.0, bottom.y), egui::pos2(left + 3.0, bottom.y)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "a click that began off the page leaves it selected"); +} + +/// A touch's long press on a comment selects it for its menu, as a right-click does: egui reports +/// it as a right-click with the finger still down, which holds no drag. +#[test] +fn a_long_press_on_a_comment_selects_it_for_its_menu() { + let mut h = open(stacked_comments_pdf(), "150", false); + let top = at(&h, 190.0, 290.0); + let touch = |phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos, force: None }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + one_frame(&mut h, [touch(egui::TouchPhase::Start, top), egui::Event::PointerMoved(top), button(top, true)]); + // Held still, past egui's long-press time. + h.run_steps(6); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the finger is selected"); + one_frame(&mut h, [touch(egui::TouchPhase::End, top), button(top, false), egui::Event::PointerGone]); + h.run_steps(2); +} From f0caaf9259cf37fcb892b664e6aaa4890a52280e Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 17:46:08 +0100 Subject: [PATCH 33/63] fix(ui): a press Prepare or added content took draws no comment; menus open where right-clicked Found in review: - **Comments acted on presses another tool had taken.** Comments run every frame now, and a commenting tool acted on any press nobody held, so with Prepare open a Rectangle drag drew a rectangle as well as Prepare's selection rectangle. Added content, with the Edit panel open, could likewise open a note composer under the Note tool. `main` skipped comments when another module took the frame. They are now told (`taken`), and their drawing and placing tools leave such a press alone. A click there still unselects. - **The page menu's anchor went stale.** A right-click while the primary button was held skipped the call that also anchors the page menu, so Delete page, Paste and Copy Image acted on the page right-clicked before. Every right-click on a page now anchors the menu there, whoever takes it, and only the selections wait for the release. That includes the right-clicks Add text, Edit text, the Link tool and a field tool take, which used to keep the previous anchor. - **Edit text's right-clicks.** Edit text took right-clicks on added content too. It now takes those on its paragraphs, pictures and the objects selected together, and comments see a right-click on added text, as before. - **Camera corners.** A camera corner at the page's edge showed its grab cursor past the edge, where a press is no page's. Its hit now stops at the page, as a link's does. - One source for a comment's layer, dead code in Add text's right-click, and true docs for `PagePress::Elsewhere` and the "Page presses" rule. Tests: a shape-tool drag with Prepare open, a right-click with the button held on another page, a right-click on added text with Edit text, the menu anchored by an Edit text right-click, and a camera corner at the page's edge. Each was seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- crates/ui-egui/src/canvas/page_input.rs | 40 ++++++++++-------- crates/ui-egui/src/canvas/page_router.rs | 2 +- crates/ui-egui/src/canvas/tests.rs | 11 +++++ crates/ui-egui/src/tools/camera_ui.rs | 7 ++-- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 35 ++++++++++------ crates/ui-egui/tests/it/pointer.rs | 47 +++++++++++++++++++++- crates/ui-egui/tests/it/prepare.rs | 18 +++++++++ 9 files changed, 127 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47a80b86bc..5c7034b7a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. -- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own: the page view tells it what the press is to its page (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the press is its own or, for the active tool, nobody's: the page view tells it which (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 4a9ed3a54e..647a39d970 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -201,9 +201,12 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut view.links.context = Some((i, index)); } // Comments, every frame: their own presses, a commenting tool's, and a click elsewhere that - // leaves them unselected. - let on_comment = comments::page_input(ui, resp, &pcx, press, view); - let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; + // leaves them unselected. A press nobody holds that another tool took this frame (Prepare's + // selection rectangle, added content, which still decide for themselves) is not also a + // commenting tool's, as before. + let taken = on_objects || on_edit_text || on_link || on_content || boxing || on_field; + let on_comment = comments::page_input(ui, resp, &pcx, press, taken, view); + let consumed = taken || on_comment; let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed)); PageInput { consumed, area_tool, hover, released_over } } @@ -324,33 +327,36 @@ fn tool_input( } } -/// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the -/// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is -/// selected; links take theirs from what this returns. +/// A right-click at `p`: the page's menu opens there, and it goes to what lies on top there +/// ([`PageHits::context_at`]), which the menu then acts on. A comment there is selected, and none +/// elsewhere; a Prepare field is selected; links take theirs from what this returns. /// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the /// selections its press acts on are left alone until its release. fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2, press_held: bool) -> Option { let context = hits.context_at(view, p, under); - // Add text takes every right-click on a page it can change, Edit text those on anything of its - // own (paragraphs, pictures, added content, the objects selected together and their grips), the - // Link tool those on a link, and a Prepare field tool the whole page, as before: comments see - // none of them. + // Delete page, Paste and Copy Image act where the menu opened, whoever takes the right-click. + comments::anchor(hits.pcx, p, view); + if press_held { + return context; + } + // Add text takes every right-click on a page it can change, Edit text those on its own + // paragraphs and pictures (the objects selected together, and crop mode's picture, among them), + // the Link tool those on a link, and a Prepare field tool the whole page, as before: comments + // see none of them. let tool_takes_it = match hits.tool { - QuickTool::AddText => hits.can_modify || top(under.iter().cloned(), hits.tool).is_some(), - QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), + QuickTool::AddText => hits.can_modify, + QuickTool::EditText => under.iter().any(|h| matches!(h.owner, Owner::Paragraph { .. } | Owner::Picture { .. } | Owner::Objects { .. })), QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), _ => hits.field_tool.is_some(), }; - if !tool_takes_it && !press_held { + if !tool_takes_it { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, }; - comments::context(hits.pcx, p, comment, view); + comments::context(hits.pcx, comment, view); } - if let Some(Owner::PrepareField { field, widget }) = context - && !press_held - { + if let Some(Owner::PrepareField { field, widget }) = context { crate::prepare::context(hits.form, field, widget, view); } context diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index be83411072..c689f9fde5 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -119,7 +119,7 @@ pub(crate) enum PagePress { /// tool's. Also every page's while a press that began off the pages is held, and after the /// press was dropped ([`PageRouter::cancel`]) until the button is up. Free, - /// The press began on another page, or between the pages. + /// The press began on another page, or off the pages (between them, or beside one). Elsewhere, } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index c0a797fa67..82b340e571 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2047,3 +2047,14 @@ fn cancelling_pointer_gestures_lets_go_of_a_camera_corner() { v.cancel_pointer_gestures(); assert_eq!(v.camera.map(|q| q.grab), Some(None)); } + +/// A camera corner at the page's edge is hit on the page only: a press off the page is no page's, +/// so its grab cursor does not show there. +#[test] +fn a_camera_corner_at_the_page_edge_is_hit_on_the_page_only() { + let mut v = view(1, PageLayout::Single, true); + v.camera = Some(crate::camera_ui::CameraQuad { page: 0, corners: [[0.0, 0.0], [300.0, 0.0], [300.0, 400.0], [0.0, 400.0]], grab: None }); + let xf = PageXform { rect: Rect::from_min_size(pos2(10.0, 20.0), egui::vec2(300.0, 400.0)), rot: 0, pw: 300.0, ph: 400.0 }; + assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(14.0, 24.0)).is_some(), "on the page"); + assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(6.0, 16.0)).is_none(), "just off it"); +} diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index a7e7d6c934..c220f8aea3 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -61,10 +61,11 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { } /// The corner of the quadrilateral under `p` on `page`, the nearest within reach: the one hit test -/// of the camera tool, for the cursor and the press. `None` once the page is enhanced, or on a page -/// without the quadrilateral. +/// of the camera tool, for the cursor and the press. `None` once the page is enhanced, on a page +/// without the quadrilateral, or off the page (a press there is no page's), though a corner at its +/// edge reaches past it. pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option { - if view.camera_done.is_some() { + if view.camera_done.is_some() || !xf.rect.contains(p) { return None; } let q = view.camera.filter(|q| q.page == page)?; diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 83ee620ae1..5ba9ca5401 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized}; pub use geometry::{drawn_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; -pub(crate) use input::{context, context_hit, describe, hit, keys, nudge_keys, page_after_text, page_input}; +pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 4c026604ea..ccb12c7676 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -11,12 +11,13 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// Comment input on one page, before text selection runs. `press` is what this page has to do with /// the press the pages hold: a press on a comment where the tool reaches it ([`hit`]: the Select /// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that -/// comment's, and one nobody holds is a commenting tool's. Returns `true` when the pointer gesture belongs to -/// commenting (text selection must ignore it). -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, view: &mut DocView) -> bool { +/// comment's, and one nobody holds is a commenting tool's, unless another tool took it this frame +/// (`taken`: Prepare's selection rectangle, added content), though a click there still unselects. +/// Returns `true` when the pointer gesture belongs to commenting (text selection must ignore it). +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, taken: bool, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); let page_rect = cx.xf.rect; - let free = press == PagePress::Free; + let free = press == PagePress::Free && !taken; let pressed_here = free && origin.is_some_and(|o| page_rect.contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); let owned = match press { @@ -98,11 +99,15 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op } let markup = is_markup(&a.subtype); let icon = if cx.allowed && !markup { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; - // What a drag moves whole (a note, a shape, a signature, a Fill & Sign mark) is placed on the - // page, over a form field; text markup lies on the text, under one. - let layer = if a.fill_sign || !markup { Layer::Placed } else { Layer::Comment }; // 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, icon) }) + Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), icon) }) +} + +/// The layer a comment's body lies on: what a drag moves whole (a note, a shape, a signature, a +/// Fill & Sign mark) is placed on the page, over a form field; text markup lies on the text, under +/// one. +fn layer(a: &Annotation) -> Layer { + if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment } } /// The comment a right-click at `p` is on: the topmost, with every tool, as a comment's menu shows @@ -113,14 +118,18 @@ pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option { return None; } let a = cx.comment_at(p)?; - let layer = if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment }; - Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, egui::CursorIcon::Default)) + Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), egui::CursorIcon::Default)) } -/// A right-click at `p` on this page: the page's menu is anchored there (Copy Image), and the -/// comment it is on ([`context_hit`], when it wins there) is selected, or none. -pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option, view: &mut DocView) { +/// A right-click at `p` on this page: the page's menu is anchored there (Delete page, Paste, Copy +/// Image, a note added here). +pub(crate) fn anchor(cx: &PageCx<'_>, p: Pos2, view: &mut DocView) { view.comments.context_at = Some((cx.page, cx.to_user(p))); +} + +/// A right-click on comment `index` of this page ([`context_hit`], when it wins there) selects it, +/// and one on anything else selects none. +pub(crate) fn context(cx: &PageCx<'_>, index: Option, view: &mut DocView) { view.comments.selected = index.map(|i| (cx.page, i)); } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 6685085614..d412496fcf 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1419,7 +1419,8 @@ fn the_link_tools_hand_stops_at_the_pages_edge() { } /// With Edit text a right-click on a paragraph is Edit text's, as before, though a comment lies -/// over it: the comment is not selected. +/// over it: the comment is not selected. The page's menu is anchored there all the same (Paste on +/// the picture menu, Delete page); it used to stay where the last right-click comments saw was. #[test] fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_comment() { let mut h = open(comment_on_text_pdf(), "150", false); @@ -1429,6 +1430,7 @@ fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_commen wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default); right_click(&mut h, on_text); assert_eq!(h.state().views[0].comments.selected, None); + assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "the menu is anchored on the page"); } /// With a Prepare field tool a right-click is the tool's, as before: a comment under it is not @@ -1544,3 +1546,46 @@ fn a_long_press_on_a_comment_selects_it_for_its_menu() { one_frame(&mut h, [touch(egui::TouchPhase::End, top), button(top, false), egui::Event::PointerGone]); h.run_steps(2); } + +/// A right-click while the primary button is held anchors the page's menu where it is (Delete page, +/// Paste): the held press leaves only the selections alone. It used to keep the menu on the page +/// right-clicked before. +#[test] +fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() { + let mut h = open(two_pages_pdf(), "100", false); + h.run_steps(2); + h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0])); + let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + let press = two.min + egui::vec2(50.0, 30.0); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + h.hover_at(press + egui::vec2(40.0, 0.0)); + h.run_steps(1); + let menu = press + egui::vec2(100.0, 60.0); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: menu, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(1); + assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(1), "the menu is page 2's"); + h.drop_at(press + egui::vec2(40.0, 0.0)); + h.run_steps(2); +} + +/// With Edit text a right-click on added text reaches comments, as before (Edit text keeps those on +/// its own paragraphs and pictures): the selected comment is unselected. +#[test] +fn a_right_click_with_edit_text_on_added_text_unselects_the_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() }, + })); + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + h.state_mut().views[0].comments.selected = Some((0, 0)); + let on_added = at(&h, 90.0, 202.0); + right_click(&mut h, on_added); + assert_eq!(h.state().views[0].comments.selected, None); +} diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 281e3d9e18..51999431d9 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1599,3 +1599,21 @@ fn a_right_click_during_a_field_drag_moves_the_dragged_field() { assert_eq!(field_rect(&h, "city"), city, "the field right-clicked stays where it was"); assert_ne!(field_rect(&h, "name"), name, "the dragged field moved"); } + +/// With Prepare open, a drag with a shape tool on the page's empty part draws Prepare's selection +/// rectangle and no comment, as before: Prepare takes the press nobody holds. It used to draw both. +#[test] +fn with_prepare_open_a_shape_tool_drag_draws_no_comment() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + assert!(h.state_mut().execute("comment.square")); + h.run_steps(3); + assert!(h.state().is_preparing(), "picking the tool leaves Prepare open"); + // Below the fields. + let (from, to) = (at(&h, 40.0, 260.0), at(&h, 200.0, 360.0)); + drag(&mut h, from, to); + let s = h.state(); + let squares = s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Square").count(); + assert_eq!(squares, 0, "no rectangle under Prepare's selection rectangle"); +} From c1d34fc9eaaf9caaaa3eb1c165870e1a9991e5af Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 18:11:07 +0100 Subject: [PATCH 34/63] fix(ui): commenting tools leave a frame another tool took; Escape keeps the menu's anchor true Found in review: - **A double-click another tool took finished a polygon.** Round 5 kept commenting tools off a press another tool had taken, but a polygon, polyline or cloud finishes on a double-click or Enter whoever has the press. With Prepare open, a double-click on a field opened its properties and committed the polygon being clicked out. Added text opened for editing did the same with the Edit panel open. The commenting tools now skip such a frame entirely, as `main`'s consumed chain did. The comments' own presses and a click's unselecting still run. - **The menu's anchor after Escape.** While a press Escape cancelled is held, the page's tools are skipped, and a right-click left the page menu on the page right-clicked before. It now anchors where it is, as every other right-click does. - **The guard.** It now lists who asks the page view's hits at a point (`PageHits::at` and `context_at`), so a new place in `page_input.rs` can't test a point again. It takes a file that uses the comments' page context (`PageCx`) for a page module. Its doc of the point tests it doesn't list is now true. - **Docs and visibility.** True docs for `PagePress::Free`, where a press is off the pages, and the camera's hit. `link_ui::screen_rect` is private. Tests: a polygon left unfinished by a double-click on a Prepare field, and a right-click after Escape anchoring the menu. Each was seen failing without its fix. A planted ask of the page view's hits, and a planted unlisted file using `PageCx`, each fail the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 8 +++- .../ui-egui/src/canvas/page_pointer/tests.rs | 39 ++++++++++++++++--- crates/ui-egui/src/canvas/page_router.rs | 7 ++-- crates/ui-egui/src/tools/camera_ui.rs | 4 +- crates/ui-egui/src/tools/comments/input.rs | 11 ++++-- crates/ui-egui/src/tools/link_ui.rs | 2 +- crates/ui-egui/tests/it/pointer.rs | 30 +++++++++++--- crates/ui-egui/tests/it/prepare.rs | 27 +++++++++++++ 8 files changed, 105 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6cb3211c7c..6775702384 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1498,14 +1498,18 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let slot = PageSlot { i, r, xf }; - // A cancelled press takes nothing until the button is up. + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + // A cancelled press takes nothing until the button is up, though the page's menu still opens + // where a right-click is. let input = if view.press_cancelled { + if let Some(p) = pointer.filter(|p| frame.resp.secondary_clicked() && xf.rect.contains(*p)) { + comments::anchor(&pcx, p, view); + } PageInput { consumed: true, area_tool: false, hover: None, released_over: false } } else { page_tool_input(ui, view, frame, &slot) }; let painter = ui.painter(); - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 1c41858651..2890a63cb1 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -190,9 +190,11 @@ const PAGE_MODULES: [&str; 37] = [ "tools/stamps_ui.rs", ]; -/// What a file that acts on the pages uses: the pointer, a tool's page context, what a press is to -/// a page, or the router's hits and owners. Such a file is a page module. -const PAGE_MODULE_MARKS: [&str; 4] = ["PagePointer", "PageToolContext", "PagePress", "page_router::"]; +/// What a file that acts on the pages uses: the pointer, a tool's or the comments' page context, +/// what a press is to a page, or the router's hits and owners. Such a file is a page module. One +/// that acts on the pages through the page view's response and a bare point alone uses none of +/// them (`canvas/image_pick.rs`): PAGE_MODULES lists it by hand. +const PAGE_MODULE_MARKS: [&str; 5] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::"]; /// The router's own files, which define what page modules use. const ROUTER_FILES: [&str; 3] = ["canvas/page_pointer.rs", "canvas/page_router.rs", "canvas/page_cursor.rs"]; @@ -439,8 +441,10 @@ fn hit_test_calls(line: &str) -> Vec { } /// The functions that call a hit test: (file, function, why). Hit tests are found by name (a -/// function named `hit` or `…_hit`, called by path, bare or as a method); a point test by another -/// name has a list of its own (`COMMENT_POINT_TESTERS`). `PageHits::hits` is the one: the hits +/// function named `hit` or `…_hit`, called by path, bare or as a method). The comments' point test +/// has a list of its own (`COMMENT_POINT_TESTERS`); the others by other names (added content's, +/// Edit text's paragraphs', Redact's text test, the text layer's links) are not listed yet, and +/// most are PR 4b's. `PageHits::hits` is the one: the hits /// at the press point decide its owner, and those under the pointer give its cursor, its tip, hover /// painting and a right-click. const HIT_TESTERS: [(&str, &str, &str); 1] = [( @@ -449,6 +453,23 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", )]; +/// The page view's asks for every module's hits at a point (`PageHits::at`, `PageHits::context_at`). +const PAGE_HITS_ASKS: [&str; 2] = [".at(", ".context_at("]; + +/// The functions that ask the page view's hits at a point: (file, function, why). `PageHits` is +/// private to `page_input.rs`, so this keeps a new place there from testing a point again; a +/// second ask inside one of these is for review to catch. +const PAGE_HITS_ASKERS: [(&str, &str, &str); 3] = [ + ( + "canvas/page_input.rs", + "page_tool_input", + "the hits at the press point decide its owner, once per press; those under the pointer before the tools act, once a \ + frame, serve a click's release, a right-click and Fill & Sign's preview", + ), + ("canvas/page_input.rs", "claim_under", "what lies under the pointer after the tools act: its cursor, its tip and hover painting"), + ("canvas/page_input.rs", "right_click", "what a right-click is on"), +]; + /// The comments' point test, which their hit tests are built on: which comment lies under a point. const COMMENT_POINT_TESTS: [&str; 1] = [".comment_at("]; @@ -550,6 +571,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { function to COMMENT_POINT_TESTERS in src/canvas/page_pointer/tests.rs, with why.", "no longer asks which comment lies under a point: remove it from COMMENT_POINT_TESTERS.", )); + problems.extend(misuses( + &PAGE_HITS_ASKS, + &PAGE_HITS_ASKERS, + "asks the page view's hits at a point. The hits at the press point decide its owner once, and those under the \ + pointer are taken once before the tools act and once after: use those (`under`, `PageInput.hover`). A new \ + place that must ask? Add the function to PAGE_HITS_ASKERS in src/canvas/page_pointer/tests.rs, with why.", + "no longer asks the page view's hits: remove it from PAGE_HITS_ASKERS.", + )); problems.extend(misuses( &PRESS_IGNORES, &PRESS_IGNORERS, diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index c689f9fde5..b59aee58be 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -116,8 +116,9 @@ pub(crate) enum PagePress { /// only let go of their own state (a click on anything but the selected comment unselects it). Mine(Owner), /// No button is down, or the press began on this page and nobody's hit won it: the active - /// tool's. Also every page's while a press that began off the pages is held, and after the - /// press was dropped ([`PageRouter::cancel`]) until the button is up. + /// tool's. Also every page's while a press the page view does not hold is down (one that began + /// over a panel), and after the press was dropped ([`PageRouter::cancel`]) until the button is + /// up. Free, /// The press began on another page, or off the pages (between them, or beside one). Elsewhere, @@ -144,7 +145,7 @@ pub(crate) struct Capture { #[derive(Debug, Default)] pub(crate) struct PageRouter { capture: Option, - /// A press is held, and the page it began on (`None`: between the pages). + /// A press is held, and the page it began on (`None`: off the pages, between them or beside one). began: Option>, } diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index c220f8aea3..ce88da25cf 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -62,8 +62,8 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { /// The corner of the quadrilateral under `p` on `page`, the nearest within reach: the one hit test /// of the camera tool, for the cursor and the press. `None` once the page is enhanced, on a page -/// without the quadrilateral, or off the page (a press there is no page's), though a corner at its -/// edge reaches past it. +/// without the quadrilateral, or off the page (a press there is no page's), even where a corner at +/// its edge would reach past it. pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option { if view.camera_done.is_some() || !xf.rect.contains(p) { return None; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index ccb12c7676..8366e36453 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -11,13 +11,14 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// Comment input on one page, before text selection runs. `press` is what this page has to do with /// the press the pages hold: a press on a comment where the tool reaches it ([`hit`]: the Select /// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that -/// comment's, and one nobody holds is a commenting tool's, unless another tool took it this frame -/// (`taken`: Prepare's selection rectangle, added content), though a click there still unselects. -/// Returns `true` when the pointer gesture belongs to commenting (text selection must ignore it). +/// comment's, and one nobody holds is a commenting tool's. When another tool took this frame +/// (`taken`: Prepare, added content, which still decide for themselves), the commenting tools leave +/// it to that tool, as before, though a click there still unselects. Returns `true` when the +/// pointer gesture belongs to commenting (text selection must ignore it). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, taken: bool, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); let page_rect = cx.xf.rect; - let free = press == PagePress::Free && !taken; + let free = press == PagePress::Free; let pressed_here = free && origin.is_some_and(|o| page_rect.contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); let owned = match press { @@ -37,6 +38,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, match cx.tool { // The selected shape's handles, and Fill & Sign's marks, take their own presses. QuickTool::Comment(_) | QuickTool::Fill(_) if owned.is_some() || moving => select_input(ui, resp, cx, view, press, pointer, origin), + // Neither a press nor a double-click or a key another tool took this frame. + QuickTool::Comment(_) if taken => false, QuickTool::Comment(tool) if tool.draws() => gestures.draw_shape(view, tool), QuickTool::Comment(tool) if tool.clicks_points() => gestures.click_points(view, tool), QuickTool::Comment(CommentTool::Callout) => gestures.callout(view), diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 069564afff..041edaf1c7 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -49,7 +49,7 @@ pub struct LinkDraft { pub style: LinkStyle, } -pub(crate) fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { +fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]) } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index d412496fcf..1fc7c51dd3 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1547,11 +1547,9 @@ fn a_long_press_on_a_comment_selects_it_for_its_menu() { h.run_steps(2); } -/// A right-click while the primary button is held anchors the page's menu where it is (Delete page, -/// Paste): the held press leaves only the selections alone. It used to keep the menu on the page -/// right-clicked before. -#[test] -fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() { +/// With the page menu anchored on page 1, presses on page 2 and holds the button, presses Escape +/// first with `escape`, right-clicks on page 2, and gives the page the menu is anchored on. +fn menu_page_after_a_right_click_with_the_button_held(escape: bool) -> Option { let mut h = open(two_pages_pdf(), "100", false); h.run_steps(2); h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0])); @@ -1563,14 +1561,34 @@ fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() { h.run_steps(1); h.hover_at(press + egui::vec2(40.0, 0.0)); h.run_steps(1); + if escape { + h.key_press(egui::Key::Escape); + h.run_steps(1); + } let menu = press + egui::vec2(100.0, 60.0); for pressed in [true, false] { h.event(egui::Event::PointerButton { pos: menu, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); } h.run_steps(1); - assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(1), "the menu is page 2's"); + let page = h.state().views[0].comments.context_at.map(|(page, _)| page); h.drop_at(press + egui::vec2(40.0, 0.0)); h.run_steps(2); + page +} + +/// A right-click while the primary button is held anchors the page's menu where it is (Delete page, +/// Paste): the held press leaves only the selections alone. It used to keep the menu on the page +/// right-clicked before. +#[test] +fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() { + assert_eq!(menu_page_after_a_right_click_with_the_button_held(false), Some(1), "the menu is page 2's"); +} + +/// So does a right-click while a press Escape cancelled is held, though the cancelled press takes +/// nothing else. +#[test] +fn a_right_click_after_escape_cancelled_the_press_anchors_the_menu_on_its_page() { + assert_eq!(menu_page_after_a_right_click_with_the_button_held(true), Some(1), "the menu is page 2's"); } /// With Edit text a right-click on added text reaches comments, as before (Edit text keeps those on diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 51999431d9..749eaac69a 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1617,3 +1617,30 @@ fn with_prepare_open_a_shape_tool_drag_draws_no_comment() { let squares = s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Square").count(); assert_eq!(squares, 0, "no rectangle under Prepare's selection rectangle"); } + +/// With Prepare open, a double-click on a field is the field's (it opens its properties) and leaves +/// a polygon being clicked out unfinished, as before. It used to finish the polygon too. +#[test] +fn with_prepare_open_a_double_click_on_a_field_leaves_the_polygon_unfinished() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + assert!(h.state_mut().execute("comment.polygon")); + h.run_steps(3); + // Three points below the fields. + for (x, y) in [(40.0, 260.0), (200.0, 260.0), (120.0, 360.0)] { + let p = at(&h, x, y); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + } + let on_field = field_rect(&h, "name").center(); + crate::support::input::double_click(&mut h, on_field); + let s = h.state(); + let polygons = s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Polygon").count(); + assert_eq!(polygons, 0, "the polygon is not finished"); + assert!(matches!(s.views[0].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Draw { .. })), "it is still being clicked out"); +} From 2a4032b2fc54652a8b0dae1a07002cb0028b64c3 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 18:38:01 +0100 Subject: [PATCH 35/63] fix(ui): a press where two pages touch is one page's; the guard sees PageHits::hits Found in review: - **A press where two pages touch.** With the page gaps hidden, pages can share a row of pixels, and a press there was claimed by both: the first page acted on it, then the second took it over, and the text layer could count it twice. Only a press just begun is claimed now, by the first page that does. This was so on `main` too. - **The guard** listed who asks `PageHits::at` and `context_at`, but not `PageHits::hits`, which they wrap. A new call of it in `page_input.rs` now fails, and the guard's doc says what it still can't see. - **Docs.** `Hit::declines_drag` (a link's drag with the Link tool), `over_mark`, and when the Select tool still unselects under another tool's frame. Tests: two pages claiming one press, seen failing without the fix. A planted call of `PageHits::hits` fails the guard. The router's unit tests now begin a press before claiming it, as `draw_pages` does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 5 +++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 14 ++++++++----- crates/ui-egui/src/canvas/page_router.rs | 21 ++++++++++++++++++- crates/ui-egui/src/tools/comments/input.rs | 4 ++-- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 647a39d970..39b39e11b0 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -236,8 +236,9 @@ fn link_of(owner: Option) -> Option { /// stamps, Fill & Sign, Sign area, zoom and snapshot, the camera's quadrilateral and crop act only /// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor /// and gestures. The camera also drags a corner whose press it holds, and crop's double-click -/// opens Set Page Boxes wherever it is. `over_mark`: a Fill & Sign mark takes the press, or would take one under the -/// pointer. +/// opens Set Page Boxes wherever it is. `over_mark`: with Fill & Sign, a hit holds the press (a +/// mark's, or a Prepare field's), or with no button down a mark lies under the pointer, where a +/// press would pick it up. fn tool_input( ui: &egui::Ui, view: &mut DocView, diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 2890a63cb1..4936c81367 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -453,13 +453,17 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", )]; -/// The page view's asks for every module's hits at a point (`PageHits::at`, `PageHits::context_at`). -const PAGE_HITS_ASKS: [&str; 2] = [".at(", ".context_at("]; +/// The page view's asks for every module's hits at a point (`PageHits::hits`, and `at` and +/// `context_at`, which wrap it). +const PAGE_HITS_ASKS: [&str; 3] = [".hits(", ".at(", ".context_at("]; /// The functions that ask the page view's hits at a point: (file, function, why). `PageHits` is -/// private to `page_input.rs`, so this keeps a new place there from testing a point again; a -/// second ask inside one of these is for review to catch. -const PAGE_HITS_ASKERS: [(&str, &str, &str); 3] = [ +/// private to `page_input.rs`, so this keeps a new function there from asking by these names; a +/// call by path (`PageHits::at(&hits, …)`) and a second ask inside a listed function are for +/// review to catch. +const PAGE_HITS_ASKERS: [(&str, &str, &str); 5] = [ + ("canvas/page_input.rs", "at", "`PageHits::at`: the hits at a point, as a press there would take them"), + ("canvas/page_input.rs", "context_at", "`PageHits::context_at`: what a right-click at a point is on"), ( "canvas/page_input.rs", "page_tool_input", diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index b59aee58be..0863642aee 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -90,7 +90,7 @@ pub(crate) struct Hit { /// Beside the pointer where this hit wins (a link's target). pub tip: Option, /// A drag from here is not this owner's: a click on a highlight selects it, a drag across it - /// selects the text underneath. + /// selects the text underneath; with the Link tool, a drag from a link draws a new one. pub declines_drag: bool, } @@ -159,7 +159,12 @@ impl PageRouter { /// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's /// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page). + /// Only a press just begun ([`Self::begin`]) is claimed, by the first page that does: pages + /// that touch (the gaps hidden) can share a row of pixels, and a press there is one page's. pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { + if self.began != Some(None) { + return; + } self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag }); self.began = Some(Some(page)); } @@ -222,6 +227,7 @@ mod tests { #[test] fn a_press_is_held_until_the_button_is_up_and_its_release_seen() { let mut r = PageRouter::default(); + r.begin(); r.press(2, [hit(Layer::Comment, 7)], QuickTool::Select); assert_eq!(r.owner(2), Some(hit(Layer::Comment, 7).owner)); assert_eq!(r.owner(1), None, "only on its page"); @@ -230,6 +236,7 @@ mod tests { assert!(r.owner(2).is_some(), "the release frame still belongs to the press"); r.settle(false, false); assert_eq!(r.owner(2), None); + r.begin(); r.press(0, [], QuickTool::Select); assert_eq!(r.owner(0), None, "a press nobody claims is held by nobody"); assert!(!r.keeps_drag()); @@ -257,9 +264,21 @@ mod tests { assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press leaves every page to the tool"); } + /// Pages that touch (the gaps hidden) can share a row of pixels: a press there is the first + /// page's to claim it, and the next page's claim is ignored. + #[test] + fn a_press_where_two_pages_touch_is_the_first_pages() { + let mut r = PageRouter::default(); + r.begin(); + r.press(0, [hit(Layer::Comment, 1)], QuickTool::Select); + r.press(1, [hit(Layer::Comment, 2)], QuickTool::Select); + assert_eq!((r.press_for(0), r.press_for(1)), (PagePress::Mine(hit(Layer::Comment, 1).owner), PagePress::Elsewhere)); + } + #[test] fn a_highlight_lets_its_drag_go_to_the_text() { let mut r = PageRouter::default(); + r.begin(); r.press(0, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select); assert!(r.owner(0).is_some() && !r.keeps_drag()); r.cancel(); diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 8366e36453..4c4251907b 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -13,8 +13,8 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that /// comment's, and one nobody holds is a commenting tool's. When another tool took this frame /// (`taken`: Prepare, added content, which still decide for themselves), the commenting tools leave -/// it to that tool, as before, though a click there still unselects. Returns `true` when the -/// pointer gesture belongs to commenting (text selection must ignore it). +/// it to that tool, as before; the Select tool's click there still unselects. Returns `true` when +/// the pointer gesture belongs to commenting (text selection must ignore it). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, taken: bool, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); let page_rect = cx.xf.rect; From 6756ab0ed46c00807bcb1f7dfcc6f1d0177cecd0 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 19:14:12 +0100 Subject: [PATCH 36/63] fix(ui): a right-click where two pages touch is one page's; the guard reads split calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in review: - **A right-click where two pages touch.** With the page gaps hidden, a right-click on the row two pages share ran on both: the first page selected its comment, the second unselected it, and the second page's anchor for the menu stood. A right-click is now the first page's, as a press has been since the last fix. This was so on `main` too. - **The guard** matched one line at a time, so a call rustfmt splits across lines (`view` / `.router` / `.owner(i)`) got past it. A continued line is now read with the one it continues. It also refuses a hit test imported under a name that isn't a hit test's (`use …::hit as under`), which the checks by name would miss. - **Docs.** Links still take a right-click while the primary button is held, as before, and when Escape comes in a press's own frame every page is `Elsewhere`, not `Free`. Tests: a stamp clicked where two pages touch lands on the first, a click pressed on one page and released on the next places nothing, a right-click where two pages touch is the first page's, Prepare keeps its selection on a click that began off the page, and the Note tool starts no note on added text with the Edit panel open. Each was seen failing with its fix, or the rule it pins, reverted. A planted split router read and a planted aliased hit test fail the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 16 +++- crates/ui-egui/src/canvas/page_input.rs | 5 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 48 ++++++++++- crates/ui-egui/src/canvas/page_router.rs | 3 +- crates/ui-egui/tests/it/pointer.rs | 83 +++++++++++++++++++ 5 files changed, 149 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6775702384..95767e2c06 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1207,6 +1207,19 @@ struct PageFrame<'a> { current_height: f32, pressed_page: Option, calc_hover: Option, + /// A page took this frame's right-click. + right_clicked: bool, +} + +impl PageFrame<'_> { + /// Where this frame's right-click is, on the page at `rect`, for the first page it is on: pages + /// that touch (the gaps hidden) can share a row of pixels, and a right-click there is one + /// page's, as a press is. + fn right_click_on(&mut self, rect: Rect) -> Option { + let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && rect.contains(*p))?; + self.right_clicked = true; + Some(p) + } } /// Visible heights (points) closer than this are equal: one page size can meet the viewport a @@ -1502,7 +1515,7 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i // A cancelled press takes nothing until the button is up, though the page's menu still opens // where a right-click is. let input = if view.press_cancelled { - if let Some(p) = pointer.filter(|p| frame.resp.secondary_clicked() && xf.rect.contains(*p)) { + if let Some(p) = frame.right_click_on(xf.rect) { comments::anchor(&pcx, p, view); } PageInput { consumed: true, area_tool: false, hover: None, released_over: false } @@ -1683,6 +1696,7 @@ fn draw_pages( current_height: f32::INFINITY, pressed_page: None, calc_hover: None, + right_clicked: false, }; for &i in visible_pages { draw_page(ui, view, &mut frame, i); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 39b39e11b0..0a5d9b24e5 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -109,7 +109,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // goes down, so the button is what tells. A touch's long press, which egui reports as a // right-click with the finger still down, opens its menu. let press_held = ptr.primary_down && !resp.long_touched(); - let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p, press_held)); + let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held)); // A mark already placed takes its own press (comments, below), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. let over_mark = @@ -332,7 +332,8 @@ fn tool_input( /// ([`PageHits::context_at`]), which the menu then acts on. A comment there is selected, and none /// elsewhere; a Prepare field is selected; links take theirs from what this returns. /// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the -/// selections its press acts on are left alone until its release. +/// comment and Prepare selections its press acts on are left alone until its release; links still +/// take theirs, as before. fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2, press_held: bool) -> Option { let context = hits.context_at(view, p, under); // Delete page, Paste and Copy Image act where the menu opened, whoever takes the right-click. diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 4936c81367..e0ddc358fe 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -293,7 +293,8 @@ fn outside_test_modules(source: &str) -> Vec<(usize, &str)> { } /// Each name `find` finds in a line of the page modules' code (their tests left out), as (file, -/// the top-level function or `impl` method it is in, line, name). +/// the top-level function or `impl` method it is in, line, name). A line rustfmt continued (a +/// method chain split as `view` / `.router` / `.owner(i)`) is read with the one it continues. fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, Option, usize, String)> { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut found = Vec::new(); @@ -301,6 +302,7 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); // The top-level function, or method of an `impl`, that each line is in. let (mut function, mut in_impl) = (None, false); + let mut lines: Vec<(usize, Option<&str>, String)> = Vec::new(); for (n, line) in outside_test_modules(&source) { if line.starts_with("impl") { (function, in_impl) = (None, true); @@ -312,7 +314,13 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, if line.trim_start().starts_with("//") { continue; } - for name in find(line) { + match lines.last_mut() { + Some((_, _, text)) if line.trim_start().starts_with('.') => text.push_str(line.trim_start()), + _ => lines.push((n, function, line.to_owned())), + } + } + for (n, function, text) in lines { + for name in find(&text) { found.push((file, function.map(str::to_owned), n, name)); } } @@ -320,6 +328,41 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, found } +/// Each hit test a page module imports under a name that is not a hit test's (`use …::hit as +/// under;`), which the checks by name would not see. +fn hit_test_aliases() -> Vec { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut found = Vec::new(); + for file in PAGE_MODULES { + let source = std::fs::read_to_string(src.join(file)).expect("page module"); + // Each `use` statement, from its first line to its `;`, wherever it is. + let mut statement: Option = None; + for line in source.lines().map(str::trim) { + if statement.is_none() && ["use ", "pub use ", "pub(crate) use ", "pub(super) use "].iter().any(|start| line.starts_with(start)) { + statement = Some(String::new()); + } + let Some(text) = statement.as_mut() else { continue }; + text.push_str(line); + text.push(' '); + if !line.ends_with(';') { + continue; + } + for part in text.split([',', '{', '}', ';']) { + let Some((path, alias)) = part.split_once(" as ") else { continue }; + let (name, alias) = (path.trim().rsplit("::").next().unwrap_or_default(), alias.trim()); + if is_hit_test(name) && !is_hit_test(alias) { + found.push(format!( + "src/{file}: `{name} as {alias}` imports a hit test under another name, which the checks by name miss: \ + name it `hit` or `…_hit`." + )); + } + } + statement = None; + } + } + found +} + /// Each use of one of `names` in the page modules' code (their tests left out). fn page_module_uses(names: &[&str]) -> Vec<(&'static str, Option, usize, String)> { page_module_matches(|line| names.iter().filter(|name| uses(line, name)).map(|name| (*name).to_owned()).collect()) @@ -615,6 +658,7 @@ fn page_modules_act_on_a_press_only_through_the_router() { src/canvas/page_pointer/tests.rs, with why.", "calls no hit test any more: remove it from HIT_TESTERS.", )); + problems.extend(hit_test_aliases()); // An entry no page module calls any more no longer guards anything. let called = page_module_uses(&PAGE_HIT_CALLS); problems.extend( diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 0863642aee..e3b88d1a2c 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -118,7 +118,8 @@ pub(crate) enum PagePress { /// No button is down, or the press began on this page and nobody's hit won it: the active /// tool's. Also every page's while a press the page view does not hold is down (one that began /// over a panel), and after the press was dropped ([`PageRouter::cancel`]) until the button is - /// up. + /// up (`Elsewhere` instead when Escape came in the press's own frame; page input skips a + /// cancelled press either way). Free, /// The press began on another page, or off the pages (between them, or beside one). Elsewhere, diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 1fc7c51dd3..205a5b2663 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1607,3 +1607,86 @@ fn a_right_click_with_edit_text_on_added_text_unselects_the_comment() { right_click(&mut h, on_added); assert_eq!(h.state().views[0].comments.selected, None); } + +/// Two pages with their gaps hidden, after `tool` set the tool up, and a point on the row of pixels +/// where they touch, which lies on both. +fn touching_pages(tool: impl FnOnce(&mut PdfCraftApp)) -> (Harness<'static, PdfCraftApp>, egui::Pos2) { + let mut h = open(two_pages_pdf(), "100", false); + h.state_mut().views[0].show_page_gaps = false; + tool(h.state_mut()); + h.run_steps(4); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + let seam = egui::pos2(one.min.x + 100.0, one.max.y); + assert!(one.contains(seam) && two.contains(seam), "the pages touch: {one:?} {two:?}"); + (h, seam) +} + +/// The pages the document's stamps are on. +fn stamp_pages(h: &Harness<'_, PdfCraftApp>) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Stamp").map(|a| a.page).collect() +} + +/// Where pages touch (the gaps hidden), a press on the row they share is the first page's: a stamp +/// clicked there lands on page 1. The second page used to take the press over. +#[test] +fn a_stamp_clicked_where_two_pages_touch_lands_on_the_first() { + let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::Stamp(StampKind::DynApproved)); + mouse_click(&mut h, seam); + assert_eq!(stamp_pages(&h), vec![0]); +} + +/// A click pressed on one page and released on the next (the gaps hidden, within egui's click +/// distance) places nothing: the press is the first page's, and the pointer is off it. `main` placed +/// the stamp on the second page. +#[test] +fn a_stamp_click_pressed_on_one_page_and_released_on_the_next_places_nothing() { + let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::Stamp(StampKind::DynApproved)); + slipping_click(&mut h, seam - egui::vec2(0.0, 2.0), seam + egui::vec2(0.0, 2.0)); + assert_eq!(stamp_pages(&h), Vec::::new()); +} + +/// Where pages touch (the gaps hidden), a right-click on the row they share is the first page's: +/// the menu is anchored there. Both pages used to take it, and the second's anchor stood. +#[test] +fn a_right_click_where_two_pages_touch_is_the_first_pages() { + let (mut h, seam) = touching_pages(|_| {}); + right_click(&mut h, seam); + assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0)); +} + +/// With Prepare open, a click that began off the page (in the gutter beside it) is no page's: the +/// selected field stays selected, as the Link tool's and comments' selections do. +#[test] +fn with_prepare_a_click_that_began_off_the_page_keeps_the_selected_field() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(4); + let on_box = at(&h, 110.0, 210.0); + mouse_click(&mut h, on_box); + assert!(h.state().views[0].prepare.selected.is_some(), "the field is selected"); + let left = page(&h).min.x; + slipping_click(&mut h, egui::pos2(left - 2.0, on_box.y), egui::pos2(left + 3.0, on_box.y)); + assert!(h.state().views[0].prepare.selected.is_some(), "a click that began off the page leaves it selected"); +} + +/// With the Edit panel open and the Note tool, a click on added text is the added text's: no note is +/// started under it, as before. +#[test] +fn a_note_tool_click_on_added_text_starts_no_note() { + let mut h = open(comment_on_text_pdf(), "150", false); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() }, + })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Note); + h.run_steps(3); + let on_added = at(&h, 90.0, 202.0); + mouse_click(&mut h, on_added); + assert!(h.state().views[0].comments.composer.is_none(), "no note is started"); +} From 6752b36807159bb0710035e38bbd7d3a3b40856d Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 19:40:46 +0100 Subject: [PATCH 37/63] fix(ui): a mark being moved keeps Fill & Sign's presses; the guard sees calls by path Found in review: - **Fill & Sign during a mark's move.** `main` placed no Fill & Sign mark while a comment's move, resize or reshape was live on the page, whoever held the press. That guard was lost: with a move left over (its release never seen), the next click placed a new mark. A live gesture on the page now counts as over a mark again (`comments::moving_on`, which the comments' own dispatch uses too). - **The guard** missed the router's and the page view's hits called by path (`PageRouter::owner(&view.router, i)`), and its doc said the `.at(` it matches was confined to `page_input.rs`. - `measure_ui::Clicks` derived traits nothing uses. Tests: a Fill & Sign click while a mark's move is live places nothing, seen failing without the fix. A planted router read by path fails the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 13 ++++++----- .../ui-egui/src/canvas/page_pointer/tests.rs | 22 ++++++++++++++----- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 10 ++++++--- crates/ui-egui/src/tools/measure_ui.rs | 2 +- crates/ui-egui/tests/it/pointer.rs | 18 +++++++++++++++ 6 files changed, 50 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 0a5d9b24e5..2f74110d7c 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -110,10 +110,11 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // right-click with the finger still down, opens its menu. let press_held = ptr.primary_down && !resp.long_touched(); let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held)); - // A mark already placed takes its own press (comments, below), and with no button down the - // Fill & Sign tool's preview hides over one, where a press would pick it up. - let over_mark = - matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); + // A mark already placed takes its own press (comments, below), as does one being moved on this + // page until its move ends; with no button down the Fill & Sign tool's preview hides over one, + // where a press would pick it up. + let over_mark = matches!(tool, QuickTool::Fill(_)) + && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); let on_field = if preparing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view); @@ -237,8 +238,8 @@ fn link_of(owner: Option) -> Option { /// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor /// and gestures. The camera also drags a corner whose press it holds, and crop's double-click /// opens Set Page Boxes wherever it is. `over_mark`: with Fill & Sign, a hit holds the press (a -/// mark's, or a Prepare field's), or with no button down a mark lies under the pointer, where a -/// press would pick it up. +/// mark's, or a Prepare field's), a mark is being moved on this page, or with no button down a mark +/// lies under the pointer, where a press would pick it up. fn tool_input( ui: &egui::Ui, view: &mut DocView, diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index e0ddc358fe..f9280e9045 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -454,7 +454,16 @@ fn page_modules_claim_the_cursor_and_never_set_it() { } /// What decides, keeps or drops the press the pages hold. -const ROUTER_WRITES: [&str; 4] = ["router.begin(", "router.press(", "router.settle(", "router.cancel("]; +const ROUTER_WRITES: [&str; 8] = [ + "router.begin(", + "router.press(", + "router.settle(", + "router.cancel(", + "PageRouter::begin(", + "PageRouter::press(", + "PageRouter::settle(", + "PageRouter::cancel(", +]; /// The functions that decide, keep or drop the press the pages hold: (file, function, why). const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ @@ -498,12 +507,12 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( /// The page view's asks for every module's hits at a point (`PageHits::hits`, and `at` and /// `context_at`, which wrap it). -const PAGE_HITS_ASKS: [&str; 3] = [".hits(", ".at(", ".context_at("]; +const PAGE_HITS_ASKS: [&str; 6] = [".hits(", ".at(", ".context_at(", "PageHits::hits(", "PageHits::at(", "PageHits::context_at("]; /// The functions that ask the page view's hits at a point: (file, function, why). `PageHits` is -/// private to `page_input.rs`, so this keeps a new function there from asking by these names; a -/// call by path (`PageHits::at(&hits, …)`) and a second ask inside a listed function are for -/// review to catch. +/// private to `page_input.rs`, so this keeps a new function there from asking; a second ask inside +/// a listed function is for review to catch. The method names are matched in every page module, +/// so an `at` of another type there would need them narrowed. const PAGE_HITS_ASKERS: [(&str, &str, &str); 5] = [ ("canvas/page_input.rs", "at", "`PageHits::at`: the hits at a point, as a press there would take them"), ("canvas/page_input.rs", "context_at", "`PageHits::context_at`: what a right-click at a point is on"), @@ -528,7 +537,8 @@ const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ ]; /// What reads who holds the press the pages hold. -const ROUTER_READS: [&str; 3] = ["router.press_for(", "router.owner(", "router.keeps_drag("]; +const ROUTER_READS: [&str; 6] = + ["router.press_for(", "router.owner(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::owner(", "PageRouter::keeps_drag("]; /// The functions that read who holds the press: (file, function, why). The page view asks once per /// page and tells each module what the press is to its page (`PagePress`); the Edit text group, the diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 5ba9ca5401..8d118ac3c9 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized}; pub use geometry::{drawn_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; -pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, nudge_keys, page_after_text, page_input}; +pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 4c4251907b..ac3c2a6878 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -31,9 +31,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, cv.gesture = None; return true; } - // A comment being moved, resized or reshaped on this page keeps the frame until it ends. - let moving = - matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page); + let moving = moving_on(view, cx.page); let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free }; match cx.tool { // The selected shape's handles, and Fill & Sign's marks, take their own presses. @@ -51,6 +49,12 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, } } +/// A comment (a Fill & Sign mark included) is being moved, resized or reshaped on `page`: it keeps +/// the page's frames until its gesture ends. +pub(crate) fn moving_on(view: &DocView, page: usize) -> bool { + matches!(view.comments.gesture, Some(Gesture::Move { page: p, .. } | Gesture::Resize { page: p, .. } | Gesture::Reshape { page: p, .. }) if p == page) +} + /// What of the comments lies under `p`, for the cursor, the tip and the press: the one hit test /// of this module. With the Select tool, the selected comment's point handles, then its resize /// handles, then the topmost comment. With a Fill & Sign tool, the same over Fill & Sign's marks diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index a81f01b0b2..2d6336cee6 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -203,7 +203,7 @@ fn snap_to_content( } /// The Measure tools' own clicks this frame: those of a press nobody holds. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy)] pub(crate) struct Clicks { pub single: bool, pub double: bool, diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 205a5b2663..51444f4517 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1690,3 +1690,21 @@ fn a_note_tool_click_on_added_text_starts_no_note() { mouse_click(&mut h, on_added); assert!(h.state().views[0].comments.composer.is_none(), "no note is started"); } + +/// With Fill & Sign, a mark being moved on the page keeps the page's presses, as before: a click while +/// its move is still live (its release never seen) places no new mark. +#[test] +fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() { + let mut h = open(comment_on_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Check); + h.run_steps(4); + let marks = |h: &Harness<'_, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.len() + }; + let before = marks(&h); + let blank = at(&h, 150.0, 100.0); + h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: blank }); + mouse_click(&mut h, blank); + assert_eq!(marks(&h), before, "no mark is placed"); +} From 568ea94f48db05398eb3f3c9d54cbd70a06c49f1 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 20:09:10 +0100 Subject: [PATCH 38/63] fix(ui): a right-click between the pages anchors no page's menu; the guard finds router readers Found in review: - **A right-click off the pages.** Between the pages or beside one, no page took the right-click, but the page menu still opened over the page view, on the last anchor: Delete page, Paste and Copy Image acted on a page right-clicked long before. This was so on `main` too. The anchor is now cleared on every right-click before the pages set it, so off the pages the menu acts on the current page. - **The guard** took a file as a page module only by marks a router read doesn't need. A file that reads `.router.` is one too, so its router reads are checked. - **Docs.** egui ends a press's drag when any button is released, not when a second goes down. `link_ui::hit` takes the tip from the link it found rather than looking it up again. Tests: a right-click between the pages clears the anchor, seen failing without the fix. A planted router read in an unlisted file fails the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 +++ crates/ui-egui/src/canvas/page_input.rs | 6 +++--- crates/ui-egui/src/canvas/page_pointer/tests.rs | 8 ++++---- crates/ui-egui/src/tools/link_ui.rs | 10 ++++++---- crates/ui-egui/tests/it/pointer.rs | 17 +++++++++++++++++ 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 95767e2c06..97e867a090 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1645,6 +1645,9 @@ fn draw_pages( if resp.secondary_clicked() { // Set again below when the right-click lands on a link. view.links.context = None; + // Set again below by the page right-clicked: a right-click off the pages (between them, + // beside one) anchors no page's menu, which then acts on the current page. + view.comments.context_at = None; let mut over_image = false; if tool == QuickTool::EditText { let pointer_pos = ptr.pos; diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 2f74110d7c..49cf70067b 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -105,9 +105,9 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut _ => false, }; // A right-click while the primary button is held (a comment moved, a box drawn) leaves the - // selections that press acts on alone; egui ends the press's own interaction when another button - // goes down, so the button is what tells. A touch's long press, which egui reports as a - // right-click with the finger still down, opens its menu. + // selections that press acts on alone. egui ends the press's own interaction when any button is + // released, the right-click's own frame included, so the button is what tells. A touch's long + // press, which egui reports as a right-click with the finger still down, opens its menu. let press_held = ptr.primary_down && !resp.long_touched(); let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held)); // A mark already placed takes its own press (comments, below), as does one being moved on this diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index f9280e9045..40548a998a 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -191,10 +191,10 @@ const PAGE_MODULES: [&str; 37] = [ ]; /// What a file that acts on the pages uses: the pointer, a tool's or the comments' page context, -/// what a press is to a page, or the router's hits and owners. Such a file is a page module. One -/// that acts on the pages through the page view's response and a bare point alone uses none of -/// them (`canvas/image_pick.rs`): PAGE_MODULES lists it by hand. -const PAGE_MODULE_MARKS: [&str; 5] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::"]; +/// what a press is to a page, the router's hits and owners, or the router itself. Such a file is a +/// page module. One that acts on the pages through the page view's response and a bare point alone +/// uses none of them (`canvas/image_pick.rs`): PAGE_MODULES lists it by hand. +const PAGE_MODULE_MARKS: [&str; 6] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::", ".router."]; /// The router's own files, which define what page modules use. const ROUTER_FILES: [&str; 3] = ["canvas/page_pointer.rs", "canvas/page_router.rs", "canvas/page_cursor.rs"]; diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 041edaf1c7..63db81b9f3 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -70,12 +70,14 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, links: &[LinkItem if !xf.rect.contains(p) { return None; } - let index = if tool == QuickTool::Link { - links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index + let (index, link) = if tool == QuickTool::Link { + let index = links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index; + (index, info.links.iter().find(|l| l.page == page && l.index == index)) } else { - info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?.index + let link = info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; + (link.index, Some(link)) }; - let tip = info.links.iter().find(|l| l.page == page && l.index == index).map(|l| target_label(info, &l.target)); + let tip = link.map(|l| target_label(info, &l.target)); let declines_drag = tool == QuickTool::Link; Some(Hit { tip, declines_drag, ..Hit::new(Owner::Link { index }, Layer::Link, egui::CursorIcon::PointingHand) }) } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 51444f4517..8364f3a635 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1708,3 +1708,20 @@ fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() { mouse_click(&mut h, blank); assert_eq!(marks(&h), before, "no mark is placed"); } + +/// A right-click between the pages anchors no page's menu, so Delete page and Paste act on the +/// current page. The menu used to keep the anchor of the last right-click on a page. +#[test] +fn a_right_click_between_the_pages_anchors_no_pages_menu() { + let mut h = open(two_pages_pdf(), "100", false); + h.run_steps(2); + h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0])); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + let gap = egui::pos2(one.center().x, (one.max.y + two.min.y) / 2.0); + assert!(!one.contains(gap) && !two.contains(gap), "between the pages: {one:?} {two:?}"); + right_click(&mut h, gap); + assert_eq!(h.state().views[0].comments.context_at, None); +} From 58bb5ba3d0eb3bfac73a40f8ac95c2c684dd64fd Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 20:37:51 +0100 Subject: [PATCH 39/63] test(ui): the page guard knows any router method, and its readers are tested Found in review: - **The router guard** listed the router's methods by name, so a method added later (`view.router.holds(i)`) would pass every check. A use of the router by a method neither list names now fails outside the functions allowed to write or read it. The comments' point test called by path (`PageCx::comment_at(cx, p)`) is caught too. - **The guard's own readers** were proven only by planted builds. Reading the lines of code (the function each is in, a call rustfmt split) and finding hit tests imported under another name now work on a source string, and a unit test pins them with `hit_test_calls` and `declares`. - **Docs.** Fill & Sign's preview hides wherever a press would be taken (a mark, its handles, a Prepare field), and the hits under the pointer before the tools act are taken on the frames that need them, not "once". Tests: the guard's readers, seen failing with the line joining planted out. A planted router method no list names, and a planted `PageCx::comment_at` by path, each fail the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 12 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 163 ++++++++++++------ 2 files changed, 121 insertions(+), 54 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 49cf70067b..03bd3e807d 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -96,8 +96,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } // What this page has to do with the press: its owner's, the active tool's, or another page's. let press = view.router.press_for(i); - // What lies under the pointer before the tools act, asked once: for a click's release, a - // right-click, and Fill & Sign's preview. + // What lies under the pointer before the tools act, on the frames that need it: for a click's + // release, a right-click's tool checks, and Fill & Sign's preview. let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p)).unwrap_or_default(); let released_over = match press { @@ -111,8 +111,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let press_held = ptr.primary_down && !resp.long_touched(); let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held)); // A mark already placed takes its own press (comments, below), as does one being moved on this - // page until its move ends; with no button down the Fill & Sign tool's preview hides over one, - // where a press would pick it up. + // page until its move ends; with no button down the Fill & Sign tool's preview hides where a + // press would be taken (a mark, its handles, a Prepare field). let over_mark = matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); @@ -238,8 +238,8 @@ fn link_of(owner: Option) -> Option { /// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor /// and gestures. The camera also drags a corner whose press it holds, and crop's double-click /// opens Set Page Boxes wherever it is. `over_mark`: with Fill & Sign, a hit holds the press (a -/// mark's, or a Prepare field's), a mark is being moved on this page, or with no button down a mark -/// lies under the pointer, where a press would pick it up. +/// mark's, or a Prepare field's), a mark is being moved on this page, or with no button down a press +/// under the pointer would be taken (a mark, its handles, a Prepare field). fn tool_input( ui: &egui::Ui, view: &mut DocView, diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 40548a998a..f3c0952527 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -292,34 +292,40 @@ fn outside_test_modules(source: &str) -> Vec<(usize, &str)> { kept } +/// The lines of code in `source` outside its test modules, numbered from 1, each with the +/// top-level function or `impl` method it is in; comment lines are left out. A line rustfmt +/// continued (a method chain split as `view` / `.router` / `.owner(i)`) is read with the one it +/// continues, under that one's number. +fn code_lines(source: &str) -> Vec<(usize, Option<&str>, String)> { + let (mut function, mut in_impl) = (None, false); + let mut lines: Vec<(usize, Option<&str>, String)> = Vec::new(); + for (n, line) in outside_test_modules(source) { + if line.starts_with("impl") { + (function, in_impl) = (None, true); + } else if line == "}" { + (function, in_impl) = (None, false); + } else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) { + function = Some(name); + } + if line.trim_start().starts_with("//") { + continue; + } + match lines.last_mut() { + Some((_, _, text)) if line.trim_start().starts_with('.') => text.push_str(line.trim_start()), + _ => lines.push((n, function, line.to_owned())), + } + } + lines +} + /// Each name `find` finds in a line of the page modules' code (their tests left out), as (file, -/// the top-level function or `impl` method it is in, line, name). A line rustfmt continued (a -/// method chain split as `view` / `.router` / `.owner(i)`) is read with the one it continues. +/// the top-level function or `impl` method it is in, line, name) ([`code_lines`]). fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, Option, usize, String)> { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut found = Vec::new(); for file in PAGE_MODULES { let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); - // The top-level function, or method of an `impl`, that each line is in. - let (mut function, mut in_impl) = (None, false); - let mut lines: Vec<(usize, Option<&str>, String)> = Vec::new(); - for (n, line) in outside_test_modules(&source) { - if line.starts_with("impl") { - (function, in_impl) = (None, true); - } else if line == "}" { - (function, in_impl) = (None, false); - } else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) { - function = Some(name); - } - if line.trim_start().starts_with("//") { - continue; - } - match lines.last_mut() { - Some((_, _, text)) if line.trim_start().starts_with('.') => text.push_str(line.trim_start()), - _ => lines.push((n, function, line.to_owned())), - } - } - for (n, function, text) in lines { + for (n, function, text) in code_lines(&source) { for name in find(&text) { found.push((file, function.map(str::to_owned), n, name)); } @@ -328,36 +334,45 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, found } -/// Each hit test a page module imports under a name that is not a hit test's (`use …::hit as -/// under;`), which the checks by name would not see. +/// Each hit test `source` imports under a name that is not a hit test's (`use …::hit as under;`), +/// as (name, alias), from each `use` statement, from its first line to its `;`, wherever it is. +fn aliased_hit_tests(source: &str) -> Vec<(String, String)> { + let mut found = Vec::new(); + let mut statement: Option = None; + for line in source.lines().map(str::trim) { + if statement.is_none() && ["use ", "pub use ", "pub(crate) use ", "pub(super) use "].iter().any(|start| line.starts_with(start)) { + statement = Some(String::new()); + } + let Some(text) = statement.as_mut() else { continue }; + text.push_str(line); + text.push(' '); + if !line.ends_with(';') { + continue; + } + for part in text.split([',', '{', '}', ';']) { + let Some((path, alias)) = part.split_once(" as ") else { continue }; + let (name, alias) = (path.trim().rsplit("::").next().unwrap_or_default(), alias.trim()); + if is_hit_test(name) && !is_hit_test(alias) { + found.push((name.to_owned(), alias.to_owned())); + } + } + statement = None; + } + found +} + +/// Each hit test a page module imports under a name that is not a hit test's, which the checks by +/// name would not see ([`aliased_hit_tests`]). fn hit_test_aliases() -> Vec { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut found = Vec::new(); for file in PAGE_MODULES { let source = std::fs::read_to_string(src.join(file)).expect("page module"); - // Each `use` statement, from its first line to its `;`, wherever it is. - let mut statement: Option = None; - for line in source.lines().map(str::trim) { - if statement.is_none() && ["use ", "pub use ", "pub(crate) use ", "pub(super) use "].iter().any(|start| line.starts_with(start)) { - statement = Some(String::new()); - } - let Some(text) = statement.as_mut() else { continue }; - text.push_str(line); - text.push(' '); - if !line.ends_with(';') { - continue; - } - for part in text.split([',', '{', '}', ';']) { - let Some((path, alias)) = part.split_once(" as ") else { continue }; - let (name, alias) = (path.trim().rsplit("::").next().unwrap_or_default(), alias.trim()); - if is_hit_test(name) && !is_hit_test(alias) { - found.push(format!( - "src/{file}: `{name} as {alias}` imports a hit test under another name, which the checks by name miss: \ - name it `hit` or `…_hit`." - )); - } - } - statement = None; + for (name, alias) in aliased_hit_tests(&source) { + found.push(format!( + "src/{file}: `{name} as {alias}` imports a hit test under another name, which the checks by name miss: name \ + it `hit` or `…_hit`." + )); } } found @@ -527,7 +542,7 @@ const PAGE_HITS_ASKERS: [(&str, &str, &str); 5] = [ ]; /// The comments' point test, which their hit tests are built on: which comment lies under a point. -const COMMENT_POINT_TESTS: [&str; 1] = [".comment_at("]; +const COMMENT_POINT_TESTS: [&str; 2] = [".comment_at(", "PageCx::comment_at("]; /// The functions that ask which comment lies under a point: (file, function, why). const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ @@ -620,6 +635,28 @@ fn page_modules_act_on_a_press_only_through_the_router() { Add the function to ROUTER_READERS in src/canvas/page_pointer/tests.rs, with why.", "no longer reads who holds the press: remove it from ROUTER_READERS.", )); + // A router method the lists above don't name yet is the router's all the same: only their + // functions use it. + let known: Vec<&str> = ROUTER_WRITES.iter().chain(ROUTER_READS.iter()).copied().collect(); + let unlisted = page_module_matches(|line| { + let router = uses(line, ".router.") || uses(line, "PageRouter::"); + if router && !known.iter().any(|name| uses(line, name)) { vec!["router".to_owned()] } else { Vec::new() } + }); + problems.extend( + unlisted + .into_iter() + .filter(|(file, function, _, _)| { + !ROUTER_WRITERS.iter().chain(ROUTER_READERS.iter()).any(|(f, name, _)| f == file && function.as_deref() == Some(*name)) + }) + .map(|(file, _, n, _)| { + format!( + "src/{file}:{n}: uses the router by a method ROUTER_WRITES and ROUTER_READS don't name. The page view \ + decides the press and tells each module what it is to its page (`PagePress`): add the method to \ + ROUTER_WRITES or ROUTER_READS in src/canvas/page_pointer/tests.rs, and the function to their lists \ + only if it must." + ) + }), + ); problems.extend(misuses( &COMMENT_POINT_TESTS, &COMMENT_POINT_TESTERS, @@ -697,6 +734,36 @@ fn the_page_guards_read_past_a_test_module_in_the_middle_of_a_file() { assert_eq!(kept, [(1, "fn before() {}"), (6, ""), (7, "fn after() {}"), (10, "fn last() {}")]); } +/// The guard's readers: hit tests by name, the function a line declares, a call rustfmt split, +/// and a hit test imported as another name. +#[test] +fn the_page_guards_read_hit_tests_split_calls_and_aliases() { + assert_eq!(hit_test_calls("let h = crate::forms_ui::hit(xf, p);"), ["hit"]); + assert_eq!(hit_test_calls("cx.comment_hit(p) && picture_hit(p)"), ["comment_hit", "picture_hit"]); + assert!(hit_test_calls("pub(crate) fn hit(xf: &PageXform) -> Option {").is_empty()); + assert!(hit_test_calls("let all = self.hits(view, p, false);").is_empty()); + assert_eq!(declares("pub(crate) const fn crop_hit<'a>(view: &DocView) {"), Some("crop_hit")); + assert_eq!(declares("let hit = 1;"), None); + let source = "impl PageHits<'_> {\n fn at(&self) {\n self\n .hits(view, p, false)\n }\n}\nfn after() {\n // view.router.owner(0)\n let _ = view\n .router\n .owner(0);\n}\n"; + assert_eq!( + code_lines(source), + [ + (1, None, "impl PageHits<'_> {".to_owned()), + (2, Some("at"), " fn at(&self) {".to_owned()), + (3, Some("at"), " self.hits(view, p, false)".to_owned()), + (5, Some("at"), " }".to_owned()), + (6, None, "}".to_owned()), + (7, Some("after"), "fn after() {".to_owned()), + (9, Some("after"), " let _ = view.router.owner(0);".to_owned()), + (12, None, "}".to_owned()), + ] + ); + let source = + "fn a() {}\n\n#[cfg(any())]\nuse crate::camera_ui::hit as corner_under;\nuse crate::x::{\n hit as x_hit,\n picture_hit as under,\n};\n"; + let aliases: Vec<(String, String)> = [("hit", "corner_under"), ("picture_hit", "under")].map(|(a, b)| (a.to_owned(), b.to_owned())).to_vec(); + assert_eq!(aliased_hit_tests(source), aliases); +} + #[test] fn the_page_press_guard_rejects_missing_owners_and_second_hit_tests() { assert!(routed_press_handler("let owner = view.router.owner(page);")); From b77903012d8433f5aac5d2cd1e6d1e64c5f3c401 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 21:51:52 +0100 Subject: [PATCH 40/63] fix(ui): a camera click on another page moves the quadrilateral there Found in review (CodeRabbit): with the camera tool, a press nobody holds on a page without the quadrilateral moves it there, but a click was located by its press's origin, which egui has cleared by the release frame. So only a drag moved it, and a click did nothing. This was so on `main` too. A click is now located where it came up; `PagePress::Free` already says its press began on this page. Test: `a_camera_click_on_another_page_moves_the_quadrilateral_there`, seen failing without the fix. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/camera_ui.rs | 5 ++++- crates/ui-egui/tests/it/pointer.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index ce88da25cf..f2e07fab35 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -98,9 +98,12 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe if view.camera.is_none() && page == view.current { view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); } + // A press nobody holds on a page without the quadrilateral moves it there: a drag from where it + // began, a click where it came up (egui has cleared the press's origin by its release). + let at = if resp.clicked() { pointer } else { origin }; if (resp.drag_started() || resp.clicked()) && press == PagePress::Free - && origin.is_some_and(|o| xf.rect.contains(o)) + && at.is_some_and(|p| xf.rect.contains(p)) && view.camera.is_none_or(|q| q.page != page) { view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 8364f3a635..2fc79d1f63 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1725,3 +1725,17 @@ fn a_right_click_between_the_pages_anchors_no_pages_menu() { right_click(&mut h, gap); assert_eq!(h.state().views[0].comments.context_at, None); } + +/// With the camera tool, a click on another page moves the quadrilateral there, as a drag from it +/// does. The click was located by its press's origin, which egui has cleared by the release frame, +/// so it did nothing (on `main` too). +#[test] +fn a_camera_click_on_another_page_moves_the_quadrilateral_there() { + let mut h = open(two_pages_pdf(), "100", false); + h.state_mut().quick_tool = QuickTool::EnhanceCamera; + h.run_steps(4); + assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0), "it starts on the current page"); + let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + mouse_click(&mut h, two.min + egui::vec2(100.0, 60.0)); + assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(1)); +} From bdd2ff0ee64cef3b3bc74d6d95c88a29e3ee9a10 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 22:04:19 +0100 Subject: [PATCH 41/63] fix(ui): a camera click is located where it came up Found in review (CodeRabbit): the last fix located a camera click by the pointer, which can have moved on by the end of the release's frame. A click pressed on page 2 and released on page 1, with the pointer back on page 2 by then, moved the quadrilateral to page 2. `PagePointer` keeps each release's position, so `PagePointer::released` gives where the click came up, and the click is located there. Test: `a_camera_click_released_off_its_page_moves_nothing`, seen failing without the fix. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_pointer.rs | 9 +++++++++ crates/ui-egui/src/tools/camera_ui.rs | 5 +++-- crates/ui-egui/tests/it/pointer.rs | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index 02cb2208ed..f56614411d 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -78,6 +78,15 @@ impl PagePointer { pub(crate) fn pressed(&self) -> Option { first_press(&self.edges) } + + /// Where this frame's last primary release was: where a click came up, which the pointer may + /// have left by the frame's end. + pub(crate) fn released(&self) -> Option { + self.edges.iter().rev().find_map(|e| match e { + Edge::Release(p) => Some(*p), + Edge::Press(_) => None, + }) + } } /// Whether egui gives the drag to a widget that is not the pages' (a panel's tab, a floating panel's diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index f2e07fab35..99b4356a26 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -99,8 +99,9 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); } // A press nobody holds on a page without the quadrilateral moves it there: a drag from where it - // began, a click where it came up (egui has cleared the press's origin by its release). - let at = if resp.clicked() { pointer } else { origin }; + // began, a click where it came up (egui has cleared the press's origin by its release, and the + // pointer may have moved on in the same frame). + let at = if resp.clicked() { ptr.released() } else { origin }; if (resp.drag_started() || resp.clicked()) && press == PagePress::Free && at.is_some_and(|p| xf.rect.contains(p)) diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 2fc79d1f63..d01a74cff0 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1739,3 +1739,22 @@ fn a_camera_click_on_another_page_moves_the_quadrilateral_there() { mouse_click(&mut h, two.min + egui::vec2(100.0, 60.0)); assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(1)); } + +/// A camera click is located where it came up, not where the pointer went after it in the same +/// frame: pressed on page 2 and released on page 1 (the gaps hidden), it moves nothing, though the +/// pointer is back on page 2 by the frame's end. The pointer's last position used to move the +/// quadrilateral to page 2. +#[test] +fn a_camera_click_released_off_its_page_moves_nothing() { + let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::EnhanceCamera); + assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0), "it starts on the current page"); + let (press, release, back) = (seam + egui::vec2(0.0, 2.0), seam - egui::vec2(0.0, 2.0), seam + egui::vec2(0.0, 40.0)); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + let up = egui::Event::PointerButton { pos: release, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }; + one_frame(&mut h, [up, egui::Event::PointerMoved(back)]); + h.run_steps(2); + assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0)); +} From 16ffab9b03960572e822514ce837671c3a948fde Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 16:09:08 -0500 Subject: [PATCH 42/63] M12: add a 3D model (U3D or PRC) to a page with a poster (#1297) A new `model_add` automation tool and `Edit::AddModel3d` embed a U3D or PRC file as a 3D annotation (ISO 32000-2 13.6.2) in a chosen rectangle: - The model goes into a Flate-compressed 3D stream (13.6.3) with /Type /3D and /Subtype U3D or PRC, read from the file's header (`U3D` and a zero byte, or `PRC`); anything else is refused before the document changes. Models over 256 MiB (MAX_MODEL_BYTES) are refused. model_add checks the file's size (from the file system) and its header before reading the rest, so a large file that is not a model is never loaded; the path goes through the read root, and `read_model` joins the helpers the file-access test knows take a resolved path. - /3DD references the stream, so the annotation has its own instance. - /3DA states /A: click (XA, the standard's default and the tool's), page_open (PO) or page_visible (PV). - /AP /N is the poster readers print and show before activation: a chosen image or PDF page fitted inside the box keeping its shape and centred (the custom-stamp picture path: `mark_source`, `fit_picture`, and the stamp's placement matrix now shared as `appearance::picture_place`), or, with no picture, a light frame labelled "3D model". It is drawn in user space and not counter-rotated, so on a page with /Rotate it turns with the page, as the model does. - /Contents carries the name (the file stem by default) as the alternate description (12.5.2). - A document older than the model needs gets /Version in its catalog (7.7.2): 1.6 for U3D, 1.7 for PRC, as the optimizer does for JBIG2. Adobe's extension level for PRC in 1.7 is not written. - The 3D rectangle has its own MAX_MODEL_SIDE (14400 pt). The edit is undoable ("Add 3D model"), needs the comments permission and rebuilds only the comments view. A new parity entry, misc.3d-add, is partial: no poster rendered from the model, no views (/VA, /3DV), no replacing the poster, no UI command, no /Extensions. misc.3d-view is unchanged. ROADMAP.md progress log line. Adapted from pdfcer (MIT, same author), commit 1ca5ece, rewritten to this repository's types and rules. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- ROADMAP.md | 2 + crates/annot/src/appearance.rs | 29 +- crates/annot/src/lib.rs | 8 + crates/annot/src/tests.rs | 60 ++++ crates/annot/src/threed.rs | 258 ++++++++++++++++++ crates/automation/src/comments.rs | 2 +- crates/automation/src/dispatch.rs | 1 + crates/automation/src/file_access.rs | 14 +- crates/automation/src/lib.rs | 1 + crates/automation/src/model3d.rs | 114 ++++++++ crates/automation/src/tools.rs | 14 + .../automation/tests/it/automation_roots.rs | 4 + crates/automation/tests/it/main.rs | 1 + crates/automation/tests/it/model_3d.rs | 120 ++++++++ crates/engine/src/edit.rs | 13 +- crates/engine/src/edit/labels.rs | 2 + crates/engine/src/edit/run.rs | 13 +- crates/engine/src/history.rs | 1 + crates/engine/src/lib.rs | 7 +- crates/engine/src/pictures.rs | 9 + crates/engine/src/tests.rs | 60 ++++ parity/acrobat-features.toml | 12 + 22 files changed, 727 insertions(+), 18 deletions(-) create mode 100644 crates/annot/src/threed.rs create mode 100644 crates/automation/src/model3d.rs create mode 100644 crates/automation/tests/it/model_3d.rs diff --git a/ROADMAP.md b/ROADMAP.md index a39d6f8b6c..adf6bdb5cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -219,6 +219,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **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-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. - **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 909250ef26..89c0f70f48 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -278,7 +278,7 @@ fn ext_gstate(opacity: f64, multiply: bool) -> Option { Some(res) } -fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream { +pub(crate) fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); @@ -288,6 +288,18 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream { Stream::flate(d, content) } +/// The `cm` that draws a picture into `at`: an image (its unit square) or a form XObject whose +/// `/Matrix` maps it to `size` points (`None`, or a side that is not positive, draws nothing). +pub(crate) fn picture_place(at: [f64; 4], image: bool, size: Option<(f64, f64)>) -> Option { + let [x0, y0, x1, y1] = at; + let (w, h) = (x1 - x0, y1 - y0); + if image { + return Some(format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))); + } + let (sw, sh) = size.filter(|(sw, sh)| *sw > 0.0 && *sh > 0.0)?; + Some(format!("{} 0 0 {} {} {} cm", n(w / sw), n(h / sh), n(x0), n(y0))) +} + /// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written /// when the signature is added to a turned page): 0, 90, 180 or 270. pub(crate) fn picture_turn(d: &Dict) -> i64 { @@ -325,7 +337,7 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { } /// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding. -fn helvetica_resource() -> Object { +pub(crate) fn helvetica_resource() -> Object { let mut font = Dict::new(); font.set(b"Type".to_vec(), Object::name("Font")); font.set(b"Subtype".to_vec(), Object::name("Type1")); @@ -786,15 +798,12 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - // the standard counter-rotation as its form `/Matrix`: viewers fit the turned // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); - let place = if image { - format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) - } else { - let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; - format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) - }; + let size = nums(d, b"PCPictureSize").and_then(|s| match s.as_slice() { + [w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)), + _ => None, + }); + let place = picture_place(rect, image, size)?; c.push_str(&format!("q {place} /Pic Do Q\n")); let mut xo = Dict::new(); xo.set(b"Pic".to_vec(), Object::Ref(pic)); diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 7bd0894c66..5b7492bea2 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -23,6 +23,7 @@ mod draw; mod info; mod lookup; mod modify; +mod threed; mod types; #[cfg(test)] @@ -41,6 +42,7 @@ pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, }; +pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, @@ -70,6 +72,12 @@ pub enum AnnotError { /// with a font that has those characters. The message names the character. #[error("{0}")] Undrawable(String), + /// A 3D model file that is neither U3D (starts with `U3D` and a zero byte) nor PRC (starts `PRC`). + #[error("not a 3D model: the file must be U3D (ECMA-363) or PRC (ISO 14739-1)")] + NotAModel, + /// A 3D model larger than `MAX_MODEL_BYTES`. + #[error("the 3D model is {size} bytes; the largest accepted is {max}")] + ModelTooLarge { size: usize, max: usize }, #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index cf800fa98a..c31ef19b17 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2250,3 +2250,63 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() { assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}"); } } + +/// A 3D annotation is PDF 1.6 and a PRC model 1.7 (ISO 32000-2 §13.6): an older document's +/// catalog says so, and a newer one is left as it is. +#[test] +fn a_3d_model_raises_an_older_document_to_its_version() { + let catalog_version = |doc: &Document| { + let root = doc.root().unwrap(); + doc.get(root).as_dict().unwrap().name(b"Version").map(<[u8]>::to_vec) + }; + let add = |doc: &mut Document, model: &[u8]| { + let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" }; + add_3d_model(doc, &new, &meta("m")).unwrap(); + }; + let mut doc = fixture(); + add(&mut doc, b"U3D\0rest"); + assert_eq!(catalog_version(&reopen(&doc)), None, "a 1.7 file needs nothing for U3D"); + for (model, version) in [(&b"U3D\0rest"[..], &b"1.6"[..]), (b"PRC rest", b"1.7")] { + let mut doc = fixture(); + doc.set_version("1.4"); + add(&mut doc, model); + assert_eq!(catalog_version(&reopen(&doc)).as_deref(), Some(version)); + } + let mut doc = fixture(); + doc.set_version("2.0"); + add(&mut doc, b"PRC rest"); + assert_eq!(catalog_version(&reopen(&doc)), None, "a 2.0 file takes PRC as it is"); +} + +#[test] +fn a_picture_is_placed_by_its_box_and_a_form_needs_a_positive_size() { + let at = [10.0, 20.0, 110.0, 70.0]; + assert_eq!(appearance::picture_place(at, true, None).as_deref(), Some("100 0 0 50 10 20 cm"), "an image fills its unit square"); + assert_eq!(appearance::picture_place(at, false, Some((200.0, 25.0))).as_deref(), Some("0.5 0 0 2 10 20 cm")); + for size in [None, Some((0.0, 25.0)), Some((200.0, -1.0))] { + assert_eq!(appearance::picture_place(at, false, size), None, "{size:?}"); + } +} + +/// The poster's box must lie in the rectangle, and a form poster needs a size to scale by. +#[test] +fn a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused() { + let picture = |at: [f64; 4], size: (f64, f64)| PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size, at }; + for (poster, why) in [ + (picture([50.0, 100.0, 250.0, 300.0], (10.0, 10.0)), "starts left of the rectangle"), + (picture([100.0, 100.0, 300.0, 301.0], (10.0, 10.0)), "ends above it"), + (picture([100.0, 100.0, 300.0, 300.0], (0.0, 10.0)), "has no width"), + ] { + let mut doc = fixture(); + let new = NewModel3d { + page: 0, + rect: [100.0, 100.0, 300.0, 300.0], + model: b"PRC rest", + activation: Activation3d::default(), + poster: Some(poster), + contents: "", + }; + assert!(matches!(add_3d_model(&mut doc, &new, &meta("m")), Err(AnnotError::Invalid(_))), "{why}"); + assert_eq!(list(&doc, 0).len(), 1, "{why}: nothing added"); + } +} diff --git a/crates/annot/src/threed.rs b/crates/annot/src/threed.rs new file mode 100644 index 0000000000..be632337cb --- /dev/null +++ b/crates/annot/src/threed.rs @@ -0,0 +1,258 @@ +//! 3D annotations (ISO 32000-2 §13.6.2): a U3D or PRC model shown in a rectangle on a page, +//! with a poster (`/AP /N`) for printing, for readers without 3D, and until it is activated. + +use super::create::{finite, normalize, num_array}; +use super::lookup::{annots, page_ref, set_annots}; +use super::types::{FLAG_PRINT, Meta}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, Stream, appearance, n}; + +/// The largest model accepted, in bytes: well past real U3D/PRC files, and bounded because the +/// model is held and compressed in memory. +pub const MAX_MODEL_BYTES: usize = 256 << 20; + +/// What the poster says when there is no picture. +const PLACEHOLDER_LABEL: &str = "3D model"; + +/// A 3D stream's data format (§13.6.3): ECMA-363 U3D or ISO 14739-1 PRC. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModelFormat { + U3d, + Prc, +} + +impl ModelFormat { + /// The format a model file's first bytes declare: U3D files start `U3D\0` (ECMA-363 file + /// header block type), PRC files start `PRC`. + /// + /// # Errors + /// + /// [`AnnotError::NotAModel`] for anything else. + pub fn detect(bytes: &[u8]) -> Result { + if bytes.starts_with(b"U3D\0") { + Ok(Self::U3d) + } else if bytes.starts_with(b"PRC") { + Ok(Self::Prc) + } else { + Err(AnnotError::NotAModel) + } + } + + /// The 3D stream's `/Subtype`. + pub fn subtype(self) -> &'static str { + match self { + Self::U3d => "U3D", + Self::Prc => "PRC", + } + } + + /// The PDF version a document holding it needs (§13.6): a 3D annotation is 1.6, a PRC stream + /// 1.7 (with Adobe's extension level 1, which is not written; PDF 2.0 takes PRC as standard). + pub fn version(self) -> &'static str { + match self { + Self::U3d => "1.6", + Self::Prc => "1.7", + } + } +} + +/// When a reader turns the poster into the live model (§13.6.2, the activation dictionary's +/// `/A`). The standard's default is [`Self::Click`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Activation { + /// `/XA`: when the user clicks it; it prints as its poster. + #[default] + Click, + /// `/PO`: when the page opens. + PageOpen, + /// `/PV`: when the page becomes visible. + PageVisible, +} + +impl Activation { + /// Every activation, with the word a tool argument names it by. + pub const ALL: [(Self, &'static str); 3] = [(Self::Click, "click"), (Self::PageOpen, "page_open"), (Self::PageVisible, "page_visible")]; + + /// The `/A` name. + pub fn name(self) -> &'static str { + match self { + Self::Click => "XA", + Self::PageOpen => "PO", + Self::PageVisible => "PV", + } + } +} + +/// A picture already in the document (an image XObject, or a form XObject whose `/Matrix` maps it +/// to `size` points) drawn into `at`, a box inside the annotation's rectangle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PosterPicture { + pub xobject: ObjRef, + pub image: bool, + pub size: (f64, f64), + pub at: [f64; 4], +} + +/// A 3D model to add on `page` (0-based) in `rect` (user space). +#[derive(Clone, Debug, PartialEq)] +pub struct NewModel3d<'a> { + pub page: usize, + pub rect: [f64; 4], + pub model: &'a [u8], + pub activation: Activation, + /// The poster's picture; `None` draws a labelled frame. + pub poster: Option, + /// `/Contents`: the annotation's alternate description (§12.5.2). + pub contents: &'a str, +} + +/// Check a model before anything is added: its size and its format. +/// +/// # Errors +/// +/// [`AnnotError::ModelTooLarge`] past [`MAX_MODEL_BYTES`], else as [`ModelFormat::detect`]. +pub fn check_model(model: &[u8]) -> Result { + check_model_size(model, MAX_MODEL_BYTES)?; + ModelFormat::detect(model) +} + +fn check_model_size(model: &[u8], max: usize) -> Result<(), AnnotError> { + if model.len() > max { + return Err(AnnotError::ModelTooLarge { size: model.len(), max }); + } + Ok(()) +} + +/// Add a 3D annotation: the model in a Flate-compressed 3D stream referenced from `/3DD` (so the +/// annotation has its own instance, §13.6.2), `/3DA` stating only `/A`, and the poster as `/AP /N`. +/// No view (`/3DV`, `/VA`) is written: readers open on the model's own default view. A document +/// older than [`ModelFormat::version`] is raised to it. Returns its index in the page's `/Annots`. +/// +/// # Errors +/// +/// As [`check_model`]; [`AnnotError::NoSuchPage`]; [`AnnotError::Invalid`] for a rectangle with no +/// area or a poster box outside it. +pub fn add_3d_model(doc: &mut Document, new: &NewModel3d<'_>, meta: &Meta) -> Result { + let format = check_model(new.model)?; + let page = page_ref(doc, new.page)?; + let rect = normalize(new.rect); + if !finite(&new.rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid rectangle for the 3D model (each side at least 1 pt)".into())); + } + let poster = match &new.poster { + Some(p) => picture_poster(rect, p)?, + None => placeholder_poster(rect), + }; + let mut stream = Dict::new(); + stream.set(b"Type".to_vec(), Object::name("3D")); + stream.set(b"Subtype".to_vec(), Object::name(format.subtype())); + let stream = doc.add(Object::Stream(Stream::flate(stream, new.model))); + let poster = doc.add(Object::Stream(poster)); + let d = annotation_dict(new, rect, page, stream, poster, meta); + let r = doc.add(Object::Dict(d)); + let mut list = annots(doc, page); + let index = list.len(); + list.push(Object::Ref(r)); + set_annots(doc, page, list)?; + at_least_version(doc, format.version())?; + Ok(index) +} + +/// A document whose header and catalog `/Version` both say less than `min` gets `/Version min` in +/// its catalog (§7.7.2: the later of the two applies). +fn at_least_version(doc: &mut Document, min: &str) -> Result<(), AnnotError> { + let below = |v: &[u8]| v.len() < 3 || v < min.as_bytes(); + let Some(root) = doc.root() else { return Ok(()) }; + let catalog = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()); + let catalog = catalog.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec)); + if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) { + doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name(min)))?; + } + Ok(()) +} + +fn annotation_dict(new: &NewModel3d<'_>, rect: [f64; 4], page: ObjRef, stream: ObjRef, poster: ObjRef, meta: &Meta) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("3D")); + d.set(b"Rect".to_vec(), num_array(&rect)); + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT)); + if !new.contents.is_empty() { + d.set(b"Contents".to_vec(), PdfString::text(new.contents)); + } + if !meta.id.is_empty() { + d.set(b"NM".to_vec(), PdfString::text(&meta.id)); + } + if let Some(date) = &meta.date { + d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } + d.set(b"3DD".to_vec(), Object::Ref(stream)); + let mut activation = Dict::new(); + activation.set(b"A".to_vec(), Object::name(new.activation.name())); + d.set(b"3DA".to_vec(), Object::Dict(activation)); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(poster)); + d.set(b"AP".to_vec(), Object::Dict(ap)); + d +} + +/// The poster drawing `p` into its box, which must lie within `rect`. +fn picture_poster(rect: [f64; 4], p: &PosterPicture) -> Result { + let at = normalize(p.at); + let inside = finite(&p.at) && at[0] >= rect[0] && at[1] >= rect[1] && at[2] <= rect[2] && at[3] <= rect[3] && at[2] > at[0] && at[3] > at[1]; + if !inside { + return Err(AnnotError::Invalid("the poster must fit inside the 3D model's rectangle".into())); + } + let place = appearance::picture_place(at, p.image, Some(p.size)).ok_or_else(|| AnnotError::Invalid("the poster picture has no size".into()))?; + let mut xobjects = Dict::new(); + xobjects.set(b"Pic".to_vec(), Object::Ref(p.xobject)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + Ok(appearance::form(rect, format!("q {place} /Pic Do Q\n").as_bytes(), resources)) +} + +/// A light frame with "3D model" centred in it, shrunk to fit (left out when even 2 pt won't). +fn placeholder_poster(rect: [f64; 4]) -> Stream { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + let mut c = + format!("0.94 g {} {} {} {} re f\n0.45 G 1 w {} {} {} {} re S\n", n(x0), n(y0), n(w), n(h), n(x0 + 0.5), n(y0 + 0.5), n(w - 1.0), n(h - 1.0)); + let mut resources = Dict::new(); + let unit = appearance::text_width(PLACEHOLDER_LABEL, 1.0); + let size = if unit > 0.0 { (w * 0.8 / unit).min(h * 0.4).min(14.0) } else { 0.0 }; + if size >= 2.0 { + let (tx, ty) = (x0 + (w - unit * size) / 2.0, y0 + (h - size * 0.7) / 2.0); + c.push_str(&format!("BT 0.3 g /Helv {} Tf {} {} Td ({PLACEHOLDER_LABEL}) Tj ET\n", n(size), n(tx), n(ty))); + resources.set(b"Font".to_vec(), appearance::helvetica_resource()); + } + appearance::form(rect, c.as_bytes(), resources) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_size_is_capped() { + assert_eq!(check_model_size(&[0; 8], 8), Ok(())); + assert_eq!(check_model_size(&[0; 9], 8), Err(AnnotError::ModelTooLarge { size: 9, max: 8 })); + } + + #[test] + fn the_format_is_read_from_the_header() { + assert_eq!(ModelFormat::detect(b"U3D\0rest"), Ok(ModelFormat::U3d)); + assert_eq!(ModelFormat::detect(b"PRC8800"), Ok(ModelFormat::Prc)); + for junk in [&b""[..], b"U3D", b"U3DX", b"PR", b"%PDF-1.7", b"prc"] { + assert_eq!(ModelFormat::detect(junk), Err(AnnotError::NotAModel), "{junk:?}"); + } + } + + #[test] + fn the_placeholder_label_shrinks_to_fit_and_is_dropped_when_it_cannot() { + let wide = String::from_utf8(placeholder_poster([0.0, 0.0, 300.0, 200.0]).decoded().unwrap().to_vec()).unwrap(); + assert!(wide.contains("/Helv 14 Tf") && wide.contains("(3D model) Tj"), "{wide}"); + let tiny = placeholder_poster([0.0, 0.0, 4.0, 4.0]); + assert!(!String::from_utf8(tiny.decoded().unwrap().to_vec()).unwrap().contains("Tj")); + assert!(tiny.dict.get(b"Resources").and_then(Object::as_dict).is_none_or(|r| r.get(b"Font").is_none())); + } +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 5dbd5b8542..0817613865 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -75,7 +75,7 @@ fn to_user(p: &PageInfo, x: f64, y: f64) -> [f64; 2] { [ux as f64, uy as f64] } -fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] { +pub(crate) fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] { let (a, b) = (to_user(p, r[0], r[1]), to_user(p, r[2], r[3])); [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] } diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 49d4e6fd4d..e5d84fd28e 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -768,6 +768,7 @@ impl Automation { "comment_list" => self.comment_list(&a)?, "comment_add" => self.comment_add(&a)?, "stamp_custom" => self.stamp_custom(&a)?, + "model_add" => self.model_add(&a)?, "comment_reply" => self.comment_reply(&a)?, "comment_set_status" => self.comment_set_status(&a)?, "sign_list" => self.sign_list(&a)?, diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index 067a593888..dab2760f5d 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -394,8 +394,18 @@ mod tests { // 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. - const RESOLVED_BY_CALLER: &[&str] = - &["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"]; + const RESOLVED_BY_CALLER: &[&str] = &[ + "write_atomic", + "write_atomic_with", + "read_input", + "read_input_bytes", + "read_model", + "read_review_sidecar", + "process", + "linked", + "walk", + "publish", + ]; let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; let mut files = vec![]; while let Some(dir) = dirs.pop() { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f1910867c9..cd75302d5a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -26,6 +26,7 @@ mod links; #[cfg(feature = "mcp")] pub mod mcp; mod measure; +mod model3d; mod printing; mod reading; mod redact; diff --git a/crates/automation/src/model3d.rs b/crates/automation/src/model3d.rs new file mode 100644 index 0000000000..2d89e4ae60 --- /dev/null +++ b/crates/automation/src/model3d.rs @@ -0,0 +1,114 @@ +//! 3D model tools (Acrobat: Rich Media ▸ Add 3D, AGENTS.md §3). + +use std::path::Path; + +use pdfcraft_engine::{Activation3d, Edit, MAX_3D_MODEL_BYTES, MarkFile, Model3dFormat}; +use serde_json::Value; + +use crate::comments::rect_to_user; +use crate::{Args, Automation, Result, ToolError, failed}; + +/// The largest side of a 3D model's box, in points: the largest page PDF allows (ISO 32000-2 Annex C). +const MAX_MODEL_SIDE: f64 = 14_400.0; + +impl Automation { + /// A U3D or PRC model in `rect` on `page`, its poster the picture at `poster` (a PDF page or an + /// image) fitted inside the box, or a labelled frame. + pub(crate) fn model_add(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("page {} does not exist", page + 1)))?; + let r = a.nums::<4>("rect")?.ok_or_else(|| ToolError::InvalidArgs("missing argument rect".into()))?; + let (w, h) = ((r[2] - r[0]).abs(), (r[3] - r[1]).abs()); + if !((1.0..=MAX_MODEL_SIDE).contains(&w) && (1.0..=MAX_MODEL_SIDE).contains(&h)) { + return Err(ToolError::InvalidArgs(format!( + "rect must be [x0, y0, x1, y1] with each side at least 1 pt and at most {MAX_MODEL_SIDE} pt" + ))); + } + let activation = match a.opt_str("activation")? { + None => Activation3d::default(), + Some(word) => Activation3d::ALL + .iter() + .find(|(_, w)| *w == word) + .map(|(act, _)| *act) + .ok_or_else(|| ToolError::InvalidArgs(format!("activation must be one of: {}", Activation3d::ALL.map(|(_, w)| w).join(", "))))?, + }; + let path = self.resolve(a.str("path")?, false)?; + let model = read_model(&path, MAX_3D_MODEL_BYTES)?; + let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned())); + let poster = match a.opt_str("poster")? { + None => None, + Some(p) => { + let path = self.resolve(p, false)?; + let bytes = crate::read_input(&path)?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let page = usize::try_from(a.opt_int("poster_page")?.unwrap_or(1).max(1)).unwrap_or(1).saturating_sub(1); + Some(MarkFile { name, bytes: std::sync::Arc::new(bytes), page }) + } + }; + let edit = Edit::AddModel3d { page, rect: rect_to_user(&info, r), model: std::sync::Arc::new(model), poster, activation, name }; + self.apply(a, edit) + } +} + +/// Read a resolved model file of at most `max` bytes. Its size (from the file system) and its first +/// bytes are checked before the rest is read, so a large file that is not a model is never loaded. +fn read_model(path: &Path, max: usize) -> Result> { + use std::io::Read as _; + let io = |e: std::io::Error| failed(format!("{}: {e}", path.display())); + let too_large = |size: u64| failed(format!("the 3D model is {size} bytes; the largest accepted is {max}")); + let cap = u64::try_from(max).unwrap_or(u64::MAX); + let mut file = std::fs::File::open(path).map_err(io)?; + if let Ok(meta) = file.metadata() + && meta.is_file() + && meta.len() > cap + { + return Err(too_large(meta.len())); + } + let mut bytes = Vec::new(); + (&mut file).take(4).read_to_end(&mut bytes).map_err(io)?; + Model3dFormat::detect(&bytes).map_err(failed)?; + let rest = cap.saturating_add(1).saturating_sub(bytes.len() as u64); + file.take(rest).read_to_end(&mut bytes).map_err(io)?; + if bytes.len() > max { + return Err(failed(format!("the 3D model is more than {max} bytes, the largest accepted"))); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path + } + + #[test] + fn a_model_is_read_whole_when_it_fits() { + let dir = tempfile::tempdir().unwrap(); + let mut prc = b"PRC".to_vec(); + prc.extend_from_slice(&[7; 40]); + assert_eq!(read_model(&file(dir.path(), "m.prc", &prc), 64).unwrap(), prc); + assert_eq!(read_model(&file(dir.path(), "m.u3d", b"U3D\0x"), 6).unwrap(), b"U3D\0x"); + } + + #[test] + fn a_model_past_the_cap_is_refused_by_its_size_before_it_is_read() { + let dir = tempfile::tempdir().unwrap(); + let path = file(dir.path(), "big.prc", &[b'P', b'R', b'C', 0, 0, 0, 0, 0, 0, 0]); + let err = read_model(&path, 4).unwrap_err().to_string(); + assert!(err.contains("is 10 bytes") && err.contains("largest accepted is 4"), "the file's own size, from the file system: {err}"); + } + + #[test] + fn a_file_that_is_not_a_model_is_refused_by_its_header() { + let dir = tempfile::tempdir().unwrap(); + for junk in [&b"%PDF-1.7 a page"[..], b"PR", b"", b"U3DX and more"] { + let err = read_model(&file(dir.path(), "junk.bin", junk), 64).unwrap_err().to_string(); + assert!(err.contains("not a 3D model"), "{junk:?}: {err}"); + } + } +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 7cafcb4a37..b37cb54429 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -461,6 +461,20 @@ pub fn tools() -> Vec { }), &["doc", "page", "path"], )), + t("model_add", "Add a 3D model", "Add a 3D model (Acrobat: Rich Media ▸ Add 3D): the U3D or PRC file at path, recognised by its first bytes, as a 3D annotation in rect [x0, y0, x1, y1] (points from the top-left of the displayed page; each side 1–14400 pt; at most 256 MiB). Its poster, what prints and what readers show until the model is activated, is the picture at poster (a PDF page, poster_page default 1, or an image) fitted inside rect keeping its shape and centred; without poster, a frame labelled \"3D model\". activation: click (default), page_open or page_visible. name is its description (default: the file name). No views are added: readers open the model on its own default view. Undoable.") + .with(schema( + json!({ + "doc": doc(), + "page": { "type": "integer", "minimum": 1 }, + "path": { "type": "string" }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + "poster": { "type": "string" }, + "poster_page": { "type": "integer", "minimum": 1 }, + "activation": { "type": "string", "enum": ["click", "page_open", "page_visible"] }, + "name": { "type": "string" }, + }), + &["doc", "page", "path", "rect"], + )), t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.") .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])), t( diff --git a/crates/automation/tests/it/automation_roots.rs b/crates/automation/tests/it/automation_roots.rs index a96870ca02..e65c582a73 100644 --- a/crates/automation/tests/it/automation_roots.rs +++ b/crates/automation/tests/it/automation_roots.rs @@ -145,6 +145,8 @@ fn tools_that_read_a_path_are_held_to_the_read_root() { ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))})); let (secret, pic, gone) = (s(&base.join("outside/secret.pdf")), s(&base.join("outside/pic.png")), s(&base.join("outside/gone.bin"))); let out = s(&base.join("out/result.pdf")); + std::fs::write(base.join("in/m.prc"), b"PRC model").unwrap(); + let model = s(&base.join("in/m.prc")); for (tool, args) in [ ("page_insert_file", json!({"doc": 1, "path": secret, "at": 1})), ("page_replace", json!({"doc": 1, "pages": [1], "path": secret})), @@ -153,6 +155,8 @@ fn tools_that_read_a_path_are_held_to_the_read_root() { ("doc_create", json!({"from": "images", "paths": [pic]})), ("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})), ("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 493, 300]})), + ("model_add", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 100, 100]})), + ("model_add", json!({"doc": 1, "page": 1, "path": model, "rect": [10, 10, 100, 100], "poster": pic})), ("page_add_image", json!({"doc": 1, "page": 1, "path": pic})), ("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})), ("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})), diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index d36dbe93a9..f396601edb 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -8,6 +8,7 @@ mod batch; mod button_caption; mod form_bulk; mod hostile_args; +mod model_3d; mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; diff --git a/crates/automation/tests/it/model_3d.rs b/crates/automation/tests/it/model_3d.rs new file mode 100644 index 0000000000..179192f421 --- /dev/null +++ b/crates/automation/tests/it/model_3d.rs @@ -0,0 +1,120 @@ +//! `model_add` embeds a U3D or PRC model as a 3D annotation (ISO 32000-2 §13.6.2) with a poster, +//! end to end: the tool, the saved file, undo, and the refusals. + +use pdfcraft_automation::{Automation, Content, ToolError}; +use pdfcraft_cos::{Dict, Document, Object, Stream}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +fn png(w: u32, h: u32) -> Vec { + let mut out = Vec::new(); + let mut encoder = png::Encoder::new(&mut out, w, h); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header().unwrap().write_image_data(&vec![90; (w * h * 3) as usize]).unwrap(); + out +} + +fn stream(doc: &Document, obj: &Object) -> Stream { + match doc.resolve(obj).as_ref() { + Object::Stream(s) => s.clone(), + other => panic!("expected a stream: {other:?}"), + } +} + +/// The 3D annotations on the first page of the saved file. +fn saved_3d(dir: &std::path::Path) -> (Document, Vec) { + let doc = Document::open(std::sync::Arc::new(std::fs::read(dir.join("out.pdf")).unwrap())).unwrap(); + let page = &pdfcraft_model::pages(&doc)[0]; + let list = page.dict.get(b"Annots").map(|a| doc.resolve(a).as_array().cloned().unwrap()).unwrap_or_default(); + let found = list.iter().map(|o| doc.resolve(o).as_dict().unwrap().clone()).filter(|d| d.name(b"Subtype") == Some(&b"3D"[..])).collect(); + (doc, found) +} + +#[test] +fn model_add_embeds_the_model_with_a_fitted_poster() { + let dir = tempfile::tempdir().unwrap(); + let mut prc = b"PRC".to_vec(); + prc.extend_from_slice(&[7; 200]); + std::fs::write(dir.path().join("bracket.prc"), &prc).unwrap(); + std::fs::write(dir.path().join("poster.png"), png(80, 40)).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap(); + // A 300 × 300 box from the top-left of the page: user space [100, 400, 400, 700]. + let call = + json!({ "doc": doc, "page": 1, "path": "bracket.prc", "rect": [100, 100, 400, 400], "poster": "poster.png", "activation": "page_open" }); + tool(&mut a, "model_add", call); + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + + let (saved, found) = saved_3d(dir.path()); + let [annot] = found.as_slice() else { panic!("one 3D annotation: {found:?}") }; + let rect: Vec = annot.get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect(); + assert_eq!(rect, [100.0, 400.0, 400.0, 700.0]); + assert_eq!(annot.get(b"Contents").and_then(Object::as_string).map(|s| s.to_text()).as_deref(), Some("bracket")); + let model = stream(&saved, annot.get(b"3DD").unwrap()); + assert_eq!(model.dict.name(b"Type"), Some(&b"3D"[..])); + assert_eq!(model.dict.name(b"Subtype"), Some(&b"PRC"[..])); + assert_eq!(model.dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert_eq!(model.decoded().unwrap().to_vec(), prc, "the model is stored unchanged"); + let activation = saved.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().clone(); + assert_eq!(activation.name(b"A"), Some(&b"PO"[..])); + // The 80 × 40 picture fitted in the 300 × 300 box: 300 × 150, centred top to bottom. + let ap = saved.resolve(annot.get(b"AP").unwrap()).as_dict().unwrap().clone(); + let poster = stream(&saved, ap.get(b"N").unwrap()); + let content = String::from_utf8(poster.decoded().unwrap().to_vec()).unwrap(); + assert!(content.contains("300 0 0 150 100 475 cm /Pic Do"), "{content}"); + + assert_eq!(tool(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add 3D model"); + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + assert!(saved_3d(dir.path()).1.is_empty(), "undo removes it"); +} + +#[test] +fn model_add_without_a_poster_draws_a_labelled_frame_and_activates_on_click() { + let dir = tempfile::tempdir().unwrap(); + let mut u3d = b"U3D\0".to_vec(); + u3d.extend_from_slice(&[0; 60]); + std::fs::write(dir.path().join("part.u3d"), &u3d).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap(); + tool(&mut a, "model_add", json!({ "doc": doc, "page": 1, "path": "part.u3d", "rect": [50, 50, 250, 200], "name": "Part" })); + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + + let (saved, found) = saved_3d(dir.path()); + let [annot] = found.as_slice() else { panic!("one 3D annotation: {found:?}") }; + assert_eq!(stream(&saved, annot.get(b"3DD").unwrap()).dict.name(b"Subtype"), Some(&b"U3D"[..])); + let activation = saved.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().clone(); + assert_eq!(activation.name(b"A"), Some(&b"XA"[..]), "the standard's default, stated"); + let ap = saved.resolve(annot.get(b"AP").unwrap()).as_dict().unwrap().clone(); + let content = String::from_utf8(stream(&saved, ap.get(b"N").unwrap()).decoded().unwrap().to_vec()).unwrap(); + assert!(content.contains("re S") && content.contains("(3D model) Tj"), "{content}"); +} + +#[test] +fn model_add_refuses_what_is_not_a_model_and_adds_nothing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("notes.txt"), b"PR not a model").unwrap(); + std::fs::write(dir.path().join("part.prc"), b"PRC....").unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap(); + let not_a_model = a.call("model_add", &json!({ "doc": doc, "page": 1, "path": "notes.txt", "rect": [10, 10, 100, 100] })); + assert!(matches!(¬_a_model, Err(ToolError::Failed(m)) if m.contains("U3D") && m.contains("PRC")), "{not_a_model:?}"); + for (args, why) in [ + (json!({ "rect": [10, 10, 10, 100] }), "zero width"), + (json!({ "rect": [0, 0, 20000, 100] }), "wider than a PDF page can be"), + (json!({}), "no rect"), + (json!({ "rect": [10, 10, 100, 100], "activation": "now" }), "an unknown activation"), + ] { + let mut call = json!({ "doc": doc, "page": 1, "path": "part.prc" }); + call.as_object_mut().unwrap().extend(args.as_object().unwrap().clone()); + assert!(matches!(a.call("model_add", &call), Err(ToolError::InvalidArgs(_))), "{why}"); + } + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + assert!(saved_3d(dir.path()).1.is_empty()); +} diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 8f43689aa9..60d2c46a3d 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -6,7 +6,7 @@ use crate::{ AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; mod labels; @@ -157,6 +157,17 @@ pub enum Edit { file: MarkFile, author: String, }, + /// Rich Media ▸ Add 3D: a U3D or PRC `model` as a 3D annotation in `rect` on `page`, named + /// `name` (its alternate description). Its poster is the picture in `poster` (a PDF page or an + /// image) fitted inside `rect`, keeping its shape and centred, or a labelled frame without one. + AddModel3d { + page: usize, + rect: [f64; 4], + model: Arc>, + poster: Option, + activation: pdfcraft_annot::Activation3d, + name: String, + }, /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. DeleteAnnotation { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 5343804ef3..cb5187fdcc 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -68,6 +68,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Add inserted text", "Add file attachment", "Add stamp", + "Add 3D model", "Delete comment", "Edit comment", "Reply", @@ -205,6 +206,7 @@ impl Edit { Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), Edit::AddCustomStamp { .. } => Label::of("Add stamp"), + Edit::AddModel3d { .. } => Label::of("Add 3D model"), Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), Edit::ReplyToAnnotation { .. } => Label::of("Reply"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 5ed70aed7a..658013775d 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -1,6 +1,6 @@ use super::{ AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; use crate::SourceProblem; use crate::sources::{comment_source, import_pdf_comments}; @@ -49,6 +49,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re Edit::AddMeasurement(_) | Edit::AddAnnotation(_) | Edit::AddCustomStamp { .. } + | Edit::AddModel3d { .. } | Edit::DeleteAnnotation { .. } | Edit::SetAnnotationContents { .. } | Edit::ReplyToAnnotation { .. } @@ -509,6 +510,16 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; } + Edit::AddModel3d { page, rect, model, poster, activation, name } => { + // A file that is not a model is refused before its poster is brought in. + pdfcraft_annot::check_model(model)?; + let poster = match poster { + Some(file) => Some(model_poster(doc, *rect, file)?), + None => None, + }; + let new = pdfcraft_annot::NewModel3d { page: *page, rect: *rect, model, activation: *activation, poster, contents: name }; + pdfcraft_annot::add_3d_model(doc, &new, &cx.meta())?; + } Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, Edit::ReplyToAnnotation { page, index, text, author } => { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index fafa65b4c2..8869e5d1a7 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -54,6 +54,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { Edit::AddMeasurement(_) | Edit::AddAnnotation(_) | Edit::AddCustomStamp { .. } + | Edit::AddModel3d { .. } | Edit::DeleteAnnotation { .. } | Edit::SetAnnotationContents { .. } | Edit::ReplyToAnnotation { .. } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe139164be..4865281fb7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -108,9 +108,10 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr pub use pdfcraft_annot::appearance as annot_text; pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; pub use pdfcraft_annot::{ - AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, - ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, + Activation3d, AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, + Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, }; +pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat}; pub use pdfcraft_forms::detect; pub use pdfcraft_optimize as optimize; pub use pdfcraft_preflight as pdfa; @@ -140,7 +141,7 @@ use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; pub use page_filter::{PageOrientation, PageParity, filter_pages}; pub use pictures::{MarkFile, image_dimensions}; -use pictures::{embed_image, fit_picture, mark_source}; +use pictures::{embed_image, fit_picture, mark_source, model_poster}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs index 49d76f07c0..29d0d47768 100644 --- a/crates/engine/src/pictures.rs +++ b/crates/engine/src/pictures.rs @@ -60,6 +60,15 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res } } +/// A 3D model's poster: the picture in `file` brought into `doc` and fitted inside `rect` +/// (user space), keeping its shape, and centred. It is drawn in user space and not counter-rotated +/// (unlike a stamp), so on a page with `/Rotate` it turns with the page, as the model does. +pub(crate) fn model_poster(doc: &mut pdfcraft_cos::Document, rect: [f64; 4], file: &MarkFile) -> Result { + let src = mark_source(doc, file)?; + let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + Ok(pdfcraft_annot::PosterPicture { xobject: src.xobject, image: src.image, size: (sw, sh), at: fit_picture(rect, sw, sh) }) +} + /// The dimensions of an image from its bytes (JPEG, PNG, TIFF), without decoding the full raster. pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { let format = image::guess_format(bytes).ok()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 25a185e835..7d76cc7c85 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -549,6 +549,66 @@ fn pdf_page_stamps_fit_inside_an_explicit_box() { assert!(content.contains("/Pic Do") && !content.contains("BI"), "{content}"); } +/// #1297: a 3D model on a page keeps the whole box as its rectangle and draws a PDF poster as a +/// form XObject fitted inside it, keeping its shape and centred. +#[test] +fn a_3d_model_gets_a_fitted_vector_poster_and_a_file_that_is_not_one_is_refused() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let blank = session.create_blank(595.0, 842.0, 1).unwrap(); + let id = session.open_new("model.pdf", blank).unwrap(); + let mut prc = b"PRC".to_vec(); + prc.extend_from_slice(&[1; 64]); + let add = |model: Vec| Edit::AddModel3d { + page: 0, + rect: [56.0, 400.0, 539.0, 700.0], + model: Arc::new(model), + poster: Some(MarkFile { name: "poster.pdf".into(), bytes: Arc::new(half_red_page()), page: 0 }), + activation: Activation3d::default(), + name: "Bracket".into(), + }; + assert!(session.apply(id, add(b"%PDF-1.7 not a model".to_vec())).is_err()); + session.apply(id, add(prc)).unwrap(); + + let doc = pdfcraft_cos::Document::open(session.save_bytes(id).unwrap()).unwrap(); + let page = &pdfcraft_model::pages(&doc)[0]; + let annots = doc.resolve(page.dict.get(b"Annots").unwrap()); + let [only] = annots.as_array().unwrap().as_slice() else { panic!("the refused model added nothing: {annots:?}") }; + let annot = doc.get(only.as_ref().unwrap()); + let annot = annot.as_dict().unwrap(); + assert_eq!(annot.name(b"Subtype"), Some(&b"3D"[..])); + let rect: Vec = annot.get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(pdfcraft_cos::Object::as_f64).collect(); + assert_eq!(rect, [56.0, 400.0, 539.0, 700.0], "the whole box, not the fitted picture"); + assert_eq!(doc.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().name(b"A"), Some(&b"XA"[..])); + let ap = doc.resolve(annot.get(b"AP").unwrap()); + let pdfcraft_cos::Object::Stream(normal) = doc.get(ap.as_dict().unwrap().reference(b"N").unwrap()).as_ref().clone() else { + panic!("an appearance stream") + }; + let pic = doc.resolve(normal.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let pic = doc.resolve(pic.get(b"XObject").unwrap()).as_dict().unwrap().reference(b"Pic").unwrap(); + let pdfcraft_cos::Object::Stream(picture) = doc.get(pic).as_ref().clone() else { panic!("the picture is a stream") }; + assert_eq!(picture.dict.name(b"Subtype"), Some(&b"Form"[..]), "the page is kept as a form XObject"); + // The 80 × 40 page in the 483 × 300 box: 483 × 241.5 from y 429.25. + let content = String::from_utf8(normal.decoded().unwrap().to_vec()).unwrap(); + assert!(content.contains(" 56 429.25 cm /Pic Do"), "{content}"); +} + +/// Adding a 3D model is a comment change: refused without the comments permission, and only the +/// comments are rebuilt after it. +#[test] +fn a_3d_model_needs_the_comments_permission_and_changes_the_comments() { + let edit = Edit::AddModel3d { + page: 0, + rect: [0.0, 0.0, 100.0, 100.0], + model: Arc::new(b"PRC rest".to_vec()), + poster: None, + activation: Activation3d::default(), + name: String::new(), + }; + let p = pdfcraft_cos::Permissions { bits: 0, owner: false }; + assert_eq!(check_permission(&edit, &p), Err(EditError::NotPermitted("comments"))); + assert_eq!(crate::history::scope_of(&edit), crate::history::Scope::Comments); +} + fn page_texts(s: &Session, id: DocId) -> Vec { let doc = s.get(id).unwrap(); let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 290a410e14..714599338c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9896,6 +9896,18 @@ tools = [] evidence = [] notes = "" +[[feature]] +id = "misc.3d-add" +title = "Add a 3D model (U3D/PRC) to a page" +area = "N" +tier = "P3" +milestone = "M12" +status = "partial" +commands = [] +tools = ["model_add"] +evidence = ["crates/automation/tests/it/model_3d.rs::model_add_embeds_the_model_with_a_fitted_poster", "crates/automation/tests/it/model_3d.rs::model_add_without_a_poster_draws_a_labelled_frame_and_activates_on_click", "crates/automation/tests/it/model_3d.rs::model_add_refuses_what_is_not_a_model_and_adds_nothing", "crates/engine/src/tests.rs::a_3d_model_gets_a_fitted_vector_poster_and_a_file_that_is_not_one_is_refused", "crates/annot/src/tests.rs::a_3d_model_raises_an_older_document_to_its_version"] +notes = "Embeds a U3D or PRC file (detected from its header) as a 3D annotation with an activation choice and a poster: a chosen image or PDF page fitted in the box, or a labelled frame. Not yet: a poster rendered from the model, views (/VA, /3DV), replacing the poster later, a UI command. The poster is drawn in user space and not counter-rotated, so on a page with /Rotate it turns with the page, as the model does. An older file's catalog /Version is raised to 1.6 (U3D) or 1.7 (PRC); Adobe's extension level for PRC in 1.7 is not written." + [[feature]] id = "misc.legacy-multimedia" title = "Legacy multimedia (screen annotations, renditions)" From a0dfbecd4c897d8c0857e50fe905833da71a3586 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 22:16:00 +0100 Subject: [PATCH 43/63] fix(ui): a frame's last release is kept past the cap on its edges Found in review (CodeRabbit): `PagePointer` keeps a frame's first eight primary presses and releases, and the camera's click was located at the last release among those. After a stall with more presses and releases than that, the click was located at an earlier release, so the quadrilateral could go to the wrong page. The frame's last release is now read from all its events (`PagePointer::released`), however many came before it. Test: `a_frames_last_release_is_kept_past_the_cap_on_its_edges`, seen failing with the release read from the capped edges. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_pointer.rs | 36 ++++++++++++------- .../ui-egui/src/canvas/page_pointer/tests.rs | 14 ++++++++ crates/ui-egui/src/tools/camera_ui.rs | 2 +- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index f56614411d..38b028de24 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -39,6 +39,9 @@ pub(crate) struct PagePointer { pub origin: Option, /// This frame's primary presses and releases of a press the pages hold, in order. pub edges: Vec, + /// Where this frame's last primary release of a press the pages hold was: where a click came + /// up, which the pointer may have left by the frame's end. Kept past the cap on `edges`. + pub released: Option, /// The primary button is down, wherever it was pressed: a gesture ends once it is up. pub primary_down: bool, /// Some button is down, wherever it was pressed. @@ -49,8 +52,16 @@ impl PagePointer { /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`, while /// a dialog is open over the window or not (`modal`). pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response, modal: bool) -> Self { - let (at, origin, edges, primary_down, any_down) = - ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events), i.pointer.primary_down(), i.pointer.any_down())); + let (at, origin, edges, released, primary_down, any_down) = ui.input(|i| { + ( + i.pointer.interact_pos(), + i.pointer.press_origin(), + edges(&i.events), + last_release(&i.events), + i.pointer.primary_down(), + i.pointer.any_down(), + ) + }); // egui's own answer to "do the pages hold a press?": from the press on until the frame // of its click, release or drag stop. A drag can outlive egui's press while the // pointer is outside the window. With a dialog open only a drag counts, up to the frame @@ -64,9 +75,9 @@ impl PagePointer { resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p)))); let pos = at.filter(|p| held || on_top(p)); if !held { - return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down }; + return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down }; } - Self { pos, origin: press_origin, edges, primary_down, any_down } + Self { pos, origin: press_origin, edges, released, primary_down, any_down } } /// Where the pointer is, when it is inside `r`. @@ -78,15 +89,6 @@ impl PagePointer { pub(crate) fn pressed(&self) -> Option { first_press(&self.edges) } - - /// Where this frame's last primary release was: where a click came up, which the pointer may - /// have left by the frame's end. - pub(crate) fn released(&self) -> Option { - self.edges.iter().rev().find_map(|e| match e { - Edge::Release(p) => Some(*p), - Edge::Press(_) => None, - }) - } } /// Whether egui gives the drag to a widget that is not the pages' (a panel's tab, a floating panel's @@ -106,6 +108,14 @@ fn first_press(edges: &[Edge]) -> Option { }) } +/// Where the last primary release among `events` was, however many came before it. +fn last_release(events: &[Event]) -> Option { + events.iter().rev().find_map(|e| match e { + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, .. } if pos.is_finite() => Some(*pos), + _ => None, + }) +} + /// The first [`MAX_EDGES`] primary presses and releases among `events`, in order. fn edges(events: &[Event]) -> Vec { events diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index f3c0952527..3f5d1e105f 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -53,6 +53,20 @@ fn a_touch_tap_keeps_its_position_on_the_frame_it_lifts() { assert_eq!(frame(&ctx, vec![], false), PagePointer::default(), "and forget it the frame after"); } +/// A click is located where it came up even after a stall's many presses and releases, past the +/// cap on a frame's edges. Read from the edges kept, it was an earlier release's. +#[test] +fn a_frames_last_release_is_kept_past_the_cap_on_its_edges() { + let ctx = Context::default(); + let (p, q) = (pos2(50.0, 60.0), pos2(80.0, 90.0)); + frame(&ctx, vec![Event::PointerMoved(p)], false); + let mut events: Vec = (0..2 * MAX_EDGES).map(|i| button(p, PointerButton::Primary, i % 2 == 0)).collect(); + events.extend([button(q, PointerButton::Primary, true), button(q, PointerButton::Primary, false)]); + let stall = frame(&ctx, events, false); + assert_eq!(stall.edges.len(), MAX_EDGES); + assert_eq!(stall.released, Some(q)); +} + #[test] fn a_press_and_release_in_one_frame_keep_the_press() { let ctx = Context::default(); diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index 99b4356a26..817d77bb9f 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -101,7 +101,7 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe // A press nobody holds on a page without the quadrilateral moves it there: a drag from where it // began, a click where it came up (egui has cleared the press's origin by its release, and the // pointer may have moved on in the same frame). - let at = if resp.clicked() { ptr.released() } else { origin }; + let at = if resp.clicked() { ptr.released } else { origin }; if (resp.drag_started() || resp.clicked()) && press == PagePress::Free && at.is_some_and(|p| xf.rect.contains(p)) From 9da253ebee1fc10a6f7cc6551119527cffe506fc Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 17:24:42 -0500 Subject: [PATCH 44/63] fix(annot): a 3D poster fitted to its rectangle with rounding is taken A poster box fitted to the annotation rectangle in floating point can end a few ulps outside it (a 7x3 picture centred in 0 100 3.6 110.8 spans -2.2e-16 to 3.6000000000000005), and the exact containment check refused it. The check now allows a slack of 1e-9 of the rectangle's largest coordinate, clamps the box to the rectangle, and only then requires it to have width and height, so a box wholly within the slack outside the rectangle is still refused. Tests: a_3d_poster_fitted_to_its_rectangle_with_rounding_is_taken fails without the slack (Invalid); the new case in a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused fails when the size check runs before the clamp. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/annot/src/tests.rs | 20 ++++++++++++++++++++ crates/annot/src/threed.rs | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index c31ef19b17..aae69ddb7a 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2296,6 +2296,7 @@ fn a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused() { (picture([50.0, 100.0, 250.0, 300.0], (10.0, 10.0)), "starts left of the rectangle"), (picture([100.0, 100.0, 300.0, 301.0], (10.0, 10.0)), "ends above it"), (picture([100.0, 100.0, 300.0, 300.0], (0.0, 10.0)), "has no width"), + (picture([100.0 - 2e-7, 150.0, 100.0 - 1e-7, 200.0], (10.0, 10.0)), "lies outside within the rounding slack"), ] { let mut doc = fixture(); let new = NewModel3d { @@ -2310,3 +2311,22 @@ fn a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused() { assert_eq!(list(&doc, 0).len(), 1, "{why}: nothing added"); } } + +/// A poster box fitted to the rectangle in floating point ends an ulp outside it: a 7×3 picture +/// centred in [0 100 3.6 110.8] spans -2.2e-16 to 3.6000000000000005. It is taken, inside the +/// rectangle. +#[test] +fn a_3d_poster_fitted_to_its_rectangle_with_rounding_is_taken() { + let mut doc = fixture(); + let at = [-2.220446049250313e-16, 104.62857142857143, 3.6000000000000005, 106.17142857142858]; + let poster = PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size: (7.0, 3.0), at }; + let new = NewModel3d { + page: 0, + rect: [0.0, 100.0, 3.6, 110.8], + model: b"PRC rest", + activation: Activation3d::default(), + poster: Some(poster), + contents: "", + }; + assert_eq!(add_3d_model(&mut doc, &new, &meta("m")), Ok(1)); +} diff --git a/crates/annot/src/threed.rs b/crates/annot/src/threed.rs index be632337cb..cea6568bda 100644 --- a/crates/annot/src/threed.rs +++ b/crates/annot/src/threed.rs @@ -199,8 +199,11 @@ fn annotation_dict(new: &NewModel3d<'_>, rect: [f64; 4], page: ObjRef, stream: O /// The poster drawing `p` into its box, which must lie within `rect`. fn picture_poster(rect: [f64; 4], p: &PosterPicture) -> Result { let at = normalize(p.at); - let inside = finite(&p.at) && at[0] >= rect[0] && at[1] >= rect[1] && at[2] <= rect[2] && at[3] <= rect[3] && at[2] > at[0] && at[3] > at[1]; - if !inside { + // A box fitted to `rect` in floating point can end a few ulps outside it: allow that, then clamp. + let slack = rect.iter().fold(1.0_f64, |m, v| m.max(v.abs())) * 1e-9; + let near = finite(&p.at) && at[0] >= rect[0] - slack && at[1] >= rect[1] - slack && at[2] <= rect[2] + slack && at[3] <= rect[3] + slack; + let at = [at[0].max(rect[0]), at[1].max(rect[1]), at[2].min(rect[2]), at[3].min(rect[3])]; + if !(near && at[2] > at[0] && at[3] > at[1]) { return Err(AnnotError::Invalid("the poster must fit inside the 3D model's rectangle".into())); } let place = appearance::picture_place(at, p.image, Some(p.size)).ok_or_else(|| AnnotError::Invalid("the poster picture has no size".into()))?; From 4c087336aa68fdeedc9f71602ee95fa79d734b2a Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 18:45:41 -0500 Subject: [PATCH 45/63] M12: 3D models: declare PRC's Adobe extension before 2.0; refuse a poster with no area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before PDF 2.0, PRC in a 3D stream is an Adobe extension to PDF 1.7, so a PRC model added to a file below 2.0 now declares /Extensions /ADBE at BaseVersion 1.7, ExtensionLevel 1, keeping other extensions and a later ADBE level, and leaving an /ADBE that is not a dictionary (an array of them) alone (ISO 32000-2 §7.12). U3D and 2.0 files are unchanged. A PDF poster whose page box has no area was drawn as nothing behind a size forced to 1; it is now refused with EditError::Source. Both found by CodeRabbit. Tests a_prc_model_declares_the_adobe_extension_before_pdf_2 and a_3d_poster_page_without_area_is_refused fail with their fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/annot/src/tests.rs | 55 +++++++++++++++++++++++++++++++++++ crates/annot/src/threed.rs | 41 ++++++++++++++++++++++---- crates/engine/src/pictures.rs | 5 +++- crates/engine/src/tests.rs | 19 ++++++++++++ 4 files changed, 114 insertions(+), 6 deletions(-) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index aae69ddb7a..32a04d201f 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2278,6 +2278,61 @@ fn a_3d_model_raises_an_older_document_to_its_version() { assert_eq!(catalog_version(&reopen(&doc)), None, "a 2.0 file takes PRC as it is"); } +/// Before 2.0, PRC is an Adobe extension to PDF 1.7 (§7.12): a PRC model declares it, keeping +/// other extensions and a later ADBE level; U3D and a 2.0 file need nothing. +#[test] +fn a_prc_model_declares_the_adobe_extension_before_pdf_2() { + let adbe = |doc: &Document| { + let root = doc.root().unwrap(); + let ext = doc.get(root).as_dict().unwrap().get(b"Extensions").map(|e| doc.resolve(e).as_dict().unwrap().clone()); + ext.map(|e| { + let adbe = e.get(b"ADBE").map(|a| a.as_dict().unwrap().clone()); + (e.get(b"XYZ").is_some(), adbe.map(|a| (a.name(b"BaseVersion").map(<[u8]>::to_vec), a.get(b"ExtensionLevel").and_then(Object::as_int)))) + }) + }; + let add = |doc: &mut Document, model: &[u8]| { + let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" }; + add_3d_model(doc, &new, &meta("m")).unwrap(); + }; + let mut doc = fixture(); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), Some((false, Some((Some(b"1.7".to_vec()), Some(1)))))); + let mut doc = fixture(); + add(&mut doc, b"U3Drest"); + assert_eq!(adbe(&reopen(&doc)), None, "U3D is in ISO 32000-1"); + let mut doc = fixture(); + doc.set_version("2.0"); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), None, "PRC is in ISO 32000-2"); + + let mut doc = fixture(); + let root = doc.root().unwrap(); + let mut level3 = Dict::new(); + level3.set(b"BaseVersion".to_vec(), Object::name("1.7")); + level3.set(b"ExtensionLevel".to_vec(), Object::Int(3)); + let mut ext = Dict::new(); + ext.set(b"ADBE".to_vec(), Object::Dict(level3)); + ext.set(b"XYZ".to_vec(), Object::Dict(Dict::new())); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext.clone()))).unwrap(); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(3))))), "a later level is kept"); + ext.remove(b"ADBE"); + let mut doc = fixture(); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext))).unwrap(); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(1))))), "other extensions are kept"); + + let mut doc = fixture(); + let mut ext = Dict::new(); + ext.set(b"ADBE".to_vec(), Object::Array(vec![Object::Dict(Dict::new())])); + let ext = doc.add(Object::Dict(ext)); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Ref(ext))).unwrap(); + add(&mut doc, b"PRC rest"); + let doc = reopen(&doc); + let ext = doc.resolve(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Extensions").unwrap()); + assert!(ext.as_dict().unwrap().get(b"ADBE").unwrap().as_array().is_some(), "an array of ADBE extensions is left alone"); +} + #[test] fn a_picture_is_placed_by_its_box_and_a_form_needs_a_positive_size() { let at = [10.0, 20.0, 110.0, 70.0]; diff --git a/crates/annot/src/threed.rs b/crates/annot/src/threed.rs index cea6568bda..cf828388c0 100644 --- a/crates/annot/src/threed.rs +++ b/crates/annot/src/threed.rs @@ -46,7 +46,7 @@ impl ModelFormat { } /// The PDF version a document holding it needs (§13.6): a 3D annotation is 1.6, a PRC stream - /// 1.7 (with Adobe's extension level 1, which is not written; PDF 2.0 takes PRC as standard). + /// 1.7 under Adobe's extension, declared by [`add_3d_model`] (PDF 2.0 takes PRC as standard). pub fn version(self) -> &'static str { match self { Self::U3d => "1.6", @@ -154,17 +154,48 @@ pub fn add_3d_model(doc: &mut Document, new: &NewModel3d<'_>, meta: &Meta) -> Re list.push(Object::Ref(r)); set_annots(doc, page, list)?; at_least_version(doc, format.version())?; + if format == ModelFormat::Prc && !at_least(doc, "2.0") { + declare_prc_extension(doc)?; + } Ok(index) } +/// Whether the header or the catalog `/Version` says `min` or later (§7.7.2). +fn at_least(doc: &Document, min: &str) -> bool { + let reached = |v: &[u8]| v.len() >= 3 && v >= min.as_bytes(); + let catalog = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned())); + let catalog = catalog.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec)); + reached(doc.version().as_bytes()) || catalog.as_deref().is_some_and(reached) +} + +/// PRC came before PDF 2.0 as an Adobe extension to 1.7: such a file declares +/// `/Extensions /ADBE` with ExtensionLevel 1 or later (§7.12). Other extensions and an ADBE entry +/// that already has a level are kept; one that is not a dictionary (an array of them) is left alone. +fn declare_prc_extension(doc: &mut Document) -> Result<(), AnnotError> { + let Some(root) = doc.root() else { return Ok(()) }; + let catalog = doc.get(root); + // `None` when present but not a dictionary; an empty one when absent. + let dict_or_new = |o: Option<&Object>| match o.map(|o| doc.resolve(o)) { + None => Some(Dict::new()), + Some(o) => o.as_dict().cloned(), + }; + let Some(mut extensions) = dict_or_new(catalog.as_dict().and_then(|c| c.get(b"Extensions"))) else { return Ok(()) }; + let Some(mut adbe) = dict_or_new(extensions.get(b"ADBE")) else { return Ok(()) }; + if adbe.get(b"ExtensionLevel").map(|l| doc.resolve(l)).and_then(|l| l.as_int()).is_some_and(|l| l >= 1) { + return Ok(()); + } + adbe.set(b"BaseVersion".to_vec(), Object::name("1.7")); + adbe.set(b"ExtensionLevel".to_vec(), Object::Int(1)); + extensions.set(b"ADBE".to_vec(), Object::Dict(adbe)); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(extensions)))?; + Ok(()) +} + /// A document whose header and catalog `/Version` both say less than `min` gets `/Version min` in /// its catalog (§7.7.2: the later of the two applies). fn at_least_version(doc: &mut Document, min: &str) -> Result<(), AnnotError> { - let below = |v: &[u8]| v.len() < 3 || v < min.as_bytes(); let Some(root) = doc.root() else { return Ok(()) }; - let catalog = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()); - let catalog = catalog.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec)); - if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) { + if !at_least(doc, min) { doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name(min)))?; } Ok(()) diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs index 29d0d47768..d8b8835dc7 100644 --- a/crates/engine/src/pictures.rs +++ b/crates/engine/src/pictures.rs @@ -65,7 +65,10 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res /// (unlike a stamp), so on a page with `/Rotate` it turns with the page, as the model does. pub(crate) fn model_poster(doc: &mut pdfcraft_cos::Document, rect: [f64; 4], file: &MarkFile) -> Result { let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let (sw, sh) = src.size; + if !(sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0) { + return Err(EditError::Source(format!("{}: the poster has no area", file.name))); + } Ok(pdfcraft_annot::PosterPicture { xobject: src.xobject, image: src.image, size: (sw, sh), at: fit_picture(rect, sw, sh) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7d76cc7c85..eee46c7b3e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -592,6 +592,25 @@ fn a_3d_model_gets_a_fitted_vector_poster_and_a_file_that_is_not_one_is_refused( assert!(content.contains(" 56 429.25 cm /Pic Do"), "{content}"); } +/// A PDF poster whose page has no area is refused rather than drawn as nothing. +#[test] +fn a_3d_poster_page_without_area_is_refused() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let blank = session.create_blank(595.0, 842.0, 1).unwrap(); + let id = session.open_new("model.pdf", blank).unwrap(); + let flat = String::from_utf8(half_red_page()).unwrap().replace("[0 0 80 40]", "[0 0 80 0 ]"); + let add = Edit::AddModel3d { + page: 0, + rect: [56.0, 400.0, 539.0, 700.0], + model: Arc::new(b"PRC rest".to_vec()), + poster: Some(MarkFile { name: "flat.pdf".into(), bytes: Arc::new(flat.into_bytes()), page: 0 }), + activation: Activation3d::default(), + name: "Bracket".into(), + }; + assert!(matches!(session.apply(id, add), Err(EditError::Source(_)))); + assert!(session.get(id).unwrap().info.annotations.is_empty(), "nothing was added"); +} + /// Adding a 3D model is a comment change: refused without the comments permission, and only the /// comments are rebuilt after it. #[test] From eaf06f9cb9a91a5d283c5f6dc58c746d3d957efa Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 20:28:06 -0500 Subject: [PATCH 46/63] fix(cos): a save after undo keeps objects packed in a dropped object stream When the working snapshot is restored from an earlier revision (undo after a save), the next save appends to the last saved file through overlay_changes_from. An object stream of that file that the restored graph frees or reuses the number of took its unchanged members with it: they stayed listed in the dropped stream, and the file reread with no pages. Such members are now written again on their own. Adding a PRC model now edits the catalog (the ADBE extension), which made the model_3d automation test (save, undo, save) reach this case on CI. The same change is part of #3305 (skylartaylor), which also covers security changes. Test: cos a_restored_revision_keeps_what_the_saved_file_held_in_object_streams, failing with the fix reverted (the reread catalog's /Pages resolves to nothing). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/cos/src/document/objects.rs | 21 +++++++++++++++++++-- crates/cos/src/document/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 2ac18cff03..3fba3fd395 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -682,8 +682,9 @@ impl Document { /// /// Used when the working snapshot was restored from an earlier revision and the next /// incremental save must still append to this file (the last saved revision). Objects - /// that already match are left untouched. Reads go through private readers so neither - /// document's editing cache is filled. + /// that already match are left untouched, except members of an object stream of this file + /// whose stream number `current` frees or reuses: those are written again on their own. + /// Reads go through private readers so neither document's editing cache is filled. pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> { let saved_nums = self.object_numbers(); let current_nums = current.object_numbers(); @@ -707,6 +708,22 @@ impl Document { } } } + // An object stream of this file that is freed or replaced takes its members with it, so + // a member that is otherwise unchanged is written again on its own. + let gone = |n: u32| freed.binary_search(&n).is_ok() || changed.binary_search(&n).is_ok(); + let orphaned: Vec = current_nums + .iter() + .copied() + .filter(|&num| changed.binary_search(&num).is_err()) + .filter(|&num| { + let packed_in = match self.xref_entry(num) { + Some(XrefEntry::InStream { stream, .. }) => Some(stream), + _ => None, + }; + packed_in.is_some_and(gone) + }) + .collect(); + changed.extend(orphaned); for num in freed { let generation = self.generation(num); self.free(ObjRef::new(num, generation)); diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index cca4b87326..044a410862 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1701,3 +1701,29 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() { }); rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially"); } + +#[test] +fn a_restored_revision_keeps_what_the_saved_file_held_in_object_streams() { + // The earlier revision: an empty document read back from a file whose catalog and pages + // sit in an object stream. + let earlier = Document::open(Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap())).unwrap(); + // The saved revision adds an object the catalog links to, so a full save numbers its objects + // anew; an undo then returns to the earlier graph. + let mut later = earlier.clone(); + let extra = later.add(Object::Int(7)); + let root = later.root().unwrap(); + later.update_dict(root, |c| c.set(b"Extra".to_vec(), Object::Ref(extra))).unwrap(); + let saved = crate::write_full(&later, &crate::SaveOptions::default()).unwrap(); + let mut base = Document::open(Arc::new(saved)).unwrap(); + assert!( + base.object_numbers().iter().any(|&n| matches!(base.xref_entry(n), Some(XrefEntry::InStream { .. }))), + "the saved file holds objects in a stream" + ); + base.overlay_changes_from(&earlier).unwrap(); + let file = crate::write_incremental(&base, &crate::SaveOptions::default()).unwrap(); + let reread = Document::open(Arc::new(file)).unwrap(); + let catalog = reread.get(reread.root().unwrap()).as_dict().unwrap().clone(); + assert!(catalog.get(b"Extra").is_none(), "the restored catalog"); + let pages = reread.resolve(catalog.get(b"Pages").unwrap()); + assert_eq!(pages.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Pages"[..]), "the pages survive: {pages:?}"); +} From e69d1221ff4bc588d28edc4afe30ff7cfe85191f Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 14:41:30 +0100 Subject: [PATCH 47/63] fix(ui): a right-click between the pages deselects the comment; Paste keeps to the picture's page Two of CodeRabbit's findings on #5803. A right-click off the pages cleared only the menu's anchor and kept the selected comment, so the menu that opened was that comment's, not the current page's. A right-click on a page's empty part deselects it, so one that no page takes (`PageFrame.right_clicked`) now does the same. A selected Prepare field keeps its menu off the pages, as it does on a page's empty part; the comment there says so. Where pages touch, the first page takes a right-click on the row they share and anchors the menu there, but a picture on the second page under it still opens its menu. Paste from that menu centred the image on page 1's point, which on page 2 is at its foot. The image menu now uses the anchor only on the picture's own page, and otherwise pastes as Ctrl+V does. Tests, each seen failing with its fix reverted: `a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments`, `paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 24 ++++++++++-- crates/ui-egui/tests/it/pointer.rs | 60 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5b22a38956..a1eeeafadb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1662,7 +1662,9 @@ fn draw_pages( // Set again below when the right-click lands on a link. view.links.context = None; // Set again below by the page right-clicked: a right-click off the pages (between them, - // beside one) anchors no page's menu, which then acts on the current page. + // beside one) anchors no page's menu, which then acts on the current page. It deselects the + // comment too (below), as one on a page's empty part does. A selected Prepare field keeps + // its menu, off the pages as on them. view.comments.context_at = None; let mut over_image = false; if tool == QuickTool::EditText { @@ -1734,8 +1736,22 @@ fn draw_pages( draw_page(ui, view, &mut frame, i); } let PageFrame { - wanted, visible_now, mut current, best_overlap, current_overlap, current_height, pressed_page, calc_hover, out: page_out, .. + wanted, + visible_now, + mut current, + best_overlap, + current_overlap, + current_height, + pressed_page, + calc_hover, + out: page_out, + right_clicked, + .. } = frame; + // A right-click no page took deselects the comment, so the page's menu opens, not the comment's. + if resp.secondary_clicked() && !right_clicked { + view.comments.selected = None; + } let PageOut { mut canvas_action, clicked_link, @@ -1905,7 +1921,9 @@ fn draw_pages( })); if is_image_menu && let Some(sel) = view.image_selection.clone() { let images = cached_edit_images(view, doc, sel.page).to_vec(); - let at = view.comments.context_at.map(|(_, at)| at); + // Paste lands where the menu opened only on the picture's page: where pages touch, the + // first page takes the right-click, and its point means nothing on the second. + let at = view.comments.context_at.filter(|(page, _)| *page == sel.page).map(|(_, at)| at); let rotation = (view.rotation % 360 + info.pages.get(sel.page).map_or(0, |p| p.rotation % 360)) % 360; crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation); return; diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index d01a74cff0..50801d903f 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1726,6 +1726,66 @@ fn a_right_click_between_the_pages_anchors_no_pages_menu() { assert_eq!(h.state().views[0].comments.context_at, None); } +/// Two 300×400 pages, a rectangle comment on the first. +fn two_pages_and_a_comment_pdf() -> Vec { + pdf(&[ + CATALOG, + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [30 290 200 320] /C [1 0 0] /F 4 /T (Reviewer) >>", + ]) +} + +/// A right-click between the pages selects no comment, as one on a page's empty part does not, so +/// the menu that opens is the current page's. The selected comment's menu used to open there. +#[test] +fn a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments() { + let mut h = open(two_pages_and_a_comment_pdf(), "100", false); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected"); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + let gap = egui::pos2(one.center().x, (one.max.y + two.min.y) / 2.0); + assert!(!one.contains(gap) && !two.contains(gap), "between the pages: {one:?} {two:?}"); + right_click(&mut h, gap); + assert_eq!(h.state().views[0].comments.selected, None); + assert!(h.query_by_label("Rotate view counterclockwise").is_some(), "the page menu opens"); + assert!(h.query_by_label("Reply").is_none(), "not the comment's"); +} + +/// Where pages touch, a right-click on the row they share is page 1's, and a picture on page 2 there +/// opens its menu, whose Paste then places the image as Ctrl+V does. It used to centre the image on +/// page 1's point, which on page 2 lies at its foot. +#[test] +fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point() { + let (mut h, seam) = touching_pages(|app| { + app.system_clipboard = false; + app.quick_tool = QuickTool::EditText; + }); + let clip = || pdfcraft_ui_egui::Clip::Image { width: 60, height: 40, rgba: [50u8, 150, 250, 255].repeat(60 * 40) }; + h.state_mut().clipboard_override = Some(clip()); + assert!(h.state_mut().paste_image_at(1, [100.0, 400.0]), "a picture across page 2's top edge"); + h.state_mut().clipboard_override = Some(clip()); + h.state_mut().views[0].image_selection = None; + h.run_steps(4); + right_click(&mut h, seam); + assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "page 1 takes the right-click"); + assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.page), Some(1), "page 2's picture opens its menu"); + h.get_by_label("Paste").click(); + h.run_steps(3); + let pasted = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().page_images(1) + }; + assert_eq!(pasted.len(), 2, "the image is pasted on page 2"); + let r = pasted[1].rect; + assert!((r[1] + r[3]) / 2.0 > 100.0, "not centred at page 2's foot: {r:?}"); +} + /// With the camera tool, a click on another page moves the quadrilateral there, as a drag from it /// does. The click was located by its press's origin, which egui has cleared by the release frame, /// so it did nothing (on `main` too). From 01d3479f2680d1a931569a3605f540a6aac3b0dd Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 3 Nov 2026 19:06:56 +0100 Subject: [PATCH 48/63] M2: a JBIG2 region located past i32::MAX is off the page A region's location is unsigned (ISO/IEC 14492 5.4.4), and hayro-jbig2 took it for a signed one. A region at 0xFFFFFFFC was drawn back onto the page four pixels left of it, and a refinement anywhere past i32::MAX, 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. Vendored patch 14 (13 is #5999): Bitmap::combine_region keeps such a location past the page, for the four kinds of region drawn onto it, and a refinement's offset to its reference is taken in 64 bits and held to an i32, which leaves every reference pixel it reads outside the reference, as the exact offset does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/filters/src/jbig2/decode.rs | 51 ++++++++++ vendor/README.md | 2 +- vendor/hayro-jbig2/src/bitmap.rs | 11 +++ .../src/decode/generic_refinement.rs | 53 +++++++---- vendor/hayro-jbig2/src/lib.rs | 93 ++++++++++++++----- 5 files changed, 168 insertions(+), 42 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index a018e75937..6d6759e3ea 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -143,6 +143,57 @@ mod tests { assert_eq!(decode_jbig2(&symbols, None, w, h, usize::MAX).unwrap(), marks); } + /// A region's location is unsigned (ISO/IEC 14492 5.4.4), so one past `i32::MAX` is off the + /// page. The decoder took it for a signed one: a region at 0xFFFFFFFC came back onto the page + /// four pixels left of it, and a refinement anywhere past `i32::MAX` was refused, and the page + /// with it. Each stream is a page of 16 × 12 covered by a generic region, then a region of 8 × 6 + /// of one kind past `i32::MAX`, coded by the standard's procedures, and decodes to that page + /// alone; moved onto the page, the region draws. Vendored hayro-jbig2 patch 14. + #[test] + fn a_region_located_past_i32_max_is_off_the_page() { + const PAGE: &str = "0000000030000100000013000000100000000c00000000000000004200000000000126000100000035000000100000000c0000000000000000040003fffdff02fefefed906aa2cb6ee4ccb4bc975d02669ca7a9c14c20c638f5a5e1bffac"; + let cases = [ + // At 0xFFFFFFFC, 1. + ("generic", "00000002260001000000220000000800000006fffffffc00000001000003fffdff02fefefe17c876ae6f77ffac"), + // At 1, 0xFFFFFFFE, with its symbol dictionary. + ( + "text", + "000000020000010000001e000003fffdff02fefefe00000001000000014fdda14149cec477ed5fffac00000003062002010000001b000000080000000600000001fffffffe00001000000001a943ffac", + ), + // At 0xFFFFFFFC, 1, with its pattern dictionary. + ( + "halftone", + "000000021000010000000a000101000000011fffac00000003162002010000002e0000000800000006fffffffc0000000100000000000800000006000000000000000001000000d09301774485ffac", + ), + // At 0xFFFFFFFC, 1, reading the page there (7.4.7.4): 0s. + ("refinement of the page", "000000022a00010000001e0000000800000006fffffffc000000010400ffffffff0bd98f188428ffac"), + // Of an intermediate region at the same place (7.4.7.5), 1, 0xFFFFFFFE. + ( + "refinement of a region", + "0000000224000100000023000000080000000600000001fffffffe000003fffdff02fefefe3d81322da16b7fffac000000032a2002010000001e000000080000000600000001fffffffe0000ffffffffe29946b82229ffac", + ), + // Of an intermediate refinement of an intermediate region, all at 0xFFFFFFFC, 1. + ( + "refinement of a refinement", + "00000002240001000000230000000800000006fffffffc00000001000003fffdff02fefefecdfd921ec8c47fffac00000003282002010000001e0000000800000006fffffffc000000010000ffffffff903cdb447a47ffac000000042a2003010000001e0000000800000006fffffffc000000010000ffffffffdcd6078cab86ffac", + ), + ]; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + let decode = |segments: &str| decode_jbig2(&[hex(PAGE), hex(segments)].concat(), None, 16, 12, usize::MAX).unwrap(); + let page = decode(""); + assert!(page.iter().any(|&byte| byte != 0xFF), "the page has ink"); + for (kind, segments) in cases { + assert_eq!(decode(segments), page, "{kind}"); + // Moved onto the page, to 4, 1 or 1, 2, the same regions draw: they decode, and are + // left out for where they are. + let (off, on) = [("fffffffc00000001", "0000000400000001"), ("00000001fffffffe", "0000000100000002")] + .into_iter() + .find(|(off, _)| segments.contains(off)) + .unwrap(); + assert_ne!(decode(&segments.replace(off, on)), page, "{kind} on the page"); + } + } + /// What a page costs is the same however much a run has left. Given exactly that it decodes, /// and less stops it: spending all that's left, and what comes after isn't read. A page that /// fails still spends what it took. diff --git a/vendor/README.md b/vendor/README.md index 4fda6483af..c668c77641 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | 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 | 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, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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 | (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` | +| 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; (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, and a refinement's offset to its reference is taken in 64 bits and held to an `i32`, which leaves every reference pixel it reads outside the reference, as the exact offset does | (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`; (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 `decode::generic_refinement::tests::a_reference_offset_is_the_difference_of_unsigned_locations` | | 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; (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 | (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`; (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) | | 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 | diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index 87738a849d..e9049f84bf 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -190,6 +190,17 @@ impl Bitmap { self.data[word_idx] |= (value as Word) << bit_pos; } + /// PdfCraft patch: combine a decoded region into this page at the region's own location. + /// + /// "Unless otherwise specified, all multi-bit values shall be treated as unsigned values" + /// (5.4.4), and a region's location (7.4.1.3, 7.4.1.4) is not specified otherwise. One past + /// `i32::MAX` is past any page, where cast to a signed coordinate it read as left of the page + /// or above it, and came back onto it. + pub(crate) fn combine_region(&mut self, region: &Self, operator: CombinationOperator) { + let signed = |location: u32| i32::try_from(location).unwrap_or(i32::MAX); + self.combine(region, signed(region.x_location), signed(region.y_location), operator); + } + /// Combine another bitmap into this one at a specific location. /// /// "These operators describe how the segment's bitmap is to be combined with diff --git a/vendor/hayro-jbig2/src/decode/generic_refinement.rs b/vendor/hayro-jbig2/src/decode/generic_refinement.rs index a6634439d4..c13d9bae65 100644 --- a/vendor/hayro-jbig2/src/decode/generic_refinement.rs +++ b/vendor/hayro-jbig2/src/decode/generic_refinement.rs @@ -10,7 +10,7 @@ use crate::ScratchBuffers; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; use crate::bitmap::{Bitmap, WORD_BITS, WORD_SHIFT, Word}; use crate::decode::generic::ContextGatherer; -use crate::error::{OverflowError, ParseError, RegionError, Result, bail}; +use crate::error::{ParseError, RegionError, Result, bail}; use crate::reader::Reader; /// Generic refinement region decoding procedure (6.3). @@ -35,6 +35,20 @@ pub(crate) fn decode( }) } +/// PdfCraft patch: the offset of a refinement's reference from the region, from their locations. +/// +/// A location is unsigned (5.4.4), so one past `i32::MAX` is a place off the page, not an +/// overflow: a refinement there, of a region at the same place (7.4.7.5) or of the page, was +/// refused, and the rest of the stream with it. Both bitmaps are at most 65,535 pixels a side, so +/// an offset of `i32::MAX` either way already puts every reference pixel the decoder reads outside +/// the reference, where they are 0 (6.3.5.2), as any larger offset would. +fn reference_offset(reference: u32, region: u32) -> i32 { + let limit = i64::from(i32::MAX); + let offset = (i64::from(reference) - i64::from(region)).clamp(-limit, limit); + // Provably in range after the clamp. + i32::try_from(offset).unwrap_or(0) +} + /// Decode a generic refinement region directly into an existing bitmap. pub(crate) fn decode_into( header: &GenericRefinementRegionHeader<'_>, @@ -51,22 +65,8 @@ pub(crate) fn decode_into( bail!(RegionError::InvalidDimension); } - let reference_dx = i32::try_from(reference.x_location) - .ok() - .and_then(|r: i32| { - i32::try_from(header.region_info.x_location) - .ok() - .and_then(|h| r.checked_sub(h)) - }) - .ok_or(OverflowError::ReferenceOffset)?; - let reference_dy = i32::try_from(reference.y_location) - .ok() - .and_then(|r: i32| { - i32::try_from(header.region_info.y_location) - .ok() - .and_then(|h| r.checked_sub(h)) - }) - .ok_or(OverflowError::ReferenceOffset)?; + let reference_dx = reference_offset(reference.x_location, header.region_info.x_location); + let reference_dy = reference_offset(reference.y_location, header.region_info.y_location); let mut decoder = ArithmeticDecoder::new(data); let num_context_bits = header.template.context_bits(); @@ -509,3 +509,22 @@ pub(crate) fn decode_bitmap( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// PdfCraft patch: the reference's offset is the difference of the locations, which past + /// `i32::MAX` are places off the page: equal ones give 0, as a refinement of a region has them + /// (7.4.7.5), and one too far for an `i32` is held to `i32::MAX`, either way. + #[test] + fn a_reference_offset_is_the_difference_of_unsigned_locations() { + assert_eq!(reference_offset(5, 3), 2); + assert_eq!(reference_offset(3, 5), -2); + assert_eq!(reference_offset(0x9000_0000, 0x9000_0000), 0, "a region and its refinement off the page"); + assert_eq!(reference_offset(0, 0xFFFF_FFFC), -i32::MAX, "the page and a refinement of it off the page"); + assert_eq!(reference_offset(u32::MAX, 0), i32::MAX); + assert_eq!(reference_offset(0x7FFF_FFFF, 0), i32::MAX, "in range"); + assert_eq!(reference_offset(0, 0x7FFF_FFFF), -i32::MAX, "in range"); + } +} diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index 2ee0f8f050..5dd81bc6c0 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -356,12 +356,7 @@ fn decode_segments( generic::decode_into(&header, page_bitmap, scratch_buffers)?; } else { let region = generic::decode(&header, scratch_buffers)?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -460,12 +455,7 @@ fn decode_segments( scratch_buffers, work_left, )?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -518,12 +508,7 @@ fn decode_segments( halftone::decode_into(&header, pattern_dict, page_bitmap, scratch_buffers)?; } else { let region = halftone::decode(&header, pattern_dict, scratch_buffers)?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -582,12 +567,7 @@ fn decode_segments( } else { let reference = referred_segment.unwrap_or(page_bitmap); let region = generic_refinement::decode(&header, reference, scratch_buffers)?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -833,6 +813,71 @@ mod tests { assert!(Image::new_embedded(&stream(1 << 20, 1 << 20), None).is_err()); } + /// PdfCraft patch: a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the + /// page. Taken for a signed one, a region at 0xFFFFFFFE drew back onto the page two pixels left + /// of it, and a refinement there was refused, and the rest of the stream with it. Each region + /// here is 4 × 4 on a blank page of 8 × 8, its coded data arbitrary: on the page it draws, off + /// it nothing. + #[test] + fn a_region_located_past_i32_max_is_off_the_page() { + struct Ink(u32); + impl Decoder for Ink { + fn push_pixel(&mut self, black: bool) { + self.0 += u32::from(black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.0 += if black { 8 * chunks } else { 0 }; + } + fn next_line(&mut self) {} + } + let segment = |number: u8, kind: u8, refers_to: &[u8], data: &[u8]| { + let mut segment = vec![0, 0, 0, number, kind, (refers_to.len() as u8) << 5]; + segment.extend_from_slice(refers_to); + segment.push(1); // the page it belongs to + segment.extend_from_slice(&(data.len() as u32).to_be_bytes()); + segment.extend_from_slice(data); + segment + }; + // Region information (7.4.1) of 4 × 4 at `x`, `y`, then the region's own header, then + // arbitrary coded data. + let region = |x: u32, y: u32, operator: u8, header: &[u8]| { + let mut data: Vec = [4u32, 4, x, y].iter().flat_map(|v| v.to_be_bytes()).collect(); + data.push(operator); + data.extend_from_slice(header); + data.extend_from_slice(&[0x5a; 16]); + data + }; + const GENERIC: &[u8] = &[0, 3, 0xFF, 0xFD, 0xFF, 2, 0xFE, 0xFE, 0xFE]; // template 0, AT + const REFINEMENT: &[u8] = &[0, 0xFF, 0xFF, 0xFF, 0xFF]; // template 0, AT + let (or, replace) = (0, 4); + let ink = |x: u32, y: u32| { + // 8 × 8, resolution unknown, flags, not striped. + let page = segment(0, 48, &[], &[0, 0, 0, 8, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0x42, 0, 0]); + let forms = [ + // An immediate generic region. + vec![segment(1, 38, &[], ®ion(x, y, or, GENERIC))], + // An immediate refinement of the page, which reads the page in its own area (7.4.7.4). + vec![segment(1, 42, &[], ®ion(x, y, replace, REFINEMENT))], + // An immediate refinement of an intermediate region at the same place (7.4.7.5). + vec![ + segment(1, 36, &[], ®ion(x, y, or, GENERIC)), + segment(2, 42, &[1], ®ion(x, y, or, REFINEMENT)), + ], + ]; + forms.map(|form| { + let stream = [page.clone(), form.concat()].concat(); + let mut ink = Ink(0); + Image::new_embedded(&stream, None).and_then(|image| image.decode(&mut ink)).map(|()| ink.0) + }) + }; + for (x, y) in [(0, 0), (2, 2)] { + assert!(ink(x, y).iter().all(|ink| matches!(ink, Ok(n) if *n > 0)), "{x}, {y}: {:?}", ink(x, y)); + } + for (x, y) in [(0xFFFF_FFFE, 0), (0, 0xFFFF_FFFE), (0x8000_0000, 0x8000_0000), (u32::MAX, u32::MAX)] { + assert_eq!(ink(x, y).map(|ink| ink.ok()), [Some(0); 3], "{x:#x}, {y:#x}"); + } + } + /// A stream and its globals are one stream of segments, so they have one count. #[test] fn the_segments_of_a_stream_and_its_globals_count_together() { From b9e068aa3662d603289bfd8e10ff5a027c2b98d6 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 3 Nov 2026 19:32:29 +0100 Subject: [PATCH 49/63] M2: a JBIG2 refinement of a region refines it where it is "GRREFERENCEDX 0, GRREFERENCEDY 0" (ISO/IEC 14492 Table 35): a refinement region segment that refers to another region refines that region where it is. hayro-jbig2 offset the reference by the difference of the two locations, which 7.4.7.5 says must be equal; where a stream has them differ, the refinement decoded against a shifted reference, or, the region past i32::MAX, against none. It now decodes as coded, and only a refinement of the page takes an offset, from its own location. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/filters/src/jbig2/decode.rs | 8 +++ vendor/README.md | 2 +- .../src/decode/generic_refinement.rs | 53 ++++++++++--------- vendor/hayro-jbig2/src/lib.rs | 13 +++-- 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index 6d6759e3ea..a10de4af45 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -192,6 +192,14 @@ mod tests { .unwrap(); assert_ne!(decode(&segments.replace(off, on)), page, "{kind} on the page"); } + + // A refinement at 2, 0 of an intermediate region at 0x80000000, 0. A refinement of a + // region refines it where it is, "GRREFERENCEDX 0, GRREFERENCEDY 0" (Table 35), and the two + // should be at one place (7.4.7.5). Where they aren't, it decodes as it does where they are. + const ELSEWHERE: &str = "000000022400010000002300000008000000068000000000000000000003fffdff02fefefe1bca3149568cdfffac000000032a2002010000001e000000080000000600000002000000000000ffffffff784a909cc761ffac"; + let refined = decode(ELSEWHERE); + assert_eq!(refined, decode(&ELSEWHERE.replace("8000000000000000", "0000000200000000")), "the region at 2, 0 too"); + assert_ne!(refined, page, "drawn"); } /// What a page costs is the same however much a run has left. Given exactly that it decodes, diff --git a/vendor/README.md b/vendor/README.md index c668c77641..db21b1407e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | 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 | 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, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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; (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, and a refinement's offset to its reference is taken in 64 bits and held to an `i32`, which leaves every reference pixel it reads outside the reference, as the exact offset does | (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`; (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 `decode::generic_refinement::tests::a_reference_offset_is_the_difference_of_unsigned_locations` | +| 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; (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, and a refinement of the page reads the page at its own location, held to `i32::MAX` past that, which still leaves every page pixel it reads outside the page | (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`; (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 `decode::generic_refinement::tests::a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location` | | 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; (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 | (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`; (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) | | 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 | diff --git a/vendor/hayro-jbig2/src/decode/generic_refinement.rs b/vendor/hayro-jbig2/src/decode/generic_refinement.rs index c13d9bae65..fe7f8cc33d 100644 --- a/vendor/hayro-jbig2/src/decode/generic_refinement.rs +++ b/vendor/hayro-jbig2/src/decode/generic_refinement.rs @@ -17,6 +17,7 @@ use crate::reader::Reader; pub(crate) fn decode( header: &GenericRefinementRegionHeader<'_>, reference: &Bitmap, + refines_page: bool, ctx: &mut ScratchBuffers, ) -> Result { let mut region = Bitmap::new_with( @@ -27,7 +28,7 @@ pub(crate) fn decode( false, )?; - decode_into(header, reference, &mut region, ctx)?; + decode_into(header, reference, refines_page, &mut region, ctx)?; Ok(RegionBitmap { bitmap: region, @@ -35,24 +36,29 @@ pub(crate) fn decode( }) } -/// PdfCraft patch: the offset of a refinement's reference from the region, from their locations. +/// PdfCraft patch: the offsets of a refinement region segment's reference from the region at `x`, +/// `y`, the GRREFERENCEDX and GRREFERENCEDY of 6.3. /// -/// A location is unsigned (5.4.4), so one past `i32::MAX` is a place off the page, not an -/// overflow: a refinement there, of a region at the same place (7.4.7.5) or of the page, was -/// refused, and the rest of the stream with it. Both bitmaps are at most 65,535 pixels a side, so -/// an offset of `i32::MAX` either way already puts every reference pixel the decoder reads outside -/// the reference, where they are 0 (6.3.5.2), as any larger offset would. -fn reference_offset(reference: u32, region: u32) -> i32 { - let limit = i64::from(i32::MAX); - let offset = (i64::from(reference) - i64::from(region)).clamp(-limit, limit); - // Provably in range after the clamp. - i32::try_from(offset).unwrap_or(0) +/// "GRREFERENCEDX 0, GRREFERENCEDY 0" (Table 35): a refinement of another region refines it where +/// it is, whatever their locations say (they should be equal, 7.4.7.5). They were subtracted, so a +/// refinement of a region past `i32::MAX` was refused as an overflow, and the rest of the stream +/// with it. A refinement of the page refines the page at the refinement's location (7.4.7.4), the +/// page being at 0, 0. A location is unsigned (5.4.4), and the page is at most 65,535 pixels a +/// side, so held to `i32::MAX` one past that still puts every page pixel the decoder reads outside +/// the page, where they are 0 (6.3.5.2). +fn reference_offsets(refines_page: bool, x: u32, y: u32) -> (i32, i32) { + if !refines_page { + return (0, 0); + } + let offset = |location: u32| i32::try_from(location).map_or(-i32::MAX, |location| -location); + (offset(x), offset(y)) } /// Decode a generic refinement region directly into an existing bitmap. pub(crate) fn decode_into( header: &GenericRefinementRegionHeader<'_>, reference: &Bitmap, + refines_page: bool, region: &mut Bitmap, ctx: &mut ScratchBuffers, ) -> Result<()> { @@ -65,8 +71,8 @@ pub(crate) fn decode_into( bail!(RegionError::InvalidDimension); } - let reference_dx = reference_offset(reference.x_location, header.region_info.x_location); - let reference_dy = reference_offset(reference.y_location, header.region_info.y_location); + let (reference_dx, reference_dy) = + reference_offsets(refines_page, header.region_info.x_location, header.region_info.y_location); let mut decoder = ArithmeticDecoder::new(data); let num_context_bits = header.template.context_bits(); @@ -514,17 +520,14 @@ pub(crate) fn decode_bitmap( mod tests { use super::*; - /// PdfCraft patch: the reference's offset is the difference of the locations, which past - /// `i32::MAX` are places off the page: equal ones give 0, as a refinement of a region has them - /// (7.4.7.5), and one too far for an `i32` is held to `i32::MAX`, either way. + /// PdfCraft patch: a refinement of a region refines it where it is (Table 35), wherever their + /// locations are; a refinement of the page refines it at its own location, held to `i32::MAX` + /// past that. #[test] - fn a_reference_offset_is_the_difference_of_unsigned_locations() { - assert_eq!(reference_offset(5, 3), 2); - assert_eq!(reference_offset(3, 5), -2); - assert_eq!(reference_offset(0x9000_0000, 0x9000_0000), 0, "a region and its refinement off the page"); - assert_eq!(reference_offset(0, 0xFFFF_FFFC), -i32::MAX, "the page and a refinement of it off the page"); - assert_eq!(reference_offset(u32::MAX, 0), i32::MAX); - assert_eq!(reference_offset(0x7FFF_FFFF, 0), i32::MAX, "in range"); - assert_eq!(reference_offset(0, 0x7FFF_FFFF), -i32::MAX, "in range"); + fn a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location() { + assert_eq!(reference_offsets(false, 5, 0x8000_0000), (0, 0)); + assert_eq!(reference_offsets(true, 5, 3), (-5, -3)); + assert_eq!(reference_offsets(true, 0x7FFF_FFFF, 0), (-i32::MAX, 0), "in range"); + assert_eq!(reference_offsets(true, 0x8000_0000, u32::MAX), (-i32::MAX, -i32::MAX)); } } diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index 5dd81bc6c0..1ada85fd2a 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -527,16 +527,17 @@ fn decode_segments( } SegmentType::IntermediateGenericRefinementRegion => { // Same logic as immediate refinement, but store result instead of combining. - let reference = seg + let referred_segment = seg .header .referred_to_segments .first() - .and_then(|&num| page_state.get_referred_segment(num)) - .unwrap_or(page_bitmap); + .and_then(|&num| page_state.get_referred_segment(num)); let header = generic_refinement::parse(&mut reader)?; charge_work(work_left, region_work(&header.region_info))?; - let region = generic_refinement::decode(&header, reference, scratch_buffers)?; + let reference = referred_segment.unwrap_or(page_bitmap); + let refines_page = referred_segment.is_none(); + let region = generic_refinement::decode(&header, reference, refines_page, scratch_buffers)?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::ImmediateGenericRefinementRegion @@ -561,12 +562,14 @@ fn decode_segments( generic_refinement::decode_into( &header, referred_segment, + false, page_bitmap, scratch_buffers, )?; } else { let reference = referred_segment.unwrap_or(page_bitmap); - let region = generic_refinement::decode(&header, reference, scratch_buffers)?; + let refines_page = referred_segment.is_none(); + let region = generic_refinement::decode(&header, reference, refines_page, scratch_buffers)?; page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; From 858ce9608b17cb0c8a6b3da33428c38911b480a4 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 3 Nov 2026 19:54:08 +0100 Subject: [PATCH 50/63] M2: a test pins which bitmap a JBIG2 refinement refines A region and its refinement, directly and through an intermediate refinement, decode otherwise than the same refinements of the page, and at 2, 3 to exactly the pixels they decode to at 0, 0, moved. A handler that refines the page instead, or reads the region at the page's offsets, fails it, as does dropping intermediate refinements; no test caught those. The test's helpers move to the module. A comment still said a refinement of the page reads "its own area"; it reads the page at its own location. And only a location within a region's size of 2^32 came back onto the page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- vendor/README.md | 2 +- vendor/hayro-jbig2/src/bitmap.rs | 4 +- vendor/hayro-jbig2/src/lib.rs | 108 +++++++++++++++++++++++-------- 3 files changed, 83 insertions(+), 31 deletions(-) diff --git a/vendor/README.md b/vendor/README.md index db21b1407e..c4244ce0d5 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | 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 | 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, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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; (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, and a refinement of the page reads the page at its own location, held to `i32::MAX` past that, which still leaves every page pixel it reads outside the page | (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`; (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 `decode::generic_refinement::tests::a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location` | +| 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; (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, and a refinement of the page reads the page at its own location, held to `i32::MAX` past that, which still leaves every page pixel it reads outside the page | (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`; (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`, `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are` and `decode::generic_refinement::tests::a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location` | | 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; (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 | (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`; (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) | | 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 | diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index e9049f84bf..2e77a639b3 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -194,8 +194,8 @@ impl Bitmap { /// /// "Unless otherwise specified, all multi-bit values shall be treated as unsigned values" /// (5.4.4), and a region's location (7.4.1.3, 7.4.1.4) is not specified otherwise. One past - /// `i32::MAX` is past any page, where cast to a signed coordinate it read as left of the page - /// or above it, and came back onto it. + /// `i32::MAX` is past any page. Cast to a signed coordinate it read as left of the page or above + /// it, and one within a region's size of 2^32 came back onto it. pub(crate) fn combine_region(&mut self, region: &Self, operator: CombinationOperator) { let signed = |location: u32| i32::try_from(location).unwrap_or(i32::MAX); self.combine(region, signed(region.x_location), signed(region.y_location), operator); diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index 1ada85fd2a..b78816b1a6 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -816,6 +816,36 @@ mod tests { assert!(Image::new_embedded(&stream(1 << 20, 1 << 20), None).is_err()); } + /// Segment `number` of `kind` on page 1, referring to `refers_to`. + fn segment(number: u8, kind: u8, refers_to: &[u8], data: &[u8]) -> Vec { + let mut segment = vec![0, 0, 0, number, kind, (refers_to.len() as u8) << 5]; + segment.extend_from_slice(refers_to); + segment.push(1); + segment.extend_from_slice(&(data.len() as u32).to_be_bytes()); + segment.extend_from_slice(data); + segment + } + + /// A blank page of 8 × 8: resolution unknown, flags, not striped. + fn page_of_8() -> Vec { + segment(0, 48, &[], &[0, 0, 0, 8, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0x42, 0, 0]) + } + + /// Region information (7.4.1) of 4 × 4 at `x`, `y`, then the region's own `header`, then + /// arbitrary coded data. + fn region_of_4(x: u32, y: u32, operator: u8, header: &[u8]) -> Vec { + let mut data: Vec = [4u32, 4, x, y].iter().flat_map(|v| v.to_be_bytes()).collect(); + data.push(operator); + data.extend_from_slice(header); + data.extend_from_slice(&[0x5a; 16]); + data + } + + const GENERIC: &[u8] = &[0, 3, 0xFF, 0xFD, 0xFF, 2, 0xFE, 0xFE, 0xFE]; // template 0, AT + const REFINEMENT: &[u8] = &[0, 0xFF, 0xFF, 0xFF, 0xFF]; // template 0, AT + const OR: u8 = 0; + const REPLACE: u8 = 4; + /// PdfCraft patch: a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the /// page. Taken for a signed one, a region at 0xFFFFFFFE drew back onto the page two pixels left /// of it, and a refinement there was refused, and the rest of the stream with it. Each region @@ -833,42 +863,20 @@ mod tests { } fn next_line(&mut self) {} } - let segment = |number: u8, kind: u8, refers_to: &[u8], data: &[u8]| { - let mut segment = vec![0, 0, 0, number, kind, (refers_to.len() as u8) << 5]; - segment.extend_from_slice(refers_to); - segment.push(1); // the page it belongs to - segment.extend_from_slice(&(data.len() as u32).to_be_bytes()); - segment.extend_from_slice(data); - segment - }; - // Region information (7.4.1) of 4 × 4 at `x`, `y`, then the region's own header, then - // arbitrary coded data. - let region = |x: u32, y: u32, operator: u8, header: &[u8]| { - let mut data: Vec = [4u32, 4, x, y].iter().flat_map(|v| v.to_be_bytes()).collect(); - data.push(operator); - data.extend_from_slice(header); - data.extend_from_slice(&[0x5a; 16]); - data - }; - const GENERIC: &[u8] = &[0, 3, 0xFF, 0xFD, 0xFF, 2, 0xFE, 0xFE, 0xFE]; // template 0, AT - const REFINEMENT: &[u8] = &[0, 0xFF, 0xFF, 0xFF, 0xFF]; // template 0, AT - let (or, replace) = (0, 4); let ink = |x: u32, y: u32| { - // 8 × 8, resolution unknown, flags, not striped. - let page = segment(0, 48, &[], &[0, 0, 0, 8, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0x42, 0, 0]); let forms = [ // An immediate generic region. - vec![segment(1, 38, &[], ®ion(x, y, or, GENERIC))], - // An immediate refinement of the page, which reads the page in its own area (7.4.7.4). - vec![segment(1, 42, &[], ®ion(x, y, replace, REFINEMENT))], + vec![segment(1, 38, &[], ®ion_of_4(x, y, OR, GENERIC))], + // An immediate refinement of the page, which reads the page at its own location (7.4.7.4). + vec![segment(1, 42, &[], ®ion_of_4(x, y, REPLACE, REFINEMENT))], // An immediate refinement of an intermediate region at the same place (7.4.7.5). vec![ - segment(1, 36, &[], ®ion(x, y, or, GENERIC)), - segment(2, 42, &[1], ®ion(x, y, or, REFINEMENT)), + segment(1, 36, &[], ®ion_of_4(x, y, OR, GENERIC)), + segment(2, 42, &[1], ®ion_of_4(x, y, OR, REFINEMENT)), ], ]; forms.map(|form| { - let stream = [page.clone(), form.concat()].concat(); + let stream = [page_of_8(), form.concat()].concat(); let mut ink = Ink(0); Image::new_embedded(&stream, None).and_then(|image| image.decode(&mut ink)).map(|()| ink.0) }) @@ -881,6 +889,50 @@ mod tests { } } + /// PdfCraft patch: a refinement of a region refines it where it is, "GRREFERENCEDX 0, + /// GRREFERENCEDY 0" (Table 35), not the page at the refinement's location. A region and its + /// refinement, directly or through an intermediate refinement, decode otherwise than the same + /// refinements of the page, and at 2, 3 to the pixels they decode to at 0, 0, moved. + #[test] + fn a_refinement_of_a_region_decodes_the_same_wherever_they_are() { + #[derive(Default)] + struct Pixels(Vec); + impl Decoder for Pixels { + fn push_pixel(&mut self, black: bool) { + self.0.push(black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.0.extend((0..8 * chunks).map(|_| black)); + } + fn next_line(&mut self) {} + } + // The first refinement refers to the region before it, or `of_page`, to nothing: the page. + let decode = |x: u32, y: u32, through_intermediate: bool, of_page: bool| { + let first: &[u8] = if of_page { &[] } else { &[1] }; + let mut form = vec![segment(1, 36, &[], ®ion_of_4(x, y, OR, GENERIC))]; + if through_intermediate { + form.push(segment(2, 40, first, ®ion_of_4(x, y, OR, REFINEMENT))); + form.push(segment(3, 42, &[2], ®ion_of_4(x, y, OR, REFINEMENT))); + } else { + form.push(segment(2, 42, first, ®ion_of_4(x, y, OR, REFINEMENT))); + } + let stream = [page_of_8(), form.concat()].concat(); + let mut pixels = Pixels::default(); + Image::new_embedded(&stream, None).unwrap().decode(&mut pixels).unwrap(); + pixels.0 + }; + for through_intermediate in [false, true] { + let at_origin = decode(0, 0, through_intermediate, false); + assert!(at_origin.contains(&true), "draws"); + assert_ne!(at_origin, decode(0, 0, through_intermediate, true), "the page refined instead"); + for (i, &black) in decode(2, 3, through_intermediate, false).iter().enumerate() { + let (x, y) = (i % 8, i / 8); + let moved = x >= 2 && y >= 3 && at_origin[(y - 3) * 8 + x - 2]; + assert_eq!(black, moved, "{x}, {y}, through an intermediate refinement: {through_intermediate}"); + } + } + } + /// A stream and its globals are one stream of segments, so they have one count. #[test] fn the_segments_of_a_stream_and_its_globals_count_together() { From 07f44038842067b0e52c8ecdd786049e1b90c0f1 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 3 Nov 2026 22:49:34 +0100 Subject: [PATCH 51/63] M2: a JBIG2 refinement of the page refines only its area 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" (ISO/IEC 14492 7.4.7.4), at an offset of 0 (Table 35), and reads 0 outside that area (6.3.5.2). hayro-jbig2 read the whole page at the refinement's location, the pixels around the area too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. Vendored patch 16: Bitmap::area copies the page's area, 0 off the page and past its width whatever its rows' padding holds, and both handlers refine that. Every refinement region now reads its reference at offset 0, so the per-page offset #6016 kept goes. 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 a refinement (a refined text instance from a narrower bitmap or an RDX below -31, 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. That half was left out of #6048: alone it read the page column left of a page refinement's area, which the area now keeps at 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/filters/src/jbig2/decode.rs | 78 +++++++++++++ vendor/README.md | 2 +- vendor/hayro-jbig2/src/bitmap.rs | 38 +++++++ .../src/decode/generic_refinement.rs | 71 +++++------- vendor/hayro-jbig2/src/lib.rs | 106 ++++++++++++++++-- 5 files changed, 241 insertions(+), 54 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index a10de4af45..63fd8d06cd 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -202,6 +202,84 @@ mod tests { assert_ne!(refined, page, "drawn"); } + /// 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" + /// (ISO/IEC 14492 7.4.7.4), at an offset of 0 (Table 35), and reads 0 outside that area + /// (6.3.5.2). The decoder read the page around the area too, and refused a refinement wider or + /// taller than the page. A refinement of another region refines that region, not the page. And + /// a reference 32 or more left of a refinement (GRREFERENCEDX below −31, as a symbol refined + /// with an RDX below −31 has, Table 18) is read from where each row starts in it: each row's + /// first context read 0 for the reference column it starts at, and from 33 left for the one + /// before it too. Each stream is coded by the standard's procedures and decodes to its + /// picture, `#` for black. Vendored hayro-jbig2 patch 16. + #[test] + fn a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is() { + let cases: [(&str, &str, &[&str]); 5] = [ + ( + "a 48 × 6 page a generic region has inked, refined at 33: ink on both sides of the area, template 0", + "000000003000010000001300000030000000060000000000000000420000000000012600010000004100000030000000060000000000000000040003fffdff02fefefe331433558a5c5056cf412003ec9a38164df39bd4463b9331502c4fb1c267ab77b665aa5962ffac000000022a0001000000210000000c0000000600000021000000000400ffffffff9a6c35bbeee221f687ffac", + &[ + ".#..##.######..#.#..#####.##..#........##.##..##", + ".#..####.####.#...###.##.##.#.#.....#.#.####.###", + "##.#......#..#....#..#.###.#..#.#####.###.######", + "......#....###.#.##..#.##........#.#.#...#####..", + "#.##..###.#.#.##.....#...#.#..#.##.#..#...#...#.", + ".###...#..#..#..##..#.#..#####.#.##.......###.##", + ], + ), + ( + "a 24 × 8 inked page refined at 5, 1, template 1", + "000000003000010000001300000018000000080000000000000000420000000000012600010000003500000018000000080000000000000000040003fffdff02fefefeb1e133cb8bcbb8510e81d219bb880dfb40a311dc5c298819efffac000000022a00010000001b0000000a0000000500000005000000010401e55c0078dcd30fffac", + &[ + "#.##.##...##....##.##..#", + "#.##.##...#.####..##..#.", + "##..##.##...#...###..#.#", + ".....#.#.##.#...###....#", + "..#.#...#.#...#.#...##.#", + "....######...#.####....#", + "..######..##.#.#..##.#..", + "...#..##.####.#..##...#.", + ], + ), + ( + "a 16 × 5 inked page refined by a region 4 wider than it, template 0", + "000000003000010000001300000010000000050000000000000000420000000000012600010000002600000010000000050000000000000000040003fffdff02fefefe22e5e253a429cbf2c701ffac000000022a000100000023000000140000000500000000000000000400ffffffff3afa516532bbe3d481926fffac", + &[".#.#.#.#..#.#.#.", "##.####..###...#", "..####.#..####.#", ".###.#.....#..#.", "#.###..#########"], + ), + ( + "an intermediate region at 4, 1 on an inked 20 × 8 page, refined there (OR): the region, not the page, template 0", + "000000003000010000001300000014000000080000000000000000420000000000012600010000003000000014000000080000000000000000040003fffdff02fefefeb91e434e1726ca89ed06c2bffb0fe5fa7fccc80fffac00000002240001000000230000000a000000050000000400000001000003fffdff02fefefece7a9826ba81bfffac000000032a2002010000001e0000000a0000000500000004000000010000ffffffffd046ee6f6190ffac", + &[ + "#.#.#....#.#...#....", + "##..#.#.#.##.....##.", + "##.#.#.#.#.###.##...", + "###.##.####..###.#.#", + "...#.###..###.#.#..#", + "#...#######.#...####", + "..##.##...#..##..#.#", + "#...##...#.##....#..", + ], + ), + ( + "a symbol refined in a symbol dictionary from a 40 × 6 one, RDX −33: 33 left, template 1", + "0000000030000100000013000000070000000600000000000000004200000000000100000100000036000003fffdff02fefefe00000001000000014f913296209e2187f37bddb8589c8beb7d70109de7124ebe47d04914ef7e655c3c4cffac00000002002001010000001f100203fffdff02fefefe00000001000000014fe451a27bddf49f7ef7e7ffac00000003062002010000001b0000000700000006000000000000000004001000000001a943ffac", + &[".####.#", ".##..##", "......#", "...#..#", "#.#.###", "....##."], + ), + ]; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + for (form, stream, picture) in cases { + let (w, h) = (picture[0].len(), picture.len()); + // As the filter gives it: black as 0, each row padded to a byte with 0s. + let mut rows = vec![0u8; w.div_ceil(8) * h]; + for (y, row) in picture.iter().enumerate() { + for (x, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'.') { + rows[y * w.div_ceil(8) + x / 8] |= 0x80 >> (x % 8); + } + } + assert_eq!(decode_jbig2(&hex(stream), None, w as u32, h as u32, usize::MAX).unwrap(), rows, "{form}"); + } + } + /// What a page costs is the same however much a run has left. Given exactly that it decodes, /// and less stops it: spending all that's left, and what comes after isn't read. A page that /// fails still spends what it took. diff --git a/vendor/README.md b/vendor/README.md index c4244ce0d5..2a7ce97da2 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | 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 | 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, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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; (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, and a refinement of the page reads the page at its own location, held to `i32::MAX` past that, which still leaves every page pixel it reads outside the page | (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`; (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`, `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are` and `decode::generic_refinement::tests::a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location` | +| 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; (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); (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`; (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`; (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; (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 | (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`; (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) | | 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 | diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index 2e77a639b3..6b328451d1 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -287,4 +287,42 @@ impl Bitmap { self.data[(row * self.stride + word_idx) as usize] } + + /// PdfCraft patch: the `width` × `height` area of this bitmap at `x`, `y`, as a bitmap there. + /// It is what a refinement of the page refines: "a bitmap containing 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). Off this bitmap the area is 0, past its width + /// too whatever the padding of its rows holds, and outside the area the refinement reads 0 + /// (6.3.5.2), where reading the page itself took in the pixels around the area. + pub(crate) fn area(&self, x: u32, y: u32, width: u32, height: u32) -> Result { + let mut area = Self::new_with(width, height, x, y, false)?; + for row in 0..height { + let Some(from_row) = y.checked_add(row).filter(|r| *r < self.height) else { + continue; + }; + for word in 0..area.stride { + // The first column of this word of the area, in this bitmap; past its width, + // or past where a column can be, the rest of the row is 0. + let Some(from) = x.checked_add(word * WORD_BITS).filter(|c| *c < self.width) else { + break; + }; + let (index, shift) = (from / WORD_BITS, from % WORD_BITS); + let mut bits = self.get_word(from_row, index) << shift; + if shift > 0 { + bits |= self.get_word(from_row, index + 1) >> (WORD_BITS - shift); + } + // Both are above 0: `from` is inside this bitmap, and `word` inside the area. + let ours = self.width - from; + let theirs = width - word * WORD_BITS; + let kept = ours.min(theirs); + if kept < WORD_BITS { + bits &= !0 << (WORD_BITS - kept); + } + if let Some(slot) = area.data.get_mut((row * area.stride + word) as usize) { + *slot = bits; + } + } + } + Ok(area) + } } diff --git a/vendor/hayro-jbig2/src/decode/generic_refinement.rs b/vendor/hayro-jbig2/src/decode/generic_refinement.rs index fe7f8cc33d..b89618ba68 100644 --- a/vendor/hayro-jbig2/src/decode/generic_refinement.rs +++ b/vendor/hayro-jbig2/src/decode/generic_refinement.rs @@ -17,7 +17,6 @@ use crate::reader::Reader; pub(crate) fn decode( header: &GenericRefinementRegionHeader<'_>, reference: &Bitmap, - refines_page: bool, ctx: &mut ScratchBuffers, ) -> Result { let mut region = Bitmap::new_with( @@ -28,7 +27,7 @@ pub(crate) fn decode( false, )?; - decode_into(header, reference, refines_page, &mut region, ctx)?; + decode_into(header, reference, &mut region, ctx)?; Ok(RegionBitmap { bitmap: region, @@ -36,29 +35,10 @@ pub(crate) fn decode( }) } -/// PdfCraft patch: the offsets of a refinement region segment's reference from the region at `x`, -/// `y`, the GRREFERENCEDX and GRREFERENCEDY of 6.3. -/// -/// "GRREFERENCEDX 0, GRREFERENCEDY 0" (Table 35): a refinement of another region refines it where -/// it is, whatever their locations say (they should be equal, 7.4.7.5). They were subtracted, so a -/// refinement of a region past `i32::MAX` was refused as an overflow, and the rest of the stream -/// with it. A refinement of the page refines the page at the refinement's location (7.4.7.4), the -/// page being at 0, 0. A location is unsigned (5.4.4), and the page is at most 65,535 pixels a -/// side, so held to `i32::MAX` one past that still puts every page pixel the decoder reads outside -/// the page, where they are 0 (6.3.5.2). -fn reference_offsets(refines_page: bool, x: u32, y: u32) -> (i32, i32) { - if !refines_page { - return (0, 0); - } - let offset = |location: u32| i32::try_from(location).map_or(-i32::MAX, |location| -location); - (offset(x), offset(y)) -} - /// Decode a generic refinement region directly into an existing bitmap. pub(crate) fn decode_into( header: &GenericRefinementRegionHeader<'_>, reference: &Bitmap, - refines_page: bool, region: &mut Bitmap, ctx: &mut ScratchBuffers, ) -> Result<()> { @@ -66,13 +46,18 @@ pub(crate) fn decode_into( // Validate that the region fits within the reference bitmap. // When referring to another segment, dimensions must match exactly (7.4.7.5). - // When using the page bitmap as reference, the region must fit within the page. + // PdfCraft patch: a refinement of the page refines the page's area of its own size (7.4.7.4, + // `Bitmap::area`), which it always fits. if header.region_info.width > reference.width || header.region_info.height > reference.height { bail!(RegionError::InvalidDimension); } - let (reference_dx, reference_dy) = - reference_offsets(refines_page, header.region_info.x_location, header.region_info.y_location); + // PdfCraft patch: "GRREFERENCEDX 0, GRREFERENCEDY 0" (Table 35). The reference is another + // region, which a refinement refines where it is (their locations should be equal, 7.4.7.5), + // or the page restricted to the refinement's area (7.4.7.4, `Bitmap::area`). Offsetting it by + // the difference of the locations refused one past `i32::MAX` as an overflow, and read the + // page around a refinement of it. + let (reference_dx, reference_dy) = (0, 0); let mut decoder = ArithmeticDecoder::new(data); let num_context_bits = header.template.context_bits(); @@ -200,10 +185,24 @@ impl<'a> RefinementContextGatherer<'a> { }; self.reg_cur = 0; - self.ref_cur_x = 0; - self.ref_m1 = ContextGatherer::load_word(reference, ref_y.wrapping_sub(1) as u32, 0); - self.ref_cur = ContextGatherer::load_word(reference, ref_y as u32, 0); - self.ref_p1 = ContextGatherer::load_word(reference, ref_y.wrapping_add(1) as u32, 0); + // PdfCraft patch: the buffers start a column left of the reference column the row starts + // at, as `maybe_reload_buffers` would load them, or at column 0 where that is left of the + // reference. The row's first context takes in that column and the one left of it, which + // from column 32 on lay outside a word loaded from column 0 and read as 0: with the + // reference offset 32 left of the region its first column, and from 33 both (GRREFERENCEDX + // below −31, which a refined text instance has from a narrower bitmap or an RDX below −31, + // and a refined symbol from such an RDX). The region decoded to other pixels than were + // coded, or failed. + self.ref_cur_x = self + .reference_dx + .checked_neg() + .and_then(|column| u32::try_from(column).ok()) + .map_or(0, |column| column.saturating_sub(1)); + self.ref_m1 = + ContextGatherer::load_word(reference, ref_y.wrapping_sub(1) as u32, self.ref_cur_x); + self.ref_cur = ContextGatherer::load_word(reference, ref_y as u32, self.ref_cur_x); + self.ref_p1 = + ContextGatherer::load_word(reference, ref_y.wrapping_add(1) as u32, self.ref_cur_x); self.ctx = match self.template { RefinementTemplate::Template1 => { @@ -515,19 +514,3 @@ pub(crate) fn decode_bitmap( Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - /// PdfCraft patch: a refinement of a region refines it where it is (Table 35), wherever their - /// locations are; a refinement of the page refines it at its own location, held to `i32::MAX` - /// past that. - #[test] - fn a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location() { - assert_eq!(reference_offsets(false, 5, 0x8000_0000), (0, 0)); - assert_eq!(reference_offsets(true, 5, 3), (-5, -3)); - assert_eq!(reference_offsets(true, 0x7FFF_FFFF, 0), (-i32::MAX, 0), "in range"); - assert_eq!(reference_offsets(true, 0x8000_0000, u32::MAX), (-i32::MAX, -i32::MAX)); - } -} diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index b78816b1a6..110f5c1efc 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -313,6 +313,13 @@ fn region_work(region: &decode::RegionSegmentInfo) -> u64 { bitmap::decode_work(region.width, region.height) } +/// PdfCraft patch: the page restricted to a refinement's area, its reference when it refers to no +/// other region (7.4.7.4). Copying it a word at a time costs less than the region's own decoding, +/// already charged, and it is held, as large as the region, only while the region decodes. +fn page_area(page: &Bitmap, region: &decode::RegionSegmentInfo) -> Result { + page.area(region.x_location, region.y_location, region.width, region.height) +} + fn decode_segments( segments: &[segment::Segment<'_>], height_from_stripes: Option, @@ -535,9 +542,16 @@ fn decode_segments( let header = generic_refinement::parse(&mut reader)?; charge_work(work_left, region_work(&header.region_info))?; - let reference = referred_segment.unwrap_or(page_bitmap); - let refines_page = referred_segment.is_none(); - let region = generic_refinement::decode(&header, reference, refines_page, scratch_buffers)?; + // PdfCraft patch: a refinement of the page refines its area (`Bitmap::area`). + let area; + let reference = match referred_segment { + Some(region) => region, + None => { + area = page_area(page_bitmap, &header.region_info)?; + &area + } + }; + let region = generic_refinement::decode(&header, reference, scratch_buffers)?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::ImmediateGenericRefinementRegion @@ -545,8 +559,8 @@ fn decode_segments( // "3) Determine the buffer associated with the region segment that // this segment refers to." (7.4.7.5) // - // "2) If there are no referred-to segments, then use the page - // bitmap as the reference buffer." (7.4.7.5) + // With no region segment referred to, the reference is the page restricted to + // this segment's area (7.4.7.4). let referred_segment = seg .header .referred_to_segments @@ -562,14 +576,20 @@ fn decode_segments( generic_refinement::decode_into( &header, referred_segment, - false, page_bitmap, scratch_buffers, )?; } else { - let reference = referred_segment.unwrap_or(page_bitmap); - let refines_page = referred_segment.is_none(); - let region = generic_refinement::decode(&header, reference, refines_page, scratch_buffers)?; + // PdfCraft patch: a refinement of the page refines its area (`Bitmap::area`). + let area; + let reference = match referred_segment { + Some(region) => region, + None => { + area = page_area(page_bitmap, &header.region_info)?; + &area + } + }; + let region = generic_refinement::decode(&header, reference, scratch_buffers)?; page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; @@ -933,6 +953,74 @@ mod tests { } } + /// PdfCraft patch: a refinement of the page refines the page restricted to its area (7.4.7.4), + /// reading 0 around it, and a reference 32 or more left of a refinement is read from where each + /// row starts in it. So that a context read wrong changes what decodes, the refinements are + /// large, and their pictures come from the hash they were coded from: a 64 × 16 refinement at + /// 41, 4 of a 112 × 24 page, and a 70 × 12 text instance refined from a 140 × 12 symbol 35 + /// left of it. + #[test] + fn a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is() { + #[derive(Default)] + struct Rows(Vec>, Vec); + impl Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.1.push(u8::from(black)); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + for _ in 0..8 * chunks { + self.push_pixel(black); + } + } + fn next_line(&mut self) { + self.0.push(core::mem::take(&mut self.1)); + } + } + fn bit(seed: u32, x: u32, y: u32) -> u8 { + let mut h = x.wrapping_mul(0x9E37_79B1).wrapping_add(y.wrapping_mul(0x85EB_CA77)).wrapping_add(seed.wrapping_mul(0xC2B2_AE3D)); + h ^= h >> 15; + h = h.wrapping_mul(0x2C1B_3C6D); + h ^= h >> 12; + h = h.wrapping_mul(0x297A_2D39); + h ^= h >> 15; + ((h >> 7) & 1) as u8 + } + // About one pixel in eight: where a refinement differs from its reference. + let flipped = |seed: u32, x: u32, y: u32| bit(seed, x, y) & bit(seed + 1, x, y) & bit(seed + 2, x, y); + // A page of bit(1, ..), and over it at 41, 4 the area refined from it. + const PAGE: &str = "000000003000010000001300000070000000180000000000000000420000000000012600010000016f00000070000000180000000000000000040003fffdff02fefefefb5d354db053c2a984c01af66c619b02c245055be23a2a431b04c60325c88676f6ff1b0a68c873ad9011a27a89fc99abc7db2e8f529ecf198ff8c6a726ddcb746dc51f3812e365e74ee668fa02f59182cae39f0ef43b3201da1bcd43a21f449889e15ba0ba1a9344da1bdb9dcb4015279a39d944509febc622a235fa2c623acb443f0c10dabf7fba5e9d6fef2f1289dfd0a6a3801d036f21a62045074f5d3158ab0373ad2334102ef867dc3132dc8d02fe517771c419cdbad47d67d409ec89d0425d1f51a48af6ba3bee6e0701185bfa5f46b347b0ec39e744b665d1e8aac868838d3db25162d65534ff4771d9c0d657bd48d5814fa7cc184d0e961defab2d75a79a88345cdae5546a54221a22ddfe9c8a0514f32ac9de806fbc5fb4f5275c79d679f0258f77900daaa273cee50e50c958f323414670d7761418639342fdb2082eac5e7934f1a06169991452b1a042eaee7871ffac000000022a000100000091000000400000001000000029000000040400ffffffffd24567306e52f57232cefa9800db00fb60c21b30b0e53d9fac8a1f863a580a7cc49ab401934ac8be3a18e2dc0f01774b76c3e9196283cb5504742c763a867bc1aae8c48d74d194dacda03ce7e91904897ab61add273d9188fda65dc6fefb995d85f48142e43c1970bedb9c5f4be3cb00d458e9f4ee9714a61fffac"; + let page: Vec> = (0..24) + .map(|y| (0..112).map(|x| bit(1, x, y) ^ if (41..105).contains(&x) && (4..20).contains(&y) { flipped(10, x - 41, y - 4) } else { 0 }).collect()) + .collect(); + // A symbol of bit(3, ..), and a text instance refined from it, 70 narrower with RDX 0. + const TEXT: &str = "0000000030000100000013000000460000000c000000000000000042000000000001000001000000ed000003fffdff02fefefe00000001000000013f4efc0ce48e6b5c00246a648400fb605a44d542ed29da2b9b55c01f4e5af9842dd70c88f4913c7f552503ba52a5d7b5d9fd08ff5794b4dbfb3df7bafcf9c43d04154c1cefa181ca1aba5da826a4e4229858bd0950442df69c098ab3b61a32f71b136974b96bcb5306f3e75af1a4ef51b8f56a355acb8b69915cfa623f5a21933be8c056a348783a116ea914e886af2c64bf3b23e5610726c981092220f05411857529861fbf4c630a15fd00b081d2351279e06602f4408b25dc1d01c223027ecb8c87c4b1d76e3882a759c1adaf749f0e20735f8bd5a7306fffac000000020620010100000085000000460000000c0000000000000000040012ffffffff00000001a967c0b129999147062a4766ce10bc3dae7f1daf43dda1427d31203a1bc9fa021e0504e0a4b01739952970b54ad11ac08c0684d2f556a1843eb01dc75b963be6237b7f02fdfe17ccf7f061de310a936fd08c5ddea47609789e55f7cbca81ae38219c4abc5f9eff47ffac"; + let text: Vec> = (0..12).map(|y| (0..70).map(|x| bit(3, x + 35, y) ^ flipped(30, x, y)).collect()).collect(); + for (stream, picture) in [(PAGE, page), (TEXT, text)] { + let stream: Vec = (0..stream.len() / 2).map(|i| u8::from_str_radix(&stream[i * 2..i * 2 + 2], 16).unwrap()).collect(); + let mut rows = Rows::default(); + Image::new_embedded(&stream, None).unwrap().decode(&mut rows).unwrap(); + assert_eq!(rows.0, picture); + } + } + + /// PdfCraft patch: the area a refinement of the page refines is the page inside it and 0 + /// outside: off the page's rows and columns, past where a column can be, and past the page's + /// width and the area's own whatever the padding of the page's rows holds (a page filled with + /// 1s has 1s there). + #[test] + fn a_page_area_is_the_page_inside_it_and_0_outside() { + let page = Bitmap::new_with(40, 3, 0, 0, true).unwrap(); + let area = |x, y, width, height| page.area(x, y, width, height).unwrap().data; + assert_eq!(area(30, 1, 20, 3), [0xFFC0_0000, 0xFFC0_0000, 0], "10 columns and 2 rows of the page"); + assert_eq!(area(0, 0, 33, 1), [!0, 0x8000_0000], "the whole of a word, and none of the area's padding"); + assert_eq!(area(36, 0, 8, 1), [0xF000_0000], "4 columns, and none of the page's padding"); + assert_eq!(area(5, 0, 8, 1), [0xFF00_0000], "inside the page"); + assert_eq!(area(40, 0, 8, 1), [0], "right of the page"); + assert_eq!(area(u32::MAX - 2, 0, 40, 1), [0, 0], "past where a column can be"); + assert_eq!(area(0, u32::MAX, 8, 2), [0, 0], "below the page"); + assert!(area(3, 0, 0, 2).is_empty(), "no width"); + } + /// A stream and its globals are one stream of segments, so they have one count. #[test] fn the_segments_of_a_stream_and_its_globals_count_together() { From 801976a574e3143a7dc1b3d60db2b0c173557ee0 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 02:47:50 -0500 Subject: [PATCH 52/63] feat(organize): export, import and copy bookmark trees (#6018) bookmark_export returns an outline, or one bookmark's subtree, as JSON: titles, nesting, open state, colour, bold/italic and destinations as page plus view in PDF user space (ISO 32000-2 12.3.2.2), optionally written to a file. bookmark_import takes it back, inline or from that file, under any bookmark at any position or in place of the outline, as one undoable edit; bookmark_copy does the same from another open document by page number. A bookmark whose page the target lacks keeps its place without a destination and is reported in `unmapped`. Every node is checked before anything is written (title, page, colour, view type and arity, 33 levels, 100,000 bookmarks), and the error names the node. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/src/bookmark_io.rs | 188 +++++++++++++++++ crates/automation/src/dispatch.rs | 3 + crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 43 ++++ crates/automation/tests/it/automation.rs | 92 +++++++++ crates/engine/src/edit.rs | 9 + crates/engine/src/edit/labels.rs | 2 + crates/engine/src/edit/run.rs | 4 + crates/engine/src/lib.rs | 5 +- crates/engine/src/session/view.rs | 8 + crates/engine/src/tests.rs | 32 +++ crates/organize/src/lib.rs | 2 + crates/organize/src/outline.rs | 16 +- crates/organize/src/outline_io.rs | 253 +++++++++++++++++++++++ crates/organize/src/tests.rs | 157 ++++++++++++++ parity/acrobat-features.toml | 12 ++ 16 files changed, 822 insertions(+), 5 deletions(-) create mode 100644 crates/automation/src/bookmark_io.rs create mode 100644 crates/organize/src/outline_io.rs diff --git a/crates/automation/src/bookmark_io.rs b/crates/automation/src/bookmark_io.rs new file mode 100644 index 0000000000..84f3c61abc --- /dev/null +++ b/crates/automation/src/bookmark_io.rs @@ -0,0 +1,188 @@ +//! Bookmark trees in and out of documents: `bookmark_export`, `bookmark_import`, `bookmark_copy`. +//! +//! The portable form is JSON: `{"bookmarks": [node, …]}`, where a node is `title`, `page` +//! (1-based, or `null` for one that goes nowhere), `view` (`{"type", "values"}`: a destination +//! type and its values in PDF user space, as the file stores them), `color`, `bold`, `italic`, +//! `open` and `children`. What export writes, import takes back unchanged. +use crate::{Args, Automation, Result, ToolError, failed}; +use pdfcraft_engine::{DocId, Edit, OutlineNode}; +use serde_json::{Value, json}; + +/// Deepest nesting a bookmark tree may have (the listing reaches 33 levels). +const MAX_DEPTH: usize = 33; + +impl Automation { + pub(crate) fn bookmark_export(&self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let path = a.opt_path("path")?.unwrap_or_default(); + let tree = self.outline_of(id, &path)?; + let mut out = json!({ "bookmarks": tree.nodes.iter().map(node_json).collect::>(), "truncated": tree.truncated }); + if let Some(file) = a.opt_str("out")? { + let target = self.resolve(file, true)?; + let text = serde_json::to_vec_pretty(&json!({ "bookmarks": out["bookmarks"] })).map_err(failed)?; + crate::write_atomic(&target, &text)?; + out["path"] = json!(target); + } + Ok(out) + } + + pub(crate) fn bookmark_import(&mut self, a: &Args) -> Result { + let nodes = match (a.get("bookmarks"), a.opt_str("file")?) { + (Some(list), None) => nodes_of(list)?, + (None, Some(file)) => { + let path = self.resolve(file, false)?; + let value: Value = serde_json::from_slice(&crate::read_input(&path)?).map_err(|e| failed(format!("not a bookmark file: {e}")))?; + nodes_of(value.get("bookmarks").ok_or_else(|| failed("not a bookmark file: it has no \"bookmarks\" list"))?)? + } + _ => return Err(ToolError::InvalidArgs("give either bookmarks or file".into())), + }; + self.write_tree(a, nodes) + } + + pub(crate) fn bookmark_copy(&mut self, a: &Args) -> Result { + let from = a.int("from")?; + let from = DocId(u64::try_from(from).map_err(|_| ToolError::InvalidArgs("from must be positive".into()))?); + let path = a.opt_path("path")?.unwrap_or_default(); + let tree = self.outline_of(from, &path)?; + self.write_tree(a, tree.nodes) + } + + fn outline_of(&self, id: DocId, path: &[usize]) -> Result { + match self.session.outline_tree(id, path) { + Some(tree) => tree.map_err(failed), + None => Err(failed(format!("document {} is not open, or its bookmarks can't be read for editing", id.0))), + } + } + + /// Write `nodes` into `doc` under `parent` at `position` (or in place of the outline with + /// `replace`), and say which of them went nowhere because the document lacks their page. + fn write_tree(&mut self, a: &Args, bookmarks: Vec) -> Result { + let parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| usize::try_from(p.max(1) - 1).unwrap_or(usize::MAX)); + let replace = a.opt_bool("replace")?.unwrap_or(false); + let count = self.doc(a)?.info.pages.len(); + let unmapped = pdfcraft_engine::outline_unmapped(&bookmarks, count); + let mut out = self.apply(a, Edit::ImportBookmarks { parent, index, bookmarks, replace })?; + out["unmapped"] = json!(unmapped.iter().map(|p| one_based(p)).collect::>()); + Ok(out) + } +} + +fn one_based(path: &[usize]) -> Vec { + path.iter().map(|i| i.saturating_add(1)).collect() +} + +/// A node as JSON, leaving out what is at its default (no view, black, regular, no children). +fn node_json(n: &OutlineNode) -> Value { + let mut v = json!({ "title": n.title, "page": n.page.map(|p| p.saturating_add(1)), "open": n.open }); + if let Some((kind, values)) = &n.view { + v["view"] = json!({ "type": String::from_utf8_lossy(kind), "values": values }); + } + if let Some(c) = n.color { + v["color"] = json!(c); + } + if n.bold { + v["bold"] = json!(true); + } + if n.italic { + v["italic"] = json!(true); + } + if !n.children.is_empty() { + v["children"] = json!(n.children.iter().map(node_json).collect::>()); + } + v +} + +fn nodes_of(list: &Value) -> Result> { + let mut count = 0; + list_of(list, &[], &mut count) +} + +/// The nodes of one level; `at` is the 1-based path of their parent, for messages. +fn list_of(list: &Value, at: &[usize], count: &mut usize) -> Result> { + let items = list.as_array().ok_or_else(|| bad(at, "children must be a list of bookmarks"))?; + if at.len() >= MAX_DEPTH && !items.is_empty() { + return Err(bad(at, &format!("bookmarks may nest at most {MAX_DEPTH} levels deep"))); + } + *count = count.saturating_add(items.len()); + if *count > pdfcraft_engine::MAX_IMPORT_BOOKMARKS { + return Err(ToolError::InvalidArgs(format!("at most {} bookmarks can be imported at once", pdfcraft_engine::MAX_IMPORT_BOOKMARKS))); + } + let mut out = Vec::with_capacity(items.len()); + for (i, item) in items.iter().enumerate() { + let mut path = at.to_vec(); + path.push(i.saturating_add(1)); + out.push(node_of(item, &path, count)?); + } + Ok(out) +} + +/// One node and, through [`list_of`], its children (recursion bounded by `MAX_DEPTH`). +fn node_of(v: &Value, at: &[usize], count: &mut usize) -> Result { + let o = v.as_object().ok_or_else(|| bad(at, "must be an object"))?; + let field = |k: &str| o.get(k).filter(|x| !x.is_null()); + let title = field("title").and_then(Value::as_str).filter(|t| !t.trim().is_empty()).ok_or_else(|| bad(at, "needs a title"))?.to_string(); + let page = match field("page") { + None => None, + Some(p) => Some(p.as_u64().filter(|p| *p >= 1).and_then(|p| usize::try_from(p - 1).ok()).ok_or_else(|| bad(at, "page must be 1 or more"))?), + }; + let flag = |k: &str| field(k).map_or(Ok(None), |x| x.as_bool().map(Some).ok_or_else(|| bad(at, &format!("{k} must be true or false")))); + let color = match field("color") { + None => None, + Some(c) => { + let rgb: Option> = c.as_array().and_then(|c| c.iter().map(|x| x.as_f64().map(|x| x as f32)).collect()); + match rgb.as_deref() { + Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]), + _ => return Err(bad(at, "color must be [r, g, b], each from 0 to 1")), + } + } + }; + let children = match field("children") { + None => Vec::new(), + Some(list) => list_of(list, at, count)?, + }; + Ok(OutlineNode { + title, + page, + view: field("view").map(|x| view_of(x, at)).transpose()?, + color, + italic: flag("italic")?.unwrap_or(false), + bold: flag("bold")?.unwrap_or(false), + open: flag("open")?.unwrap_or(true), + children, + }) +} + +/// `{"type": "XYZ", "values": [left, top, zoom]}`: a destination type with exactly its values. +fn view_of(v: &Value, at: &[usize]) -> Result<(Vec, Vec>)> { + let kind = v.get("type").and_then(Value::as_str).ok_or_else(|| bad(at, "view needs a type such as XYZ or Fit"))?; + let values = match v.get("values").filter(|x| !x.is_null()) { + None => Vec::new(), + Some(list) => { + let list = list.as_array().filter(|l| l.len() <= 4).ok_or_else(|| bad(at, "view values must be a list of at most 4 numbers or nulls"))?; + list.iter() + .map(|x| match x { + Value::Null => Ok(None), + x => x.as_f64().map(Some).ok_or_else(|| bad(at, "view values must be numbers or null")), + }) + .collect::>()? + } + }; + let arity = pdfcraft_engine::outline_view_arity(kind.as_bytes()).ok_or_else(|| bad(at, VIEW_TYPES))?; + if values.len() != arity { + return Err(bad(at, &format!("a {kind} view takes {arity} values (numbers, or null to keep the reader's)"))); + } + if kind == "FitR" && values.iter().any(Option::is_none) { + return Err(bad(at, "a FitR view needs all four values: left, bottom, right, top")); + } + Ok((kind.as_bytes().to_vec(), values)) +} + +const VIEW_TYPES: &str = "view type must be XYZ (3 values: left, top, zoom), Fit, FitB (none), FitH, FitV, FitBH, FitBV (1) or FitR (4)"; + +fn bad(at: &[usize], what: &str) -> ToolError { + if at.is_empty() { + return ToolError::InvalidArgs(format!("bookmarks: {what}")); + } + ToolError::InvalidArgs(format!("bookmark {at:?}: {what}")) +} diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index f3c51d22e0..4106ef8bdb 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -196,6 +196,9 @@ impl Automation { out } "bookmark_list" => self.bookmark_listing(&a)?, + "bookmark_export" => self.bookmark_export(&a)?, + "bookmark_import" => self.bookmark_import(&a)?, + "bookmark_copy" => self.bookmark_copy(&a)?, "bookmark_add" => { let page = self.page(&a)?; let parent = a.opt_path("parent")?.unwrap_or_default(); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5bcb032fce..62e33a9d04 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -50,6 +50,7 @@ use file_access::foreign_share; use text_cache::TextCache; mod assemble; +mod bookmark_io; mod dispatch; mod documents; mod error; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4672e49886..374e2e704b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -323,6 +323,49 @@ pub fn tools() -> Vec { }), &["doc", "title", "page"], )), + t( + "bookmark_export", + "Export bookmarks", + "The bookmark tree, or the subtree at path, as a list of nodes bookmark_import takes back unchanged. A node has title, page (1-based, null when it goes nowhere), open, and when set view ({type, values}: a destination type from ISO 32000-2 §12.3.2.2 Table 149 and its values in PDF user space, as the file stores them), color ([r, g, b], 0-1), bold, italic and children. truncated is true when bookmarks nested deeper than 33 levels were left out. With out, the tree is also written there as a JSON file {\"bookmarks\": [...]}.", + ) + .with(schema( + json!({ "doc": doc(), "path": path("The bookmark whose subtree to export (omit for the whole outline)"), "out": path_arg() }), + &["doc"], + )), + t( + "bookmark_import", + "Import bookmarks", + "Add a tree of bookmarks, as bookmark_export gives it (bookmarks) or as a JSON file it wrote (file), under a parent bookmark (or at the top level) at a position, or with replace in place of the whole outline. A bookmark whose page this document lacks is kept without a destination and listed in unmapped (1-based paths into the given tree). A view takes exactly its type's values (XYZ 3, FitR 4, FitH/FitV/FitBH/FitBV 1, Fit/FitB none). Give exactly one of bookmarks and file. Every node is checked before anything is written: at most 100000 bookmarks, nested at most 33 levels counting the parent's. Undoable.", + ) + .destructive() + .with(schema( + json!({ + "doc": doc(), + "bookmarks": { "type": "array", "items": { "type": "object" }, "minItems": 1, "description": "Nodes as bookmark_export returns them." }, + "file": path_arg(), + "parent": path("Parent bookmark (omit for the top level)"), + "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." }, + "replace": { "type": "boolean", "description": "Replace the document's bookmarks instead of adding to them (default false; leave parent out)." } + }), + &["doc"], + )), + t( + "bookmark_copy", + "Copy bookmarks from another document", + "Copy the bookmarks of document from (or the subtree at path) into doc, by page number: under a parent bookmark (or at the top level) at a position, or with replace in place of doc's outline. A bookmark whose page doc lacks is kept without a destination and listed in unmapped (1-based paths into the copied tree). Undoable.", + ) + .destructive() + .with(schema( + json!({ + "doc": doc(), + "from": { "type": "integer", "minimum": 1, "description": "Id of the document to copy from." }, + "path": path("The bookmark whose subtree to copy (omit for the whole outline)"), + "parent": path("Parent bookmark in doc (omit for the top level)"), + "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." }, + "replace": { "type": "boolean", "description": "Replace doc's bookmarks instead of adding to them (default false; leave parent out)." } + }), + &["doc", "from"], + )), t("bookmark_rename", "Rename a bookmark", "Change a bookmark's title. Undoable.") .with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "title": { "type": "string", "minLength": 1 } }), &["doc", "path", "title"])), t("bookmark_delete", "Delete a bookmark", "Delete a bookmark and the bookmarks under it. Undoable.") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 13f9af4371..6e9d2148ae 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10642,3 +10642,95 @@ fn multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance() { assert_eq!(rendered, re.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), "saved appearances are identical"); std::fs::remove_dir_all(dir).unwrap(); } + +/// Export, then import into another document and copy between open documents: the tree comes +/// back unchanged, a page the target lacks is reported, and the import undoes in one step. +#[test] +fn bookmarks_export_import_and_copy_between_documents() { + let dir = workdir("bookmark-io"); + let mut a = auto(&dir); + let src = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "Intro", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "Detail", "page": 3, "parent": [1] })); + ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "End", "page": 2 })); + ok(&mut a, "bookmark_set_style", json!({ "doc": src, "path": [1], "color": [1, 0, 0], "bold": true })); + let exported = ok(&mut a, "bookmark_export", json!({ "doc": src, "out": "marks.json" })); + assert_eq!(exported["truncated"], false); + let tree = exported["bookmarks"].clone(); + assert_eq!((tree[0]["title"].clone(), tree[0]["color"].clone(), tree[0]["bold"].clone()), (json!("Intro"), json!([1.0, 0.0, 0.0]), json!(true))); + assert_eq!((tree[0]["children"][0]["title"].clone(), tree[0]["children"][0]["page"].clone()), (json!("Detail"), json!(3))); + assert_eq!(tree[0]["view"]["type"], "XYZ"); + let subtree = ok(&mut a, "bookmark_export", json!({ "doc": src, "path": [1, 1] }))["bookmarks"].clone(); + assert_eq!(subtree, json!([tree[0]["children"][0]])); + + // b.pdf has two pages: Detail goes to the third, so it arrives without a destination. + let dst = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + let imported = ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "marks.json" })); + assert_eq!(imported["unmapped"], json!([[1, 1]])); + let back = ok(&mut a, "bookmark_export", json!({ "doc": dst }))["bookmarks"].clone(); + assert_eq!(back[0]["children"][0]["page"], json!(null)); + assert_eq!((back[0]["title"].clone(), back[1].clone()), (tree[0]["title"].clone(), tree[1].clone())); + ok(&mut a, "edit_undo", json!({ "doc": dst })); + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": dst }))["bookmarks"], json!([])); + + // Into a document with every page, the tree is unchanged, wherever it is copied from. + let same = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": same, "title": "Old", "page": 1 })); + let copied = ok(&mut a, "bookmark_copy", json!({ "doc": same, "from": src, "replace": true })); + assert_eq!(copied["unmapped"], json!([])); + assert_eq!(ok(&mut a, "bookmark_export", json!({ "doc": same }))["bookmarks"], tree); + ok(&mut a, "bookmark_import", json!({ "doc": same, "bookmarks": subtree, "parent": [2], "position": 1 })); + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": same }))["bookmarks"][1]["children"][0]["title"], "Detail"); +} + +#[test] +fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() { + let dir = workdir("bookmark-io-bad"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let refused = |a: &mut Automation, bookmarks: Value| match a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": bookmarks })) { + Err(ToolError::InvalidArgs(m)) => m, + other => panic!("expected InvalidArgs, got {other:?}"), + }; + let fine = json!({ "title": "Fine", "page": 1 }); + let m = refused(&mut a, json!([fine, { "title": "Parent", "children": [{ "page": 1 }] }])); + assert!(m.contains("[2, 1]") && m.contains("title"), "{m}"); + let m = refused(&mut a, json!([{ "title": " " }])); + assert!(m.contains("[1]") && m.contains("title"), "{m}"); + let m = refused(&mut a, json!([fine, { "title": "Bad", "page": 0 }])); + assert!(m.contains("[2]") && m.contains("page"), "{m}"); + let m = refused(&mut a, json!([{ "title": "Bad", "color": [2, 0, 0] }])); + assert!(m.contains("color"), "{m}"); + let m = refused(&mut a, json!([{ "title": "Bad", "view": { "type": "XYZ", "values": [1, 2, 3, 4, 5] } }])); + assert!(m.contains("view"), "{m}"); + let mut deep = json!({ "title": "Leaf" }); + for _ in 0..40 { + deep = json!({ "title": "Level", "children": [deep] }); + } + assert!(refused(&mut a, json!([deep])).contains("33 levels")); + let m = refused(&mut a, json!([fine, { "title": "Z", "page": 1, "view": { "type": "Zoom" } }])); + assert!(m.contains("[2]") && m.contains("XYZ (3 values"), "{m}"); + let m = refused(&mut a, json!([fine, { "title": "Z", "page": 1, "view": { "type": "FitH", "values": [1, 2] } }])); + assert!(m.contains("[2]") && m.contains("FitH view takes 1"), "{m}"); + let m = refused(&mut a, json!([{ "title": "R", "view": { "type": "FitR", "values": [0, null, 1, 1] } }])); + assert!(m.contains("all four"), "{m}"); + assert!(matches!(a.call("bookmark_import", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": [fine], "file": "x.json" })), Err(ToolError::InvalidArgs(_)))); + // A real bookmark file outside the root is refused for being outside, not for being missing. + let outside = dir.with_extension("outside.json"); + std::fs::write(&outside, br#"{"bookmarks": [{"title": "Outside", "page": 1}]}"#).unwrap(); + let name = format!("../{}", outside.file_name().unwrap().to_string_lossy()); + match a.call("bookmark_import", &json!({ "doc": doc, "file": name })) { + Err(ToolError::Failed(m)) => assert!(m.contains("outside the allowed directory"), "{m}"), + other => panic!("expected the root to refuse it, got {other:?}"), + } + let _ = std::fs::remove_file(&outside); + let out = dir.with_extension("out.json"); + let name = format!("../{}", out.file_name().unwrap().to_string_lossy()); + match a.call("bookmark_export", &json!({ "doc": doc, "out": name })) { + Err(ToolError::Failed(m)) => assert!(m.contains("outside the allowed directory"), "{m}"), + other => panic!("expected the root to refuse it, got {other:?}"), + } + assert!(!out.exists()); + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([])); +} diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 8f43689aa9..759fe89638 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -119,6 +119,15 @@ pub enum Edit { bold: Option, italic: Option, }, + /// Write `bookmarks` as children of `parent` from child `index` on, or with `replace` in + /// place of the whole outline (`parent` must then be `[]`). A bookmark whose page this + /// document lacks is kept without a destination (see [`pdfcraft_organize::import_outline`]). + ImportBookmarks { + parent: Vec, + index: usize, + bookmarks: Vec, + replace: bool, + }, /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first /// "Untitled" bookmark (New Bookmarks from Structure). BookmarksFromStructure, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 5343804ef3..fd246b3eac 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -38,6 +38,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Set bookmark destination", "Colour", "Style", + "Import bookmarks", "New bookmarks from structure", "New bookmarks from text styles", "Number pages", @@ -193,6 +194,7 @@ impl Edit { // The bookmark Style and Colour menus' own names, already said in every language. Edit::SetBookmarkColor { .. } => Label::of("Colour"), Edit::SetBookmarkStyle { .. } => Label::of("Style"), + Edit::ImportBookmarks { .. } => Label::of("Import bookmarks"), Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"), Edit::NumberPages { .. } => Label::of("Number pages"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 5ed70aed7a..6fc751e90a 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -27,6 +27,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::SetBookmarkPage { .. } | Edit::SetBookmarkColor { .. } | Edit::SetBookmarkStyle { .. } + | Edit::ImportBookmarks { .. } | Edit::BookmarksFromStructure | Edit::NumberPages { .. } => { if p.assemble() { @@ -454,6 +455,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, + Edit::ImportBookmarks { parent, index, bookmarks, replace } => { + pdfcraft_organize::import_outline(doc, parent, *index, bookmarks, *replace)?; + } Edit::BookmarksFromStructure => { let entries: Vec<_> = pdfcraft_a11y::headings(doc) .into_iter() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe139164be..97ac16746a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -71,7 +71,10 @@ pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::{CommentLayer, StampPreview}; pub use text_preview::{TextEditPreview, UnmarkedPages}; -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; +pub use pdfcraft_organize::{ + BoxSpec, FieldConflicts, MAX_IMPORT_BOOKMARKS, OutlineNode, OutlineTree, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, + split_ranges, unmapped as outline_unmapped, view_arity as outline_view_arity, +}; pub use pdfcraft_organize::LabelStyle; pub use pdfcraft_organize::Standard; diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index c0e7417c8e..5ef135d6c8 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -168,6 +168,14 @@ impl Session { Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) } + /// The bookmark at `path` with everything under it, or the whole outline for `[]`, as a tree + /// another document can take ([`crate::Edit::ImportBookmarks`]). `None` when no document has + /// `id`, or its structure could not be read for editing. + pub fn outline_tree(&self, id: DocId, path: &[usize]) -> Option> { + let editor = self.get(id)?.editor.as_ref()?; + Some(pdfcraft_organize::outline_tree(&editor.cos, path)) + } + /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { let Some(doc) = self.get(id) else { return Vec::new() }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a77e471a7d..ccffb39c61 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10268,3 +10268,35 @@ fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { assert_eq!(reopened.get(again).unwrap().info.annotations[0].contents.as_deref(), Some(text), "reopening preserves all blank lines"); assert_eq!(before.rgba, render_page(&reopened, again).rgba, "the saved appearance is identical"); } + +/// A whole tree goes in as one undoable step, and a refused tree changes nothing. +#[test] +fn imported_bookmarks_undo_in_one_step() { + let (mut s, id) = session_with(2); + let node = |title: &str, page, children| pdfcraft_organize::OutlineNode { + title: title.into(), + page, + view: None, + color: None, + italic: false, + bold: false, + open: true, + children, + }; + let bookmarks = vec![node("Intro", Some(0), vec![node("Detail", Some(5), vec![])]), node("End", Some(1), vec![])]; + s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: bookmarks.clone(), replace: false }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Intro→1[Detail→0]", "End→2"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Import bookmarks")); + assert_eq!(s.outline_tree(id, &[]).unwrap().unwrap().nodes[0].title, "Intro"); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().info.outline.is_empty()); + s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: bookmarks.clone(), replace: false }).unwrap(); + s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: vec![node("New", Some(1), vec![])], replace: true }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["New→2"]); + s.undo(id).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Intro→1[Detail→0]", "End→2"]); + s.undo(id).unwrap(); + let err = s.apply(id, Edit::ImportBookmarks { parent: vec![4], index: 0, bookmarks, replace: false }).unwrap_err(); + assert!(err.to_string().contains("no bookmark"), "{err}"); + assert!(s.get(id).unwrap().info.outline.is_empty()); +} diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index b890965d0c..3b3d56cd83 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -20,6 +20,7 @@ mod dedupe; mod import; mod labels; mod outline; +mod outline_io; mod pdfx; mod prune; mod transition; @@ -40,6 +41,7 @@ pub use outline::{ Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_at, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark, move_bookmark, rename_bookmark, set_bookmark_color, set_bookmark_open, set_bookmark_page, set_bookmark_style, top_level_bookmarks, }; +pub use outline_io::{MAX_IMPORT_BOOKMARKS, OutlineNode, OutlineTree, import_outline, outline_tree, unmapped, view_arity}; pub use transition::{TRANSITION_DIRECTIONS, TRANSITION_STYLES, Transition, TransitionSpec, set_transition}; pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view}; diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index ff7d3d8da7..13c02eb662 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -19,7 +19,7 @@ use crate::{OrganizeError, walk}; /// Deepest level a listing enters (the top level is 0). Bookmarks deeper than this are not /// listed; edits still reach them by path. -const LIST_DEPTH: usize = 32; +pub(crate) const LIST_DEPTH: usize = 32; /// Deepest name-tree node read when looking up named destinations. const NAME_TREE_DEPTH: usize = 32; @@ -72,6 +72,14 @@ pub enum OutlineError { BadColor, #[error("this document has no tagged headings to make bookmarks from")] NoEntries, + #[error("there are no bookmarks to add")] + NothingToImport, + #[error("the bookmark at {0:?} has a view that is not a known destination type with its values")] + BadView(Vec), + #[error("bookmarks may nest at most {0} levels deep")] + TooDeep(usize), + #[error("at most {0} bookmarks can be added at once")] + TooManyBookmarks(usize), #[error("{0}")] Organize(#[from] OrganizeError), #[error("{0}")] @@ -449,7 +457,7 @@ impl From for OrganizeError { /// Make `kids` the children of `parent`, in order: rewires `/Parent`, `/Prev`, `/Next`, /// `/First` and `/Last`. -fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> { +pub(crate) fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> { for (i, k) in kids.iter().enumerate() { put(doc, *k, b"Parent", Some(Object::Ref(parent)))?; put(doc, *k, b"Prev", i.checked_sub(1).map(|p| Object::Ref(kids[p])))?; @@ -473,7 +481,7 @@ struct Counting { /// Recompute every `/Count` (§12.3.3: open items count their visible descendants, closed items /// the negative of what opening them would show; the root counts all visible items). -fn recount(doc: &mut Document) -> Result<()> { +pub(crate) fn recount(doc: &mut Document) -> Result<()> { let Some(root) = outline_root(doc) else { return Ok(()) }; let mut seen = HashSet::from([root]); let kids = children_of(doc, root, &mut seen); @@ -714,7 +722,7 @@ pub fn move_bookmark(doc: &mut Document, from: &[usize], to_parent: &[usize], in /// Open `parent` so a bookmark just added or moved into it shows (the root is always open). /// Only the sign of `/Count` matters here: [`recount`] sets its value. -fn show_children(doc: &mut Document, parent: ObjRef, parent_path: &[usize]) -> Result<()> { +pub(crate) fn show_children(doc: &mut Document, parent: ObjRef, parent_path: &[usize]) -> Result<()> { if !parent_path.is_empty() && doc.get(parent).as_dict().and_then(|d| d.int(b"Count")).is_none_or(|c| c <= 0) { put(doc, parent, b"Count", Some(Object::Int(1)))?; } diff --git a/crates/organize/src/outline_io.rs b/crates/organize/src/outline_io.rs new file mode 100644 index 0000000000..053be74672 --- /dev/null +++ b/crates/organize/src/outline_io.rs @@ -0,0 +1,253 @@ +//! Moving a whole outline (or one bookmark's subtree) out of a document and into another +//! (ISO 32000-2 §12.3.3 outlines, §12.3.2.2 explicit destinations). +//! +//! [`outline_tree`] reads bookmarks as a portable tree of [`OutlineNode`]s: titles, nesting, open +//! state, colour, style and destinations as page index plus view in the file's own user space, +//! so writing a tree back with [`import_outline`] gives the same structure. A node whose page the +//! target lacks keeps its place in the tree without a destination, and [`unmapped`] names it, so +//! nothing is dropped silently. Not carried: actions other than a GoTo, named destinations (they +//! travel resolved to their page), and structure links (`/SE`). + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; + +use crate::outline::{LIST_DEPTH, children, container, recount, relink, show_children}; +use crate::{OrganizeError, OutlineError, walk}; + +/// Most bookmarks one import writes. +pub const MAX_IMPORT_BOOKMARKS: usize = 100_000; + +/// The destination types (Table 149) and how many numbers follow each. +const VIEWS: [(&[u8], usize); 8] = [(b"XYZ", 3), (b"Fit", 0), (b"FitH", 1), (b"FitV", 1), (b"FitR", 4), (b"FitB", 0), (b"FitBH", 1), (b"FitBV", 1)]; + +/// One bookmark and the bookmarks under it, independent of any document. +#[derive(Clone, Debug, PartialEq)] +pub struct OutlineNode { + pub title: String, + /// The 0-based page it goes to; `None` for a bookmark that goes nowhere in its document. + pub page: Option, + /// Where on the page: a destination type from Table 149 and exactly as many values as that + /// type takes, in PDF user space (`None` keeps the reader's value). `None` goes to the page at + /// the reader's position and zoom. + pub view: Option<(Vec, Vec>)>, + /// The title's colour (`/C`), each component from 0 to 1; `None` is the default black. + pub color: Option<[f32; 3]>, + pub italic: bool, + pub bold: bool, + /// Shown expanded, when it has children. + pub open: bool, + pub children: Vec, +} + +/// The tree [`outline_tree`] read. +#[derive(Clone, Debug, PartialEq)] +pub struct OutlineTree { + pub nodes: Vec, + /// Bookmarks nested deeper than the listing reaches (32 levels) were left out. + pub truncated: bool, +} + +/// The bookmark at `path` with everything under it, or the whole outline for `[]`. +pub fn outline_tree(doc: &Document, path: &[usize]) -> Result { + // A whole outline's top level sits at depth 0; a subtree's root is the bookmark at `path`. + let base = path.len().max(1); + let mut truncated = false; + // The open nodes, outermost first, each with its depth. + let mut open: Vec<(usize, OutlineNode)> = Vec::new(); + let mut roots = Vec::new(); + for b in crate::bookmarks(doc).into_iter().filter(|b| b.path.starts_with(path) && !b.path.is_empty()) { + let depth = b.path.len().saturating_sub(base); + if b.path.len() > LIST_DEPTH && has_children(doc, b.obj) { + truncated = true; + } + while open.last().is_some_and(|(d, _)| *d >= depth) { + close(&mut open, &mut roots); + } + let view = b.view.and_then(|(kind, values)| portable_view(kind, values)); + let node = + OutlineNode { title: b.title, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold, open: b.open, children: Vec::new() }; + open.push((depth, node)); + } + while !open.is_empty() { + close(&mut open, &mut roots); + } + if !path.is_empty() && roots.is_empty() { + return Err(OutlineError::NoSuchBookmark(path.to_vec())); + } + Ok(OutlineTree { nodes: roots, truncated }) +} + +fn close(open: &mut Vec<(usize, OutlineNode)>, roots: &mut Vec) { + if let Some((_, node)) = open.pop() { + match open.last_mut() { + Some((_, parent)) => parent.children.push(node), + None => roots.push(node), + } + } +} + +fn has_children(doc: &Document, r: ObjRef) -> bool { + doc.get(r).as_dict().is_some_and(|d| d.reference(b"First").is_some()) +} + +/// A destination's view as [`import_outline`] takes it: a known type with exactly its number of +/// values (extra ones dropped, missing ones `None`). An unknown type gives `None`. +fn portable_view(kind: Vec, mut values: Vec>) -> Option<(Vec, Vec>)> { + let (_, arity) = VIEWS.iter().find(|(k, _)| *k == kind.as_slice())?; + values.resize(*arity, None); + Some((kind, values)) +} + +/// The nodes of `nodes` whose page `page_count` pages don't have, as paths into `nodes` +/// (0-based child indices), in tree order. [`import_outline`] keeps these without a destination. +pub fn unmapped(nodes: &[OutlineNode], page_count: usize) -> Vec> { + let mut out = Vec::new(); + visit(nodes, |path, node| { + if node.page.is_some_and(|p| p >= page_count) { + out.push(path.to_vec()); + } + }); + out +} + +/// How many values a view of destination type `kind` takes (Table 149), or `None` for a type +/// that is not one of them. +pub fn view_arity(kind: &[u8]) -> Option { + VIEWS.iter().find(|(k, _)| *k == kind).map(|(_, arity)| *arity) +} + +/// Call `f` with each node's path and the node, parents first. Iterative. +fn visit<'a>(nodes: &'a [OutlineNode], mut f: impl FnMut(&[usize], &'a OutlineNode)) { + // One entry per open level: its nodes and how many of them have been visited. + let mut stack: Vec<(&'a [OutlineNode], usize)> = vec![(nodes, 0)]; + while let Some(level) = stack.last_mut() { + let list = level.0; + let Some(node) = list.get(level.1) else { + stack.pop(); + continue; + }; + level.1 = level.1.saturating_add(1); + let path: Vec = stack.iter().map(|(_, visited)| visited.saturating_sub(1)).collect(); + f(&path, node); + if !node.children.is_empty() { + stack.push((&node.children, 0)); + } + } +} + +/// Check every node before anything is written: depth (counting the `base` levels above the +/// tree), count, colour and view. +fn check(nodes: &[OutlineNode], base: usize) -> Result<(), OutlineError> { + let mut problem = None; + let mut count = 0usize; + visit(nodes, |path, node| { + count = count.saturating_add(1); + if problem.is_some() { + return; + } + if base.saturating_add(path.len()) > LIST_DEPTH.saturating_add(1) { + problem = Some(OutlineError::TooDeep(LIST_DEPTH.saturating_add(1))); + } else if node.color.is_some_and(|c| !c.iter().all(|v| (0.0..=1.0).contains(v))) { + problem = Some(OutlineError::BadColor); + } else if node.view.as_ref().is_some_and(|v| !view_is_valid(v)) { + problem = Some(OutlineError::BadView(path.to_vec())); + } + }); + if count > MAX_IMPORT_BOOKMARKS { + return Err(OutlineError::TooManyBookmarks(MAX_IMPORT_BOOKMARKS)); + } + problem.map_or(Ok(()), Err) +} + +/// A known type with its number of values, each finite; `/FitR` needs all four. +fn view_is_valid((kind, values): &(Vec, Vec>)) -> bool { + VIEWS.iter().any(|(k, arity)| *k == kind.as_slice() && *arity == values.len()) + && values.iter().flatten().all(|v| v.is_finite()) + && (kind.as_slice() != b"FitR" || values.iter().all(Option::is_some)) +} + +/// Write `nodes` as children of the bookmark at `parent_path` (`[]` = top level), starting at +/// child `index` (past the end appends), or, with `replace`, in place of the outline's +/// top-level bookmarks (then `parent_path` must be `[]`). Nodes whose page this document lacks +/// are written without a destination (see [`unmapped`]). Everything is checked before anything +/// is written. Returns the path of the first node written. +pub fn import_outline( + doc: &mut Document, + parent_path: &[usize], + index: usize, + nodes: &[OutlineNode], + replace: bool, +) -> Result, OutlineError> { + if nodes.is_empty() { + return Err(OutlineError::NothingToImport); + } + if replace && !parent_path.is_empty() { + return Err(OrganizeError::Invalid("replace applies to the whole outline: leave the parent out".into()).into()); + } + check(nodes, parent_path.len())?; + let pages: Vec = walk(doc)?.into_iter().map(|(r, _)| r).collect(); + let parent = container(doc, parent_path)?; + let top = write_nodes(doc, &pages, nodes)?; + let mut kids = if replace { Vec::new() } else { children(doc, parent) }; + let at = index.min(kids.len()); + kids.splice(at..at, top); + relink(doc, parent, &kids)?; + show_children(doc, parent, parent_path)?; + recount(doc)?; + let mut path = parent_path.to_vec(); + path.push(at); + Ok(path) +} + +/// Add one item per node, linked under each other, and return the top-level items in order. +fn write_nodes(doc: &mut Document, pages: &[ObjRef], nodes: &[OutlineNode]) -> Result, OutlineError> { + // Each written item with its node, then its children's items once they are written. + let mut written: Vec<(ObjRef, Vec)> = Vec::new(); + let mut top = Vec::new(); + // (the nodes of one level, the next to write, the index in `written` of their parent) + let mut stack: Vec<(&[OutlineNode], usize, Option)> = vec![(nodes, 0, None)]; + while let Some(level) = stack.last_mut() { + let (list, parent) = (level.0, level.2); + let Some(node) = list.get(level.1) else { + stack.pop(); + continue; + }; + level.1 = level.1.saturating_add(1); + let item = doc.add(Object::Dict(item_dict(node, pages))); + match parent.and_then(|p| written.get_mut(p)) { + Some((_, kids)) => kids.push(item), + None => top.push(item), + } + written.push((item, Vec::new())); + if !node.children.is_empty() { + stack.push((&node.children, 0, Some(written.len().saturating_sub(1)))); + } + } + for (item, kids) in &written { + relink(doc, *item, kids)?; + } + Ok(top) +} + +/// The item's own entries; the links and `/Count`'s value come from [`relink`] and [`recount`] +/// (only the sign of the `/Count` written here matters: it keeps the open state). +fn item_dict(node: &OutlineNode, pages: &[ObjRef]) -> Dict { + let mut d = Dict::new(); + d.set(b"Title".to_vec(), Object::String(PdfString::text(&node.title))); + if let Some(page) = node.page.and_then(|p| pages.get(p)) { + let (kind, values) = node.view.clone().unwrap_or_else(|| (b"XYZ".to_vec(), vec![None; 3])); + let mut dest = vec![Object::Ref(*page), Object::Name(kind)]; + dest.extend(values.into_iter().map(|v| v.map_or(Object::Null, Object::Real))); + d.set(b"Dest".to_vec(), Object::Array(dest)); + } + if let Some(c) = node.color.filter(|c| *c != [0.0; 3]) { + d.set(b"C".to_vec(), Object::Array(c.iter().map(|v| Object::Real(f64::from(*v))).collect())); + } + let flags = i64::from(node.italic) | i64::from(node.bold) << 1; + if flags != 0 { + d.set(b"F".to_vec(), Object::Int(flags)); + } + if !node.children.is_empty() { + d.set(b"Count".to_vec(), Object::Int(if node.open { 1 } else { -1 })); + } + d +} diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 33dc262fce..fe5981be8c 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3813,3 +3813,160 @@ fn contents_pieces_join_with_a_space_across_a_trailing_comment() { ctrl_names.sort(); assert_eq!(ctrl_names, [b"ImHidden".to_vec(), b"ImShown".to_vec()]); } + +/// Intro (closed, red bold, at a point) > [Part (italic, FitH), Figure (FitR)], then Results. +fn outline_source() -> Document { + let mut d = doc_a(); + crate::add_bookmark_at(&mut d, &[], 0, "Intro", 0, Some(20.0), Some(380.0)).unwrap(); + crate::add_bookmark(&mut d, &[0], 0, "Part", 1).unwrap(); + crate::add_bookmark(&mut d, &[0], 1, "Figure", 2).unwrap(); + crate::add_bookmark(&mut d, &[], 1, "Results", 2).unwrap(); + crate::set_bookmark_color(&mut d, &[0], Some([1.0, 0.0, 0.0])).unwrap(); + crate::set_bookmark_style(&mut d, &[0], Some(true), None).unwrap(); + crate::set_bookmark_style(&mut d, &[0, 0], None, Some(true)).unwrap(); + let pages = crate::walk(&d).unwrap(); + let (part, figure) = (crate::bookmarks(&d)[1].obj, crate::bookmarks(&d)[2].obj); + let dest = |p: usize, rest: Vec| Object::Array([vec![Object::Ref(pages[p].0)], rest].concat()); + d.update_dict(part, |x| x.set(b"Dest".to_vec(), dest(1, vec![Object::name("FitH"), Object::Real(300.0)]))).unwrap(); + let fit_r = vec![Object::name("FitR"), Object::Int(10), Object::Int(20), Object::Int(110), Object::Int(220)]; + d.update_dict(figure, |x| x.set(b"Dest".to_vec(), dest(2, fit_r))).unwrap(); + crate::set_bookmark_open(&mut d, &[0], false).unwrap(); + d +} + +fn node(title: &str, page: Option, children: Vec) -> crate::OutlineNode { + crate::OutlineNode { title: title.into(), page, view: None, color: None, italic: false, bold: false, open: true, children } +} + +/// The `/Prev`, `/Last` and `/Parent` links agree with `/First`–`/Next` at every level. +fn assert_back_links(doc: &Document) { + let root = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap(); + let mut stack = vec![root]; + while let Some(parent) = stack.pop() { + let kids = crate::outline::children(doc, parent); + let d = doc.get(parent).as_dict().cloned().unwrap(); + assert_eq!(d.reference(b"Last"), kids.last().copied()); + for (i, k) in kids.iter().enumerate() { + let kd = doc.get(*k).as_dict().cloned().unwrap(); + assert_eq!(kd.reference(b"Prev"), i.checked_sub(1).map(|p| kids[p])); + assert_eq!(kd.reference(b"Parent"), Some(parent)); + } + stack.extend(kids); + } +} + +#[test] +fn an_exported_outline_imports_into_another_document_unchanged() { + let source = outline_source(); + let tree = crate::outline_tree(&source, &[]).unwrap(); + assert!(!tree.truncated); + let intro = &tree.nodes[0]; + assert_eq!((intro.title.as_str(), intro.page, intro.open, intro.bold, intro.color), ("Intro", Some(0), false, true, Some([1.0, 0.0, 0.0]))); + assert_eq!(intro.view, Some((b"XYZ".to_vec(), vec![Some(20.0), Some(380.0), None]))); + assert_eq!(intro.children[0].view, Some((b"FitH".to_vec(), vec![Some(300.0)]))); + assert!(intro.children[0].italic); + assert_eq!(intro.children[1].view, Some((b"FitR".to_vec(), vec![Some(10.0), Some(20.0), Some(110.0), Some(220.0)]))); + + let mut target = doc_a(); + assert_eq!(crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(), vec![0]); + assert_back_links(&target); + let back = full_roundtrip(&target); + assert_eq!(crate::outline_tree(&back, &[]).unwrap(), tree); + // The closed parent stays closed: its count is negative and the root counts only what shows. + let first = crate::bookmarks(&back)[0].obj; + let root = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap(); + assert_eq!((count_of(&back, first), count_of(&back, root)), (Some(-2), Some(2))); +} + +#[test] +fn a_subtree_exports_alone_and_a_missing_path_is_refused() { + let source = outline_source(); + let tree = crate::outline_tree(&source, &[0]).unwrap(); + assert_eq!(tree.nodes.len(), 1); + assert_eq!(tree.nodes[0].title, "Intro"); + assert_eq!(tree.nodes[0].children.iter().map(|n| n.title.as_str()).collect::>(), ["Part", "Figure"]); + let leaf = crate::outline_tree(&source, &[0, 1]).unwrap(); + assert_eq!((leaf.nodes.len(), leaf.nodes[0].title.as_str()), (1, "Figure")); + assert_eq!(crate::outline_tree(&source, &[5]), Err(crate::OutlineError::NoSuchBookmark(vec![5]))); + assert_eq!(crate::outline_tree(&doc_a(), &[]).unwrap().nodes, []); +} + +#[test] +fn a_page_the_target_lacks_keeps_its_bookmark_without_a_destination() { + let tree = crate::outline_tree(&outline_source(), &[]).unwrap(); + // doc_b has two pages: Figure and Results go to the third. + assert_eq!(crate::unmapped(&tree.nodes, 2), [vec![0, 1], vec![1]]); + let mut target = doc_b(); + crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(); + let listed = crate::bookmarks(&target); + assert_eq!(titles(&listed), ["Intro[Part,Figure]", "Results"]); + assert_eq!(listed.iter().map(|b| b.page).collect::>(), [Some(0), Some(1), None, None]); + assert!(target.get(listed[2].obj).as_dict().unwrap().get(b"Dest").is_none()); +} + +#[test] +fn an_import_appends_inserts_or_replaces() { + let mut d = doc_a(); + crate::add_bookmark(&mut d, &[], 0, "Old A", 0).unwrap(); + crate::add_bookmark(&mut d, &[], 1, "Old B", 1).unwrap(); + let new = [node("New", Some(2), vec![node("Child", Some(1), vec![])])]; + assert_eq!(crate::import_outline(&mut d, &[], 1, &new, false).unwrap(), vec![1]); + assert_eq!(titles(&crate::bookmarks(&d)), ["Old A", "New[Child]", "Old B"]); + assert_eq!(crate::import_outline(&mut d, &[2], 9, &new, false).unwrap(), vec![2, 0]); + assert_eq!(titles(&crate::bookmarks(&d)), ["Old A", "New[Child]", "Old B[New[Child]]"]); + assert_back_links(&d); + assert_eq!(crate::import_outline(&mut d, &[], 5, &new, true).unwrap(), vec![0]); + assert_eq!(titles(&crate::bookmarks(&d)), ["New[Child]"]); + assert_back_links(&d); + let refused = crate::import_outline(&mut d, &[0], 0, &new, true); + assert!(matches!(refused, Err(crate::OutlineError::Organize(crate::OrganizeError::Invalid(_))))); + assert_eq!(crate::import_outline(&mut d, &[], 0, &[], false), Err(crate::OutlineError::NothingToImport)); + assert_eq!(crate::import_outline(&mut d, &[7], 0, &new, false), Err(crate::OutlineError::NoSuchBookmark(vec![7]))); +} + +#[test] +fn a_bad_node_anywhere_refuses_the_whole_import() { + let mut d = doc_a(); + crate::add_bookmark(&mut d, &[], 0, "Kept", 0).unwrap(); + let before = crate::bookmarks(&d); + let with = |f: &dyn Fn(&mut crate::OutlineNode)| { + let mut child = node("Child", Some(0), vec![]); + f(&mut child); + vec![node("Fine", Some(0), vec![]), node("Parent", Some(0), vec![child])] + }; + let bad_views: [(&[u8], Vec>); 4] = + [(b"XYZ", vec![Some(1.0)]), (b"Zoom", vec![]), (b"FitH", vec![Some(f64::NAN)]), (b"FitR", vec![Some(0.0), None, Some(1.0), Some(1.0)])]; + for (kind, values) in bad_views { + let nodes = with(&|n| n.view = Some((kind.to_vec(), values.clone()))); + assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadView(vec![1, 0])), "{kind:?}"); + } + let nodes = with(&|n| n.color = Some([0.5, 1.5, 0.0])); + assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadColor)); + let nodes = with(&|n| n.color = Some([f32::NAN, 0.0, 0.0])); + assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadColor)); + let mut deep = node("Leaf", None, vec![]); + for _ in 0..33 { + deep = node("Level", None, vec![deep]); + } + assert_eq!(crate::import_outline(&mut d, &[], 0, &[deep], false), Err(crate::OutlineError::TooDeep(33))); + // 33 levels fit at the top, but not under a bookmark: the deepest would be past the listing. + let mut chain = node("Leaf", None, vec![]); + for _ in 0..32 { + chain = node("Level", None, vec![chain]); + } + assert_eq!(crate::import_outline(&mut d, &[0], 0, std::slice::from_ref(&chain), false), Err(crate::OutlineError::TooDeep(33))); + let many = vec![node("Item", None, vec![]); crate::MAX_IMPORT_BOOKMARKS + 1]; + assert_eq!(crate::import_outline(&mut d, &[], 0, &many, false), Err(crate::OutlineError::TooManyBookmarks(crate::MAX_IMPORT_BOOKMARKS))); + assert_eq!(crate::bookmarks(&d), before); +} + +#[test] +fn an_outline_deeper_than_the_listing_is_exported_as_truncated() { + let tree = crate::outline_tree(&open(deep_outline(40)), &[]).unwrap(); + assert!(tree.truncated); + assert!(!crate::outline_tree(&open(deep_outline(33)), &[]).unwrap().truncated); + // What was exported imports back: 33 levels is the deepest an import takes. + let mut target = doc_a(); + crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(); + assert_eq!(crate::bookmarks(&target).len(), 33); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8b7ac909a0..909cec18da 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3421,6 +3421,18 @@ tools = ["text_styles", "bookmark_from_text_styles"] evidence = ["crates/engine/src/tests.rs::bookmarks_from_text_styles_nest_the_headings_and_undo", "crates/engine/src/text_style_bookmarks.rs::subset_tags_are_left_out_of_the_font_name", "crates/engine/src/text_style_bookmarks.rs::styles_match_within_a_quarter_point", "crates/automation/tests/it/automation.rs::bookmarks_from_text_styles_through_tools", "crates/ui-egui/tests/it/bookmarks.rs::new_bookmarks_from_text_styles_from_the_bookmark_options"] notes = "Bookmark options (and Pages) ▸ New bookmarks from text styles…, for documents whose headings aren't tagged (#1693): the dialog lists the document's text styles (font without its subset tag, the size shown at, bold, italic, line count and a sample), the largest first and offered for level 1; one style per level, up to four levels (nine headlessly). Every line in a level's style becomes a bookmark of that level under a new first \"Untitled\" bookmark, a heading set on several rows is one bookmark, and each goes to the top of its heading (/XYZ). Lines are read down each page, then left to right, so a multi-column page's headings are taken row by row across the columns; sizes within a quarter point match. Not yet: several styles for one level, colour as part of a style, and the first 5000 pages only. Acrobat has no such command (its bookmarks come from structure); this is beyond it" +[[feature]] +id = "organize.bookmarks-import-export" +title = "Export, import and copy bookmarks" +area = "D" +tier = "P2" +milestone = "M4" +status = "partial" +commands = [] +tools = ["bookmark_export", "bookmark_import", "bookmark_copy"] +evidence = ["crates/organize/src/tests.rs::an_exported_outline_imports_into_another_document_unchanged", "crates/organize/src/tests.rs::a_page_the_target_lacks_keeps_its_bookmark_without_a_destination", "crates/organize/src/tests.rs::an_import_appends_inserts_or_replaces", "crates/organize/src/tests.rs::a_bad_node_anywhere_refuses_the_whole_import", "crates/automation/tests/it/automation.rs::bookmarks_export_import_and_copy_between_documents", "crates/automation/tests/it/automation.rs::a_bad_bookmark_tree_is_refused_whole_and_names_the_node"] +notes = "#6018, headless only. The bookmark tree (or one bookmark's subtree) goes out as JSON with titles, nesting, open state, colour, bold/italic and destinations as page plus view in PDF user space, and comes back unchanged: into another document, under any bookmark, at any position, or in place of its outline, as one undoable step; bookmark_copy does the same between open documents by page number. A bookmark whose page the target lacks keeps its place without a destination and is reported, never dropped. Not yet: a panel command and preview, CSV/Markdown/pdftk formats, remapping by named destination (named destinations travel resolved to their page), actions other than go-to-page, structure links (/SE)" + [[feature]] id = "organize.attachments-add" title = "Add file attachments" From 43939cc13f1112f09c76aad9eeb7e357ac0595fd Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 04:39:20 -0500 Subject: [PATCH 53/63] M4: bookmark tree round-trips what real files hold; review fixes (#6018) - organize: a /FitR destination missing one of its four numbers is copied with its page and no view, instead of failing the whole copy (the rectangle can't be kept, and import refuses a partial one). - organize: `visit` keeps the visited node's path in step with its stack instead of rebuilding it per node (O(depth) per node before). - automation: an empty title is accepted when present, so a bookmark a file left untitled exports and imports back; a missing title is still refused. - automation: "no bookmark at" names the path 1-based, as it was given. - automation: `bookmark_copy` and `bookmark_import` report `truncated`; an exported file carries `"truncated": true` when the export left bookmarks out, and importing it says so. - automation: `bookmark_export` is marked destructive (it overwrites `out`) and idempotent for MCP. Tests: a_fit_r_missing_a_value_copies_with_its_page_and_no_view (organize), odd_bookmarks_round_trip_and_a_cut_tree_says_so (automation). Each fix reverted alone fails one of them; the visit change is behaviour-neutral and covered by the existing outline tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/src/bookmark_io.rs | 33 +++++++++---- crates/automation/src/mcp.rs | 2 +- crates/automation/src/tools.rs | 7 +-- crates/automation/tests/it/automation.rs | 61 +++++++++++++++++++++++- crates/organize/src/outline_io.rs | 16 +++++-- crates/organize/src/tests.rs | 15 ++++++ 6 files changed, 116 insertions(+), 18 deletions(-) diff --git a/crates/automation/src/bookmark_io.rs b/crates/automation/src/bookmark_io.rs index 84f3c61abc..82c4927f62 100644 --- a/crates/automation/src/bookmark_io.rs +++ b/crates/automation/src/bookmark_io.rs @@ -3,7 +3,8 @@ //! The portable form is JSON: `{"bookmarks": [node, …]}`, where a node is `title`, `page` //! (1-based, or `null` for one that goes nowhere), `view` (`{"type", "values"}`: a destination //! type and its values in PDF user space, as the file stores them), `color`, `bold`, `italic`, -//! `open` and `children`. What export writes, import takes back unchanged. +//! `open` and `children`. What export writes, import takes back unchanged. A file also carries +//! `"truncated": true` when the export left out bookmarks nested past the listing limit. use crate::{Args, Automation, Result, ToolError, failed}; use pdfcraft_engine::{DocId, Edit, OutlineNode}; use serde_json::{Value, json}; @@ -19,7 +20,11 @@ impl Automation { let mut out = json!({ "bookmarks": tree.nodes.iter().map(node_json).collect::>(), "truncated": tree.truncated }); if let Some(file) = a.opt_str("out")? { let target = self.resolve(file, true)?; - let text = serde_json::to_vec_pretty(&json!({ "bookmarks": out["bookmarks"] })).map_err(failed)?; + let mut saved = json!({ "bookmarks": out["bookmarks"] }); + if tree.truncated { + saved["truncated"] = json!(true); + } + let text = serde_json::to_vec_pretty(&saved).map_err(failed)?; crate::write_atomic(&target, &text)?; out["path"] = json!(target); } @@ -27,16 +32,19 @@ impl Automation { } pub(crate) fn bookmark_import(&mut self, a: &Args) -> Result { - let nodes = match (a.get("bookmarks"), a.opt_str("file")?) { - (Some(list), None) => nodes_of(list)?, + let (nodes, truncated) = match (a.get("bookmarks"), a.opt_str("file")?) { + (Some(list), None) => (nodes_of(list)?, false), (None, Some(file)) => { let path = self.resolve(file, false)?; let value: Value = serde_json::from_slice(&crate::read_input(&path)?).map_err(|e| failed(format!("not a bookmark file: {e}")))?; - nodes_of(value.get("bookmarks").ok_or_else(|| failed("not a bookmark file: it has no \"bookmarks\" list"))?)? + let list = value.get("bookmarks").ok_or_else(|| failed("not a bookmark file: it has no \"bookmarks\" list"))?; + (nodes_of(list)?, value.get("truncated") == Some(&Value::Bool(true))) } _ => return Err(ToolError::InvalidArgs("give either bookmarks or file".into())), }; - self.write_tree(a, nodes) + let mut out = self.write_tree(a, nodes)?; + out["truncated"] = json!(truncated); + Ok(out) } pub(crate) fn bookmark_copy(&mut self, a: &Args) -> Result { @@ -44,12 +52,18 @@ impl Automation { let from = DocId(u64::try_from(from).map_err(|_| ToolError::InvalidArgs("from must be positive".into()))?); let path = a.opt_path("path")?.unwrap_or_default(); let tree = self.outline_of(from, &path)?; - self.write_tree(a, tree.nodes) + let mut out = self.write_tree(a, tree.nodes)?; + out["truncated"] = json!(tree.truncated); + Ok(out) } fn outline_of(&self, id: DocId, path: &[usize]) -> Result { match self.session.outline_tree(id, path) { - Some(tree) => tree.map_err(failed), + // The engine names the path 0-based; the caller gave it 1-based. + Some(tree) => tree.map_err(|e| match e { + pdfcraft_organize::OutlineError::NoSuchBookmark(_) => failed(format!("there is no bookmark at {:?}", one_based(path))), + e => failed(e), + }), None => Err(failed(format!("document {} is not open, or its bookmarks can't be read for editing", id.0))), } } @@ -121,7 +135,8 @@ fn list_of(list: &Value, at: &[usize], count: &mut usize) -> Result Result { let o = v.as_object().ok_or_else(|| bad(at, "must be an object"))?; let field = |k: &str| o.get(k).filter(|x| !x.is_null()); - let title = field("title").and_then(Value::as_str).filter(|t| !t.trim().is_empty()).ok_or_else(|| bad(at, "needs a title"))?.to_string(); + // An empty title is kept: files have them, and export writes them as "". + let title = field("title").and_then(Value::as_str).ok_or_else(|| bad(at, "needs a title"))?.to_string(); let page = match field("page") { None => None, Some(p) => Some(p.as_u64().filter(|p| *p >= 1).and_then(|p| usize::try_from(p - 1).ok()).ok_or_else(|| bad(at, "page must be 1 or more"))?), diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index 84cc21b258..b033871867 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -557,7 +557,7 @@ fn tool_json(t: &crate::ToolDef) -> Value { "title": t.title, "description": t.description, "inputSchema": t.input_schema, - "annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save"), "openWorldHint": false }, + "annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save" | "bookmark_export"), "openWorldHint": false }, }) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 374e2e704b..b25a653a83 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -326,8 +326,9 @@ pub fn tools() -> Vec { t( "bookmark_export", "Export bookmarks", - "The bookmark tree, or the subtree at path, as a list of nodes bookmark_import takes back unchanged. A node has title, page (1-based, null when it goes nowhere), open, and when set view ({type, values}: a destination type from ISO 32000-2 §12.3.2.2 Table 149 and its values in PDF user space, as the file stores them), color ([r, g, b], 0-1), bold, italic and children. truncated is true when bookmarks nested deeper than 33 levels were left out. With out, the tree is also written there as a JSON file {\"bookmarks\": [...]}.", + "The bookmark tree, or the subtree at path, as a list of nodes bookmark_import takes back unchanged. A node has title, page (1-based, null when it goes nowhere), open, and when set view ({type, values}: a destination type from ISO 32000-2 §12.3.2.2 Table 149 and its values in PDF user space, as the file stores them), color ([r, g, b], 0-1), bold, italic and children. truncated is true when bookmarks nested deeper than 33 levels were left out. With out, the tree is also written there as a JSON file {\"bookmarks\": [...]}, with \"truncated\": true when that is so; out is overwritten.", ) + .destructive() .with(schema( json!({ "doc": doc(), "path": path("The bookmark whose subtree to export (omit for the whole outline)"), "out": path_arg() }), &["doc"], @@ -335,7 +336,7 @@ pub fn tools() -> Vec { t( "bookmark_import", "Import bookmarks", - "Add a tree of bookmarks, as bookmark_export gives it (bookmarks) or as a JSON file it wrote (file), under a parent bookmark (or at the top level) at a position, or with replace in place of the whole outline. A bookmark whose page this document lacks is kept without a destination and listed in unmapped (1-based paths into the given tree). A view takes exactly its type's values (XYZ 3, FitR 4, FitH/FitV/FitBH/FitBV 1, Fit/FitB none). Give exactly one of bookmarks and file. Every node is checked before anything is written: at most 100000 bookmarks, nested at most 33 levels counting the parent's. Undoable.", + "Add a tree of bookmarks, as bookmark_export gives it (bookmarks) or as a JSON file it wrote (file), under a parent bookmark (or at the top level) at a position, or with replace in place of the whole outline. A bookmark whose page this document lacks is kept without a destination and listed in unmapped (1-based paths into the given tree). A view takes exactly its type's values (XYZ 3, FitR 4, FitH/FitV/FitBH/FitBV 1, Fit/FitB none). Give exactly one of bookmarks and file. A title may be empty but not missing. Every node is checked before anything is written: at most 100000 bookmarks, nested at most 33 levels counting the parent's. truncated is true when the file says its export left bookmarks out. Undoable.", ) .destructive() .with(schema( @@ -352,7 +353,7 @@ pub fn tools() -> Vec { t( "bookmark_copy", "Copy bookmarks from another document", - "Copy the bookmarks of document from (or the subtree at path) into doc, by page number: under a parent bookmark (or at the top level) at a position, or with replace in place of doc's outline. A bookmark whose page doc lacks is kept without a destination and listed in unmapped (1-based paths into the copied tree). Undoable.", + "Copy the bookmarks of document from (or the subtree at path) into doc, by page number: under a parent bookmark (or at the top level) at a position, or with replace in place of doc's outline. A bookmark whose page doc lacks is kept without a destination and listed in unmapped (1-based paths into the copied tree). truncated is true when bookmarks nested deeper than 33 levels were left out of the copy. Undoable.", ) .destructive() .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6e9d2148ae..4a36e3630a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10683,6 +10683,65 @@ fn bookmarks_export_import_and_copy_between_documents() { assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": same }))["bookmarks"][1]["children"][0]["title"], "Detail"); } +/// A one-page document whose outline has an untitled item, a /FitR destination with three of +/// its four numbers, and a chain nested `deep` levels below them. +fn odd_outline(deep: usize) -> Vec { + let first_deep = 7; + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Outlines /First 5 0 R /Last 6 0 R >>".into(), + "<< /Title () /Parent 4 0 R /Next 6 0 R /Dest [3 0 R /Fit] >>".into(), + format!( + "<< /Title (Short) /Parent 4 0 R /Prev 5 0 R /Dest [3 0 R /FitR 0 0 10]{} >>", + if deep > 0 { format!(" /First {first_deep} 0 R /Last {first_deep} 0 R") } else { String::new() } + ), + ]; + for i in 0..deep { + let (me, parent) = (first_deep + i, if i == 0 { 6 } else { first_deep + i - 1 }); + let child = if i + 1 < deep { format!(" /First {0} 0 R /Last {0} 0 R", me + 1) } else { String::new() }; + objs.push(format!("<< /Title (Level) /Parent {parent} 0 R /Dest [3 0 R /Fit]{child} >>")); + } + pdf_of(&objs.iter().map(String::as_str).collect::>()) +} + +/// What a real file holds and the JSON form can't say exactly still copies and round-trips: an +/// empty title stays empty, a short /FitR keeps its page and drops the view, and bookmarks past +/// the listing depth are reported as left out at every step. +#[test] +fn odd_bookmarks_round_trip_and_a_cut_tree_says_so() { + let dir = workdir("bookmark-io-odd"); + std::fs::write(dir.join("odd.pdf"), odd_outline(0)).unwrap(); + std::fs::write(dir.join("deep.pdf"), odd_outline(40)).unwrap(); + let mut a = auto(&dir); + let src = ok(&mut a, "doc_open", json!({ "path": "odd.pdf" }))["doc"].as_u64().unwrap(); + let tree = ok(&mut a, "bookmark_export", json!({ "doc": src, "out": "odd.json" }))["bookmarks"].clone(); + assert_eq!((tree[0]["title"].clone(), tree[1]["page"].clone(), tree[1].get("view")), (json!(""), json!(1), None)); + let dst = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "odd.json", "replace": true }))["truncated"], false); + // A destination with no view is written as XYZ with nulls: the same page, the reader's view. + let back = ok(&mut a, "bookmark_export", json!({ "doc": dst }))["bookmarks"].clone(); + assert_eq!((back[0].clone(), back[1]["title"].clone(), back[1]["page"].clone()), (tree[0].clone(), json!("Short"), json!(1))); + assert_eq!(back[1]["view"], json!({ "type": "XYZ", "values": [null, null, null] })); + let copied = ok(&mut a, "bookmark_copy", json!({ "doc": dst, "from": src, "replace": true })); + assert_eq!((copied["unmapped"].clone(), copied["truncated"].clone()), (json!([]), json!(false))); + match a.call("bookmark_export", &json!({ "doc": src, "path": [3] })) { + Err(ToolError::Failed(m)) => assert!(m.contains("no bookmark at [3]"), "{m}"), + other => panic!("expected no bookmark, got {other:?}"), + } + + let deep = ok(&mut a, "doc_open", json!({ "path": "deep.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "bookmark_export", json!({ "doc": deep, "out": "deep.json" }))["truncated"], true); + let saved: Value = serde_json::from_slice(&std::fs::read(dir.join("deep.json")).unwrap()).unwrap(); + assert_eq!(saved["truncated"], true); + assert_eq!(ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "deep.json", "replace": true }))["truncated"], true); + assert_eq!(ok(&mut a, "bookmark_copy", json!({ "doc": dst, "from": deep, "replace": true }))["truncated"], true); + let export = tools().into_iter().find(|t| t.name == "bookmark_export").unwrap(); + assert!(export.destructive && !export.read_only, "bookmark_export overwrites out"); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() { let dir = workdir("bookmark-io-bad"); @@ -10695,7 +10754,7 @@ fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() { let fine = json!({ "title": "Fine", "page": 1 }); let m = refused(&mut a, json!([fine, { "title": "Parent", "children": [{ "page": 1 }] }])); assert!(m.contains("[2, 1]") && m.contains("title"), "{m}"); - let m = refused(&mut a, json!([{ "title": " " }])); + let m = refused(&mut a, json!([{ "title": 5 }])); assert!(m.contains("[1]") && m.contains("title"), "{m}"); let m = refused(&mut a, json!([fine, { "title": "Bad", "page": 0 }])); assert!(m.contains("[2]") && m.contains("page"), "{m}"); diff --git a/crates/organize/src/outline_io.rs b/crates/organize/src/outline_io.rs index 053be74672..1fc3afc4f3 100644 --- a/crates/organize/src/outline_io.rs +++ b/crates/organize/src/outline_io.rs @@ -90,10 +90,14 @@ fn has_children(doc: &Document, r: ObjRef) -> bool { } /// A destination's view as [`import_outline`] takes it: a known type with exactly its number of -/// values (extra ones dropped, missing ones `None`). An unknown type gives `None`. +/// values (extra ones dropped, missing ones `None`). An unknown type, or a FitR missing one of +/// its four values (the rectangle can't be kept), gives `None`. fn portable_view(kind: Vec, mut values: Vec>) -> Option<(Vec, Vec>)> { let (_, arity) = VIEWS.iter().find(|(k, _)| *k == kind.as_slice())?; values.resize(*arity, None); + if kind.as_slice() == b"FitR" && values.iter().any(Option::is_none) { + return None; + } Some((kind, values)) } @@ -119,14 +123,18 @@ pub fn view_arity(kind: &[u8]) -> Option { fn visit<'a>(nodes: &'a [OutlineNode], mut f: impl FnMut(&[usize], &'a OutlineNode)) { // One entry per open level: its nodes and how many of them have been visited. let mut stack: Vec<(&'a [OutlineNode], usize)> = vec![(nodes, 0)]; + // The visited node's path, kept in step with the stack rather than rebuilt per node. + let mut path: Vec = Vec::new(); while let Some(level) = stack.last_mut() { let list = level.0; - let Some(node) = list.get(level.1) else { + let index = level.1; + let Some(node) = list.get(index) else { stack.pop(); continue; }; - level.1 = level.1.saturating_add(1); - let path: Vec = stack.iter().map(|(_, visited)| visited.saturating_sub(1)).collect(); + level.1 = index.saturating_add(1); + path.truncate(stack.len().saturating_sub(1)); + path.push(index); f(&path, node); if !node.children.is_empty() { stack.push((&node.children, 0)); diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index fe5981be8c..d5ff2116ff 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3904,6 +3904,21 @@ fn a_page_the_target_lacks_keeps_its_bookmark_without_a_destination() { assert!(target.get(listed[2].obj).as_dict().unwrap().get(b"Dest").is_none()); } +#[test] +fn a_fit_r_missing_a_value_copies_with_its_page_and_no_view() { + let mut src = outline_source(); + let pages = crate::walk(&src).unwrap(); + let figure = crate::bookmarks(&src)[2].obj; + let short = vec![Object::Ref(pages[2].0), Object::name("FitR"), Object::Int(10), Object::Int(20), Object::Int(110)]; + src.update_dict(figure, |x| x.set(b"Dest".to_vec(), Object::Array(short))).unwrap(); + let tree = crate::outline_tree(&src, &[]).unwrap(); + let copied = &tree.nodes[0].children[1]; + assert_eq!((copied.page, copied.view.clone()), (Some(2), None)); + let mut target = doc_a(); + crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(); + assert_eq!(titles(&crate::bookmarks(&target)), ["Intro[Part,Figure]", "Results"]); +} + #[test] fn an_import_appends_inserts_or_replaces() { let mut d = doc_a(); From c16c6b5716db5fef30d1e17840ed3f37419bdbfa Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 04:56:35 -0500 Subject: [PATCH 54/63] M4: cut, copy and paste bookmark subtrees (#3994) The Bookmarks panel's item menu gets Cut, Copy and Paste, and Bookmark options gets Paste bookmarks (to the end of the outline, which is how you paste into a document without one). The clipboard lives in the app (`PdfCraftApp::bookmark_clipboard`), like the Organize Pages clipboard, so it works across tabs and on the web build. - Copy reads the subtree with `outline_tree`; a copy cut at the listing depth says so. - Cut copies first and deletes only if the copy succeeded: one undo step. - Paste is one `ImportBookmarks` edit after the bookmark it was asked on, at its level. A bookmark whose page the document lacks keeps its place without a destination, and a notice counts them (plural forms through `trn`). Parity: new `organize.bookmark-clipboard` entry with what is missing (shortcuts, system clipboard, multi-select, page-ordered insertion). Test: cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents. Reverting copy, cut, paste, the paste's parent, the one-step undo or the notice each fails it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/editing.rs | 52 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/panels/bookmarks.rs | 16 ++++++ crates/ui-egui/src/panels/right.rs | 6 +++ crates/ui-egui/tests/it/bookmarks.rs | 72 ++++++++++++++++++++++++++ parity/acrobat-features.toml | 12 +++++ 7 files changed, 162 insertions(+) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6ac8ea8783..43a1e5ce1d 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -260,6 +260,7 @@ impl PdfCraftApp { fonts_cjk: theme::CjkPreference::Default, control: None, bookmark_rename: None, + bookmark_clipboard: None, last_opened_url: None, community_card_closed: false, pending_link: None, diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4440637a81..f2b20f39ed 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -977,6 +977,20 @@ impl PdfCraftApp { let at = parent[parent.len() - 1] + 1; Edit::MoveBookmark { from: path, to_parent: grand, index: at } } + A::Copy(path) => { + self.copy_bookmark(id, &path); + return; + } + A::Cut(path) => { + if !self.copy_bookmark(id, &path) { + return; + } + Edit::DeleteBookmark { path } + } + A::Paste { parent, index } => { + self.paste_bookmarks(id, parent, index); + return; + } A::FromStructure => { self.right = Some(crate::RightPanel::Bookmarks); Edit::BookmarksFromStructure @@ -991,6 +1005,44 @@ impl PdfCraftApp { } } +impl PdfCraftApp { + /// Put the bookmark at `path` and its subtree on the bookmark clipboard; `false` if it could + /// not be read (the reason is shown). + fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> bool { + match self.session.outline_tree(id, path) { + Some(Ok(tree)) => { + if tree.truncated { + self.notify_tr("Bookmarks nested too deep to list were left out of the copy."); + } + self.bookmark_clipboard = Some(tree.nodes); + true + } + Some(Err(e)) => { + self.notify_error(e); + false + } + None => false, + } + } + + /// Paste the bookmark clipboard as one undoable edit. Bookmarks whose page this document lacks + /// arrive without a destination, and the toast says how many. + fn paste_bookmarks(&mut self, id: pdfcraft_engine::DocId, parent: Vec, index: usize) { + let Some(bookmarks) = self.bookmark_clipboard.clone() else { return }; + let pages = self.session.get(id).map_or(0, |d| d.info.pages.len()); + let unmapped = pdfcraft_engine::outline_unmapped(&bookmarks, pages).len(); + if self.apply_edit(Edit::ImportBookmarks { parent, index, bookmarks, replace: false }) && unmapped > 0 { + let n = u64::try_from(unmapped).unwrap_or(u64::MAX); + self.notify(crate::i18n::trn( + crate::i18n::current(), + n, + "{n} pasted bookmark points past the last page of this document and has no destination.", + "{n} pasted bookmarks point past the last page of this document and have no destination.", + )); + } + } +} + fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> Option<&'a pdfcraft_render::OutlineItem> { let (first, rest) = path.split_first()?; let item = items.get(*first)?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 643d5e90ff..28382ea255 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -611,6 +611,9 @@ pub struct PdfCraftApp { control: Option, /// A bookmark being renamed in the Bookmarks panel: (path, text so far). pub bookmark_rename: Option<(Vec, String)>, + /// Bookmarks copied or cut in the Bookmarks panel, with their subtrees, ready to paste into + /// any document. Destinations are page indices. + pub bookmark_clipboard: Option>, /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, pub(crate) word_count_draft: word_count_ui::Draft, diff --git a/crates/ui-egui/src/panels/bookmarks.rs b/crates/ui-egui/src/panels/bookmarks.rs index ccc45d4eb0..2b68b93158 100644 --- a/crates/ui-egui/src/panels/bookmarks.rs +++ b/crates/ui-egui/src/panels/bookmarks.rs @@ -19,6 +19,15 @@ pub enum BmAction { Indent(Vec), /// Move it out to follow its parent. Outdent(Vec), + /// Copy the bookmark and everything under it to the bookmark clipboard. + Copy(Vec), + /// Copy it, then delete it (one undo step: only the delete changes the document). + Cut(Vec), + /// Paste the bookmark clipboard under `parent` at `index` (past the end appends). + Paste { + parent: Vec, + index: usize, + }, /// Bookmarks from the tagged headings, under a new first "Untitled" bookmark. FromStructure, /// Open New bookmarks from text styles, to choose a style per bookmark level (#1693). @@ -55,6 +64,8 @@ pub(super) struct OutlineCtx<'a> { pub(super) action: &'a mut Option, pub(super) rename: &'a mut Option<(Vec, String)>, pub(super) editable: bool, + /// The bookmark clipboard holds something to paste. + pub(super) can_paste: bool, pub(super) current: usize, /// Expand all (`Some(usize::MAX)`), collapse all (`Some(0)`) or expand to a depth, this frame. pub(super) expand: Option, @@ -242,6 +253,11 @@ pub(super) fn outline_item( pick(ui, tl!("Indent"), i > 0, BmAction::Indent(path.to_vec())); pick(ui, tl!("Outdent"), depth > 0, BmAction::Outdent(path.to_vec())); ui.separator(); + pick(ui, tl!("Cut"), true, BmAction::Cut(path.to_vec())); + pick(ui, tl!("Copy"), true, BmAction::Copy(path.to_vec())); + let parent = path.get(..depth).unwrap_or_default().to_vec(); + pick(ui, tl!("Paste"), cx.can_paste, BmAction::Paste { parent, index: i.saturating_add(1) }); + ui.separator(); pick(ui, tl!("Delete"), true, BmAction::Delete(path.to_vec())); if let Some(a) = styled { *cx.action = Some(a); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 79769867d3..ae9fbb9266 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -24,6 +24,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut compare_action: Option = None; let mut bm_rename = app.bookmark_rename.clone(); let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none()); + let bm_can_paste = app.bookmark_clipboard.is_some(); // Prepare a form is open: the Fields panel orders tabs. let preparing = app.is_preparing(); { @@ -111,6 +112,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { bm_action = Some(BmAction::FromTextStyles); ui.close(); } + if bm_editable && ui.add_enabled(bm_can_paste, egui::Button::new(tl!("Paste bookmarks"))).clicked() { + bm_action = Some(BmAction::Paste { parent: Vec::new(), index: usize::MAX }); + ui.close(); + } if !info.outline.is_empty() { for (levels, label) in [ (usize::MAX, tl!("Expand all bookmarks")), @@ -195,6 +200,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { action: &mut bm_action, rename: &mut bm_rename, editable: bm_editable, + can_paste: bm_can_paste, current: view.current, expand: bm_expand, matches: &bookmark_matches, diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 1f07102a18..5251871401 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -459,3 +459,75 @@ trailer << /Root 1 0 R >> h.run_steps(3); assert!(!shown(&h, "Closed child B") && !shown(&h, "Open child B"), "search leaves the open state alone"); } + +/// The active document's outline, flattened like [`outline`]. +fn active_outline(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, Option, usize)> { + let i = h.state().active.unwrap(); + let id = h.state().views[i].id; + fn flat(items: &[pdfcraft_render::OutlineItem], depth: usize, out: &mut Vec<(String, Option, usize)>) { + for i in items { + out.push((i.title.clone(), i.page, depth)); + flat(&i.children, depth + 1, out); + } + } + let mut out = Vec::new(); + flat(&h.state().session.get(id).unwrap().info.outline, 0, &mut out); + out +} + +const ONE_PAGE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents() { + let mut h = harness(); + add(&mut h, "Intro"); + h.state_mut().set_option("page", "3").unwrap(); + h.run_steps(3); + add(&mut h, "Results"); + menu(&mut h, "Results", "Indent"); + let tree = [("Intro".to_string(), Some(0), 0), ("Results".to_string(), Some(2), 1)]; + assert_eq!(outline(&h), tree); + + // Copy and paste after itself: the whole subtree, as one undo step. + menu(&mut h, "Intro", "Copy"); + menu(&mut h, "Intro", "Paste"); + assert_eq!(outline(&h), [tree.clone(), tree.clone()].concat()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(outline(&h), tree, "a paste is one undo step"); + // Pasted on a child, it goes in after that child, at the child's level. + menu(&mut h, "Results", "Paste"); + let nested = [("Intro".to_string(), Some(0), 1), ("Results".to_string(), Some(2), 2)]; + assert_eq!(outline(&h), [tree.clone(), nested].concat()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(outline(&h), tree); + + // Cut takes the subtree away in one step and keeps it to paste. + menu(&mut h, "Intro", "Cut"); + assert!(outline(&h).is_empty()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(outline(&h), tree, "a cut is one undo step"); + + // Into a one-page document: Results' page 3 is not there, so it keeps its place without a + // destination rather than pointing at the wrong page, and the toast says so. + h.state_mut().open_bytes("one.pdf", None, ONE_PAGE.to_vec()).unwrap(); + h.run_steps(3); + h.get_by_label("Bookmark options").click(); + h.run_steps(2); + h.get_by_label("Paste bookmarks").click(); + h.run_steps(3); + assert_eq!(active_outline(&h), [("Intro".to_string(), Some(0), 0), ("Results".to_string(), None, 1)]); + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("1 pasted bookmark points past the last page"), "{toast}"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert!(active_outline(&h).is_empty()); + assert_eq!(outline(&h), tree, "the source is untouched"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 909cec18da..ac1ebdd20b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3433,6 +3433,18 @@ tools = ["bookmark_export", "bookmark_import", "bookmark_copy"] evidence = ["crates/organize/src/tests.rs::an_exported_outline_imports_into_another_document_unchanged", "crates/organize/src/tests.rs::a_page_the_target_lacks_keeps_its_bookmark_without_a_destination", "crates/organize/src/tests.rs::an_import_appends_inserts_or_replaces", "crates/organize/src/tests.rs::a_bad_node_anywhere_refuses_the_whole_import", "crates/automation/tests/it/automation.rs::bookmarks_export_import_and_copy_between_documents", "crates/automation/tests/it/automation.rs::a_bad_bookmark_tree_is_refused_whole_and_names_the_node"] notes = "#6018, headless only. The bookmark tree (or one bookmark's subtree) goes out as JSON with titles, nesting, open state, colour, bold/italic and destinations as page plus view in PDF user space, and comes back unchanged: into another document, under any bookmark, at any position, or in place of its outline, as one undoable step; bookmark_copy does the same between open documents by page number. A bookmark whose page the target lacks keeps its place without a destination and is reported, never dropped. Not yet: a panel command and preview, CSV/Markdown/pdftk formats, remapping by named destination (named destinations travel resolved to their page), actions other than go-to-page, structure links (/SE)" +[[feature]] +id = "organize.bookmark-clipboard" +title = "Cut, copy and paste bookmarks" +area = "D" +tier = "P2" +milestone = "M4" +status = "shipped" +commands = [] +tools = ["bookmark_copy", "bookmark_export", "bookmark_import", "bookmark_delete"] +evidence = ["crates/ui-egui/tests/it/bookmarks.rs::cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents", "crates/automation/tests/it/automation.rs::bookmarks_export_import_and_copy_between_documents"] +notes = "#3994 (the cut/copy/paste part). Cut, Copy and Paste in a bookmark's menu, and Paste bookmarks in Bookmark options, carry a bookmark with its whole subtree within a document or into another one, each as one undo step. A destination is a page index: a bookmark whose page the target lacks keeps its place without a destination and the toast counts them. Headless, bookmark_copy copies between open documents directly (there is no clipboard). Not yet: keyboard shortcuts in the panel, the system clipboard (the bookmarks stay inside the app), several bookmarks at once, copying out of a document that cannot be edited (the bookmark menu is hidden there), page-ordered insertion (the other half of #3994), actions other than go-to-page" + [[feature]] id = "organize.attachments-add" title = "Add file attachments" From bfa6db7bd39975fade5180404e5639dea47b9fa0 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 05:53:41 -0500 Subject: [PATCH 55/63] M4: a cut too deep to copy leaves the bookmarks in place (#3994) A cut copies the subtree, then deletes it. When the copy is truncated (bookmarks nested past the listing depth), paste cannot bring back what was left out, so the cut now keeps the copy, deletes nothing and says why. Test: cutting_a_subtree_too_deep_to_copy_keeps_it (40-level synthetic outline); fails when the truncated copy is allowed to delete. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/ui-egui/src/editing.rs | 24 ++++++++++-------- crates/ui-egui/tests/it/bookmarks.rs | 37 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index f2b20f39ed..879025b01d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -981,12 +981,15 @@ impl PdfCraftApp { self.copy_bookmark(id, &path); return; } - A::Cut(path) => { - if !self.copy_bookmark(id, &path) { + A::Cut(path) => match self.copy_bookmark(id, &path) { + Some(false) => Edit::DeleteBookmark { path }, + // Paste could not bring back what the copy left out, so the bookmarks stay. + Some(true) => { + self.notify_tr("Bookmarks nested too deep to list could not be copied, so nothing was cut."); return; } - Edit::DeleteBookmark { path } - } + None => return, + }, A::Paste { parent, index } => { self.paste_bookmarks(id, parent, index); return; @@ -1006,22 +1009,23 @@ impl PdfCraftApp { } impl PdfCraftApp { - /// Put the bookmark at `path` and its subtree on the bookmark clipboard; `false` if it could - /// not be read (the reason is shown). - fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> bool { + /// Put the bookmark at `path` and its subtree on the bookmark clipboard. `Some(true)` when + /// bookmarks nested too deep to list were left out; `None` if it could not be read (the reason + /// is shown). + fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> Option { match self.session.outline_tree(id, path) { Some(Ok(tree)) => { if tree.truncated { self.notify_tr("Bookmarks nested too deep to list were left out of the copy."); } self.bookmark_clipboard = Some(tree.nodes); - true + Some(tree.truncated) } Some(Err(e)) => { self.notify_error(e); - false + None } - None => false, + None => None, } } diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 5251871401..668227e8e7 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -531,3 +531,40 @@ fn cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents() { assert!(active_outline(&h).is_empty()); assert_eq!(outline(&h), tree, "the source is untouched"); } + +/// A one-page document whose single bookmark "Top" heads a chain `depth` bookmarks deep. +fn deep_outline_pdf(depth: usize) -> Vec { + let mut pdf = String::from( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Outlines /First 10 0 R /Last 10 0 R /Count 1 >> endobj +", + ); + for k in 0..depth { + let id = 10 + k; + let parent = if k == 0 { 4 } else { id - 1 }; + let title = if k == 0 { "Top".to_string() } else { format!("L{k}") }; + let kids = if k + 1 < depth { format!("/First {0} 0 R /Last {0} 0 R /Count 1", id + 1) } else { String::new() }; + pdf.push_str(&format!("{id} 0 obj << /Title ({title}) /Parent {parent} 0 R {kids} /Dest [3 0 R /Fit] >> endobj\n")); + } + pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + pdf.into_bytes() +} + +/// Cutting a subtree nested deeper than a copy can hold would lose what the copy left out, so +/// the cut copies and says why it deleted nothing. +#[test] +fn cutting_a_subtree_too_deep_to_copy_keeps_it() { + let mut h = harness(); + h.state_mut().open_bytes("deep.pdf", None, deep_outline_pdf(40)).unwrap(); + h.run_steps(3); + let before = active_outline(&h); + assert_eq!(before.first().map(|b| b.0.as_str()), Some("Top")); + menu(&mut h, "Top", "Cut"); + assert_eq!(active_outline(&h), before, "nothing was cut"); + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("so nothing was cut"), "{toast}"); + assert!(h.state().bookmark_clipboard.is_some(), "the copy is still on the bookmark clipboard"); +} From bc7c60bb41f2a5d98121301b4398bf57e805b8a3 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Wed, 4 Nov 2026 12:49:11 +0100 Subject: [PATCH 56/63] M11: JBIG2 encodes only what its decoder takes: Optimize no longer stores large images PdfCraft can't draw (#5511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M11: JBIG2 encodes only what its decoder takes: Optimize no longer loses large images encode_jbig2 took rows of up to 2^20 samples and any number of them, while hayro-jbig2, which draws and reads these streams, takes 65,535 on a side and 2^28 samples. The optimizer doesn't read back what it stores, so with Monochrome Images on JBIG2, any 1-bit image with a side of 65,536 or more was replaced by a stream that draws nothing: 65,536 × 16 went from 37,875 dark pixels to none, and was reported as recompressed. The encoder now refuses what the decoder can't read, so such an image stays as it is. Symbol coding's limit of 2^24 rows can't be reached any more, so it goes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: the JBIG2 boundary test checks a pixel It counted rows, which a page decoded blank would pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: the encoder's bound on samples is MAX_JBIG2_PIXELS, the decoder's main brought decode_jbig2 and its public MAX_JBIG2_PIXELS into this crate, 2^28 like this branch's private MAX_JBIG2_SAMPLES and for the same reason. One constant now bounds both, so the encoder can't drift past what the decoder reads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: an image too large for JBIG2 isn't stored as JBIG2, as the notes say The parity note said such an image stays as it is. A 1-bit one does unless it is averaged, but one packed from more bits may become 1-bit Flate. The note now says it isn't stored as JBIG2 and cites an_image_too_large_for_the_jbig2_decoder_stays_as_it_is, and the optimizer README lists the bound under Not yet. MAX_JBIG2_PIXELS is the most the decoder takes, not about that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: the optimizer's own size limit, and tests for what JBIG2 can't take The optimizer leaves images over 80 million pixels alone whatever the settings, and nothing said so; the README and parity note say it now, and that JBIG2's 2^28 in all is past it, so from Optimize the side bound is the one that applies. The note says what the image step does: it leaves a 1-bit image past the bound as it is unless it is averaged, and may store one packed from more bits as 1-bit Flate. what_is_too_large_for_jbig2_is_stored_another_way tests those two cases: eight-bit grey of black and white at 65,536 wide becomes 1-bit Flate, and a 1-bit image at 600 ppi averaged to 70,000 wide does too. At 65,535 and 35,000 both become JBIG2. The symbol cap's comment says why hayro-jbig2 refuses 32,768 symbols: it caps new and exported symbols together at 65,535, and a plan exports every symbol it codes. Measured: 32,767 decode, 32,768, 40,000 and 65,535 are refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: the 80-million-pixel limit is the image steps', tested on both sides The README and parity note said images over 80 million pixels are left alone whatever the settings, but Clean Up still Flates an unfiltered or LZW one. They now say the image steps leave them alone, and the note cites group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit for it. (The last commit said the note also gives 2^28 as past that limit; only the README does.) what_is_too_large_for_jbig2_is_stored_another_way covered the width bound only: it now runs tall images too, packed and averaged, and checks the averaged ones are one bit and half the size both ways. The older test uses the same bars() fixture, and its comment says such a stream drew nothing in PdfCraft, not that the picture was lost: Poppler and Ghostscript draw it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: the JBIG2 stored at the decoder's bound reads back, and grey keeps the pixel limit - The optimize test at 65,535 on a side decoded nothing it stored: it checked the filter only, and the coding stored there is symbols, not the generic coding the filters test reads back. It now decodes the stored stream and compares it with the bars. - The README's 80-million-pixel sentence covers the colour and grey steps too, and only the 1-bit step had a test. A grey image of 10,000 × 8,000 at 600 ppi is resampled; a row more stays as it is. - The filters README names decode_jbig2_within, with its work, as main renamed decode_jbig2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: the filters README names #5569's other renames too The README named decode_jbig2_within but still decode_ccitt, which #5569 made private behind decode_ccitt_within, and gave Document::decoded_image as both decoders' caller, which is test-only now: ImageBudget::decode calls them, as lib.rs's doc says. The ccitt.rs test line keeps decode_ccitt, which those tests call. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --------- Co-authored-by: Claude Opus 5.5 --- crates/filters/README.md | 16 ++-- crates/filters/src/jbig2.rs | 57 +++++++++++-- crates/filters/src/jbig2/decode.rs | 3 +- crates/filters/src/jbig2/symbols.rs | 53 +++++------- crates/optimize/README.md | 9 +- crates/optimize/tests/optimize.rs | 123 ++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 211 insertions(+), 54 deletions(-) diff --git a/crates/filters/README.md b/crates/filters/README.md index 90e1485177..cdaef6ff95 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -27,10 +27,10 @@ encode(filter, params, data) -> Result, FilterError> encode_flate(data) -> Vec // zlib, level 6 encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS pub struct CcittParams { k, columns, rows, end_of_line, encoded_byte_align, end_of_block, black_is_1 } // Table 11; Default = its defaults -decode_ccitt(data, ¶ms, max_rows) -> Result, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS -decode_jbig2(data, globals, columns, rows, max) -> Result, FilterError> // JBIG2 as JBIG2Decode gives it; a page of columns × rows, within max and MAX_JBIG2_PIXELS -encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS -encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; columns <= MAX_JBIG2_COLUMNS +decode_ccitt_within(data, ¶ms, max_rows, work: &mut u64) -> Result, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS; its rows paid for from work before they are decoded +decode_jbig2_within(data, globals, columns, rows, max, work: &mut u64) -> Result, FilterError> // JBIG2 as JBIG2Decode gives it; a page of columns × rows, within max and MAX_JBIG2_PIXELS; reading and decoding it spend from work, in the decoder's steps +encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns and rows <= MAX_JBIG2_COLUMNS (65,535) and MAX_JBIG2_PIXELS (2^28) samples, what the decoder takes +encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; the same limits ``` - `chain` is applied first to last, as in a `/Filter` array. @@ -50,8 +50,8 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row | ASCIIHexDecode | white space ignored, odd digit padded with 0, `>` EOD (optional), other characters → Corrupt | upper case, newline every 64 digits, `>` | | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | -| CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says, so data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at `k` 0 may come out as other rows (`pdfcraft_cos::Document::decoded_image` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | -| JBIG2Decode | not in a chain (`Unsupported`); `decode_jbig2` decodes it on its own with `hayro-jbig2`, given what its `/JBIG2Globals` decode to, as rows of 1-bit samples each on a new byte, black 0; the page has to be `columns` × `rows` (PdfCraft's renderer draws the whole page into the image), within `max` bytes as such rows and 2^28 pixels (`MAX_JBIG2_PIXELS`, about the most the decoder takes), checked before it is decoded; regions inside it are bounded by the decoder alone (a region may claim more pixels than its page); damaged data, or globals that don't decode, are `Corrupt` (`pdfcraft_cos::Document::decoded_image` calls it for a JBIG2 image, after the filters before it) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made | +| CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt_within` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says, so data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at `k` 0 may come out as other rows (`pdfcraft_cos::ImageBudget::decode` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | +| JBIG2Decode | not in a chain (`Unsupported`); `decode_jbig2_within` decodes it on its own with `hayro-jbig2`, given what its `/JBIG2Globals` decode to, as rows of 1-bit samples each on a new byte, black 0; the page has to be `columns` × `rows` (PdfCraft's renderer draws the whole page into the image), within `max` bytes as such rows and 2^28 pixels (`MAX_JBIG2_PIXELS`, the most the decoder takes), checked before it is decoded; regions inside it are bounded by the decoder alone (a region may claim more pixels than its page); damaged data, or globals that don't decode, are `Corrupt` (`pdfcraft_cos::ImageBudget::decode` calls it for a JBIG2 image, after the filters before it) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 65,535 samples on a side and 2^28 in all, where `hayro-jbig2` stops; no rows or columns, or too large → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made | ## Tests @@ -61,8 +61,8 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row - `ccitt.rs`: `decode_ccitt` gives back the rows `encode_g4` was given (with `/BlackIs1`, `/Rows` and a row limit), refuses bad widths, and bounds its output for any input; byte-aligned Group 3 in each layout, over 32 seeded pictures, decodes to its picture, and a byte short to fewer rows. Group 4 output decoded by `hayro-ccitt` (an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. - `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. - `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. -- `jbig2/decode.rs`: `decode_jbig2` gives back the rows of both codings, padding bits 0; refuses a page of another width or height, past `max` or past `MAX_JBIG2_PIXELS` before decoding it (2^28 pixels take about 34 MB, a bit each), globals that aren't JBIG2 and damaged data, and returns rather than panics on every prefix of a stream. -- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; any input returns rather than panics. +- `jbig2/decode.rs`: `decode_jbig2_within` gives back the rows of both codings, padding bits 0; refuses a page of another width or height, past `max` or past `MAX_JBIG2_PIXELS` before decoding it (2^28 pixels take about 34 MB, a bit each), globals that aren't JBIG2 and damaged data, and returns rather than panics on every prefix of a stream. +- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs index 1b705b0c72..ffcfe45e77 100644 --- a/crates/filters/src/jbig2.rs +++ b/crates/filters/src/jbig2.rs @@ -12,9 +12,11 @@ pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2_within}; use crate::FilterError; use crate::mq::{Context, MqEncoder}; -/// The widest row [`encode_jbig2_generic`] encodes. JBIG2 allows 32-bit widths; this keeps a -/// row's buffers small and its positions in range. -pub const MAX_JBIG2_COLUMNS: u32 = 1 << 20; +/// The widest row, and the most rows, [`encode_jbig2_generic`] encodes: what `hayro-jbig2`, the +/// decoder behind `JBIG2Decode` here, takes on a side. JBIG2 allows 32-bit sizes, but past this +/// nothing here could read the stream, and the optimizer, which doesn't read back what it stores, +/// replaced such an image with one that draws nothing. +pub const MAX_JBIG2_COLUMNS: u32 = 65_535; /// Template 0's pixels (Figure 3), as (x, y) offsets from the pixel being coded, each a bit of the /// context in this order: the four on the current row, the five fixed ones on the row above, the @@ -72,10 +74,12 @@ pub fn encode_jbig2(data: &[u8], columns: u32, rows: u32) -> Result, Fil /// samples have it. Bytes after the last row are ignored, as are the bits that pad a row to a whole /// byte. /// -/// Errors: no rows or columns, or rows wider than [`MAX_JBIG2_COLUMNS`] -/// ([`FilterError::Unsupported`]); fewer bytes than the rows need ([`FilterError::Corrupt`]). +/// Errors: no rows or columns, more of either than [`MAX_JBIG2_COLUMNS`], or more samples than +/// [`MAX_JBIG2_PIXELS`], what the decoder takes ([`FilterError::Unsupported`]); fewer bytes than the +/// rows need ([`FilterError::Corrupt`]). pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { - if columns == 0 || rows == 0 || columns > MAX_JBIG2_COLUMNS { + let too_large = columns > MAX_JBIG2_COLUMNS || rows > MAX_JBIG2_COLUMNS || u64::from(columns) * u64::from(rows) > MAX_JBIG2_PIXELS; + if columns == 0 || rows == 0 || too_large { return Err(FilterError::Unsupported(format!("JBIG2 encoding of {columns} × {rows} samples"))); } let width = columns as usize; @@ -155,7 +159,7 @@ fn segment(out: &mut Vec, number: u32, kind: u8, refers_to: &[(u8, bool)], r out.push((refers_to.len() as u8) << 5 | kept | u8::from(retained)); out.extend(refers_to.iter().map(|(n, _)| *n)); out.push(1); - // A segment's data never comes near 4 GiB here: a row is at most 1 MiB wide. + // A segment's data never comes near 4 GiB here: an image is at most 2^28 samples. out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes()); out.extend_from_slice(data); } @@ -343,9 +347,44 @@ mod tests { fn what_it_cannot_encode_is_an_error() { assert!(matches!(encode_jbig2_generic(&[0; 8], 0, 1), Err(FilterError::Unsupported(_)))); assert!(matches!(encode_jbig2_generic(&[0; 8], 8, 0), Err(FilterError::Unsupported(_)))); - assert!(matches!(encode_jbig2_generic(&[0; 8], MAX_JBIG2_COLUMNS + 1, 1), Err(FilterError::Unsupported(_)))); assert!(matches!(encode_jbig2_generic(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes"); - assert!(matches!(encode_jbig2_generic(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. }))); + assert!(matches!(encode_jbig2_generic(&[], 1, 2), Err(FilterError::Corrupt { .. }))); + } + + /// The encoder stops where the decoder does, on each side and in total: past that it would + /// write a stream nothing here can read. 16,384 squared is exactly the bound on samples. + #[test] + fn it_encodes_only_what_the_decoder_takes() { + let unsupported = |columns, rows| matches!(encode_jbig2_generic(&[], columns, rows), Err(FilterError::Unsupported(_))); + assert!(unsupported(MAX_JBIG2_COLUMNS + 1, 1), "too wide"); + assert!(unsupported(1, MAX_JBIG2_COLUMNS + 1), "too tall"); + assert!(unsupported(16_385, 16_384), "too many samples, each side within the bound"); + assert!(unsupported(u32::MAX, u32::MAX), "the product doesn't overflow"); + // At the bounds what it writes reads back, through the decoder that has to read it. + let wide = vec![0x0F; (MAX_JBIG2_COLUMNS as usize).div_ceil(8) * 4]; + assert_eq!(decode(&encode_jbig2_generic(&wide, MAX_JBIG2_COLUMNS, 4).unwrap()), unpack(&wide, MAX_JBIG2_COLUMNS, 4)); + let tall = vec![0x7F; MAX_JBIG2_COLUMNS as usize]; + assert_eq!(decode(&encode_jbig2_generic(&tall, 1, MAX_JBIG2_COLUMNS).unwrap()), unpack(&tall, 1, MAX_JBIG2_COLUMNS)); + struct Rows(u32, u64); + impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.1 += u64::from(black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.1 += u64::from(black) * u64::from(chunks) * 8; + } + fn next_line(&mut self) { + self.0 += 1; + } + } + // One black pixel, so a page that came back blank wouldn't pass. + let mut largest = vec![0xFF; 2_048 * 16_384]; + largest[0] = 0x7F; + let largest = encode_jbig2_generic(&largest, 16_384, 16_384).unwrap(); + let mut rows = Rows(0, 0); + hayro_jbig2::Image::new_embedded(&largest, None).unwrap().decode(&mut rows).unwrap(); + assert_eq!(rows.0, 16_384); + assert_eq!(rows.1, 1); } proptest::proptest! { diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index a018e75937..7832b96aaa 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -4,7 +4,8 @@ use crate::FilterError; use crate::ccitt::Rows; /// The largest JBIG2 page [`decode_jbig2_within`] decodes, in pixels. The decoder decodes a page whole, -/// and takes pages of up to about this many (20,000 × 10,000 decodes). +/// and takes pages of up to this many (20,000 × 10,000 decodes). It is also the most +/// [`encode_jbig2_generic`](crate::encode_jbig2_generic) encodes, so what it stores reads back. pub const MAX_JBIG2_PIXELS: u64 = 1 << 28; /// A `JBIG2Decode` stream's `data`, with what its `/JBIG2Globals` stream decodes to, decoded as the diff --git a/crates/filters/src/jbig2/symbols.rs b/crates/filters/src/jbig2/symbols.rs index e9fed24441..82244750b7 100644 --- a/crates/filters/src/jbig2/symbols.rs +++ b/crates/filters/src/jbig2/symbols.rs @@ -27,7 +27,9 @@ const MAX_SYMBOLS: usize = 1 << 16; const MAX_SYMBOL_PIXELS: usize = 1 << 24; /// The most symbols one plan codes. The decoder PdfCraft renders JBIG2 with (`hayro-jbig2`) -/// refuses 32,768 symbols or more, and others may too, so a plan with more isn't made. +/// refuses a dictionary whose new and exported symbols pass 65,535 together, and a plan exports +/// every symbol it codes, so it refuses 32,768 or more; others may too, so a plan with more isn't +/// made. const MAX_CODED_SYMBOLS: usize = (1 << 15) - 1; /// The most symbol instances one text region places. The decoder PdfCraft renders JBIG2 with @@ -40,10 +42,6 @@ const MAX_REGION_INSTANCES: usize = 10_000; // generic region. const _: () = assert!(MAX_RUNS.div_ceil(MAX_REGION_INSTANCES) + 3 <= 256); -/// The most rows symbol coded. With rows at most 2^20 samples wide, every coordinate and -/// difference coded then fits the 32-bit integers Annex A codes. -const MAX_ROWS: u32 = 1 << 24; - /// The integer coders' ranges (Table A.1): how many 1 bits pick the range, how many bits give the /// offset into it, and its first value. The last range has no 0 after its prefix. const RANGES: [(u32, u32, u32); 6] = [(0, 2, 0), (1, 4, 4), (2, 6, 20), (3, 8, 84), (4, 12, 340), (5, 32, 4436)]; @@ -104,15 +102,12 @@ struct Instance { /// `None` when neither plan has from two to [`MAX_CODED_SYMBOLS`] symbols, or the image is too /// large or too busy to look at. pub(super) fn encode(samples: &[u8], columns: u32, rows: u32) -> Option> { - let page = page(samples, columns, rows)?; + let page = page(samples, columns)?; [1, 2].into_iter().filter_map(|copies| plan(&page, columns, rows, copies, None)).min_by_key(Vec::len) } /// The image's marks and symbols; `None` past the limits above. -fn page(samples: &[u8], columns: u32, rows: u32) -> Option { - if rows > MAX_ROWS { - return None; - } +fn page(samples: &[u8], columns: u32) -> Option { let width = usize::try_from(columns).ok()?; let stride = width.div_ceil(8); if stride == 0 { @@ -665,7 +660,7 @@ mod tests { fn every_plan_round_trips(picture: &Bitmap) -> usize { let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); let data = samples(picture); - let page = page(&data, columns, rows).unwrap(); + let page = page(&data, columns).unwrap(); let mut plans = 0; for copies in [1, 2] { for log_strips in 0..4 { @@ -735,7 +730,7 @@ mod tests { row[stride - 1] &= 0xFF << (8 * stride - columns); } let (c, r) = (columns as u32, 120); - let page = page(&data, c, r).unwrap(); + let page = page(&data, c).unwrap(); assert!(page.marks.iter().all(|m| m.x1 as usize <= columns), "{columns} columns"); for copies in [1, 2] { if let Some(coded) = plan(&page, c, r, copies, None) { @@ -771,7 +766,7 @@ mod tests { row[290] = 1; } let data = samples(&picture); - let page = page(&data, 300, 300).unwrap(); + let page = page(&data, 300).unwrap(); let boxes: Vec<(u32, u32, u32, u32)> = page.marks.iter().map(|m| (m.x0, m.y0, m.x1 - m.x0, m.y1 - m.y0)).collect(); assert_eq!( boxes, @@ -792,18 +787,14 @@ mod tests { assert_eq!(every_plan_round_trips(&picture), 8); } - /// Past 2^20 black runs or 2^24 rows the image is left to generic coding; at either cap it is - /// still looked at. + /// Past 2^20 black runs the image is left to generic coding; at the cap it is still looked at. #[test] - fn busy_or_tall_images_are_left_to_generic_coding() { + fn busy_images_are_left_to_generic_coding() { // 0x55: four black pixels a byte, each its own run, under the same ones on the next row. let columns = 1 << 20; let row = vec![0x55u8; columns / 8]; - assert!(page(&row.repeat(2), columns as u32, 2).is_some(), "2^20 runs"); - assert!(page(&row.repeat(3), columns as u32, 3).is_none(), "1.5 × 2^20 runs"); - let tall = vec![0xFFu8; MAX_ROWS as usize + 1]; - assert!(page(&tall, 1, MAX_ROWS + 1).is_none()); - assert!(page(&tall, 1, MAX_ROWS).is_some()); + assert!(page(&row.repeat(2), columns as u32).is_some(), "2^20 runs"); + assert!(page(&row.repeat(3), columns as u32).is_none(), "1.5 × 2^20 runs"); } /// A plan codes at most 32,767 symbols, which an independent decoder reads back; with more, @@ -828,16 +819,16 @@ mod tests { (samples(&picture), columns as u32, rows as u32) }; let (data, columns, rows) = codes(MAX_CODED_SYMBOLS, 0); - let at_cap = page(&data, columns, rows).unwrap(); + let at_cap = page(&data, columns).unwrap(); assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); // One more distinct mark, each once: neither plan is made. let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 0); - let past = page(&data, columns, rows).unwrap(); + let past = page(&data, columns).unwrap(); assert!(plan(&past, columns, rows, 1, None).is_none() && plan(&past, columns, rows, 2, None).is_none()); assert_eq!(encode_jbig2(&data, columns, rows).unwrap(), encode_jbig2_generic(&data, columns, rows).unwrap()); // With two of them repeated, the second plan codes those two. let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 200); - let past = page(&data, columns, rows).unwrap(); + let past = page(&data, columns).unwrap(); assert!(plan(&past, columns, rows, 1, None).is_none()); assert_eq!(decode(&plan(&past, columns, rows, 2, None).unwrap()), unpack(&data, columns, rows)); // Above a page of text whose letters repeat, that second plan is the one `encode_jbig2` @@ -857,10 +848,10 @@ mod tests { let coded = encode_jbig2(&data, columns, rows).unwrap(); assert_eq!(segment_types(&coded)[1], 0, "symbol coded"); assert_eq!(decode(&coded), unpack(&data, columns, rows)); - let (data, columns, rows) = codes(MAX_SYMBOLS, 0); - assert_eq!(page(&data, columns, rows).map(|p| p.symbols.len()), Some(MAX_SYMBOLS)); - let (data, columns, rows) = codes(MAX_SYMBOLS + 1, 0); - assert!(page(&data, columns, rows).is_none()); + let (data, columns, _) = codes(MAX_SYMBOLS, 0); + assert_eq!(page(&data, columns).map(|p| p.symbols.len()), Some(MAX_SYMBOLS)); + let (data, columns, _) = codes(MAX_SYMBOLS + 1, 0); + assert!(page(&data, columns).is_none()); // `n` black squares of 256 × 256, each with a white pixel in a different place: 2^16 // pixels a symbol, so 256 of them are exactly the cap. let squares = |n: usize| { @@ -876,11 +867,11 @@ mod tests { (samples(&picture), columns as u32, rows as u32) }; let (data, columns, rows) = squares(256); - let at_cap = page(&data, columns, rows).unwrap(); + let at_cap = page(&data, columns).unwrap(); assert_eq!(at_cap.symbols.len(), 256); assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); - let (data, columns, rows) = squares(257); - assert!(page(&data, columns, rows).is_none()); + let (data, columns, _) = squares(257); + assert!(page(&data, columns).is_none()); } /// More than 10,000 marks are placed by several text regions, each of at most 10,000 (more diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 2a6c664e6a..6616e1b558 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -30,7 +30,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. A stencil `/Mask` has its own resolution, so the picture changes under it and it stays as it is. - An image is replaced only if the result is smaller. Left alone by these colour and grey + An image is replaced only if the result is smaller. Images over 80 million pixels are left + alone by the image steps, here and below, whatever their settings (memory); Clean Up may still + Flate an unfiltered or LZW one. Left alone by these colour and grey settings: CMYK and other colour spaces, image masks, colour-key masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT (black-and-white images, CCITT and JBIG2 among them, are below). A JPEG is read with its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one @@ -96,8 +98,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going space, decode array and `/Mask` stay, but for packed RGB, which becomes grey with one decode pair. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit art Flate can be smaller. Not yet: inline images, an image's own `/SMask` or `/Mask` - image (only images drawn on a page are visited), and Group 4 for rows wider than 32,767 - samples. + image (only images drawn on a page are visited), Group 4 for rows wider than 32,767 + samples, and JBIG2 past 65,535 samples on a side, where the decoder stops (its 2^28 in all + is past the 80 million pixels above). - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2 diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index ad710f3097..fe607bcd8b 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3621,6 +3621,129 @@ fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() { } } +/// An image past what the JBIG2 decoder takes on a side stays as it is: JBIG2 can state the size, +/// but PdfCraft's decoder refuses such a stream, so stored as JBIG2 it drew nothing here. At 65,535 +/// on a side, the same bars do become JBIG2, so the fixture does reach that choice, and the coding +/// stored (symbols, here) reads back through that decoder. +#[test] +fn an_image_too_large_for_the_jbig2_decoder_stays_as_it_is() { + for (w, h, stored) in + [(65_535u32, 16u32, &b"JBIG2Decode"[..]), (16, 65_535, b"JBIG2Decode"), (65_536, 16, b"FlateDecode"), (16, 65_536, b"FlateDecode")] + { + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &bars(w, h), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", image)], &format!("q {w} 0 0 {h} 0 0 cm /Im Do Q")); + let before = stream(&doc, image).raw.clone(); + let settings = + Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, downsample: false, ..MonoSettings::REDUCE }, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + let s = stream(&doc, image); + assert_eq!(s.dict.name(b"Filter"), Some(stored), "{w} × {h}"); + if stored == b"FlateDecode" { + assert_eq!(s.raw, before, "{w} × {h}"); + } else { + // The decoder gives padding bits as 0; the bars have them 1. + let mut bits = bars(w, h); + if w % 8 != 0 { + for row in bits.chunks_mut((w as usize).div_ceil(8)) { + if let Some(last) = row.last_mut() { + *last &= 0xFFu8 << (8 - w % 8); + } + } + } + assert!(decoded_image(&doc, &s).unwrap() == bits, "{w} × {h} reads back"); + } + } +} + +/// The colour and grey steps leave an image over 80 million pixels as it is, whatever their settings +/// (memory): 10,000 × 8,000 grey, the limit itself, drawn at 600 ppi, is resampled, and a row more +/// stays as it is. +#[test] +fn grey_images_are_resampled_up_to_the_pixel_limit() { + for (h, resampled) in [(8_000u32, true), (8_001, false)] { + let mut doc = Document::new_empty(); + let grey = image(&mut doc, 10_000, h, 1, false, None); + let before = stream(&doc, grey).raw.clone(); + let (pw, ph) = (10_000.0 * 72.0 / 600.0, f64::from(h) * 72.0 / 600.0); + page(&mut doc, &[("G", grey)], &format!("q {pw} 0 0 {ph} 0 0 cm /G Do Q")); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, usize::from(resampled), "10,000 × {h}: {report:?}"); + let s = stream(&doc, grey); + if resampled { + assert_eq!(s.dict.int(b"Width"), Some(2_500), "10,000 × {h}"); + } else { + assert!(s.dict.int(b"Width") == Some(10_000) && s.raw == before, "10,000 × {h} stays as it is"); + } + } +} + +/// Dark bars of irregular lengths across the long side of a `w` × `h` 1-bit image, which Flate +/// shrinks less than JBIG2. +fn bars(w: u32, h: u32) -> Vec { + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut state = 0x1234_5678u32; + let mut next = move || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state as usize + }; + let long = w.max(h) as usize; + let mut at = 10; + while at + 60 < long { + let len = 5 + next() % 50; + for along in at..at + len { + for across in 0..12 { + let (x, y) = if w > h { (along, across) } else { (across, along) }; + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + at += len + 5 + next() % 50; + } + data +} + +/// Past what the JBIG2 decoder takes on either side, an image packed from more bits, or one +/// averaged to a size still past it, isn't stored as JBIG2 either: it becomes 1-bit Flate. Within +/// the bound the same pictures do become JBIG2, so the fixtures reach that choice. +#[test] +fn what_is_too_large_for_jbig2_is_stored_another_way() { + let settings = |downsample| Settings { + mono: MonoSettings { compression: MonoCompression::Jbig2, downsample, ..MonoSettings::REDUCE }, + ..Settings::default() + }; + let (jbig2, flate) = (&b"JBIG2Decode"[..], &b"FlateDecode"[..]); + // Eight-bit grey of only black and white, packed to one bit first, wide and tall. + for (w, h, stored) in [(65_535u32, 16u32, jbig2), (65_536, 16, flate), (16, 65_535, jbig2), (16, 65_536, flate)] { + let (bits, stride) = (bars(w, h), (w as usize).div_ceil(8)); + let grey: Vec = (0..h as usize) + .flat_map(|y| (0..w as usize).map(move |x| (y, x))) + .map(|(y, x)| if bits[y * stride + x / 8] & (0x80 >> (x % 8)) == 0 { 0 } else { 255 }) + .collect(); + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &grey, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", image)], &format!("q {w} 0 0 {h} 0 0 cm /Im Do Q")); + optimize(&mut doc, &settings(false)).unwrap(); + let s = stream(&doc, image); + assert_eq!(s.dict.name(b"Filter"), Some(stored), "{w} × {h}, packed"); + assert_eq!(s.dict.int(b"BitsPerComponent"), Some(1), "{w} × {h}, packed"); + } + // One bit, drawn at 600 ppi and averaged to 300: half the size, so 140,000 is still past it. + for (w, h, stored) in [(70_000u32, 16u32, jbig2), (140_000, 16, flate), (16, 70_000, jbig2), (16, 140_000, flate)] { + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &bars(w, h), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let (pw, ph) = (f64::from(w) * 72.0 / 600.0, f64::from(h) * 72.0 / 600.0); + page(&mut doc, &[("Im", image)], &format!("q {pw} 0 0 {ph} 0 0 cm /Im Do Q")); + optimize(&mut doc, &settings(true)).unwrap(); + let s = stream(&doc, image); + assert_eq!(s.dict.name(b"Filter"), Some(stored), "{w} × {h}, averaged"); + assert_eq!(s.dict.int(b"BitsPerComponent"), Some(1), "{w} × {h}, averaged"); + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(i64::from(w / 2)), Some(i64::from(h / 2))), "{w} × {h}, averaged"); + } +} + /// An indexed image also drawn where the optimizer doesn't read (an annotation's appearance, where /// other default colour spaces may be in force and change which palette entry is darker) keeps its /// resolution, as black-and-white RGB keeps its colour space; drawn only on the page, it is diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8b7ac909a0..e60a1ad2fb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7439,8 +7439,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] -evidence = ["crates/optimize/src/images.rs::flate_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are"] -notes = "All of a run's images share one allowance of decoding, filters, decoders and pixels alike (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are, those the image pass read being counted in the report, which doc_optimize and doc_reduce return and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." +evidence = ["crates/optimize/src/images.rs::flate_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::an_image_too_large_for_the_jbig2_decoder_stays_as_it_is", "crates/optimize/tests/optimize.rs::what_is_too_large_for_jbig2_is_stored_another_way", "crates/optimize/tests/optimize.rs::group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit"] +notes = "All of a run's images share one allowance of decoding, filters, decoders and pixels alike (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are, those the image pass read being counted in the report, which doc_optimize and doc_reduce return and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: images over 80 million pixels (left alone by the image steps, whatever their settings; Clean Up may still Flate an unfiltered or LZW one), inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples, JBIG2 past 65,535 samples on a side, where the decoder stops: such an image isn't stored as JBIG2 (the image step leaves a 1-bit one as it is unless it is averaged, and may store one packed from more bits as 1-bit Flate)." [[feature]] id = "optimize.only-if-smaller" From e38b9d182ee55b9e092a098b542001f63aabb82e Mon Sep 17 00:00:00 2001 From: Piohrun Date: Wed, 4 Nov 2026 12:49:49 +0100 Subject: [PATCH 57/63] fix(render): JBIG2 refinement reads 0 past its reference's width (hayro-jbig2 patch 13) (#5999) * M2: a JBIG2 refinement reads 0 past its reference's width hayro-jbig2 keeps each row of a bitmap in 32-bit words and filled a new bitmap whole, so with a default pixel of 1 the bits past its width in each row's last word were 1s too. A refinement reads its reference a word at a time, those bits included, where ISO/IEC 14492 6.3.5.2 says a pixel outside the reference is 0. A refinement of the page, or of an intermediate text or halftone region, whose default pixel is 1 and whose width isn't a multiple of 32 decoded to other pixels than were coded. Vendored patch 13: Bitmap::clear_padding clears those bits when new_with or reinitialize fills a bitmap with 1s, as cutting symbols and patterns out of a collective bitmap already did. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M2: the doc on a JBIG2 bitmap's words says their padding is 0 Three places in hayro-jbig2 now clear the bits past a row's width, and the field's doc said only that rows are padded. It now says the padding is 0 and why: generic and refinement decoding read the rows above their own pixels a word at a time, as a refinement reads its reference, so whatever next writes whole words keeps it 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --------- Co-authored-by: Claude Opus 5.5 --- crates/filters/src/jbig2/decode.rs | 83 ++++++++++++++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-jbig2/src/bitmap.rs | 35 ++++++++++++- vendor/hayro-jbig2/src/lib.rs | 13 +++++ 4 files changed, 130 insertions(+), 3 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index 7832b96aaa..3a5b254492 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -214,4 +214,87 @@ mod tests { let _ = decode_jbig2(&data[..end], None, w, h, usize::MAX); } } + + /// Pixels past a bitmap's edge are 0 (ISO/IEC 14492 6.3.5.2). A page whose default pixel is 1, + /// and an intermediate text or halftone region whose default pixel is 1, kept 1s past their + /// width in their last word of each row, and a refinement, which reads its reference a word at + /// a time, read them as pixels: of rows not a multiple of 32 wide, it decoded to other pixels + /// than were coded. Each stream is a refinement coded by the standard's procedures from a + /// reference read as 0 past its edge, and decodes to the picture given, `#` for black. + /// Vendored hayro-jbig2 patch 13. + #[test] + fn a_refinement_reads_nothing_past_its_references_width() { + // A page of 7 × 6, then of 33 × 6, whose default pixel is 1, refined whole. + const PAGE_7: &str = "000000003000010000001300000007000000060000000000000000460000000000012a00010000001e000000070000000600000000000000000400ffffffffe42d7e889ebcffac"; + const PAGE_33: &str = "000000003000010000001300000021000000060000000000000000460000000000012a000100000034000000210000000600000000000000000400ffffffff314a8d598037eb3f915ab3d526ccae915ad6a6a7b814111b124ae60fffac"; + // An intermediate text region of 31 × 6 whose default pixel is 1, then one of 7 × 6 on a + // page of 40, each refined into the page. + const TEXT_31: &str = "00000000300001000000130000001f0000000600000000000000004200000000000100000100000030000003fffdff02fefefe00000001000000014f96dceb76c7f1ff585971969cd9f88e538261a6f713be68d77e8e1fffac00000002042001010000001b0000001f00000006000000000000000000029000000001a943ffac000000032a2002010000002f0000001f0000000600000000000000000400ffffffff9ac311688adbc8f570a2f96aef93d332e3b1f4b27b704bffac"; + const TEXT_7: &str = "000000003000010000001300000028000000060000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fe1f79f4a95c5b77fffac00000002042001010000001b0000000700000006000000000000000000029000000001a943ffac000000032a2002010000001e000000070000000600000000000000000400ffffffffa71dae12bd7fffac"; + // An intermediate halftone region of 33 × 6 whose default pixel is 1, refined into the page. + const HALFTONE_33: &str = "000000003000010000001300000021000000060000000000000000420000000000011000010000000a000101000000011fffac00000002142001010000004100000021000000060000000000000000009000000021000000060000000000000000010000004ea6618f551d48ff24c2c5c98124d449f2adea6fca76e4297fffac000000032a20020100000032000000210000000600000000000000000400ffffffffe87db809393a3251e565e7a749e6470ab37f7aef186ceaf5167bffac"; + let cases: [(&str, &str, [&str; 6]); 5] = [ + ("page 7", PAGE_7, ["###.#.#", ".......", "#.####.", "######.", "#.#...#", ".#...##"]), + ( + "page 33", + PAGE_33, + [ + ".#..##........###..####....#.##.#", + ".#.###.###.##.##..#..#...#.....##", + ".....##..#....#.##.######.###..#.", + "##...#..##.###..###.##..#.#.#.#.#", + ".##.#........##.#.#.....###......", + "...#.#####.#..#.###......###..###", + ], + ), + ( + "text 31", + TEXT_31, + [ + "....##....###......#.##.#.#....", + ".#.....##.......##..###.#...#..", + ".#.#.#.####...#####.####...#.#.", + "......#...#...#.##...##...#..#.", + ".#..##.###.#.###..####...#...#.", + "#...#...#..#.#....#.##.#.....#.", + ], + ), + ( + "text 7 on a page of 40", + TEXT_7, + [ + "......#.................................", + ".....##.................................", + "##...##.................................", + "..#.....................................", + "##......................................", + "..#.....................................", + ], + ), + ( + "halftone 33", + HALFTONE_33, + [ + "##...#.###.#.###.....#.####..####", + "##.##.#...#.##.##.##.#...#.#.###.", + "###..#.#....##.###...#..##.##..##", + "##..#...###...##..#.####.###.#...", + "####.###.###.#..#..#.#.####...###", + "#.#..#........#....#.#.##.##..#.#", + ], + ), + ]; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + for (form, stream, picture) in cases { + let w = picture[0].len(); + // As the filter gives it: black as 0, each row padded to a byte with 0s. + let mut rows = vec![0u8; w.div_ceil(8) * picture.len()]; + for (y, row) in picture.iter().enumerate() { + for (x, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'.') { + rows[y * w.div_ceil(8) + x / 8] |= 0x80 >> (x % 8); + } + } + assert_eq!(decode_jbig2(&hex(stream), None, w as u32, 6, usize::MAX).unwrap(), rows, "{form}"); + } + } } diff --git a/vendor/README.md b/vendor/README.md index 4fda6483af..bf0f5f9129 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | 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 | 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, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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 | (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` | +| 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 | (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` | | 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; (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 | (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`; (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) | | 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 | diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index 87738a849d..3e0f24e417 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -93,6 +93,11 @@ pub(crate) struct Bitmap { pub(crate) stride: u32, /// Packed pixel data, one bit per pixel, row-major order. /// Each row is padded to a word boundary. + /// + /// PdfCraft patch: the padding is 0s, as pixels past the edge are (6.2.5.2, 6.3.5.2). Generic + /// and refinement decoding read the rows above their own pixels a word at a time, padding + /// included, and a refinement reads its reference the same way: whatever writes whole words + /// keeps the padding 0 (see `clear_padding`). pub(crate) data: Vec, /// "This four-byte field gives the horizontal offset in pixels of the bitmap /// encoded in this segment relative to the page bitmap." (7.4.1.3) @@ -134,14 +139,37 @@ impl Bitmap { let stride = width.div_ceil(WORD_BITS); let default_word: Word = if default_pixel { !0 } else { 0 }; let data = vec![default_word; stride as usize * height as usize]; - Ok(Self { + let mut bitmap = Self { width, height, stride, data, x_location, y_location, - }) + }; + if default_pixel { + bitmap.clear_padding(); + } + Ok(bitmap) + } + + /// PdfCraft patch: clear the bits past `width` in each row's last word. "All pixels lying + /// outside the bounds of the actual bitmap or the reference bitmap have the value 0" (6.3.5.2), + /// and a refinement reads its reference a word at a time, those bits included: filled whole + /// with a default pixel of 1, they read as 1s, so a refinement of the page, or of an + /// intermediate text or halftone region, decoded to other pixels than were coded. + fn clear_padding(&mut self) { + let trailing = self.width % WORD_BITS; + // A width of 0 has no words to clear, and `chunks_exact_mut` would take a stride of 0. + if trailing == 0 { + return; + } + let mask: Word = !0 << (WORD_BITS - trailing); + for row in self.data.chunks_exact_mut(self.stride as usize) { + if let Some(last) = row.last_mut() { + *last &= mask; + } + } } pub(crate) fn reinitialize( @@ -162,6 +190,9 @@ impl Bitmap { self.data.clear(); self.data .resize(stride as usize * height as usize, default_word); + if default_pixel { + self.clear_padding(); + } Ok(()) } diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index 2ee0f8f050..baf354b18f 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -858,4 +858,17 @@ mod tests { page.retained_words = MAX_RETAINED_WORDS; assert_eq!(page.retained_room(), 0); } + + /// Pixels past a bitmap's edge are 0 (6.3.5.2), and a refinement reads its reference a word at + /// a time, so a bitmap filled with 1s has none past its width: made, and a page made again for + /// the next image. + #[test] + fn a_bitmap_of_1s_has_nothing_past_its_width() { + assert_eq!(Bitmap::new_with(33, 2, 0, 0, true).unwrap().data, [!0, 1 << 31, !0, 1 << 31]); + let mut page = Bitmap::new_with(40, 1, 0, 0, false).unwrap(); + page.reinitialize(7, 2, true).unwrap(); + assert_eq!(page.data, [0xFE00_0000, 0xFE00_0000]); + assert_eq!(Bitmap::new_with(64, 1, 0, 0, true).unwrap().data, [!0, !0], "words filled whole"); + assert!(Bitmap::new_with(0, 3, 0, 0, true).unwrap().data.is_empty(), "no width"); + } } From e79b5594557f6cc10d41874f5dbeb0e3e5431b79 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 07:10:55 -0500 Subject: [PATCH 58/63] automation: bookmark_import refuses a node key it does not know node_of read only the keys it knew and ignored the rest, so a misspelt key such as "colour" was dropped and the bookmark imported without its colour and without an error. A node may now carry only the eight keys bookmark_export writes (title, page, view, color, bold, italic, open, children); any other key refuses the whole import, naming the node's path and the key, before anything is written. The tool description says so. Test: a_misspelt_bookmark_key_is_refused_not_dropped puts "colour" on a nested node and checks the import is refused and the outline left empty; it fails with the check reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/src/bookmark_io.rs | 6 ++++++ crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/bookmark_io.rs b/crates/automation/src/bookmark_io.rs index 82c4927f62..2a06d912f4 100644 --- a/crates/automation/src/bookmark_io.rs +++ b/crates/automation/src/bookmark_io.rs @@ -12,6 +12,9 @@ use serde_json::{Value, json}; /// Deepest nesting a bookmark tree may have (the listing reaches 33 levels). const MAX_DEPTH: usize = 33; +/// The keys a node may have: the ones [`node_json`] writes. +const NODE_KEYS: [&str; 8] = ["title", "page", "view", "color", "bold", "italic", "open", "children"]; + impl Automation { pub(crate) fn bookmark_export(&self, a: &Args) -> Result { let id = self.doc(a)?.id; @@ -134,6 +137,9 @@ fn list_of(list: &Value, at: &[usize], count: &mut usize) -> Result Result { let o = v.as_object().ok_or_else(|| bad(at, "must be an object"))?; + if let Some(k) = o.keys().find(|k| !NODE_KEYS.contains(&k.as_str())) { + return Err(bad(at, &format!("unknown key {k:?} (a bookmark has {})", NODE_KEYS.join(", ")))); + } let field = |k: &str| o.get(k).filter(|x| !x.is_null()); // An empty title is kept: files have them, and export writes them as "". let title = field("title").and_then(Value::as_str).ok_or_else(|| bad(at, "needs a title"))?.to_string(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b25a653a83..553f79b12d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -336,7 +336,7 @@ pub fn tools() -> Vec { t( "bookmark_import", "Import bookmarks", - "Add a tree of bookmarks, as bookmark_export gives it (bookmarks) or as a JSON file it wrote (file), under a parent bookmark (or at the top level) at a position, or with replace in place of the whole outline. A bookmark whose page this document lacks is kept without a destination and listed in unmapped (1-based paths into the given tree). A view takes exactly its type's values (XYZ 3, FitR 4, FitH/FitV/FitBH/FitBV 1, Fit/FitB none). Give exactly one of bookmarks and file. A title may be empty but not missing. Every node is checked before anything is written: at most 100000 bookmarks, nested at most 33 levels counting the parent's. truncated is true when the file says its export left bookmarks out. Undoable.", + "Add a tree of bookmarks, as bookmark_export gives it (bookmarks) or as a JSON file it wrote (file), under a parent bookmark (or at the top level) at a position, or with replace in place of the whole outline. A bookmark whose page this document lacks is kept without a destination and listed in unmapped (1-based paths into the given tree). A view takes exactly its type's values (XYZ 3, FitR 4, FitH/FitV/FitBH/FitBV 1, Fit/FitB none). Give exactly one of bookmarks and file. A title may be empty but not missing; a key a node does not have is refused. Every node is checked before anything is written: at most 100000 bookmarks, nested at most 33 levels counting the parent's. truncated is true when the file says its export left bookmarks out. Undoable.", ) .destructive() .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 4a36e3630a..2d6c92fdc1 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10742,6 +10742,20 @@ fn odd_bookmarks_round_trip_and_a_cut_tree_says_so() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +fn a_misspelt_bookmark_key_is_refused_not_dropped() { + let dir = workdir("bookmark-io-key"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let tree = json!([{ "title": "Parent", "children": [{ "title": "Red", "page": 1, "colour": [1, 0, 0] }] }]); + match a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": tree })) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains("[1, 1]") && m.contains("\"colour\""), "{m}"), + other => panic!("expected InvalidArgs, got {other:?}"), + } + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([])); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() { let dir = workdir("bookmark-io-bad"); From 031af8cef87a0e320ea3c45123d206b80ac94c3c Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:16:44 +0100 Subject: [PATCH 59/63] vendor/README.md: one Test cell in the hayro-jbig2 row The merge left a | after patch (13)'s test, which made a sixth cell and hid (14) and (16) from the rendered table. Co-Authored-By: Claude Opus 5.5 --- vendor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/README.md b/vendor/README.md index eaceebc1c2..48f81a497d 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | 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 | 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, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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); (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`; (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` | +| 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); (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`; (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; (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 | (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`; (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) | | 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 | From 9203e4a6be69068d634d8ead7debb48b791ffc14 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Wed, 4 Nov 2026 13:17:29 +0100 Subject: [PATCH 60/63] M1: encryption reads every legal form: crypt filters by reference, a stream's own filter on screen, and full saves that keep /Encrypt (#5548) * M1: a crypt filter selected by reference is used A stream can name its own crypt filter, and every part of that can be indirect. cos read only the direct forms, and took anything else for no name, which is /Identity. So a stream selecting a filter by reference was saved in the clear in a file meant to be encrypted, and in a file from elsewhere its ciphertext was read as data. The /Encrypt dictionary's /StmF, /StrF, /EFF, /CFM, /Filter, /EncryptMetadata and a filter's /Length had the same fault, and /StmF by reference left every stream undecrypted. A stream's /Type and a signature dictionary's are read through references too. transform now reads these from the document, so a full save encrypts before it renumbers: the references in what it reads have to be the document's. Dropping a crypt filter that new protection doesn't define comes before the walk, so what it copies out of a reference is encrypted too. Loading can re-enter here, and LoadGuard ends a cycle, which reads as absent. A chain longer than any decoder takes names no filter: its entries would be followed for every stream sharing it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M2: a stream with a crypt filter of its own is drawn The renderer decrypted every stream with the document's filter and then failed on /Crypt as a filter it couldn't decode, so a stream naming its own filter read as nothing, however it was spelled, and a page whose content was such a stream stayed blank. It now uses the filter the stream names, /Identity without one, and /Crypt is nothing further to decode (hayro-syntax patch 30). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M1: tests for a crypt filter's place among the others A lone parameter dictionary is the first filter's, so /Crypt behind another filter has no name of its own, and an array too short for /Crypt's entry stays as it is when the filter is dropped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M1: encryption reads every legal form, and a full save keeps them From a review of the crypt filter fix. The renderer read a stream's own filter from the form /DecodeParms had to match the form of /Filter, so [/Crypt] with a lone dictionary, an everyday spelling, read as /Identity and the page stayed blank. Parameters are now taken in either form, which also gives [/FlateDecode] its predictor. It also refused a V4 file without /StmF or /StrF, which are optional, and used a /CF entry named /Identity, which is never one. A chain past what cos decodes names no filter there, so it names none here either (hayro-syntax patch 30). A full save lost what a direct /Encrypt refers to, and kept its references under the old numbers, so nothing in the file read afterwards. /O or /U given by reference were decrypted and encrypted again under a new number, so the file it wrote no longer opened. What /Encrypt and /ID refer to now stays as stored. /ID by reference, which has to be direct, is read, and written direct. A signature dictionary is known by a /ByteRange given by reference, and a null or dangling /Type or /Subtype is an absent one. References to references are followed in Document::resolve, so /Name by way of two no longer saves a stream in the clear. New protection drops every /Crypt in a chain. An object stream's /DecodeParms can be given by reference. Reading what decides how an object is treated can load more objects, each doing the same, which a file could make a tree that doubles with every level once the cache lets go of it. What is loaded for one of these no longer follows its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M1: what a read follows is bounded by a budget, and nothing shared is copied per stream From a review of the fixes before this, all in them. A flag that kept what is loaded for a selector from following its own stopped the fan-out, but a legal file lost object stream members to it: read first through a selector, an object stream with parameters by reference was kept undecoded, and a full save wrote nulls without saying so. A budget replaces it: one read or write of an object, with all it loads on the way, follows up to 32 references, which is more than any object needs when all of it is by reference. Past that they read as absent, so a file's ladder of selectors still stops, and nothing a real file holds is lost. Finding out whether a dictionary is a signature's loaded objects for every dictionary of an object, and a load that fails takes a scan of the file, so one read could hang. Only a dictionary with a direct string /Contents is asked now, and the budget bounds those. /ID written direct copied every entry, so a 1 MB file naming a large string thousands of times ran out of memory: it is at most two strings now. Dropping a crypt filter copied a shared /DecodeParms or /Filter into every stream, 1 GB from a 1 MB file: of what's shared only filter names and the parameters decoders read are copied now, through Document::with_direct_parms. The renderer skips /Crypt in a chain instead of copying the data for each entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M1: only an object itself follows references, and a failed scan is remembered From a review of the budget. Following a reference while walking deep inside an object added that depth again for each object loaded, which overflowed a 2 MiB stack on a file under 10 KB and took the process with it, past any catch_unwind. References are now followed for the object itself and never for what is nested in it. A stream can't be nested, and a signature dictionary nested in another object reads as it says directly, as it did before this PR. walk is back to following nothing. A reference that fails to load scans the file, and nothing remembered that, so a 1 MB file took minutes to save. What a scan didn't find is kept for the document: the file doesn't change. Filter names copied out of a shared chain are bounded, since a name can be any length. /ID keeps each entry in its place, or the key made from the first entry changes. direct_id's comment was on another function. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M1: tests with no deadline a busy machine can reach Wide margins on the two that time what they guard. A test of dictionaries nested in an object goes: nothing nested follows a reference now, which the small-stack test covers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M1: a crypt filter past what a read may follow is the document's own Past the budget a reference reads as absent, and a crypt filter with no /Name is /Identity, so a stream that selected one by reference had its ciphertext read as data. What can't be told takes the document's filter, as a /Filter that can't be read does. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M1: a read refused, not a budget full, is what leaves a crypt filter unknown My last commit took a read that used up all it may follow for one that was refused, so an /Identity stream read at exactly 32 follows was decrypted to garbage. Refused reads are counted now, and only one on the way to this stream's /Name says anything about it. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M1: a save keeps the handler, the key and the permissions Under every algorithm and by either kind of save, `/Encrypt`, `/ID`, the file key and the permissions come back as they were. No test held that. `/ID` by reference is held through an incremental save too, which leaves it as it was. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * cos: encrypt an edited or added object whatever /Encrypt names stored_as_is now also requires the object to be unedited, so an edit of an object /Encrypt or /ID refers to, or a new object given a number they name, is encrypted on both saves. encryption_members skips numbers with no object behind them and streams, which those dictionaries never hold. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/cos/src/document.rs | 5 + crates/cos/src/document/objects.rs | 40 +- crates/cos/src/document/open.rs | 60 +- crates/cos/src/document/tests.rs | 142 ++++ crates/cos/src/security.rs | 294 +++++--- crates/cos/src/writer.rs | 52 +- crates/cos/tests/it/encryption.rs | 823 +++++++++++++++++++++++ crates/render/src/raster/tests.rs | 268 ++++++++ parity/acrobat-features.toml | 4 +- vendor/README.md | 2 +- vendor/hayro-syntax/src/crypto/mod.rs | 31 +- vendor/hayro-syntax/src/filter/mod.rs | 3 +- vendor/hayro-syntax/src/object/stream.rs | 68 +- vendor/hayro-syntax/src/xref.rs | 2 +- 14 files changed, 1657 insertions(+), 137 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 2aaf33ec63..e52aba2ce3 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -19,6 +19,7 @@ mod reader; mod state; mod xref; +pub(crate) use objects::Held; #[cfg(test)] pub(crate) use reader::find; #[cfg(test)] @@ -62,6 +63,10 @@ pub struct Document { security: Option>, /// Object number of the `/Encrypt` dictionary (never encrypted itself). encrypt_num: Option, + /// What that dictionary and `/ID` refer to, which the key comes from, and so is as stored. + encrypt_members: Arc>, + /// Object numbers a scan of the file didn't find: the file doesn't change, so it never will. + unfound: Arc>>, /// Encryption was added, changed or removed since opening: only a full save can apply it. encryption_changed: bool, /// Set by edits that must not leave earlier revisions in the file (redaction). diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 4efb185f1a..2e211927ce 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -156,12 +156,16 @@ impl Document { self.try_get(r.num).unwrap_or_else(|_| Arc::new(Object::Null)) } - /// Follow a reference if `o` is one; otherwise return `o` itself. + /// Follow a reference if `o` is one; otherwise return `o` itself. An object can be nothing but + /// a reference to another, which is followed too, up to [`MAX_REFERENCE_HOPS`] of them. pub fn resolve(&self, o: &Object) -> Arc { - match o { - Object::Ref(r) => self.get(*r), - other => Arc::new(other.clone()), + let Object::Ref(r) = o else { return Arc::new(o.clone()) }; + let mut held = self.get(*r); + for _ in 0..MAX_REFERENCE_HOPS { + let Object::Ref(r) = &*held else { break }; + held = self.get(*r); } + held } /// `s` with the `/DecodeParms` its filters use made direct, so [`Stream::decoded`] reads them: @@ -218,7 +222,7 @@ impl Document { } /// `o`, or the object it refers to, without copying either. - fn held<'o>(&self, o: &'o Object) -> Held<'o> { + pub(crate) fn held<'o>(&self, o: &'o Object) -> Held<'o> { match o { Object::Ref(_) => Held::Resolved(self.resolve(o)), o => Held::Direct(o), @@ -395,6 +399,7 @@ impl Document { // cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path. let _guard = LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?; + let _scope = crate::security::FollowScope::enter(); let obj = self.load(num, 0)?; let obj = Arc::new(obj); // The editing cache is bounded like the scan reader's: re-parsing a file-backed view @@ -562,7 +567,11 @@ impl Document { let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) }; let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize; let first = usize::try_from(s.dict.int(b"First").unwrap_or(0).max(0)).ok(); - let data = s.decoded_within(self.stream_limit)?; + // Its parameters can be given by reference, and can sit in another object stream. A scan of + // the file's object streams comes here without a read around it. + let _scope = crate::security::FollowScope::enter(); + let readable = crate::security::following(Cow::Borrowed(s), || self.with_direct_parms(s)); + let data = readable.decoded_within(self.stream_limit)?; let mut lx = Lexer::new(&data, 0); let mut index = Vec::with_capacity(n); for _ in 0..n { @@ -577,8 +586,14 @@ impl Document { Ok(stm) } - /// Last-resort lookup: scan the file for `num G obj`. + /// Last-resort lookup: scan the file for `num G obj`. What isn't there is remembered: each miss + /// reads the whole file, and a file can refer to such an object from everywhere. fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> { + if self.unfound.lock().is_ok_and(|unfound| unfound.contains(&num)) { + return None; + } + #[cfg(test)] + super::tests::FILE_SCANS.with(|scans| scans.set(scans.get() + 1)); let needle = format!("{num} "); let data = &self.data; let mut found = None; @@ -594,6 +609,11 @@ impl Document { found = Some((id, o)); // keep the last (newest) definition } } + if found.is_none() + && let Ok(mut unfound) = self.unfound.lock() + { + unfound.insert(num); + } found } @@ -786,9 +806,13 @@ const DECODE_PARMS_KEYS: [&[u8]; 13] = [ b"ColorTransform", ]; +/// How many references in a row [`Document::resolve`] follows: enough for any file, and it ends a +/// cycle of them. +const MAX_REFERENCE_HOPS: usize = 32; + /// An object read in place, or the object a reference names as the document holds it: either way /// nothing is copied ([`Document::held`]). -enum Held<'o> { +pub(crate) enum Held<'o> { Direct(&'o Object), Resolved(Arc), } diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index 395424b771..70bbcc313e 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -1,7 +1,11 @@ use super::reader::find; use super::state::Credentials; use super::xref::XrefIndex; -use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object}; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashSet, ObjRef, Object}; + +/// More objects than `/Encrypt` and `/ID` refer to in any file, so that one which refers to the page +/// tree, say, doesn't have a whole document read to open it. +const MAX_ENCRYPT_MEMBERS: usize = 64; impl Document { /// A new document with an empty page tree (for split / extract / combine). It has no @@ -27,6 +31,8 @@ impl Document { version: "1.7".into(), security: None, encrypt_num: None, + encrypt_members: Arc::default(), + unfound: Arc::default(), encryption_changed: false, full_save: false, out_security: None, @@ -111,6 +117,8 @@ impl Document { version, security: None, encrypt_num: None, + encrypt_members: Arc::default(), + unfound: Arc::default(), encryption_changed: false, full_save: false, out_security: None, @@ -185,6 +193,8 @@ impl Document { }; let params = crate::security::encrypt_dict(self, &dict); let id0 = self.first_id(); + // Read here, while nothing is decrypted yet. + let members = self.encryption_members(enc); let handler = match credentials { Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e { pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword, @@ -197,6 +207,7 @@ impl Document { }; self.security = Some(handler); self.encrypt_num = num; + self.encrypt_members = Arc::new(members); // Objects read while locating the catalog were read before decryption was possible. self.cache = Arc::default(); self.objstms = Arc::default(); @@ -205,10 +216,42 @@ impl Document { /// The first file identifier, part of the encryption key (empty when there is none). fn first_id(&self) -> Vec { - match self.trailer.get(b"ID") { - Some(Object::Array(a)) => a.first().and_then(|s| s.as_string()).map(|s| s.bytes.clone()).unwrap_or_default(), - _ => Vec::new(), + let id = self.trailer.get(b"ID").map(|id| self.resolve(id)); + let first = id.as_deref().and_then(Object::as_array).and_then(|a| a.first()).map(|s| self.resolve(s)); + first.as_deref().and_then(Object::as_string).map(|s| s.bytes.clone()).unwrap_or_default() + } + + /// The objects `/Encrypt`, given as `enc`, and `/ID` refer to, the ones they refer to, and so + /// on, up to [`MAX_ENCRYPT_MEMBERS`]. Their strings aren't encrypted (ISO 32000-2 §7.6.2): the + /// key is made from them, so they have to read as stored, and stay so. A number with no object + /// behind it, and a stream, which those dictionaries never hold, isn't one: an object added + /// under that number later, or a page's content a hostile `/Encrypt` names, is encrypted. + fn encryption_members(&self, enc: &Object) -> HashSet { + let mut pending = Vec::new(); + crate::writer::collect_refs(enc, &mut |r| pending.push(r)); + if let Some(id) = self.trailer.get(b"ID") { + crate::writer::collect_refs(id, &mut |r| pending.push(r)); } + let mut members = HashSet::new(); + while let Some(r) = pending.pop() { + if members.len() >= MAX_ENCRYPT_MEMBERS || members.contains(&r.num) { + continue; + } + let held = self.get(r); + if matches!(&*held, Object::Null | Object::Stream(_)) { + continue; + } + members.insert(r.num); + crate::writer::collect_refs(&held, &mut |x| pending.push(x)); + } + members + } + + /// Whether object `num` is written as it is held, being one of [`Document::encryption_members`], + /// unedited, and the save writing the same `/Encrypt`. What was edited or added is encrypted + /// whatever its number. + pub(crate) fn stored_as_is(&self, num: u32) -> bool { + !self.encryption_changed && !self.is_edited(num) && self.encrypt_members.contains(&num) } /// Lift the restrictions of a document opened with its user password by entering the @@ -270,8 +313,9 @@ impl Document { /// always a full rewrite. Returns the handler (authenticated as owner). pub fn set_encryption(&mut self, params: &pdfcraft_crypt::NewEncryption) -> Result<(), CosError> { // The file identifier is part of the key; make sure it exists and keep it. - let id0 = match self.trailer.get(b"ID") { - Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(), + let id = self.trailer.get(b"ID").map(|id| self.resolve(id)); + let id0 = match id.as_deref() { + Some(Object::Array(a)) if a.len() == 2 => self.first_id(), _ => { let id = pdfcraft_crypt::file_id(¶ms.seed); let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true }); @@ -338,7 +382,9 @@ impl Document { /// Decrypt a freshly parsed indirect object if the document is encrypted. pub(super) fn decrypted(&self, id: ObjRef, o: Object) -> Object { match &self.security { - Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, crate::security::Pass::Decrypt), + Some(h) if Some(id.num) != self.encrypt_num && !self.encrypt_members.contains(&id.num) => { + crate::security::transform(self, h, &o, id.num, id.generation, crate::security::Pass::Decrypt) + } _ => o, } } diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index 4c7b3eb652..991323d607 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1,5 +1,147 @@ use super::*; +std::thread_local! { + /// How many times `Document::scan_for` read the file on this thread. + pub(super) static FILE_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// A scan that doesn't find an object isn't made again: each reads the whole file, and a file can +/// refer to such an object from every object it has. +#[test] +fn what_a_scan_of_the_file_did_not_find_is_remembered() { + let body = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n"; + let mut out = body.to_vec(); + let xref = out.len(); + out.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n"); + out.extend_from_slice(format!("{:010} 00000 n \n", 9).as_bytes()); + out.extend_from_slice(format!("{:010} 00000 n \n", body.iter().position(|b| *b == b'\n').unwrap() + 49).as_bytes()); + out.extend_from_slice(b"0000000001 00000 n \n"); + out.extend_from_slice(format!("trailer << /Size 4 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + FILE_SCANS.with(|scans| scans.set(0)); + for _ in 0..5 { + assert!(doc.try_get(3).is_err()); + } + assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1); + let clone = doc.clone(); + assert!(clone.try_get(3).is_err()); + assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1, "a clone has the same file"); +} + +/// An object can be nothing but a reference to another. A chain of them is followed, and one that +/// goes round ends in what isn't a dictionary or a name, without hanging. +#[test] +fn references_to_references_are_followed() { + let mut doc = Document::new_empty(); + let name = doc.add(Object::name("Target")); + let one = doc.add(Object::Ref(name)); + let two = doc.add(Object::Ref(one)); + assert_eq!(doc.resolve(&Object::Ref(two)).as_name(), Some(b"Target".as_slice())); + let a = doc.add(Object::Null); + let b = doc.add(Object::Ref(a)); + doc.set(a, Object::Ref(b)); + assert_eq!(doc.resolve(&Object::Ref(a)).as_name(), None); +} + +/// An object stream's `/DecodeParms` can be given by reference: without them a predictor isn't +/// undone, so its members were lost. +#[test] +fn an_object_stream_with_parameters_by_reference_is_read() { + let members = b"3 0 << /Hidden (yes) >>"; + // PNG Sub, one row. + let mut rows = vec![1u8]; + let mut before = 0u8; + for b in members { + rows.push(b.wrapping_sub(before)); + before = *b; + } + let coded = pdfcraft_filters::encode_flate(&rows); + let mut out = b"%PDF-1.5\n".to_vec(); + let mut offs = [0usize; 7]; + let mut obj = |out: &mut Vec, n: usize, body: &[u8]| { + offs[n] = out.len(); + out.extend_from_slice(format!("{n} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>"); + obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>"); + let mut stm = format!("<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /DecodeParms 6 0 R /Length {} >>\nstream\n", coded.len()).into_bytes(); + stm.extend_from_slice(&coded); + stm.extend_from_slice(b"\nendstream"); + obj(&mut out, 4, &stm); + let data = [2u8, 0, 4, 0]; + let mut xs = format!("<< /Type /XRef /Size 7 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes(); + xs.extend_from_slice(&data); + xs.extend_from_slice(b"\nendstream"); + obj(&mut out, 5, &xs); + obj(&mut out, 6, format!("<< /Predictor 11 /Columns {} >>", members.len()).as_bytes()); + let table = out.len(); + out.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n"); + for (n, off) in offs.iter().enumerate().skip(1) { + let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") }; + out.extend_from_slice(line.as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size 7 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + let hidden = doc.get(ObjRef::new(3, 0)); + assert_eq!(hidden.as_dict().and_then(|d| d.get(b"Hidden")).and_then(Object::as_string).map(|s| s.bytes.clone()), Some(b"yes".to_vec())); +} + +/// Each entry of an `/Encrypt` dictionary, and of a crypt filter in it, reads the same by reference. +/// Read as absent, `/StmF` or `/CFM` was `/Identity`, so a whole file's streams stayed ciphertext. +#[test] +fn encrypt_dictionary_entries_read_the_same_by_reference() { + let string = |s: &str| Object::String(crate::PdfString::text(s)); + let mut filter = Dict::new(); + filter.set(b"CFM".to_vec(), Object::name("AESV2")); + filter.set(b"Length".to_vec(), Object::Int(5)); + let cf = |filter: Dict| { + let mut cf = Dict::new(); + cf.set(b"StdCF".to_vec(), Object::Dict(filter)); + cf.set(b"Other".to_vec(), Object::Dict(Dict::new())); + Object::Dict(cf) + }; + let mut d = Dict::new(); + let entries = [ + ("Filter", Object::name("Standard")), + ("V", Object::Int(4)), + ("R", Object::Int(4)), + ("Length", Object::Int(128)), + ("P", Object::Int(-44)), + ("O", string("owner")), + ("U", string("user")), + ("EncryptMetadata", Object::Bool(false)), + ("CF", cf(filter.clone())), + ("StmF", Object::name("StdCF")), + ("StrF", Object::name("Other")), + ("EFF", Object::name("StdCF")), + ]; + for (key, value) in &entries { + d.set(key.as_bytes().to_vec(), value.clone()); + } + let mut doc = Document::new_empty(); + let direct = crate::security::encrypt_dict(&doc, &d); + assert_eq!((direct.stm_f.as_slice(), direct.str_f.as_slice(), direct.ef_f.as_slice()), (&b"StdCF"[..], &b"Other"[..], &b"StdCF"[..])); + assert_eq!(direct.length_bits, 40, "the filter's own length, in bytes"); + assert!(!direct.encrypt_metadata); + assert_eq!(direct.crypt_filters.iter().filter(|(_, m)| *m == pdfcraft_crypt::Method::Aes128).count(), 1); + for (key, value) in &entries { + let mut one = d.clone(); + let r = doc.add(value.clone()); + one.set(key.as_bytes().to_vec(), Object::Ref(r)); + assert_eq!(crate::security::encrypt_dict(&doc, &one), direct, "/{key}"); + } + for key in ["CFM", "Length"] { + let mut by_reference = filter.clone(); + let r = doc.add(filter.get(key.as_bytes()).unwrap().clone()); + by_reference.set(key.as_bytes().to_vec(), Object::Ref(r)); + let mut one = d.clone(); + one.set(b"CF".to_vec(), cf(by_reference)); + assert_eq!(crate::security::encrypt_dict(&doc, &one), direct, "/{key} in the filter"); + } +} + /// /DecodeParms given by reference, whole, as array entries or as the numbers inside, are read /// once made direct: here a Flate predictor, which the stream alone doesn't see. Only the entries the filters use are /// resolved, and nothing is for a chain longer than a decoder takes. diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index 22c85c7f68..61c4f92a4c 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -7,10 +7,69 @@ //! false. Streams naming a crypt filter (`/Crypt` in `/Filter`) use that filter; embedded files //! use `/EFF`. -use pdfcraft_crypt::{EncryptDict, Method, SecurityHandler, StreamKind}; +use std::cell::Cell; +use pdfcraft_crypt::{EncryptDict, Method, SecurityHandler, StreamKind}; +use pdfcraft_filters::MAX_CHAIN; + +use crate::document::Held; use crate::{Dict, Document, Object, PdfString, Stream}; +/// How many references one read or write of an object may follow, with all that it loads on the +/// way. How an object is treated can be given by reference, and what that loads can do the same: a +/// file could make one read a tree of them, each object read again once the cache has let it go. +/// An object takes a few of these, and two dozen if every part of it is given by reference; past +/// this they read as absent. The parameters of an object stream, read together, count as one. +const MAX_FOLLOWED: u32 = 32; + +std::thread_local! { + /// How many [`FollowScope`]s this thread is inside. + static SCOPES: Cell = const { Cell::new(0) }; + /// The references followed in the outermost of them. + static FOLLOWED: Cell = const { Cell::new(0) }; + /// The references refused in the outermost of them, past [`MAX_FOLLOWED`]. + static REFUSED: Cell = const { Cell::new(0) }; +} + +/// One read or write of an object, with everything it loads on the way: what [`MAX_FOLLOWED`] counts. +pub(crate) struct FollowScope; + +impl FollowScope { + pub(crate) fn enter() -> Self { + if SCOPES.replace(SCOPES.get().saturating_add(1)) == 0 { + FOLLOWED.set(0); + REFUSED.set(0); + } + FollowScope + } +} + +impl Drop for FollowScope { + fn drop(&mut self) { + SCOPES.set(SCOPES.get().saturating_sub(1)); + } +} + +/// What `f` gives, counted against [`MAX_FOLLOWED`], or `spent` past it. Called inside a +/// [`FollowScope`], which is what starts the count again. +pub(crate) fn following(spent: T, f: impl FnOnce() -> T) -> T { + if FOLLOWED.get() >= MAX_FOLLOWED { + REFUSED.set(REFUSED.get().saturating_add(1)); + return spent; + } + FOLLOWED.set(FOLLOWED.get() + 1); + f() +} + +/// `o`, or the object it refers to, for what decides how a stream or dictionary is treated. Past +/// [`MAX_FOLLOWED`] a reference reads as absent. +fn followed<'o>(doc: &Document, o: &'o Object) -> Held<'o> { + match o { + Object::Ref(_) => following(Held::Direct(&Object::Null), || doc.held(o)), + o => Held::Direct(o), + } +} + fn bytes_of(doc: &Document, d: &Dict, key: &[u8]) -> Vec { d.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_string().map(|s| s.bytes.clone())).unwrap_or_default() } @@ -18,12 +77,12 @@ fn bytes_of(doc: &Document, d: &Dict, key: &[u8]) -> Vec { /// Read the handler parameters from an `/Encrypt` dictionary. pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict { let int = |k: &[u8], default: i64| d.get(k).map(|v| doc.resolve(v)).and_then(|v| v.as_int()).unwrap_or(default); - let name = |k: &[u8]| d.get(k).and_then(|v| v.as_name()).map(|n| n.to_vec()).unwrap_or_else(|| b"Identity".to_vec()); + let name = |d: &Dict, k: &[u8]| d.get(k).and_then(|v| doc.resolve(v).as_name().map(<[u8]>::to_vec)); let mut crypt_filters = Vec::new(); if let Some(cf) = d.get(b"CF").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()) { for (k, v) in cf.iter() { let Some(f) = doc.resolve(v).as_dict().cloned() else { continue }; - let method = match f.name(b"CFM") { + let method = match name(&f, b"CFM").as_deref() { Some(b"V2") => Method::Rc4, Some(b"AESV2") => Method::Aes128, Some(b"AESV3") => Method::Aes256, @@ -40,7 +99,8 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict { .get(b"CF") .map(|c| doc.resolve(c)) .and_then(|c| c.as_dict().and_then(|c| c.get(b"StdCF").map(|f| doc.resolve(f)))) - .and_then(|f| f.as_dict().and_then(|f| f.int(b"Length"))) + .and_then(|f| f.as_dict().and_then(|f| f.get(b"Length").map(|n| doc.resolve(n)))) + .and_then(|n| n.as_int()) .filter(|n| *n > 0); let length_bits = match (v, cf_len) { (5.., _) => 256, @@ -50,7 +110,7 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict { _ => int(b"Length", 40), }; EncryptDict { - filter: d.name(b"Filter").map(|n| n.to_vec()).unwrap_or_default(), + filter: name(d, b"Filter").unwrap_or_default(), v, r: int(b"R", 0), length_bits, @@ -60,28 +120,46 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict { ue: bytes_of(doc, d, b"UE"), perms: bytes_of(doc, d, b"Perms"), p: int(b"P", -1) as i32, - encrypt_metadata: !matches!(d.get(b"EncryptMetadata"), Some(Object::Bool(false))), + encrypt_metadata: !matches!(d.get(b"EncryptMetadata").map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(false))), crypt_filters, - stm_f: name(b"StmF"), - str_f: name(b"StrF"), - ef_f: d.get(b"EFF").and_then(|v| v.as_name()).map(|n| n.to_vec()).unwrap_or_default(), + stm_f: name(d, b"StmF").unwrap_or_else(|| b"Identity".to_vec()), + str_f: name(d, b"StrF").unwrap_or_else(|| b"Identity".to_vec()), + ef_f: name(d, b"EFF").unwrap_or_default(), } } -/// The crypt filter a stream names in its `/Filter` chain (`/Crypt` + `/DecodeParms /Name`). -fn named_crypt_filter(dict: &Dict) -> Option> { - let names: Vec<&[u8]> = match dict.get(b"Filter") { - Some(Object::Name(n)) => vec![n], - Some(Object::Array(a)) => a.iter().filter_map(Object::as_name).collect(), +/// Whether `f`, an entry of a `/Filter` chain or its only filter, is `/Crypt`. +fn is_crypt(doc: &Document, f: &Object) -> bool { + followed(doc, f).as_name() == Some(b"Crypt") +} + +/// The crypt filter a stream names in its `/Filter` chain (`/Crypt` + `/DecodeParms /Name`), each +/// given directly or by reference, in an array or not. A chain longer than any decoder takes names +/// none: nothing can read that stream anyway, and every stream of a file can share one long array. +/// A `/Name` that reads as absent means `/Identity`, unless a reference on the way to it was refused +/// (see [`MAX_FOLLOWED`]): that says nothing about the stream, so it names none, and the document's +/// own filter is used. +fn named_crypt_filter(doc: &Document, dict: &Dict) -> Option> { + let refused = REFUSED.get(); + let filter = followed(doc, dict.get(b"Filter")?); + let i = match &*filter { + Object::Array(a) if a.len() <= MAX_CHAIN + 1 => a.iter().position(|f| is_crypt(doc, f))?, + f if is_crypt(doc, f) => 0, _ => return None, }; - let i = names.iter().position(|n| *n == b"Crypt")?; - let parms = match dict.get(b"DecodeParms") { - Some(Object::Dict(d)) if i == 0 => Some(d.clone()), - Some(Object::Array(a)) => a.get(i).and_then(|p| p.as_dict().cloned()), + let parms = dict.get(b"DecodeParms").map(|p| followed(doc, p)); + let entry = match parms.as_deref() { + Some(Object::Array(a)) => a.get(i).map(|p| followed(doc, p)), + Some(p) if i == 0 => Some(Held::Direct(p)), _ => None, }; - Some(parms.and_then(|p| p.name(b"Name").map(|n| n.to_vec())).unwrap_or_else(|| b"Identity".to_vec())) + let name = entry.as_deref().and_then(Object::as_dict).and_then(|p| p.get(b"Name")).map(|n| followed(doc, n)); + match name.as_deref().and_then(Object::as_name) { + Some(name) => Some(name.to_vec()), + // /Identity here would read ciphertext as data, and write the stream in the clear. + None if REFUSED.get() > refused => None, + None => Some(b"Identity".to_vec()), + } } /// A signature dictionary (field signature, document timestamp or usage-rights signature), @@ -91,42 +169,67 @@ fn named_crypt_filter(dict: &Dict) -> Option> { /// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). A signature dictionary /// has no `/Subtype`; a dictionary with one (an annotation, whose `/Contents` is the comment /// text) or with another `/Type` is never exempt, whatever else it carries. -fn is_signature_dict(d: &Dict) -> bool { - if d.get(b"Subtype").is_some() { +/// With `doc` its entries are read through references; a dictionary nested in another object +/// has none (see [`transform`]). +fn is_signature_dict(doc: Option<&Document>, d: &Dict) -> bool { + // All this decides is what happens to such a /Contents. + if !matches!(d.get(b"Contents"), Some(Object::String(_))) { return false; } - match d.get(b"Type") { - Some(_) => matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")), - None => matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_))), + // A null entry, or one that refers to nothing, is an absent one (ISO 32000-2 §7.3.7, §7.3.10). + let entry = |key: &[u8]| { + let value = d.get(key)?; + let held = doc.map_or(Held::Direct(value), |doc| followed(doc, value)); + (!matches!(*held, Object::Null)).then_some(held) + }; + if entry(b"Subtype").is_some() { + return false; + } + match entry(b"Type") { + Some(t) => matches!(t.as_name(), Some(b"Sig" | b"DocTimeStamp")), + None => matches!(entry(b"ByteRange").as_deref(), Some(Object::Array(_))), } } -/// Remove the `/Crypt` entry from a stream's filter chain, with its decode parameters. -fn drop_crypt_filter(dict: &mut Dict) { - let i = match dict.get(b"Filter") { - Some(Object::Array(a)) => a.iter().position(|n| n.as_name() == Some(b"Crypt")), - Some(Object::Name(n)) if n.as_slice() == b"Crypt" => Some(0), - _ => None, +/// Longer than any filter's name. A name can be of any length, and a chain every stream shares can +/// hold a long one. +const MAX_FILTER_NAME: usize = 127; + +/// The dictionary of `source` without the `/Crypt` entries of its filter chain and their decode +/// parameters. The two come out direct when given by reference, which every stream of a file can +/// share, so of what was shared only small values are copied: filter names, and the parameters +/// decoders read ([`Document::with_direct_parms`]). The chain is short, as [`named_crypt_filter`] +/// found a filter in it. +fn drop_crypt_filter(doc: &Document, source: &Stream) -> Dict { + let entry = |f: &Object| match f { + f if is_crypt(doc, f) => Object::name("Crypt"), + Object::Name(n) if n.len() <= MAX_FILTER_NAME => f.clone(), + Object::Ref(_) => f.clone(), + _ => Object::Null, }; - let Some(i) = i else { return }; - let mut filters = match dict.get(b"Filter") { - Some(Object::Array(a)) => a.clone(), - _ => Vec::new(), + let filter = source.dict.get(b"Filter").map(|f| followed(doc, f)); + let chain: Vec = match filter.as_deref() { + Some(Object::Array(a)) => a.iter().map(entry).collect(), + Some(f) => vec![entry(f)], + None => Vec::new(), }; - if i < filters.len() { - filters.remove(i); - } + let crypt: Vec = chain.iter().map(|f| f.as_name() == Some(b"Crypt")).collect(); + let mut chained = Stream::from_raw(source.dict.clone(), Vec::new()); + chained.dict.set(b"Filter".to_vec(), Object::Array(chain.clone())); // A lone parameter dictionary belongs to the first filter (as `named_crypt_filter` reads it). - match dict.get(b"DecodeParms").cloned() { - Some(Object::Array(mut parms)) if i < parms.len() => { - parms.remove(i); - dict.set(b"DecodeParms".to_vec(), Object::Array(parms)); - } - Some(Object::Dict(_)) if i == 0 => { + let parms = match doc.with_direct_parms(&chained).dict.get(b"DecodeParms") { + Some(Object::Array(p)) => Some(Object::Array(p.iter().zip(&crypt).filter(|(_, c)| !**c).map(|(p, _)| p.clone()).collect())), + Some(Object::Dict(_)) if crypt.first() == Some(&true) => None, + _ => source.dict.get(b"DecodeParms").cloned(), + }; + let mut dict = source.dict.clone(); + match parms { + Some(p) => dict.set(b"DecodeParms".to_vec(), p), + None => { dict.remove(b"DecodeParms"); } - _ => {} } + let filters: Vec = chain.into_iter().zip(&crypt).filter(|(_, c)| !**c).map(|(f, _)| f).collect(); if filters.is_empty() { dict.remove(b"Filter"); if matches!(dict.get(b"DecodeParms"), Some(Object::Array(p)) if p.is_empty()) { @@ -135,6 +238,7 @@ fn drop_crypt_filter(dict: &mut Dict) { } else { dict.set(b"Filter".to_vec(), Object::Array(filters)); } + dict } /// What [`transform`] does to an object. @@ -152,51 +256,81 @@ pub(crate) enum Pass { /// Decrypt or encrypt every string and stream in an indirect object, except a signature /// dictionary's `/Contents` (see [`is_signature_dict`]). -pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, pass: Pass) -> Object { +/// +/// `o` is as `doc` holds it: what a stream's dictionary gives by reference is read from `doc`, so +/// its references have to be the document's, not a save's renumbered ones. +pub(crate) fn transform(doc: &Document, h: &SecurityHandler, o: &Object, num: u32, generation: u16, pass: Pass) -> Object { + let _scope = FollowScope::enter(); let decrypt = pass == Pass::Decrypt; let strings = |s: &PdfString| { let bytes = if decrypt { h.decrypt_string(num, generation, &s.bytes) } else { h.encrypt_string(num, generation, &s.bytes) }; PdfString { bytes, hex: s.hex || !decrypt } }; - walk(o, &strings, h, num, generation, pass) + // References are followed here only, for the object itself, never for what is nested in it. + // `walk` goes deep, and a load from down there adds that depth again for each object loaded, + // which overflowed the stack on a 10 KB file. A stream can't be nested, and a dictionary that + // is reads as it says directly. + match o { + Object::Stream(s) => stream(doc, s, &strings, h, num, generation, pass), + Object::Dict(d) if is_signature_dict(Some(doc), d) => signature_dict(d, &strings), + o => walk(o, &strings), + } } -fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, pass: Pass) -> Object { - let decrypt = pass == Pass::Decrypt; +/// `d`, a signature dictionary, with the strings of everything but its `/Contents` transformed. +fn signature_dict(d: &Dict, strings: &dyn Fn(&PdfString) -> PdfString) -> Object { + Object::Dict( + d.iter() + .map(|(k, v)| match (k.as_slice(), v) { + (b"Contents", Object::String(_)) => (k.clone(), v.clone()), + _ => (k.clone(), walk(v, strings)), + }) + .collect(), + ) +} + +fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString) -> Object { match o { Object::String(s) => Object::String(strings(s)), - Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, pass)).collect()), - Object::Dict(d) if is_signature_dict(d) => Object::Dict( - d.iter() - .map(|(k, v)| match (k.as_slice(), v) { - (b"Contents", Object::String(_)) => (k.clone(), v.clone()), - _ => (k.clone(), walk(v, strings, h, num, generation, pass)), - }) - .collect(), - ), - Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect()), - Object::Stream(s) => { - let mut dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect(); - let ty = s.dict.name(b"Type"); - // Cross-reference streams are never encrypted (§7.5.8), but only reading may trust the - // label: a stream a producer calls /XRef stays plaintext if writing believes it, and the - // writer builds its own cross-reference streams outside this transform. - if (decrypt && ty == Some(b"XRef")) || (ty == Some(b"Metadata") && !h.encrypts_metadata()) { - return Object::Stream(Stream { dict, raw: s.raw.clone() }); - } - let mut named = named_crypt_filter(&s.dict); - if pass == Pass::EncryptReplacing && named.as_deref().is_some_and(|n| !h.dict().crypt_filters.iter().any(|(f, _)| f == n)) { - drop_crypt_filter(&mut dict); - named = None; - } - let kind = match (&named, ty) { - (Some(n), _) => StreamKind::Named(n), - (None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile, - _ => StreamKind::Normal, - }; - let raw = if decrypt { h.decrypt_stream(num, generation, &s.raw, kind) } else { h.encrypt_stream(num, generation, &s.raw, kind) }; - Object::Stream(Stream { dict, raw: raw.into() }) - } + Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings)).collect()), + Object::Dict(d) if is_signature_dict(None, d) => signature_dict(d, strings), + Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings))).collect()), other => other.clone(), } } + +fn stream( + doc: &Document, + s: &Stream, + strings: &dyn Fn(&PdfString) -> PdfString, + h: &SecurityHandler, + num: u32, + generation: u16, + pass: Pass, +) -> Object { + let decrypt = pass == Pass::Decrypt; + let walked = |d: &Dict| -> Dict { d.iter().map(|(k, v)| (k.clone(), walk(v, strings))).collect() }; + let ty = s.dict.get(b"Type").map(|t| followed(doc, t)); + let ty = ty.as_deref().and_then(Object::as_name); + // Cross-reference streams are never encrypted (§7.5.8), but only reading may trust the + // label: a stream a producer calls /XRef stays plaintext if writing believes it, and the + // writer builds its own cross-reference streams outside this transform. + if (decrypt && ty == Some(b"XRef")) || (ty == Some(b"Metadata") && !h.encrypts_metadata()) { + return Object::Stream(Stream { dict: walked(&s.dict), raw: s.raw.clone() }); + } + let mut named = named_crypt_filter(doc, &s.dict); + // Before the walk, which then encrypts what is in the dictionary this leaves. + let foreign = pass == Pass::EncryptReplacing && named.as_deref().is_some_and(|n| !h.dict().crypt_filters.iter().any(|(f, _)| f == n)); + let dropped = foreign.then(|| drop_crypt_filter(doc, s)); + if dropped.is_some() { + named = None; + } + let dict = walked(dropped.as_ref().unwrap_or(&s.dict)); + let kind = match (&named, ty) { + (Some(n), _) => StreamKind::Named(n), + (None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile, + _ => StreamKind::Normal, + }; + let raw = if decrypt { h.decrypt_stream(num, generation, &s.raw, kind) } else { h.encrypt_stream(num, generation, &s.raw, kind) }; + Object::Stream(Stream { dict, raw: raw.into() }) +} diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index cd0137f452..dcfcb97997 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -133,11 +133,12 @@ fn write_stream(s: &Stream, out: &mut Vec) { /// The object as written: encrypted with its (output) number when the document is encrypted, /// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document). +/// `o` has the document's numbers in its references ([`crate::security::transform`]). fn prepared<'a>(doc: &Document, source_num: u32, num: u32, generation: u16, o: &'a Object) -> Cow<'a, Object> { match doc.output_security() { - (Some(h), encrypt_num) if Some(source_num) != encrypt_num => { + (Some(h), encrypt_num) if Some(source_num) != encrypt_num && !doc.stored_as_is(source_num) => { let pass = if doc.encryption_changed() { crate::security::Pass::EncryptReplacing } else { crate::security::Pass::Encrypt }; - Cow::Owned(crate::security::transform(h, o, num, generation, pass)) + Cow::Owned(crate::security::transform(doc, h, o, num, generation, pass)) } _ => Cow::Borrowed(o), } @@ -287,6 +288,10 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro queue.push_back(r); } } + // A direct /Encrypt can refer to objects, /CF or /StmF, say. + if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { + visit_refs(encrypt, &mut queue); + } // Unknown trailer entries are document data. Objects they alone reference must be written // too, or a full rewrite would keep the key and drop its value. for (key, value) in trailer_in.iter() { @@ -315,7 +320,10 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro // The /Encrypt dictionary and everything it references (e.g. indirect /CF crypt-filter // dictionaries) are needed before anything can be decrypted: never inside object streams. let mut encryption_objects: std::collections::HashSet = std::collections::HashSet::new(); - let mut pending: Vec = trailer_in.reference(b"Encrypt").into_iter().collect(); + let mut pending: Vec = Vec::new(); + if let Some(encrypt) = trailer_in.get(b"Encrypt") { + collect_refs(encrypt, &mut |r| pending.push(r)); + } while let Some(r) = pending.pop() { if encryption_objects.insert(r) { collect_refs(&reader.get(r), &mut |x| pending.push(x)); @@ -336,9 +344,10 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro if in_stream { packed.push((num, *r)); } else { - let o = renumber(&*reread(&mut reader, *r, *is_stream)?, &map); + let o = reread(&mut reader, *r, *is_stream)?; + let o = renumber(&prepared(&reader.document, r.num, num, 0, &o), &map); rows.insert(num, Row::InFile(out.len() as u64, 0)); - write_indirect(num, 0, &prepared(&reader.document, r.num, num, 0, &o), &mut out); + write_indirect(num, 0, &o, &mut out); } } // Object streams take the numbers after the last object. Their contents are not encrypted @@ -368,11 +377,11 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0))); } } - if let Some(Object::Dict(d)) = trailer_in.get(b"Encrypt") { - trailer.set(b"Encrypt".to_vec(), Object::Dict(d.clone())); + if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { + trailer.set(b"Encrypt".to_vec(), renumber(encrypt, &map)); } if let Some(id) = trailer_in.get(b"ID") { - trailer.set(b"ID".to_vec(), id.clone()); + trailer.set(b"ID".to_vec(), direct_id(&reader.document, id)); } for (key, value) in trailer_in.iter() { if !trailer_key_rewritten(key) { @@ -442,6 +451,22 @@ impl ObjectStreamBatch { } } +/// `/ID` for the trailer, direct, as it has to be with `/Encrypt`: nothing else in the file holds +/// what it refers to now. Given by reference it is two entries at most, however many references a +/// file gave, so that one large string isn't copied for each. An entry that isn't a string stays in +/// its place as an empty one, which is how the key read it. +fn direct_id(doc: &Document, id: &Object) -> Object { + if matches!(id, Object::Array(a) if !a.iter().any(|s| matches!(s, Object::Ref(_)))) { + return id.clone(); + } + let entry = |s: &Object| match &*doc.resolve(s) { + s @ Object::String(_) => s.clone(), + _ => Object::String(crate::PdfString { bytes: Vec::new(), hex: true }), + }; + let held = doc.resolve(id); + Object::Array(held.as_array().into_iter().flatten().take(2).map(entry).collect()) +} + /// Trailer keys a full rewrite generates itself. Everything else is copied, with references /// renumbered, so a save does not drop metadata the reader accepted. fn trailer_key_rewritten(key: &[u8]) -> bool { @@ -486,7 +511,7 @@ pub(crate) fn unlinked_additions(doc: &Document) -> HashSet { pending.into_keys().collect() } -fn collect_refs(o: &Object, f: &mut impl FnMut(ObjRef)) { +pub(crate) fn collect_refs(o: &Object, f: &mut impl FnMut(ObjRef)) { match o { Object::Ref(r) => f(*r), Object::Array(a) => a.iter().for_each(|x| collect_refs(x, f)), @@ -663,6 +688,15 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) { #[cfg(test)] mod tests { + /// `/ID` by reference comes out direct, each entry where it was: the key is made from the first. + #[test] + fn a_file_identifier_keeps_its_entries_in_their_places() { + let doc = Document::new_empty(); + let string = |bytes: &[u8]| Object::String(crate::PdfString { bytes: bytes.to_vec(), hex: true }); + let nowhere = Object::Ref(ObjRef::new(9_999, 0)); + assert_eq!(super::direct_id(&doc, &Object::Array(vec![nowhere, string(b"ab")])), Object::Array(vec![string(b""), string(b"ab")])); + } + use proptest::prelude::*; use super::*; diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index 2ee33afc12..b19d618f5c 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -494,3 +494,826 @@ fn a_stream_labelled_xref_is_encrypted_when_saved() { let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); assert!(!bytes.windows(SECRET.len()).any(|w| w == SECRET.as_bytes()), "the labelled stream was written in the clear"); } + +/// The content stream's reference. +fn contents_of(doc: &Document) -> ObjRef { + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let pages = doc.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap(); + let page = doc.resolve(&pages.get(b"Kids").unwrap().as_array().unwrap()[0]).as_dict().cloned().unwrap(); + page.reference(b"Contents").unwrap() +} + +/// The content stream's data as loaded, whatever its filters say. +fn raw_content(doc: &Document) -> String { + match &*doc.get(contents_of(doc)) { + Object::Stream(s) => String::from_utf8_lossy(&s.raw).trim().to_string(), + _ => panic!("no content stream"), + } +} + +/// Gives the content stream the `/Filter` and `/DecodeParms` of `selector`. +fn select(doc: &mut Document, (filter, parms): (Object, Object)) { + let contents = contents_of(doc); + let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + s.dict.set(b"Filter".to_vec(), filter); + s.dict.set(b"DecodeParms".to_vec(), parms); + doc.set(contents, Object::Stream(s)); +} + +/// `/Crypt` naming `name`, in the `form`th spelling: all direct, then each part by reference. +/// `None` past the last. +fn selector(doc: &mut Document, form: usize, name: &str) -> Option<(Object, Object)> { + let crypt = Object::name("Crypt"); + let parms = |n: Object| { + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Name".to_vec(), n); + Object::Dict(d) + }; + let named = parms(Object::name(name)); + Some(match form { + 0 => (crypt, named), + 1 => (crypt, Object::Ref(doc.add(named))), + 2 => (Object::Array(vec![crypt]), Object::Array(vec![Object::Ref(doc.add(named))])), + 3 => (Object::Array(vec![crypt]), Object::Ref(doc.add(Object::Array(vec![named])))), + 4 => (crypt, parms(Object::Ref(doc.add(Object::name(name))))), + 5 => (Object::Ref(doc.add(crypt)), named), + 6 => (Object::Ref(doc.add(Object::Array(vec![crypt]))), Object::Array(vec![named])), + 7 => (Object::Array(vec![Object::Ref(doc.add(crypt))]), Object::Array(vec![named])), + // Behind a filter given by reference: places in the two arrays pair up all the same. + 8 => (Object::Array(vec![Object::Ref(doc.add(Object::name("ASCIIHexDecode"))), crypt]), Object::Array(vec![Object::Null, named])), + // The two entries in different forms, which is how a lone filter is usually spelled. + 9 => (Object::Array(vec![crypt]), named), + 10 => (crypt, Object::Array(vec![named])), + // The filter twice, which names the first's. + 11 => (Object::Array(vec![crypt.clone(), crypt]), Object::Array(vec![named.clone(), named])), + // A reference to a reference. + 12 => { + let name = doc.add(Object::name(name)); + let hop = doc.add(Object::Ref(name)); + (crypt, parms(Object::Ref(hop))) + } + _ => return None, + }) +} + +fn holds(bytes: &[u8], text: &str) -> bool { + String::from_utf8_lossy(bytes).contains(text) +} + +/// An object `/Encrypt` names is written as stored only while it is as stored. A file whose +/// `/Encrypt` names its `/Info` reads that dictionary as stored; once the title is edited, both +/// saves encrypt it rather than writing the new title in the clear. +#[test] +fn an_edited_object_that_encrypt_names_is_encrypted() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes128, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let info = doc.trailer().reference(b"Info").unwrap(); + let mut dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap(); + dict.set(b"Junk".to_vec(), Object::Ref(info)); + doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict)); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + let info = doc.trailer().reference(b"Info").unwrap(); + doc.update_dict(info, |d| d.set(b"Title".to_vec(), PdfString::text("Merger plan"))).unwrap(); + for (save, saved) in [("incremental", write_incremental(&doc, &SaveOptions::default())), ("full", write_full(&doc, &SaveOptions::default()))] { + assert!(!holds(&saved.unwrap(), "Merger plan"), "{save}: the edited title was written in the clear"); + } +} + +/// A stream can select its crypt filter by reference at every step: the chain, an entry of it, the +/// parameters, an entry of those, the name (ISO 32000-2 §7.3.10, §7.4.10). Read as absent, the +/// filter was `/Identity`, so such a stream was saved in the clear and, in a file from elsewhere, +/// its ciphertext read as data. A full save renumbers: what it follows has the document's numbers. +#[test] +fn a_crypt_filter_selected_by_reference_is_used() { + for alg in [Algorithm::Aes128, Algorithm::Aes256] { + for form in 0.. { + let mut doc = Document::open(Arc::new(protect(alg, "", "owner", -1))).unwrap(); + let Some(selected) = selector(&mut doc, form, "StdCF") else { break }; + select(&mut doc, selected); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(!holds(&saved, "Secret page"), "{alg:?} form {form}: saved encrypted"); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(raw_content(&back), SECRET, "{alg:?} form {form}: read back"); + let full = write_full(&back, &SaveOptions::default()).unwrap(); + assert!(!holds(&full, "Secret page"), "{alg:?} form {form}: renumbered, encrypted"); + assert_eq!(raw_content(&Document::open(Arc::new(full)).unwrap()), SECRET, "{alg:?} form {form}: renumbered"); + } + } +} + +/// A reference to a stream that takes `follows` references to read cold, this one included: its +/// nine filters are all read, the last leading to the next stream. +fn spends(doc: &mut Document, follows: usize) -> Object { + let left = follows - 1; + let mut filters: Vec = (0..left.min(8)).map(|i| Object::Ref(doc.add(Object::Int(i as i64)))).collect(); + if left > 8 { + filters.push(spends(doc, left - 8)); + } + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(filters)); + Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(dict, Vec::new())))) +} + +/// Gives the content stream a `/Type` that takes `follows` references to read, ahead of its filter. +fn spend_first(doc: &mut Document, follows: usize) { + let spent = spends(doc, follows); + let contents = contents_of(doc); + let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + s.dict.set(b"Type".to_vec(), spent); + doc.set(contents, Object::Stream(s)); +} + +/// Past what one read may follow a reference reads as absent, and a crypt filter with no `/Name` is +/// `/Identity`. Together those would read this stream's ciphertext as data, so what can't be told +/// is the document's own filter, as a `/Filter` that can't be read is. +#[test] +fn a_crypt_filter_past_what_a_read_may_follow_is_the_documents_own() { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let selected = selector(&mut doc, 1, "StdCF").unwrap(); + select(&mut doc, selected); + spend_first(&mut doc, 40); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(!holds(&saved, "Secret page"), "saved encrypted"); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(raw_content(&back), SECRET); + let full = write_full(&back, &SaveOptions::default()).unwrap(); + assert!(!holds(&full, "Secret page"), "renumbered, encrypted"); + assert_eq!(raw_content(&Document::open(Arc::new(full)).unwrap()), SECRET, "renumbered"); +} + +/// A read that takes all it may follow has refused nothing: a crypt filter with no `/Name` after it +/// is still `/Identity`, and the stream is in the clear. +#[test] +fn a_crypt_filter_with_no_name_after_all_a_read_may_follow_is_identity() { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + select(&mut doc, (Object::name("Crypt"), Object::Dict(pdfcraft_cos::Dict::new()))); + spend_first(&mut doc, 32); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(holds(&saved, "Secret page"), "written as the stream asks"); + assert_eq!(raw_content(&Document::open(Arc::new(saved)).unwrap()), SECRET); +} + +/// `/Identity` selected by reference keeps a stream in the clear in a document that encrypts the +/// rest, in every spelling, on both saves. +#[test] +fn an_identity_crypt_filter_selected_by_reference_leaves_a_stream_alone() { + for form in 0.. { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let Some(selected) = selector(&mut doc, form, "Identity") else { break }; + select(&mut doc, selected); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(holds(&saved, "Secret page"), "form {form}: written as the stream asks"); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(raw_content(&back), SECRET, "form {form}"); + let full = write_full(&back, &SaveOptions::default()).unwrap(); + assert!(holds(&full, "Secret page"), "form {form}: renumbered"); + assert_eq!(raw_content(&Document::open(Arc::new(full)).unwrap()), SECRET, "form {form}: renumbered"); + } +} + +/// New protection drops a crypt filter it doesn't define, whichever way the stream selected it. The +/// chain comes out direct. Parameters in the stream's own dictionary stay, encrypted with it; of an +/// array in an object of its own, which every stream can share, only what decoders read is copied. +#[test] +fn protecting_drops_a_crypt_filter_selected_by_reference() { + let protected = |doc: &mut Document| { + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + write_full(doc, &SaveOptions::default()).unwrap() + }; + for form in 0.. { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let Some(selected) = selector(&mut doc, form, "MyCF") else { break }; + select(&mut doc, selected); + let bytes = protected(&mut doc); + assert!(!holds(&bytes, "Secret page"), "form {form}: encrypted"); + assert!(!holds(&bytes, "MyCF"), "form {form}: no selector left in the stream"); + let back = Document::open(Arc::new(bytes.clone())).unwrap(); + assert_eq!(raw_content(&back), SECRET, "form {form}"); + let contents = back.get(contents_of(&back)); + let filter = contents.as_dict().unwrap().get(b"Filter"); + match form { + 8 => assert!( + matches!(filter, Some(Object::Array(a)) if matches!(a.as_slice(), [Object::Ref(_)])), + "form 8: the other filter stays: {filter:?}" + ), + _ => assert_eq!(filter, None, "form {form}"), + } + assert!(hayro_syntax::Pdf::new(bytes).is_ok(), "form {form}"); + } + for shared in [false, true] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut named = pdfcraft_cos::Dict::new(); + named.set(b"Name".to_vec(), Object::name("MyCF")); + let mut other = pdfcraft_cos::Dict::new(); + other.set(b"Note".to_vec(), Object::String(PdfString::text("Private note"))); + other.set(b"Predictor".to_vec(), Object::Int(1)); + let parms = Object::Array(vec![Object::Dict(named), Object::Dict(other)]); + let parms = if shared { Object::Ref(doc.add(parms)) } else { parms }; + select(&mut doc, (Object::Array(vec![Object::name("Crypt"), Object::name("ASCIIHexDecode")]), parms)); + let bytes = protected(&mut doc); + assert!(!holds(&bytes, "Private note"), "shared: {shared}"); + let back = Document::open(Arc::new(bytes)).unwrap(); + let contents = back.get(contents_of(&back)); + let kept = contents.as_dict().unwrap().get(b"DecodeParms").unwrap().as_array().unwrap(); + assert_eq!(kept.len(), 1, "one filter left, one entry"); + let kept = kept[0].as_dict().unwrap(); + assert_eq!(kept.get(b"Predictor"), Some(&Object::Int(1)), "shared: {shared}"); + assert_eq!(kept.get(b"Note").is_some(), !shared, "shared: {shared}"); + } +} + +/// Every stream of a file can share one large object as its `/DecodeParms` or `/Filter`, which +/// costs nothing until new protection drops a crypt filter from each. Copied whole, a 1 MB file +/// came out at 1 GB. +#[test] +fn protecting_copies_no_large_object_into_every_stream() { + let pad = Object::Array(vec![Object::Int(1); 100_000]); + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut large = pdfcraft_cos::Dict::new(); + large.set(b"Pad".to_vec(), pad.clone()); + let parms = doc.add(Object::Array(vec![Object::Null, Object::Dict(large)])); + let filter = doc.add(Object::Array(vec![Object::name("Crypt"), Object::Name(vec![b'A'; 100_000]), pad])); + let mut streams = Vec::new(); + for _ in 0..200 { + let mut by_parms = pdfcraft_cos::Dict::new(); + by_parms.set(b"Filter".to_vec(), Object::Array(vec![Object::name("Crypt"), Object::name("ASCIIHexDecode")])); + by_parms.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + streams.push(Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(by_parms, b"41>".to_vec()))))); + let mut by_filter = pdfcraft_cos::Dict::new(); + by_filter.set(b"Filter".to_vec(), Object::Ref(filter)); + streams.push(Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(by_filter, b"41>".to_vec()))))); + } + doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Array(streams))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(bytes.len() < 1 << 20, "{} bytes", bytes.len()); +} + +/// A file can name one large string thousands of times in `/ID`, which holds two. An encrypted file +/// keeps its `/ID`, so each copy would be in the file a save writes. +#[test] +fn a_file_identifier_of_many_references_is_two_strings() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes128, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let large = doc.add(Object::String(PdfString { bytes: vec![0xAB; 100_000], hex: true })); + doc.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![Object::Ref(large); 500])); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(bytes.len() < 1 << 20, "{} bytes", bytes.len()); +} + +/// What a file can do with those references: point a stream's selector at the stream itself, or at +/// nothing. They read as absent, so a stream with `/Crypt` is `/Identity` (ISO 32000-2 §7.4.10), +/// and one that names no filter follows the document's. Loading ends either way. +#[test] +fn a_crypt_filter_reference_to_itself_or_nothing_reads_as_absent() { + let named = |n: Object| { + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Name".to_vec(), n); + Object::Dict(d) + }; + for (what, encrypted) in [ + ("/Filter to itself", true), + ("/Filter nowhere", true), + ("/DecodeParms to itself", false), + ("/DecodeParms nowhere", false), + ("/Name to itself", false), + ("/Name nowhere", false), + ] { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let me = Object::Ref(contents_of(&doc)); + let nowhere = Object::Ref(ObjRef::new(9_999, 0)); + let crypt = Object::name("Crypt"); + select( + &mut doc, + match what { + "/Filter to itself" => (me, Object::Null), + "/Filter nowhere" => (nowhere, Object::Null), + "/DecodeParms to itself" => (crypt, me), + "/DecodeParms nowhere" => (crypt, nowhere), + "/Name to itself" => (crypt, named(me)), + _ => (crypt, named(nowhere)), + }, + ); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(!holds(&saved, "Secret page"), encrypted, "{what}"); + assert_eq!(raw_content(&Document::open(Arc::new(saved)).unwrap()), SECRET, "{what}"); + } +} + +/// A lone parameter dictionary belongs to the first filter, so `/Crypt` behind another filter has +/// none of its own and is `/Identity` (ISO 32000-2 §7.4.1), whatever that dictionary says. +#[test] +fn a_lone_parameter_dictionary_is_the_first_filters() { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let mut parms = pdfcraft_cos::Dict::new(); + parms.set(b"Name".to_vec(), Object::name("StdCF")); + let first = Object::Array(vec![Object::name("Crypt"), Object::name("ASCIIHexDecode")]); + let second = Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("Crypt")]); + select(&mut doc, (first, Object::Dict(parms.clone()))); + assert!(!holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page"), "first: it names StdCF"); + select(&mut doc, (second, Object::Dict(parms))); + assert!(holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page"), "second: it names nothing"); +} + +/// A lone parameter dictionary is the first filter's, so dropping a `/Crypt` behind that filter +/// leaves it, and dropping one in front of it takes it away. +#[test] +fn protecting_keeps_the_parameters_of_the_filter_before_a_crypt_filter() { + let protected = |filters: Vec| { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut parms = pdfcraft_cos::Dict::new(); + parms.set(b"Name".to_vec(), Object::name("MyCF")); + select(&mut doc, (Object::Array(filters), Object::Dict(parms))); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let back = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + back.get(contents_of(&back)).as_dict().unwrap().contains(b"DecodeParms") + }; + let (hex, crypt) = (Object::name("ASCIIHexDecode"), Object::name("Crypt")); + assert!(protected(vec![hex.clone(), crypt.clone()]), "the first filter's, which stays"); + assert!(!protected(vec![crypt, hex]), "/Crypt's, which goes"); +} + +/// Dropping `/Crypt` takes its entry out of `/DecodeParms`, and an array too short to have one is +/// left as it is, given directly or by reference. +#[test] +fn protecting_leaves_a_parameter_array_too_short_for_the_crypt_filter() { + for by_reference in [false, true] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let short = Object::Array(vec![Object::Null]); + let parms = if by_reference { Object::Ref(doc.add(short)) } else { short }; + select(&mut doc, (Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("Crypt")]), parms)); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!holds(&bytes, "Secret page"), "by reference: {by_reference}"); + let back = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(raw_content(&back), SECRET, "by reference: {by_reference}"); + let contents = back.get(contents_of(&back)); + let filter = contents.as_dict().unwrap().get(b"Filter").unwrap().as_array().unwrap(); + assert_eq!(filter.len(), 1, "only /Crypt goes"); + assert_eq!(filter[0].as_name(), Some(b"ASCIIHexDecode".as_slice()), "the other filter stays as it was"); + } +} + +/// A chain of up to [`pdfcraft_filters::MAX_CHAIN`] filters and `/Crypt` is read; a longer one +/// names no crypt filter, so its entries aren't followed for every stream sharing it. +#[test] +fn a_chain_longer_than_any_decoder_takes_names_no_crypt_filter() { + let chain = |others: usize| { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let mut filters = vec![Object::name("ASCIIHexDecode"); others]; + filters.push(Object::name("Crypt")); + let array = doc.add(Object::Array(filters)); + select(&mut doc, (Object::Ref(array), Object::Null)); + holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page") + }; + assert!(chain(pdfcraft_filters::MAX_CHAIN), "the longest chain read: /Identity, in the clear"); + assert!(!chain(pdfcraft_filters::MAX_CHAIN + 1), "one more: the document's filter"); +} + +/// A stream's `/Type` by reference picks its handling like one given directly: metadata left +/// readable under `/EncryptMetadata false`, an embedded file under `/EFF`. +#[test] +fn a_stream_type_given_by_reference_picks_its_crypt_filter() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let meta = catalog.reference(b"Metadata").unwrap(); + let ty = doc.add(Object::name("Metadata")); + let Object::Stream(mut s) = doc.get(meta).as_ref().clone() else { panic!("no metadata stream") }; + s.dict.set(b"Type".to_vec(), Object::Ref(ty)); + doc.set(meta, Object::Stream(s)); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "o", + permissions: -1, + encrypt_metadata: false, + seed: [5; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(holds(&bytes, "meta"), "search engines can read XMP"); + + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let encrypt = doc.trailer().reference(b"Encrypt").unwrap(); + doc.update_dict(encrypt, |e| e.set(b"EFF".to_vec(), Object::name("Identity"))).unwrap(); + let mut doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let contents = contents_of(&doc); + let ty = doc.add(Object::name("EmbeddedFile")); + let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + s.dict.set(b"Type".to_vec(), Object::Ref(ty)); + doc.set(contents, Object::Stream(s)); + assert!(holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page"), "/EFF /Identity leaves it alone"); +} + +/// What makes a dictionary a signature's can be given by reference, and what is null or refers to +/// nothing is absent: `/Contents` stays as written in each of these. +#[test] +fn a_signature_dictionary_is_known_in_every_form() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let ty = doc.add(Object::name("Sig")); + let range = Object::Array([0, 10, 20, 30].into_iter().map(Object::Int).collect()); + let by_reference = doc.add(range.clone()); + let nowhere = Object::Ref(ObjRef::new(9_999, 0)); + let forms: [(Vec<(&str, Object)>, &str); 5] = [ + (vec![("Type", Object::Ref(ty))], "DEAD"), + (vec![("ByteRange", Object::Ref(by_reference))], "BEEF"), + (vec![("Type", Object::Null), ("ByteRange", range.clone())], "CAFE"), + (vec![("Type", nowhere.clone()), ("ByteRange", range.clone())], "F00D"), + (vec![("Subtype", nowhere), ("ByteRange", range)], "D00D"), + ]; + let mut signatures = Vec::new(); + for (entries, contents) in forms { + let mut sig = pdfcraft_cos::Dict::new(); + for (key, value) in entries { + sig.set(key.as_bytes().to_vec(), value); + } + let bytes = (0..contents.len()).step_by(2).map(|i| u8::from_str_radix(&contents[i..i + 2], 16).unwrap()).collect(); + sig.set(b"Contents".to_vec(), Object::String(PdfString { bytes, hex: true })); + signatures.push(Object::Ref(doc.add(Object::Dict(sig)))); + } + // Nested in another object, it follows nothing, but known directly it is one all the same. + let mut nested = pdfcraft_cos::Dict::new(); + nested.set(b"Type".to_vec(), Object::name("Sig")); + nested.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0xAB, 0xBA], hex: true })); + let mut field = pdfcraft_cos::Dict::new(); + field.set(b"V".to_vec(), Object::Dict(nested)); + signatures.push(Object::Ref(doc.add(Object::Dict(field)))); + // Neither of what makes one: a string /Contents alone is anybody's, so it's encrypted. + let mut other = pdfcraft_cos::Dict::new(); + other.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0xAB, 0xCD], hex: true })); + signatures.push(Object::Ref(doc.add(Object::Dict(other)))); + doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Array(signatures))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + for contents in ["DEAD", "BEEF", "CAFE", "F00D", "D00D", "ABBA"] { + assert!(holds(&bytes, &format!("<{contents}>")), "{contents}: written as is"); + } + assert!(!holds(&bytes, ""), "without a /ByteRange it is no signature"); +} + +/// What a test needs to write an update of a classic file protected with AES-128. +struct Classic { + bytes: Vec, + root: ObjRef, + pages: ObjRef, + info: ObjRef, + encrypt: ObjRef, + id: Vec, + dict: pdfcraft_cos::Dict, +} + +fn classic_file() -> Classic { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes128, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + let encrypt = doc.trailer().reference(b"Encrypt").unwrap(); + let id = doc.trailer().get(b"ID").unwrap().as_array().unwrap()[0].as_string().unwrap().bytes.clone(); + let dict = doc.get(encrypt).as_dict().cloned().unwrap(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + Classic { root: doc.root().unwrap(), pages, info: doc.trailer().reference(b"Info").unwrap(), bytes, encrypt, id, dict } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02X}")).collect() +} + +/// `bytes` with an update appended: `objects`, and a trailer of `trailer` entries, in which `@n` +/// stands for the offset of object `n`. An object with nothing in it gets an entry of the +/// cross-reference table that points at nothing. +fn updated(bytes: &[u8], objects: &[(u32, Vec)], trailer: &str) -> Vec { + let at = bytes.windows(9).rposition(|w| w == b"startxref").unwrap(); + let prev: usize = String::from_utf8_lossy(&bytes[at + 9..]).split_whitespace().next().unwrap().parse().unwrap(); + let mut out = bytes.to_vec(); + let mut table = String::from("xref\n"); + let mut trailer = trailer.to_string(); + for (num, body) in objects { + if body.is_empty() { + table.push_str(&format!("{num} 1\n0000000010 00000 n \n")); + continue; + } + trailer = trailer.replace(&format!("@{num}"), &out.len().to_string()); + table.push_str(&format!("{num} 1\n{:010} 00000 n \n", out.len())); + out.extend_from_slice(format!("{num} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(table.as_bytes()); + out.extend_from_slice(format!("trailer\n<< {trailer} /Size 2000 /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +/// The `/Encrypt` dictionary of `file`, with `/O`, `/StmF` and `/StrF` as given. +fn encrypt_with(file: &Classic, o: &str, filter: &str) -> Vec { + let string = |key: &[u8]| hex(&file.dict.get(key).unwrap().as_string().unwrap().bytes); + format!( + "<< /Filter /Standard /V 4 /R 4 /Length 128 /P {} /O {o} /U <{}> /CF << /StdCF << /CFM /AESV2 /Length 16 /AuthEvent /DocOpen >> >> /StmF /{filter} /StrF /{filter} >>", + file.dict.int(b"P").unwrap(), + string(b"U") + ) + .into_bytes() +} + +/// An object stream can have its parameters by reference, and a stream's crypt filter can be +/// selected by a member of it: legal together, though each is rare. What reads the stream first +/// goes through the object stream, and members must come out of it as they do read directly. They +/// didn't, while a read made for a selector wasn't allowed to follow its own: the object stream was +/// kept undecoded, its members lost for good, and a full save wrote nulls without saying so. +#[test] +fn an_object_stream_reached_from_a_selector_keeps_its_members() { + let file = classic_file(); + let parms = "[<< /Name /Identity >> null]"; + let header = format!("902 0 903 {} ", parms.len() + 1); + let members = format!("{header}{parms} << /Keep (yes) >>"); + let (mut rows, mut before) = (vec![1u8], 0u8); + for b in members.bytes() { + rows.push(b.wrapping_sub(before)); + before = b; + } + let coded = pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), &rows).raw; + let mut object_stream = + format!("<< /Type /ObjStm /N 2 /First {} /Filter /FlateDecode /DecodeParms 911 0 R /Length {} >>\nstream\n", header.len(), coded.len()) + .into_bytes(); + object_stream.extend_from_slice(&coded); + object_stream.extend_from_slice(b"\nendstream"); + let data = [2u8, 0x03, 0x8E, 0, 2, 0x03, 0x8E, 1]; + let mut xref = format!("<< /Type /XRef /Size 2000 /W [1 2 1] /Index [902 2] /Length {} >>\nstream\n", data.len()).into_bytes(); + xref.extend_from_slice(&data); + xref.extend_from_slice(b"\nendstream"); + let string = |key: &[u8]| format!("<{}>", hex(&file.dict.get(key).unwrap().as_string().unwrap().bytes)); + let objects = [ + (file.root.num, format!("<< /Type /Catalog /Pages {} 0 R /A 900 0 R /B 903 0 R >>", file.pages.num).into_bytes()), + (file.encrypt.num, encrypt_with(&file, &string(b"O"), "Identity")), + (900, b"<< /Filter [/Crypt /ASCIIHexDecode] /DecodeParms 902 0 R /Length 1 >>\nstream\n>\nendstream".to_vec()), + (910, object_stream), + (911, format!("<< /Predictor 11 /Columns {} >>", members.len()).into_bytes()), + (912, xref), + ]; + let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>] /XRefStm @912", file.root.num, file.encrypt.num, id = hex(&file.id)); + let bytes = updated(&file.bytes, &objects, &trailer); + let keep = + |doc: &Document, r: &Object| doc.resolve(r).as_dict().and_then(|d| d.get(b"Keep")).and_then(Object::as_string).map(|s| s.bytes.clone()); + let member = Object::Ref(ObjRef::new(903, 0)); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + assert_eq!(keep(&doc, &member).as_deref(), Some(b"yes".as_slice()), "read first"); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + let _ = doc.get(ObjRef::new(900, 0)); + assert_eq!(keep(&doc, &member).as_deref(), Some(b"yes".as_slice()), "read after the stream selecting from it"); + let saved = write_full(&Document::open(Arc::new(bytes)).unwrap(), &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + let catalog = back.get(back.root().unwrap()); + assert_eq!(keep(&back, catalog.as_dict().unwrap().get(b"B").unwrap()).as_deref(), Some(b"yes".as_slice()), "after a full save"); +} + +/// `walk` goes as deep as an object nests, so following a reference from down there adds that depth +/// again for each object it loads: this file, under 10 KB, overflowed a 2 MiB stack and took the +/// process with it. Only the object itself follows references, and it does so in very little stack. +#[test] +fn nested_dictionaries_load_nothing_on_a_small_stack() { + let file = classic_file(); + let first = 960u32; + let objects: Vec<(u32, Vec)> = (first..first + 40) + .map(|num| { + let open = "[".repeat(120); + let close = "]".repeat(120); + (num, format!("{open}<< /Contents () /Type {} 0 R >>{close}", num + 1).into_bytes()) + }) + .collect(); + let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>]", file.root.num, file.encrypt.num, id = hex(&file.id)); + let bytes = updated(&file.bytes, &objects, &trailer); + let reading = std::thread::Builder::new().stack_size(1 << 20).spawn(move || { + let doc = Document::open(Arc::new(bytes)).unwrap(); + doc.get(ObjRef::new(first, 0)).as_array().is_some() + }); + assert_eq!(reading.unwrap().join().ok(), Some(true)); +} + +/// The object itself can follow references, a ladder of them as long as loading may nest, and that +/// fits a small stack too. +#[test] +fn a_ladder_of_signature_checks_fits_a_small_stack() { + let file = classic_file(); + let first = 960u32; + let objects: Vec<(u32, Vec)> = + (first..first + 40).map(|num| (num, format!("<< /Contents () /Type {} 0 R >>", num + 1).into_bytes())).collect(); + let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>]", file.root.num, file.encrypt.num, id = hex(&file.id)); + let bytes = updated(&file.bytes, &objects, &trailer); + let reading = std::thread::Builder::new().stack_size(1 << 20).spawn(move || { + let doc = Document::open(Arc::new(bytes)).unwrap(); + doc.get(ObjRef::new(first, 0)).as_dict().is_some() + }); + assert_eq!(reading.unwrap().join().ok(), Some(true)); +} + +/// The strings of `/Encrypt` are never encrypted (ISO 32000-2 §7.6.2), and can be objects of their +/// own. Read as ordinary strings they were decrypted, and a full save encrypted them again under +/// another number, so the file it wrote no longer opened with its own password. +#[test] +fn encrypt_strings_given_by_reference_stay_as_stored() { + let file = classic_file(); + let string = |key: &[u8]| hex(&file.dict.get(key).unwrap().as_string().unwrap().bytes); + let replaced = format!( + "<< /Filter /Standard /V 4 /R 4 /Length 128 /P {} /O 900 0 R /U 901 0 R /CF << /StdCF << /CFM /AESV2 /Length 16 /AuthEvent /DocOpen >> >> /StmF /StdCF /StrF /StdCF >>", + file.dict.int(b"P").unwrap() + ); + let objects = [ + (file.encrypt.num, replaced.into_bytes()), + (900, format!("<{}>", string(b"O")).into_bytes()), + (901, format!("<{}>", string(b"U")).into_bytes()), + ]; + let trailer = + format!("/Root {} 0 R /Info {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>]", file.root.num, file.info.num, file.encrypt.num, id = hex(&file.id)); + let source = Document::open(Arc::new(updated(&file.bytes, &objects, &trailer))).unwrap(); + assert_eq!(content(&source), SECRET, "the file reads"); + assert_eq!(title(&source).as_deref(), Some("Quarterly numbers")); + for object_streams in [false, true] { + let saved = write_full(&source, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(content(&back), SECRET, "object streams: {object_streams}"); + assert_eq!(title(&back).as_deref(), Some("Quarterly numbers"), "object streams: {object_streams}"); + } +} + +/// A save keeps what a file is protected with: its `/Encrypt`, which holds the handler and the +/// permissions, its `/ID`, and so its file key, under every algorithm and by either kind of save. +#[test] +fn a_save_keeps_the_handler_the_key_and_the_permissions() { + for alg in ALL { + let doc = Document::open_with_password(Arc::new(protect(alg, "user", "owner", -1 ^ 0b1_0000)), Some("user")).unwrap(); + let held = |d: &Document| { + let encrypt = d.resolve(d.trailer().get(b"Encrypt").unwrap()).as_dict().unwrap().clone(); + (encrypt, d.trailer().get(b"ID").cloned(), d.security().unwrap().file_key().to_vec(), format!("{:?}", d.permissions())) + }; + let before = held(&doc); + let mut edited = doc.clone(); + let _ = edited.add(Object::Int(1)); + for (save, saved) in [ + ("incremental", write_incremental(&edited, &SaveOptions::default()).unwrap()), + ("full", write_full(&doc, &SaveOptions::default()).unwrap()), + ] { + let back = Document::open_with_password(Arc::new(saved), Some("user")).unwrap(); + assert!(held(&back) == before, "{alg:?}: a {save} save"); + assert_eq!(content(&back), SECRET, "{alg:?}: a {save} save"); + } + } +} + +/// `/ID` has to be direct in an encrypted file, but one that isn't has its key made from the same +/// bytes. A full save writes it direct, and an incremental one leaves it as it was. +#[test] +fn a_file_identifier_given_by_reference_is_read() { + let file = classic_file(); + let id = hex(&file.id); + let reference = |entries: &str, objects: &[(u32, Vec)]| { + let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID {entries}", file.root.num, file.encrypt.num); + updated(&file.bytes, objects, &trailer) + }; + let whole = reference("900 0 R", &[(900, format!("[<{id}> <{id}>]").into_bytes())]); + let entries = reference("[900 0 R 901 0 R]", &[(900, format!("<{id}>").into_bytes()), (901, format!("<{id}>").into_bytes())]); + for file in [whole, entries] { + let source = Document::open(Arc::new(file)).unwrap(); + assert_eq!(content(&source), SECRET); + let back = Document::open(Arc::new(write_full(&source, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(content(&back), SECRET); + assert!(matches!(back.trailer().get(b"ID"), Some(Object::Array(_))), "written direct"); + let mut edited = source.clone(); + let _ = edited.add(Object::Int(1)); + let back = Document::open(Arc::new(write_incremental(&edited, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(content(&back), SECRET, "an incremental save"); + assert_eq!(back.trailer().get(b"ID"), edited.trailer().get(b"ID"), "and leaves it as it was"); + } +} + +/// A direct `/Encrypt` can refer to objects. A full save dropped those it found only there, and kept +/// its references as they were under the old numbers, so nothing in the file read afterwards. +#[test] +fn a_direct_encrypt_dictionary_keeps_what_it_refers_to() { + for object_streams in [false, true] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes128, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let mut dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap(); + let filter = doc.add(Object::name("StdCF")); + let filters = doc.add(dict.get(b"CF").unwrap().clone()); + dict.set(b"StmF".to_vec(), Object::Ref(filter)); + dict.set(b"CF".to_vec(), Object::Ref(filters)); + doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict)); + let options = SaveOptions { object_streams, ..SaveOptions::default() }; + let saved = write_full(&doc, &options).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(content(&back), SECRET, "object streams: {object_streams}"); + let again = write_full(&back, &options).unwrap(); + assert_eq!(content(&Document::open(Arc::new(again)).unwrap()), SECRET, "object streams: {object_streams}, saved again"); + } +} + +/// How a stream is treated can be given by reference, and what that loads can be a stream too. A +/// file can make a ladder of those, where each rung reads the rung below, then two trees wider than +/// the object cache, which push that rung out of it, then the rung below again. Each rung would +/// take twice what the one below does, if one read could follow any number of references. +#[test] +fn selectors_that_lead_to_selectors_load_at_once() { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let stream_of = |doc: &mut Document, filters: Vec| { + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(filters)); + doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(dict, Vec::new()))) + }; + let tree = |doc: &mut Document| { + let mut level: Vec = (0..6_561).map(|i| doc.add(Object::Int(i))).collect(); + while level.len() > 1 { + let chunks: Vec> = level.chunks(9).map(|c| c.iter().map(|r| Object::Ref(*r)).collect()).collect(); + level = chunks.into_iter().map(|c| stream_of(doc, c)).collect(); + } + Object::Ref(level[0]) + }; + let (first, second) = (tree(&mut doc), tree(&mut doc)); + let mut top = doc.add(Object::Int(0)); + // Twelve rungs of two, and the trees' five levels, stay inside the 32 that loading may nest. + for _ in 0..12 { + let (a, b) = (stream_of(&mut doc, vec![Object::Ref(top)]), stream_of(&mut doc, vec![Object::Ref(top)])); + top = stream_of(&mut doc, vec![Object::Ref(a), first.clone(), second.clone(), Object::Ref(b)]); + } + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let (done, finished) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let doc = Document::open(Arc::new(saved)).unwrap(); + let _ = done.send(doc.get(top).as_dict().is_some()); + }); + // With the budget it takes milliseconds, and without it longer than anyone would wait, so a + // margin this wide can't fail on a busy machine. + assert_eq!(finished.recv_timeout(std::time::Duration::from_secs(120)), Ok(true), "loading must not fan out"); +} diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 9c5d75bfbc..a5d9b57aba 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -9127,3 +9127,271 @@ fn content_after_an_inline_image_holding_ei_is_still_read() { // `EI` inside the data was not taken for the end. assert!(page.contains("AFTER"), "the content after the image was lost: {page:?}"); } + +/// `/Crypt` has nothing to undo, so a chain naming it thousands of times costs no copy of the data +/// for each. A chain of filters that does work has its own cost, which this isn't about. +#[test] +fn a_long_chain_of_crypt_filters_takes_no_time() { + use super::*; + let content = format!("1 0 0 rg 0 0 200 200 re f\n{}", " ".repeat(16 << 20)); + let chain = "/Crypt ".repeat(100_000); + let 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 200 200] /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Filter [{chain}] /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let (done, finished) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let page = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + let start = (100 * page.width as usize + 100) * 4; + let _ = done.send(page.rgba.get(start..start + 4).map(<[u8]>::to_vec)); + }); + // Skipped, this takes a moment; copied, 1.6 TB, which a margin this wide tells apart however + // busy the machine. + let pixel = finished.recv_timeout(std::time::Duration::from_secs(120)).expect("the chain costs no copy for each entry"); + assert_eq!(pixel, Some(vec![255, 0, 0, 255])); +} + +/// A lone filter takes its parameters as a dictionary or as an array of one, and given as an array +/// of one itself it takes either too. The form of `/Filter` decided which was read, so a predictor +/// in the other form was lost and the content came out as noise. `/StmF` and `/StrF` are optional, +/// meaning `/Identity`: such a file didn't open at all, though a stream can still name a filter. +#[test] +fn a_filters_parameters_are_read_in_either_form() { + use super::*; + use pdfcraft_cos::{Algorithm, Dict, Document, NewEncryption, Object, SaveOptions, Stream, write_full, write_incremental}; + let content = b"1 0 0 rg 0 0 200 200 re f"; + let centre = |bytes: Vec| -> [u8; 4] { + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let page = |stream: &[u8]| { + 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 200] /Contents 4 0 R >> endobj\n".to_vec(); + pdf.extend_from_slice(stream); + pdf.extend_from_slice(b"\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + pdf + }; + // PNG Sub over one row, which reads as noise if it isn't undone. + let mut rows = vec![1u8]; + let mut before = 0u8; + for b in content { + rows.push(b.wrapping_sub(before)); + before = *b; + } + let coded = Stream::flate(Dict::new(), &rows).raw; + let parms = format!("<< /Predictor 11 /Columns {} >>", content.len()); + for (filter, decode_parms) in [ + ("/FlateDecode", parms.clone()), + ("/FlateDecode", format!("[{parms}]")), + ("[/FlateDecode]", parms.clone()), + ("[/FlateDecode]", format!("[{parms}]")), + ] { + let mut stream = format!("4 0 obj << /Filter {filter} /DecodeParms {decode_parms} /Length {} >> stream\n", coded.len()).into_bytes(); + stream.extend_from_slice(&coded); + stream.extend_from_slice(b"\nendstream endobj"); + assert_eq!(centre(page(&stream)), [255, 0, 0, 255], "{filter} with {decode_parms}"); + } + + let plain = format!("4 0 obj << /Length {} >> stream\n{}\nendstream endobj", content.len(), String::from_utf8_lossy(content)); + let mut doc = Document::open(Arc::new(page(plain.as_bytes()))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes128, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + // No object streams: they would be encrypted under the /StmF about to go. + let saved = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let mut doc = Document::open(Arc::new(saved)).unwrap(); + let encrypt = doc.trailer().reference(b"Encrypt").unwrap(); + doc.update_dict(encrypt, |e| { + e.remove(b"StmF"); + e.remove(b"StrF"); + }) + .unwrap(); + let mut doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let catalog = doc.get(doc.root().unwrap()); + let pages = doc.get(catalog.as_dict().unwrap().reference(b"Pages").unwrap()); + let first = doc.resolve(&pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0]); + let contents = first.as_dict().unwrap().reference(b"Contents").unwrap(); + let mut named = Dict::new(); + named.set(b"Name".to_vec(), Object::name("StdCF")); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("Crypt")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(named)); + doc.set(contents, Object::Stream(Stream::from_raw(dict, content.to_vec()))); + assert_eq!(centre(write_incremental(&doc, &SaveOptions::default()).unwrap()), [255, 0, 0, 255], "no /StmF, and a stream naming StdCF"); +} + +/// A stream can pick its own crypt filter: `/Crypt` in `/Filter`, with the filter's name in +/// `/DecodeParms`, or `/Identity` without one (ISO 32000-2 §7.4.10). The renderer used the +/// document's filter for every stream, and then couldn't decode `/Crypt`, so the page stayed blank. +#[test] +fn a_stream_with_a_crypt_filter_of_its_own_is_drawn() { + use super::*; + use pdfcraft_cos::{Algorithm, Dict, Document, NewEncryption, Object, SaveOptions, Stream, write_full, write_incremental}; + let content = "1 0 0 rg 0 0 200 200 re f"; + let page = 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 200 200] /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let centre = |bytes: Vec| -> [u8; 4] { + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let name = |n: &str| { + let mut parms = Dict::new(); + parms.set(b"Name".to_vec(), Object::name(n)); + Object::Dict(parms) + }; + let hex: String = content.bytes().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect(); + let crypt = || Object::name("Crypt"); + let contents_of = |doc: &Document| { + let catalog = doc.get(doc.root().unwrap()); + let pages = doc.get(catalog.as_dict().unwrap().reference(b"Pages").unwrap()); + let page = doc.resolve(&pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0]); + page.as_dict().unwrap().reference(b"Contents").unwrap() + }; + for (algorithm, filters) in [(Algorithm::Rc4_128, false), (Algorithm::Aes128, true), (Algorithm::Aes256, true)] { + let mut doc = Document::open(Arc::new(page.clone().into_bytes())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let protected = write_full(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(centre(protected.clone()), [255, 0, 0, 255], "{algorithm:?}: the document's own filter"); + // A filter of its own name that stores streams as they are, beside the document's. + let mut doc = Document::open(Arc::new(protected)).unwrap(); + if filters { + let encrypt = doc.trailer().reference(b"Encrypt").unwrap(); + doc.update_dict(encrypt, |e| { + let Some(Object::Dict(cf)) = e.get(b"CF") else { panic!("no /CF") }; + let mut cf = cf.clone(); + let mut stored = Dict::new(); + stored.set(b"CFM".to_vec(), Object::name("None")); + cf.set(b"Stored".to_vec(), Object::Dict(stored)); + // `/Identity` is never one of these, whatever a file says. + let mut named = Dict::new(); + named.set(b"CFM".to_vec(), Object::name("AESV2")); + cf.set(b"Identity".to_vec(), Object::Dict(named)); + e.set(b"CF".to_vec(), Object::Dict(cf)); + }) + .unwrap(); + } + let protected = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let spellings = [ + "no name", + "/Identity", + "StdCF", + "Stored", + "/DecodeParms by reference", + "/Filter by reference", + "an entry of /Filter by reference", + "an entry of /DecodeParms by reference", + "/Name by reference", + "before another filter", + "a lone dictionary for an array of one", + "an array of one for a name", + "a dictionary by reference for an array of one", + "an array of one by reference for a dictionary", + "a name by reference for an array", + "a lone dictionary before another filter", + "past the longest chain", + ]; + for what in spellings { + let mut doc = Document::open(Arc::new(protected.clone())).unwrap(); + let (filter, parms) = match what { + "no name" => (crypt(), None), + "/Identity" => (crypt(), Some(name("Identity"))), + "StdCF" => (crypt(), Some(name("StdCF"))), + "Stored" => (crypt(), Some(name("Stored"))), + "/DecodeParms by reference" => (crypt(), Some(Object::Ref(doc.add(name("StdCF"))))), + "/Filter by reference" => (Object::Ref(doc.add(crypt())), Some(name("StdCF"))), + "an entry of /Filter by reference" => (Object::Array(vec![Object::Ref(doc.add(crypt()))]), Some(Object::Array(vec![name("StdCF")]))), + "an entry of /DecodeParms by reference" => { + (Object::Array(vec![crypt()]), Some(Object::Array(vec![Object::Ref(doc.add(name("StdCF")))]))) + } + "/Name by reference" => { + let mut parms = Dict::new(); + parms.set(b"Name".to_vec(), Object::Ref(doc.add(Object::name("StdCF")))); + (crypt(), Some(Object::Dict(parms))) + } + "a lone dictionary for an array of one" => (Object::Array(vec![crypt()]), Some(name("StdCF"))), + "an array of one for a name" => (crypt(), Some(Object::Array(vec![name("StdCF")]))), + "a dictionary by reference for an array of one" => (Object::Array(vec![crypt()]), Some(Object::Ref(doc.add(name("StdCF"))))), + "an array of one by reference for a dictionary" => (Object::Ref(doc.add(Object::Array(vec![crypt()]))), Some(name("StdCF"))), + "a name by reference for an array" => (Object::Ref(doc.add(crypt())), Some(Object::Array(vec![name("StdCF")]))), + "a lone dictionary before another filter" => (Object::Array(vec![crypt(), Object::name("ASCIIHexDecode")]), Some(name("StdCF"))), + // One filter more than cos decodes: it names none there, so here neither, and the + // stream is read with the document's filter, not the `Stored` it says. + "past the longest chain" => { + let mut chain = vec![crypt()]; + chain.extend(std::iter::repeat_n(Object::name("ASCIIHexDecode"), 9)); + (Object::Array(chain), Some(Object::Array(vec![name("Stored")]))) + } + _ => (Object::Array(vec![crypt(), Object::name("ASCIIHexDecode")]), Some(Object::Array(vec![name("StdCF"), Object::Null]))), + }; + let contents = contents_of(&doc); + let Object::Stream(s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + let mut dict = s.dict.clone(); + dict.set(b"Filter".to_vec(), filter); + if let Some(parms) = parms { + dict.set(b"DecodeParms".to_vec(), parms); + } + let raw = match what { + "before another filter" | "a lone dictionary before another filter" => hex.clone().into_bytes(), + "past the longest chain" => (0..9).fold(content.as_bytes().to_vec(), |data, _| { + let mut coded: Vec = data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + coded.push(b'>'); + coded + }), + _ => content.as_bytes().to_vec(), + }; + doc.set(contents, Object::Stream(Stream::from_raw(dict, raw))); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(centre(saved), [255, 0, 0, 255], "{algorithm:?}: {what}"); + } + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e60a1ad2fb..9ef459028b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -226,8 +226,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/crypt/src/tests.rs::identity_crypt_filters_leave_data_alone"] -notes = "" +evidence = ["crates/crypt/src/tests.rs::identity_crypt_filters_leave_data_alone", "crates/cos/tests/it/encryption.rs::a_crypt_filter_selected_by_reference_is_used", "crates/cos/tests/it/encryption.rs::an_identity_crypt_filter_selected_by_reference_leaves_a_stream_alone", "crates/render/src/raster/tests.rs::a_stream_with_a_crypt_filter_of_its_own_is_drawn"] +notes = "a stream can name its own filter (/Crypt in /Filter, its name in /DecodeParms), read and written, each part given directly or by reference, in cos and in the renderer; /StmF, /StrF, /EFF and /CFM by reference too. A /Filter given by reference selects the crypt filter, but Stream::decoded still refuses it, so such a stream is decrypted and not decoded outside the renderer." [[feature]] id = "core.attachment-only-encryption" diff --git a/vendor/README.md b/vendor/README.md index bf0f5f9129..1fc2f75135 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, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | 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 | (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` | | 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; (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 | (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`; (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) | +| 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 | (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) | | 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/crypto/mod.rs b/vendor/hayro-syntax/src/crypto/mod.rs index 15371514c1..79d182c6ac 100644 --- a/vendor/hayro-syntax/src/crypto/mod.rs +++ b/vendor/hayro-syntax/src/crypto/mod.rs @@ -74,9 +74,11 @@ pub(crate) enum Decryptor { } #[derive(Debug, Copy, Clone)] -pub(crate) enum DecryptionTarget { +pub(crate) enum DecryptionTarget<'a> { String, Stream, + /// PdfCraft patch (30): a stream that names its own crypt filter. + NamedStream(&'a str), } impl Decryptor { @@ -84,7 +86,7 @@ impl Decryptor { &self, id: ObjectIdentifier, data: &[u8], - target: DecryptionTarget, + target: DecryptionTarget<'_>, ) -> Option> { match self { Self::None => Some(data.to_vec()), @@ -93,6 +95,8 @@ impl Decryptor { let crypt_dict = match target { DecryptionTarget::String => dict.string_filter, DecryptionTarget::Stream => dict.stream_filter, + // `/Identity` isn't in the table, and a name it hasn't got reads as that. + DecryptionTarget::NamedStream(name) => dict.named.get(name).copied().unwrap_or(CryptDictionary::identity(0)), }; match crypt_dict.cfm { @@ -292,10 +296,12 @@ fn decrypt_rc_aes( with_key(final_key) } -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Clone)] pub(crate) struct DecryptorData { stream_filter: CryptDictionary, string_filter: CryptDictionary, + /// PdfCraft patch (30): `/CF`, for the streams that name a filter of their own. + named: HashMap, } impl DecryptorData { @@ -312,16 +318,23 @@ impl DecryptorData { } } - let stm_f = *mappings - .get(dict.get::>(STM_F)?.as_str()) - .unwrap_or(&CryptDictionary::identity(default_length)); - let str_f = *mappings - .get(dict.get::>(STR_F)?.as_str()) - .unwrap_or(&CryptDictionary::identity(default_length)); + // PdfCraft patch (30): `/Identity` is never an entry of `/CF`, whatever a file puts there, + // and `/StmF` and `/StrF` are optional, naming it by default (ISO 32000-2 Table 20). + mappings.remove("Identity"); + let identity = CryptDictionary::identity(default_length); + let stm_f = dict + .get::>(STM_F) + .and_then(|n| mappings.get(n.as_str()).copied()) + .unwrap_or(identity); + let str_f = dict + .get::>(STR_F) + .and_then(|n| mappings.get(n.as_str()).copied()) + .unwrap_or(identity); Some(Self { stream_filter: stm_f, string_filter: str_f, + named: mappings, }) } } diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index e81a37df98..89d0512693 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -130,7 +130,8 @@ impl Filter { warn!("image decoding is not supported (enable the `images` feature)"); Err(DecodeFailure::ImageDecode) } - _ => Err(DecodeFailure::StreamDecode), + // PdfCraft patch (30): `Stream::raw_data` decrypted the data with the filter this names. + Self::Crypt => Ok(FilterResult::from_data(data.to_vec())), }; if res.is_err() { diff --git a/vendor/hayro-syntax/src/object/stream.rs b/vendor/hayro-syntax/src/object/stream.rs index a80f0730cb..4a0c08510f 100644 --- a/vendor/hayro-syntax/src/object/stream.rs +++ b/vendor/hayro-syntax/src/object/stream.rs @@ -5,7 +5,7 @@ use crate::filter::Filter; use crate::object; use crate::object::Dict; use crate::object::Name; -use crate::object::dict::keys::{DECODE_PARMS, DP, F, FILTER, LENGTH, TYPE}; +use crate::object::dict::keys::{DECODE_PARMS, DP, F, FILTER, LENGTH, NAME, TYPE}; use crate::object::{Array, ObjectIdentifier}; use crate::object::{Object, ObjectLike, ObjectRefLike}; use crate::reader::Reader; @@ -13,6 +13,7 @@ use crate::reader::{Readable, ReaderContext, ReaderExt, Skippable}; use crate::trivia::is_white_space_character; use crate::util::{OptionLog, find_needle}; use alloc::borrow::Cow; +use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt::{Debug, Formatter}; use smallvec::SmallVec; @@ -73,38 +74,44 @@ impl<'a> Stream<'a> { let mut collected_filters = SmallVec::new(); let mut collected_params = SmallVec::new(); + // PdfCraft patch (30): `/DecodeParms` in either form, whichever form `/Filter` has: one filter + // in an array with a lone dictionary is an everyday spelling, and a lone dictionary is the + // first filter's. Read as the chain asks, so a long array costs no more than a short one. + let parms = self + .dict + .get::>(DP) + .or_else(|| self.dict.get::>(DECODE_PARMS)); + let mut in_array = match &parms { + Some(Object::Array(a)) => Some(a.iter::>()), + _ => None, + }; + let mut lone = match parms { + Some(Object::Dict(d)) => Some(d), + _ => None, + }; + let mut next_params = || { + lone.take() + .or_else(|| in_array.as_mut()?.next()?.into_dict()) + .unwrap_or_default() + }; + if let Some(filter) = self .dict .get::>(F) .or_else(|| self.dict.get::>(FILTER)) .and_then(Filter::from_name) { - let params = self - .dict - .get::>(DP) - .or_else(|| self.dict.get::>(DECODE_PARMS)) - .unwrap_or_default(); - collected_filters.push(filter); - collected_params.push(params); + collected_params.push(next_params()); } else if let Some(filters) = self .dict .get::>(F) .or_else(|| self.dict.get::>(FILTER)) { let filters = filters.iter::>().map(Filter::from_name); - let mut params = self - .dict - .get::>(DP) - .or_else(|| self.dict.get::>(DECODE_PARMS)) - .map(|a| a.iter::>()); for filter in filters { - let params = params - .as_mut() - .and_then(|p| p.next()) - .and_then(|p| p.into_dict()) - .unwrap_or_default(); + let params = next_params(); if let Some(filter) = filter { collected_filters.push(filter); @@ -119,6 +126,20 @@ impl<'a> Stream<'a> { } } + /// PdfCraft patch (30): the crypt filter the stream names for itself: `/Crypt` in its chain, its + /// name in that filter's parameters, or `/Identity` without one (ISO 32000-2 §7.4.10). + fn crypt_filter(&self) -> Option { + let FiltersAndParams { filters, params } = self.filters_and_params(); + // A chain longer than cos decodes names none there, so it names none here either: + // `pdfcraft_filters::MAX_CHAIN`, and `/Crypt`. + if filters.len() > 9 { + return None; + } + let i = filters.iter().position(|f| matches!(f, Filter::Crypt))?; + let name = params.get(i).and_then(|p| p.get::>(NAME)); + Some(name.map_or_else(|| "Identity".to_string(), |n| n.as_str().to_string())) + } + /// Return the raw, decrypted data of the stream. /// /// Stream filters will not be applied. @@ -132,9 +153,13 @@ impl<'a> Stream<'a> { .map(|t| t.as_ref() != b"XRef") .unwrap_or(true) { + let own = self.crypt_filter(); + let target = own + .as_deref() + .map_or(DecryptionTarget::Stream, DecryptionTarget::NamedStream); Cow::Owned( ctx.xref() - .decrypt(self.obj_id(), self.data, DecryptionTarget::Stream) + .decrypt(self.obj_id(), self.data, target) // TODO: MAybe an error would be better? .unwrap_or_default(), ) @@ -241,6 +266,11 @@ impl<'a> Stream<'a> { .zip(filters_and_params.params.iter()) .enumerate() { + // PdfCraft patch (30): `raw_data` decrypted the data, so there's nothing to undo, and a + // chain can name this thousands of times. + if matches!(filter, Filter::Crypt) { + continue; + } // PdfCraft patch: see `filter::decode_limit`. let limit = crate::filter::decode_limit(image_params, i + 1 == count).min(max); let new = filter.apply( diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index d3ab7d324f..dd3ebb8b56 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -651,7 +651,7 @@ impl XRef { &self, id: ObjectIdentifier, data: &[u8], - target: DecryptionTarget, + target: DecryptionTarget<'_>, ) -> Option> { match &self.0 { Inner::Dummy => Some(data.to_vec()), From d579f26de342ecdf3fe6d3079ea07c7719db293d Mon Sep 17 00:00:00 2001 From: Piohrun Date: Wed, 4 Nov 2026 13:34:08 +0100 Subject: [PATCH 61/63] M11: JBIG2 images share a symbol dictionary, up to 16 at a time (optimize.mono-compression) (#5910) * M11: JBIG2 images share a symbol dictionary, up to 16 at a time With Monochrome Images set to JBIG2, Optimize stored each image's letters in a symbol dictionary of its own, so a document of N pages carried its font's shapes N times. Now up to 16 images the run stores as JBIG2 share one dictionary in a /JBIG2Globals stream, holding the marks found in more than one of them; each image's text regions refer to it first, then to a dictionary of its own for the marks only it repeats. An image is stored that way when it comes out smaller, and a group only when what its images save is more than the globals take. - filters: encode_jbig2_shared codes a group: the globals are a symbol dictionary segment numbered 0 and associated with no page (7.2.6); each page numbers its segments from 1, as a segment refers only to lower numbers (7.2.5), and the shared symbols' IDs come first, as the dictionaries are referred to in that order (Table 31, SBSYMS). At most 16,383 shared marks, those in the most images, so a page keeps room for its own under the decoder's 32,767. - optimize: the run holds the 1-bit samples of the images it stores as JBIG2 (16 at most, 64 MiB at most) and stores them again sharing, weighing each with the longest reference the globals could have. Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier, and 46% in Helvetica at 600 dpi; groups of 2, 4, 16 and 32 save 19%, 33%, 45% and 47%. Redacting one image stores the others of its group again without the dictionary, so 16 keeps that to 15 images. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: test what the mutants found untested in shared JBIG2 dictionaries - filters: the group goes through encode_jbig2_shared, which refuses it for an image the encoder refuses; two marks in common are enough to share; past the 65,536 distinct marks a group holds, a mark first seen later isn't shared while those held count every image. - optimize: optimizing again keeps both the shared pages and the pages coded alone as they are; under Retain, averaged JBIG2 pages share nothing; a last group of two shares. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: only images redaction can store again share a JBIG2 dictionary Redacting one image of a group stores the others again without the dictionary, which can hold the shapes of marks under the area, and refuses the redaction for one it can't: it takes image masks and images of one component as its own reading counts them (no DeviceN, the family named directly), and at most 64 Mi pixels. Optimize took DeviceN of one colourant and up to 80 million pixels, so such an image in a group made any redaction of the others fail, where before it was coded alone and the same redaction worked (the Consumers review). Only an image mask or a DeviceGray image named directly, of at most 2^26 pixels, now joins a group; others are coded alone. An engine test optimizes three pages, two DeviceGray and one DeviceN, and redacts the first: the DeviceN page stays alone, the second is stored again, and no image names the dictionary after a save. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: optimize and redaction share one limit on the images redaction stores again The second Consumers review: Optimize's limit on images that share a JBIG2 dictionary copied redaction's private 64 Mi, with nothing tying the two, so a lower one in redaction would bring back refused redactions with no test going red. Both now read pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS. The engine test adds an image mask, the other kind that shares, so a redaction that stopped storing either kind again fails it. doc_optimize's description, the README, the parity note and ROADMAP say which images share (named directly). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: a JBIG2 group is coded before its samples would pass the limit CodeRabbit on #5910: hold added an image to the group and then checked SHARED_BYTES, so a group could pass the 64 MiB it is documented to stay within by its last image, which can itself be up to 64 MiB (an image that shares has at most 2^26 pixels, and as its rows are whole bytes, at most that many bytes; a 1-pixel-wide one takes them all). hold_in codes the group first when an image would take it past the limit, and after when it has 16 images; with room for 10 bytes, images of 4 go two to a group, where the old order made one of 12. CodeRabbit's follow-up too: the 18-image test decodes every image and compares it with its samples, across the boundary between the groups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * optimize: count the globals' object framing; filters: test the shared coder's size limit - A JBIG2 group shares a dictionary only when it saves more than the globals' dictionary and data plus NEW_OBJECT (96 bytes: the object's framing, its xref entry, and an encrypted file's IV and padding), so a group that saves less than that no longer makes the file larger. - encode_jbig2_shared is Unsupported for an image taller than the decoder takes and codes one at the bound. Co-Authored-By: Claude Opus 5.5 * filters: drop the unused rows binding in encode_shared page() no longer takes the rows since #5511. Co-Authored-By: Claude Opus 5.5 * filters: the shared coder's at-bound test says what it returns Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- ROADMAP.md | 2 + crates/automation/src/tools.rs | 2 +- crates/cos/src/budget.rs | 6 + crates/cos/src/lib.rs | 2 +- crates/engine/src/tests.rs | 101 ++++++++ crates/filters/README.md | 5 +- crates/filters/src/jbig2.rs | 93 +++++-- crates/filters/src/jbig2/symbols.rs | 383 +++++++++++++++++++++++++--- crates/filters/src/lib.rs | 5 +- crates/optimize/README.md | 12 +- crates/optimize/src/images.rs | 188 +++++++++++++- crates/optimize/tests/optimize.rs | 202 ++++++++++++++- crates/redact/src/image.rs | 5 +- docs/roadmap.md | 1 + parity/acrobat-features.toml | 4 +- 15 files changed, 936 insertions(+), 75 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5bfe1fc817..6ef9e2c369 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -223,6 +223,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15. + - **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged. - **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged. diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4672e49886..1b4b6ac4ba 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1459,7 +1459,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 376b108be3..65b750e7b3 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -25,6 +25,12 @@ pub const MAX_PAGE_DECODED: usize = 256 << 20; /// Form XObjects one page's content walk may enter, each entry counted again. pub const MAX_PAGE_FORMS: usize = 50_000; +/// The most pixels an image redaction decodes, to clear the ones under a mark or to store again +/// without the `/JBIG2Globals` it shares with a redacted image (`pdfcraft_redact`). Optimize has +/// JBIG2 images share a dictionary only within it (`pdfcraft_optimize`), so that redaction can +/// store each of them again: one it couldn't would refuse the redaction. +pub const MAX_REDACTED_IMAGE_PIXELS: u64 = 64 << 20; + /// The steps a sample of a JPEG costs: a few bytes can hold a page of one grey, and decoding, /// resampling and storing it took about 20 to 45 ns a sample. const JPEG_WORK: u64 = 2; diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 6187acb846..8e051e4578 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -22,7 +22,7 @@ mod parser; mod security; mod writer; -pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS}; +pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS}; pub use bytes::Bytes; pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream}; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a77e471a7d..5d95486c32 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10252,6 +10252,107 @@ fn image_transform_engine_refuses_singular_matrices_without_mutation() { } } +/// Optimize has JBIG2 images share a symbol dictionary only where redaction can store each again +/// without it. Of four pages of the same scan, the two in DeviceGray and the image mask share one; +/// the one in a DeviceN of one colourant, which redaction doesn't store again, is coded alone. +/// Redacting the first page then stores the second and the mask again without the dictionary, +/// which leaves the file. +#[test] +fn redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again() { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + let (w, h) = (600usize, 480usize); + // Lines of 26 letters, each 32 rows of a run from a stem down its left side, of 2 to 24 pixels, + // the same to the pixel wherever it is, picked and spaced at random. + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as usize + }; + let letters: Vec> = (0..26).map(|_| (0..16).map(|_| 1 + next() % 12).collect()).collect(); + let stride = w.div_ceil(8); + let mut bits = vec![0xFFu8; stride * h]; + for top in (8..h - 40).step_by(44) { + let mut x = 8 + next() % 8; + while x + 32 < w { + let letter = &letters[next() % 26]; + for (row, run) in letter.iter().enumerate() { + for (px, py) in (x..x + 2 * run).flat_map(|px| [(px, top + 2 * row), (px, top + 2 * row + 1)]) { + bits[py * stride + px / 8] &= !(0x80 >> (px % 8)); + } + } + x += 2 * letter.iter().max().unwrap_or(&1) + 4 + next() % 5; + } + } + let mut doc = Document::new_empty(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let device_n = Object::Array(vec![Object::name("DeviceN"), Object::Array(vec![Object::name("Black")]), Object::name("DeviceGray"), { + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)])); + tint.set(b"N".to_vec(), Object::Int(1)); + Object::Dict(tint) + }]); + let mut images: Vec = Vec::new(); + let grey = || ("ColorSpace", Object::name("DeviceGray")); + for (k, (key, value)) in [grey(), grey(), ("ColorSpace", device_n), ("ImageMask", Object::Bool(true))].into_iter().enumerate() { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(key.as_bytes().to_vec(), value); + let image = doc.add(Object::Stream(Stream::flate(d, &bits))); + images.push(image); + let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 144 0 0 115.2 36 600 cm /Im Do Q".to_vec()))); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + if k == 0 { + let mut mark = Dict::new(); + mark.set(b"Type".to_vec(), Object::name("Annot")); + mark.set(b"Subtype".to_vec(), Object::name("Redact")); + mark.set(b"Rect".to_vec(), Object::Array(vec![40.into(), 610.into(), 100.into(), 650.into()])); + let mark = doc.add(Object::Dict(mark)); + page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(mark)])); + } + let page = doc.add(Object::Dict(page)); + doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.push(Object::Ref(page)); + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }) + .unwrap(); + } + let mono = pdfcraft_optimize::MonoSettings { + compression: pdfcraft_optimize::MonoCompression::Jbig2, + downsample: false, + ..pdfcraft_optimize::MonoSettings::REDUCE + }; + pdfcraft_optimize::optimize(&mut doc, &pdfcraft_optimize::Settings { mono, ..Default::default() }).unwrap(); + let globals = |doc: &Document, image: ObjRef| match &*doc.get(image) { + Object::Stream(s) => s.dict.get(b"DecodeParms").and_then(Object::as_dict).and_then(|d| d.reference(b"JBIG2Globals")), + _ => None, + }; + let shared = globals(&doc, images[0]); + assert!(shared.is_some() && globals(&doc, images[1]) == shared && globals(&doc, images[3]) == shared, "DeviceGray and the mask share"); + assert_eq!(globals(&doc, images[2]), None, "the DeviceN page is coded alone"); + pdfcraft_redact::apply(&mut doc, None).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let named = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if saved.jbig2_globals_ref(s).is_some())).count(); + assert_eq!(named, 0, "no image names the dictionary, which left with them"); +} + #[test] fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { let (mut session, id) = rotated_blank(0); diff --git a/crates/filters/README.md b/crates/filters/README.md index cdaef6ff95..a706f0b3d1 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -31,6 +31,7 @@ decode_ccitt_within(data, ¶ms, max_rows, work: &mut u64) -> Result, decode_jbig2_within(data, globals, columns, rows, max, work: &mut u64) -> Result, FilterError> // JBIG2 as JBIG2Decode gives it; a page of columns × rows, within max and MAX_JBIG2_PIXELS; reading and decoding it spend from work, in the decoder's steps encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns and rows <= MAX_JBIG2_COLUMNS (65,535) and MAX_JBIG2_PIXELS (2^28) samples, what the decoder takes encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; the same limits +encode_jbig2_shared(&[(data, columns, rows)]) -> Result, FilterError> // images symbol coded sharing one dictionary: the /JBIG2Globals data, and each image's stream or None; the same limits ``` - `chain` is applied first to last, as in a `/Filter` array. @@ -51,7 +52,7 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | | CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt_within` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says, so data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at `k` 0 may come out as other rows (`pdfcraft_cos::ImageBudget::decode` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | -| JBIG2Decode | not in a chain (`Unsupported`); `decode_jbig2_within` decodes it on its own with `hayro-jbig2`, given what its `/JBIG2Globals` decode to, as rows of 1-bit samples each on a new byte, black 0; the page has to be `columns` × `rows` (PdfCraft's renderer draws the whole page into the image), within `max` bytes as such rows and 2^28 pixels (`MAX_JBIG2_PIXELS`, the most the decoder takes), checked before it is decoded; regions inside it are bounded by the decoder alone (a region may claim more pixels than its page); damaged data, or globals that don't decode, are `Corrupt` (`pdfcraft_cos::ImageBudget::decode` calls it for a JBIG2 image, after the filters before it) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 65,535 samples on a side and 2^28 in all, where `hayro-jbig2` stops; no rows or columns, or too large → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made | +| JBIG2Decode | not in a chain (`Unsupported`); `decode_jbig2_within` decodes it on its own with `hayro-jbig2`, given what its `/JBIG2Globals` decode to, as rows of 1-bit samples each on a new byte, black 0; the page has to be `columns` × `rows` (PdfCraft's renderer draws the whole page into the image), within `max` bytes as such rows and 2^28 pixels (`MAX_JBIG2_PIXELS`, the most the decoder takes), checked before it is decoded; regions inside it are bounded by the decoder alone (a region may claim more pixels than its page); damaged data, or globals that don't decode, are `Corrupt` (`pdfcraft_cos::ImageBudget::decode` calls it for a JBIG2 image, after the filters before it) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 65,535 samples on a side and 2^28 in all, where `hayro-jbig2` stops; no rows or columns, or too large → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made. `encode_jbig2_shared`: the marks found in more than one image of a group (at most 16,383, those in the most images first, within the same limits on distinct marks and their pixels as one image's) go once in a symbol dictionary segment associated with no page, number 0, which is the `/JBIG2Globals` data (7.2.6, D.3); each image's stream numbers its segments from 1 (a segment refers only to lower numbers, 7.2.5) and its text regions refer to that dictionary first, then to one of its own for the marks only it repeats, so the shared symbols' IDs come first (Table 31's SBSYMS); the rest is coded as `encode_jbig2`'s symbol coding codes it, the smaller of its two plans. An image that places none of the shared marks gets no stream, to be stored alone; whether the rest save more than the globals take is the caller's to weigh | ## Tests @@ -62,7 +63,7 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row - `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. - `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. - `jbig2/decode.rs`: `decode_jbig2_within` gives back the rows of both codings, padding bits 0; refuses a page of another width or height, past `max` or past `MAX_JBIG2_PIXELS` before decoding it (2^28 pixels take about 34 MB, a bit each), globals that aren't JBIG2 and damaged data, and returns rather than panics on every prefix of a stream. -- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; any input returns rather than panics. +- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; pages in one font share one dictionary, segment 0 of no page, retained, which each page's text regions refer to first, taking less together than alone, a page's own repeated marks in a dictionary referred to after it, a page sharing nothing left alone, images with fewer than two marks in common sharing nothing, and past 16,383 shared marks those in the most images kept, with a property test over groups of scattered pages; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs index ffcfe45e77..3d3c7b81a0 100644 --- a/crates/filters/src/jbig2.rs +++ b/crates/filters/src/jbig2.rs @@ -1,8 +1,9 @@ //! JBIG2 encoding (ITU-T T.88 | ISO/IEC 14492), for `JBIG2Decode` (ISO 32000-2 §7.4.7), lossless: //! a generic region, template 0 with its nominal adaptive pixels, typical prediction on, MQ -//! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]). Written from -//! the standard's 6.2.5 (coding a generic region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8 -//! (the segments) and D.3 (the embedded organisation PDF uses). Decoding is in [`decode`]. +//! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]), which a group +//! of images may share through `/JBIG2Globals`. Written from the standard's 6.2.5 (coding a generic +//! region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8 (the segments) and D.3 (the embedded +//! organisation PDF uses). Decoding is in [`decode`]. mod decode; mod symbols; @@ -56,9 +57,7 @@ const PAD: usize = 4; /// Errors: as [`encode_jbig2_generic`]. pub fn encode_jbig2(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { let generic = encode_jbig2_generic(data, columns, rows)?; - // `encode_jbig2_generic` checked that the rows are there. - let stride = (columns as usize).div_ceil(8); - let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)); + let samples = rows_to_encode(data, columns, rows).ok(); Ok(match samples.and_then(|s| symbols::encode(s, columns, rows)) { Some(coded) if coded.len() < generic.len() => coded, _ => generic, @@ -78,16 +77,9 @@ pub fn encode_jbig2(data: &[u8], columns: u32, rows: u32) -> Result, Fil /// [`MAX_JBIG2_PIXELS`], what the decoder takes ([`FilterError::Unsupported`]); fewer bytes than the /// rows need ([`FilterError::Corrupt`]). pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { - let too_large = columns > MAX_JBIG2_COLUMNS || rows > MAX_JBIG2_COLUMNS || u64::from(columns) * u64::from(rows) > MAX_JBIG2_PIXELS; - if columns == 0 || rows == 0 || too_large { - return Err(FilterError::Unsupported(format!("JBIG2 encoding of {columns} × {rows} samples"))); - } + let samples = rows_to_encode(data, columns, rows)?; let width = columns as usize; let stride = width.div_ceil(8); - let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)).ok_or_else(|| FilterError::Corrupt { - filter: "JBIG2Decode", - detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()), - })?; let mut contexts: Vec = vec![0; 1 << 16]; let mut mq = MqEncoder::new(); let height = samples.len() / stride; @@ -105,6 +97,48 @@ pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result Result<&[u8], FilterError> { + let too_large = columns > MAX_JBIG2_COLUMNS || rows > MAX_JBIG2_COLUMNS || u64::from(columns) * u64::from(rows) > MAX_JBIG2_PIXELS; + if columns == 0 || rows == 0 || too_large { + return Err(FilterError::Unsupported(format!("JBIG2 encoding of {columns} × {rows} samples"))); + } + let stride = (columns as usize).div_ceil(8); + usize::try_from(rows) + .ok() + .and_then(|r| r.checked_mul(stride)) + .and_then(|n| data.get(..n)) + .ok_or_else(|| FilterError::Corrupt { filter: "JBIG2Decode", detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()) }) +} + +/// A group of images coded by [`encode_jbig2_shared`]: the data of the `/JBIG2Globals` stream +/// they share, and each image's own stream, in the order given, or `None` for one that shares +/// nothing with the others. +#[derive(Debug)] +pub struct SharedJbig2 { + pub globals: Vec, + pub images: Vec>>, +} + +/// Images, each `rows` rows of `columns` 1-bit PDF samples, as lossless JBIG2 sharing one symbol +/// dictionary: the marks found in more than one of them are stored once, in a symbol dictionary +/// segment not associated with a page, which is the `/JBIG2Globals` stream's data, and each image's +/// stream, in the embedded organisation, places them from there, with its other marks coded as +/// [`encode_jbig2`]'s symbol coding codes them (see [`symbols`]). With the globals, each decodes to +/// its own bits. An image that holds none of those marks, or that symbol coding doesn't take, has +/// no stream here; it is stored alone. `None` when the images share fewer than two marks. +/// +/// What each image's stream saves is left for the caller to weigh against what the globals take, +/// which the images that use them share. +/// +/// Errors: as [`encode_jbig2_generic`], for the first image it would refuse. +pub fn encode_jbig2_shared(images: &[(&[u8], u32, u32)]) -> Result, FilterError> { + let images = + images.iter().map(|(data, columns, rows)| Ok((rows_to_encode(data, *columns, *rows)?, *columns, *rows))).collect::, _>>()?; + Ok(symbols::encode_shared(&images).map(|(globals, images)| SharedJbig2 { globals, images })) +} + /// A page information segment's data (7.4.8): the page's size, resolution unknown, eventually /// lossless, default pixel 0 (white), OR combination, not striped. fn page_information(columns: u32, rows: u32) -> Vec { @@ -145,12 +179,18 @@ fn at_pixels(out: &mut Vec) { } } -/// A segment (7.2): its header (number, type, the segments it refers to, page 1, data length), -/// then its data. `retained`: a later segment refers to this one. `refers_to` holds each +/// A segment (7.2) of page 1: its header (number, type, the segments it refers to, the page, data +/// length), then its data. `retained`: a later segment refers to this one. `refers_to` holds each /// referred-to segment's number and whether a segment after this one refers to it too (its /// retain bit, 7.2.4). Segment numbers here stay below 256, so each referred-to one takes a byte /// (7.2.5); a segment refers to at most four, so their count and retain bits take one byte too. fn segment(out: &mut Vec, number: u32, kind: u8, refers_to: &[(u8, bool)], retained: bool, data: &[u8]) { + segment_of(out, 1, number, kind, refers_to, retained, data); +} + +/// [`segment`], of page `page`: 0 for a segment associated with no page (7.2.6), such as the +/// global ones `/JBIG2Globals` holds, which a page's segments may refer to. +fn segment_of(out: &mut Vec, page: u8, number: u32, kind: u8, refers_to: &[(u8, bool)], retained: bool, data: &[u8]) { out.extend_from_slice(&number.to_be_bytes()); out.push(kind); // At most four, so the count fits its three bits. @@ -158,7 +198,7 @@ fn segment(out: &mut Vec, number: u32, kind: u8, refers_to: &[(u8, bool)], r let kept = refers_to.iter().enumerate().fold(0u8, |bits, (k, (_, kept))| bits | u8::from(*kept) << (k + 1)); out.push((refers_to.len() as u8) << 5 | kept | u8::from(retained)); out.extend(refers_to.iter().map(|(n, _)| *n)); - out.push(1); + out.push(page); // A segment's data never comes near 4 GiB here: an image is at most 2^28 samples. out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes()); out.extend_from_slice(data); @@ -216,6 +256,11 @@ mod tests { /// The rows an independent decoder (`hayro-jbig2`) reads from `data`, as PDF samples: true /// for white (a JBIG2 0). pub(super) fn decode(data: &[u8]) -> Vec> { + decode_with(data, None) + } + + /// [`decode`], with `/JBIG2Globals`. + pub(super) fn decode_with(data: &[u8], globals: Option<&[u8]>) -> Vec> { struct Rows(Vec>, Vec, usize); impl hayro_jbig2::Decoder for Rows { fn push_pixel(&mut self, black: bool) { @@ -230,7 +275,7 @@ mod tests { self.0.push(row); } } - let image = hayro_jbig2::Image::new_embedded(data, None).unwrap(); + let image = hayro_jbig2::Image::new_embedded(data, globals).unwrap(); let mut rows = Rows(Vec::new(), Vec::new(), image.width() as usize); image.decode(&mut rows).unwrap(); rows.0 @@ -353,6 +398,18 @@ mod tests { /// The encoder stops where the decoder does, on each side and in total: past that it would /// write a stream nothing here can read. 16,384 squared is exactly the bound on samples. + /// `encode_jbig2_shared` refuses what `encode_jbig2_generic` refuses: an image taller than + /// the decoder takes is `Unsupported`, one at the bound isn't refused. (Its one tall mark is + /// too large to be a symbol, so nothing is shared: `Ok(None)`.) + #[test] + fn shared_coding_takes_only_what_the_decoder_takes() { + let past = vec![0x7F; MAX_JBIG2_COLUMNS as usize + 1]; + let tall = (&past[..], 1, MAX_JBIG2_COLUMNS + 1); + assert!(matches!(encode_jbig2_shared(&[tall, tall]), Err(FilterError::Unsupported(_))), "too tall"); + let at = (&past[..MAX_JBIG2_COLUMNS as usize], 1, MAX_JBIG2_COLUMNS); + assert!(matches!(encode_jbig2_shared(&[at, at]), Ok(None)), "at the bound"); + } + #[test] fn it_encodes_only_what_the_decoder_takes() { let unsupported = |columns, rows| matches!(encode_jbig2_generic(&[], columns, rows), Err(FilterError::Unsupported(_))); diff --git a/crates/filters/src/jbig2/symbols.rs b/crates/filters/src/jbig2/symbols.rs index 82244750b7..6e9a8bf952 100644 --- a/crates/filters/src/jbig2/symbols.rs +++ b/crates/filters/src/jbig2/symbols.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; use std::ops::Range; -use super::{at_pixels, code_generic, generic_region, page_information, region_information, segment}; +use super::{at_pixels, code_generic, generic_region, page_information, region_information, segment, segment_of}; use crate::mq::{Context, MqEncoder}; /// The longest side of a mark stored as a symbol. A larger one is left to the generic region. @@ -37,10 +37,14 @@ const MAX_CODED_SYMBOLS: usize = (1 << 15) - 1; /// placed by several regions, each over a band of rows, all referring to the one dictionary. const MAX_REGION_INSTANCES: usize = 10_000; -// Segment numbers stay at most 256, so a referred-to one takes a byte (7.2.5): the page, the -// dictionary, a text region for each MAX_REGION_INSTANCES of at most MAX_RUNS marks, and the -// generic region. -const _: () = assert!(MAX_RUNS.div_ceil(MAX_REGION_INSTANCES) + 3 <= 256); +/// The most symbols a group's shared dictionary holds ([`encode_shared`]): half of what a page may +/// place, so each page keeps room for its own. +const MAX_SHARED_SYMBOLS: usize = MAX_CODED_SYMBOLS / 2; + +// Segment numbers stay at most 256, so a referred-to one takes a byte (7.2.5): the shared +// dictionary, the page, its own dictionary, a text region for each MAX_REGION_INSTANCES of at most +// MAX_RUNS marks, and the generic region. +const _: () = assert!(MAX_RUNS.div_ceil(MAX_REGION_INSTANCES) + 4 <= 256); /// The integer coders' ranges (Table A.1): how many 1 bits pick the range, how many bits give the /// offset into it, and its first value. The last range has no 0 after its prefix. @@ -97,13 +101,107 @@ struct Instance { height: u32, } +/// A group of images coded by [`encode_shared`]: the globals' data, and each image's stream. +type SharedCoding = (Vec, Vec>>); + +/// The symbols of a group's shared dictionary, as one image's [`plan`] places them: the ID each of +/// the image's symbols has there, if it is there, and how many symbols that dictionary exports. +struct Shared { + ids: Vec>, + count: u32, +} + /// `rows` rows of `columns` 1-bit PDF samples (black 0), symbol coded: the smaller of two plans, /// every mark of at most [`MAX_SYMBOL_SIDE`] a side as a symbol, or only those with a copy. /// `None` when neither plan has from two to [`MAX_CODED_SYMBOLS`] symbols, or the image is too /// large or too busy to look at. pub(super) fn encode(samples: &[u8], columns: u32, rows: u32) -> Option> { let page = page(samples, columns)?; - [1, 2].into_iter().filter_map(|copies| plan(&page, columns, rows, copies, None)).min_by_key(Vec::len) + [1, 2].into_iter().filter_map(|copies| plan(&page, columns, rows, copies, None, None)).min_by_key(Vec::len) +} + +/// Images, each as [`encode`] takes one, symbol coded sharing one dictionary: the globals' data, a +/// symbol dictionary segment associated with no page (number 0) holding the distinct marks found +/// in more than one image, and each image's stream placing them from there, the smaller of +/// [`encode`]'s two plans for its other marks. The group's marks are held within the limits one +/// image's are ([`MAX_SYMBOLS`], [`MAX_SYMBOL_PIXELS`]): past them, later marks aren't shared. Past +/// [`MAX_SHARED_SYMBOLS`] shared marks, those in the most images are kept. +/// +/// An image too large or too busy to look at, or that places none of the shared marks, gets `None`; +/// `None` overall when fewer than two marks are shared. +pub(super) fn encode_shared(images: &[(&[u8], u32, u32)]) -> Option { + // The group's distinct marks, each with how many images hold it (as its uses), looked at an + // image at a time. + let mut table: Vec = Vec::new(); + let mut by_hash: HashMap> = HashMap::new(); + let mut held = 0usize; + for (samples, columns, _) in images { + let Some(page) = page(samples, *columns) else { continue }; + // An image's symbols are distinct, so each counts an image once. + for symbol in page.symbols { + match find(&table, &by_hash, &symbol) { + Some(t) => { + if let Some(s) = table.get_mut(t as usize) { + s.uses = s.uses.saturating_add(1); + } + } + None => { + let Some(more) = held.checked_add(symbol.pixels.len()).filter(|h| *h <= MAX_SYMBOL_PIXELS && table.len() < MAX_SYMBOLS) else { + continue; + }; + held = more; + let t = u32::try_from(table.len()).ok()?; + by_hash.entry(fingerprint(&symbol)).or_default().push(t); + table.push(Symbol { uses: 1, ..symbol }); + } + } + } + } + let uses = |t: &u32| table.get(*t as usize).map_or(0, |s| s.uses); + let mut shared: Vec = (0..u32::try_from(table.len()).ok()?).filter(|t| uses(t) >= 2).collect(); + if shared.len() < 2 { + return None; + } + shared.sort_by_key(|t| std::cmp::Reverse(uses(t))); + shared.truncate(MAX_SHARED_SYMBOLS); + // The dictionary's order, as `plan` keeps it. + shared.sort_by_key(|t| table.get(*t as usize).map(|s| (s.height, s.width))); + let mut global = vec![None; table.len()]; + for (k, t) in shared.iter().enumerate() { + if let Some(slot) = global.get_mut(*t as usize) { + *slot = u32::try_from(k).ok(); + } + } + let symbols: Vec<&Symbol> = shared.iter().filter_map(|t| table.get(*t as usize)).collect(); + let mut globals = Vec::new(); + segment_of(&mut globals, 0, 0, 0, &[], true, &symbol_dictionary(&symbols)?); + let count = u32::try_from(symbols.len()).ok()?; + let streams = images + .iter() + .map(|(samples, columns, rows)| { + let page = page(samples, *columns)?; + let ids = page.symbols.iter().map(|s| find(&table, &by_hash, s).and_then(|t| global.get(t as usize).copied().flatten())).collect(); + let shared = Shared { ids, count }; + [1, 2].into_iter().filter_map(|copies| plan(&page, *columns, *rows, copies, None, Some(&shared))).min_by_key(Vec::len) + }) + .collect(); + Some((globals, streams)) +} + +/// What sorts a symbol's bitmap ([`symbols`] and [`encode_shared`] look exact copies up by it). +fn fingerprint(s: &Symbol) -> u64 { + let mut hasher = DefaultHasher::new(); + (s.width, s.height, &s.pixels).hash(&mut hasher); + hasher.finish() +} + +/// The index in `table` of the symbol with `s`'s bitmap, looked up by its [`fingerprint`]. +fn find(table: &[Symbol], by_hash: &HashMap>, s: &Symbol) -> Option { + by_hash + .get(&fingerprint(s))? + .iter() + .copied() + .find(|t| table.get(*t as usize).is_some_and(|t| (t.width, t.height) == (s.width, s.height) && t.pixels == s.pixels)) } /// The image's marks and symbols; `None` past the limits above. @@ -267,23 +365,18 @@ fn symbols(page: &mut Page) -> Option<()> { page.large.push(m); continue; } - let pixels = pixels(mark, &page.runs, &page.order); - let mut hasher = DefaultHasher::new(); - (width, height, &pixels).hash(&mut hasher); - let same = by_hash.entry(hasher.finish()).or_default(); + let symbol = Symbol { width, height, pixels: pixels(mark, &page.runs, &page.order), uses: 0 }; let symbols = &mut page.symbols; - let known = - same.iter().copied().find(|s| symbols.get(*s as usize).is_some_and(|s| (s.width, s.height) == (width, height) && s.pixels == pixels)); - let id = match known { + let id = match find(symbols, &by_hash, &symbol) { Some(id) => id, None => { - kept = kept.checked_add(pixels.len())?; + kept = kept.checked_add(symbol.pixels.len())?; if kept > MAX_SYMBOL_PIXELS || symbols.len() == MAX_SYMBOLS { return None; } let id = u32::try_from(symbols.len()).ok()?; - symbols.push(Symbol { width, height, pixels, uses: 0 }); - same.push(id); + by_hash.entry(fingerprint(&symbol)).or_default().push(id); + symbols.push(symbol); id } }; @@ -300,18 +393,28 @@ fn symbols(page: &mut Page) -> Option<()> { /// the other marks in a generic region. `None` with fewer than two such symbols (their IDs would /// take no bits at all, SBSYMCODELEN 0, a corner of the standard left alone here) or more than /// [`MAX_CODED_SYMBOLS`]. -fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option) -> Option> { +/// +/// With `shared`, the symbols a group's dictionary holds are placed from there, whatever their uses +/// here, and the page's own dictionary holds only the others (none, when it has none), as +/// [`encode_shared`] writes them; `None` too for a page that places none of the shared ones. +fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option, shared: Option<&Shared>) -> Option> { let symbol = |s: u32| page.symbols.get(s as usize); + let global = |s: u32| shared.and_then(|g| g.ids.get(s as usize).copied().flatten()); + let globals = shared.map_or(0, |g| g.count); // The dictionary's order (6.5.5): height classes from the shortest, each narrowest first. - let mut kept: Vec = (0..page.symbols.len() as u32).filter(|s| symbol(*s).is_some_and(|s| s.uses >= copies)).collect(); - if kept.len() < 2 || kept.len() > MAX_CODED_SYMBOLS { + let mut kept: Vec = + (0..page.symbols.len() as u32).filter(|s| global(*s).is_none() && symbol(*s).is_some_and(|s| s.uses >= copies)).collect(); + let count = usize::try_from(globals).ok()?.checked_add(kept.len())?; + if !(2..=MAX_CODED_SYMBOLS).contains(&count) { return None; } kept.sort_by_key(|s| symbol(*s).map(|s| (s.height, s.width))); - let mut id = vec![None; page.symbols.len()]; + // The shared symbols' IDs come first, as their dictionary is referred to first (Table 31's + // SBSYMS). + let mut id: Vec> = (0..page.symbols.len() as u32).map(global).collect(); for (k, s) in kept.iter().enumerate() { if let Some(slot) = id.get_mut(*s as usize) { - *slot = u32::try_from(k).ok(); + *slot = u32::try_from(k).ok().and_then(|k| k.checked_add(globals)); } } let mut instances = Vec::new(); @@ -324,9 +427,12 @@ fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option left.push(*m), } } + if shared.is_some() && !instances.iter().any(|i| i.id < globals) { + return None; + } let kept: Vec<&Symbol> = kept.iter().filter_map(|s| symbol(*s)).collect(); - let dictionary = symbol_dictionary(&kept)?; - let count = u32::try_from(kept.len()).ok()?; + let dictionary = if kept.is_empty() { None } else { Some(symbol_dictionary(&kept)?) }; + let count = u32::try_from(count).ok()?; // Bands of at most MAX_REGION_INSTANCES, from the top. instances.sort_by_key(|i| (i.t, i.s)); let mut regions = Vec::new(); @@ -339,12 +445,21 @@ fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option = shared.map(|_| 0).into_iter().collect(); + if let Some(dictionary) = &dictionary { + segment(&mut out, number, 0, &[], true, dictionary); + dictionaries.push(u8::try_from(number).ok()?); + number += 1; + } for (k, text) in regions.iter().enumerate() { - // The dictionary stays retained for the next region, until the last (7.2.4). - segment(&mut out, number, 7, &[(1, k + 1 < regions.len())], false, text); + // The dictionaries stay retained for the next region, until the last (7.2.4). + let refers_to: Vec<(u8, bool)> = dictionaries.iter().map(|n| (*n, k + 1 < regions.len())).collect(); + segment(&mut out, number, 7, &refers_to, false, text); number += 1; } if !left.is_empty() { @@ -538,8 +653,9 @@ fn encode_id(mq: &mut MqEncoder, contexts: &mut [Context], length: u32, id: u32) #[cfg(test)] mod tests { use super::*; - use crate::jbig2::tests::{decode, samples, unpack}; - use crate::jbig2::{encode_jbig2, encode_jbig2_generic}; + use crate::FilterError; + use crate::jbig2::tests::{decode, decode_with, samples, unpack}; + use crate::jbig2::{encode_jbig2, encode_jbig2_generic, encode_jbig2_shared}; type Bitmap = Vec>; @@ -664,7 +780,7 @@ mod tests { let mut plans = 0; for copies in [1, 2] { for log_strips in 0..4 { - if let Some(coded) = plan(&page, columns, rows, copies, Some(log_strips)) { + if let Some(coded) = plan(&page, columns, rows, copies, Some(log_strips), None) { assert_eq!(decode(&coded), unpack(&data, columns, rows), "{copies} copies, strips of {}", 1 << log_strips); plans += 1; } @@ -733,7 +849,7 @@ mod tests { let page = page(&data, c).unwrap(); assert!(page.marks.iter().all(|m| m.x1 as usize <= columns), "{columns} columns"); for copies in [1, 2] { - if let Some(coded) = plan(&page, c, r, copies, None) { + if let Some(coded) = plan(&page, c, r, copies, None, None) { assert_eq!(decode(&coded), unpack(&data, c, r), "{columns} columns"); } } @@ -820,17 +936,17 @@ mod tests { }; let (data, columns, rows) = codes(MAX_CODED_SYMBOLS, 0); let at_cap = page(&data, columns).unwrap(); - assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None, None).unwrap()), unpack(&data, columns, rows)); // One more distinct mark, each once: neither plan is made. let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 0); let past = page(&data, columns).unwrap(); - assert!(plan(&past, columns, rows, 1, None).is_none() && plan(&past, columns, rows, 2, None).is_none()); + assert!(plan(&past, columns, rows, 1, None, None).is_none() && plan(&past, columns, rows, 2, None, None).is_none()); assert_eq!(encode_jbig2(&data, columns, rows).unwrap(), encode_jbig2_generic(&data, columns, rows).unwrap()); // With two of them repeated, the second plan codes those two. let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 200); let past = page(&data, columns).unwrap(); - assert!(plan(&past, columns, rows, 1, None).is_none()); - assert_eq!(decode(&plan(&past, columns, rows, 2, None).unwrap()), unpack(&data, columns, rows)); + assert!(plan(&past, columns, rows, 1, None, None).is_none()); + assert_eq!(decode(&plan(&past, columns, rows, 2, None, None).unwrap()), unpack(&data, columns, rows)); // Above a page of text whose letters repeat, that second plan is the one `encode_jbig2` // keeps: the first, of every mark, would code too many symbols. let mut picture: Bitmap = vec![vec![0u8; 256 * 18]; 512]; @@ -869,7 +985,7 @@ mod tests { let (data, columns, rows) = squares(256); let at_cap = page(&data, columns).unwrap(); assert_eq!(at_cap.symbols.len(), 256); - assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None, None).unwrap()), unpack(&data, columns, rows)); let (data, columns, _) = squares(257); assert!(page(&data, columns).is_none()); } @@ -984,7 +1100,199 @@ mod tests { } } + /// Each segment's page association (7.2.6): the byte after the numbers it refers to. + fn associations(data: &[u8]) -> Vec { + let (mut pages, mut at) = (Vec::new(), 0); + while at < data.len() { + let refers = usize::from(data[at + 5] >> 5); + pages.push(data[at + 6 + refers]); + let length_at = at + 6 + refers + 1; + at = length_at + 4 + u32::from_be_bytes(data[length_at..length_at + 4].try_into().unwrap()) as usize; + } + pages + } + + /// `pictures`, `columns` × `rows` each, coded sharing a dictionary; each image's stream, which + /// has to be there, decodes with the globals to its picture. + fn shared_round_trip(pictures: &[Bitmap]) -> (Vec, Vec>) { + let (columns, rows) = (pictures[0][0].len() as u32, pictures[0].len() as u32); + let data: Vec> = pictures.iter().map(|p| samples(p)).collect(); + let images: Vec<(&[u8], u32, u32)> = data.iter().map(|d| (&d[..], columns, rows)).collect(); + let shared = encode_jbig2_shared(&images).unwrap().unwrap(); + let (globals, coded) = (shared.globals, shared.images); + let coded: Vec> = coded.into_iter().map(Option::unwrap).collect(); + for (d, c) in data.iter().zip(&coded) { + assert_eq!(decode_with(c, Some(&globals)), unpack(d, columns, rows)); + } + (globals, coded) + } + + /// `font` with a stem 20 pixels tall down each glyph's left side: a font sharing no glyph with + /// any [`glyphs`] gives, whose glyphs are at most 18 pixels tall. + fn stemmed(font: &[Bitmap]) -> Vec { + font.iter() + .map(|g| { + (0..g.len().max(20)).map(|y| std::iter::once(1).chain(g.get(y).cloned().unwrap_or_else(|| vec![0; g[0].len()])).collect()).collect() + }) + .collect() + } + + /// Mark `k` of a set of distinct ones: a stroke 33 pixels long with a pixel above every other + /// column for each 1 bit of `k`, below 2^17. + fn comb(k: usize) -> Bitmap { + let mut g = vec![vec![0u8; 33]; 2]; + g[1].fill(1); + for bit in 0..17 { + g[0][2 * bit] = u8::from(k >> bit & 1 == 1); + } + g + } + + /// The combs `ks`, 120 a row, the rows four pixels apart. + fn combs(ks: impl ExactSizeIterator) -> Bitmap { + let mut page = vec![vec![0u8; 120 * 36]; ks.len().div_ceil(120) * 4]; + for (i, k) in ks.enumerate() { + draw(&mut page, &comb(k), (i % 120 * 36) as i64, (i / 120 * 4) as i64); + } + page + } + + /// The symbols a symbol dictionary segment exports (SDNUMEXSYMS, 7.4.2.1.4), in a stream + /// that is that segment alone: after its header, flags and adaptive pixels. + fn exported(globals: &[u8]) -> usize { + u32::from_be_bytes(globals[21..25].try_into().unwrap()) as usize + } + + /// Pages in one font share its letters: one symbol dictionary, segment 0, associated with no + /// page and retained (7.2.4, 7.2.6), which each page's text region refers to first, its own + /// segments numbered after it (7.2.5). Together they take less than they take alone. + #[test] + fn pages_in_one_font_share_one_dictionary() { + let font = glyphs(40, 7); + let pictures: Vec = (0..4).map(|seed| text(1200, 300, &font, 20 + seed)).collect(); + let (globals, coded) = shared_round_trip(&pictures); + assert_eq!(segments(&globals), [(0, 0, true, vec![])]); + assert_eq!(associations(&globals), [0]); + let (mut shared, mut alone) = (globals.len(), 0); + for (picture, c) in pictures.iter().zip(&coded) { + let segments = segments(c); + assert_eq!(segments.first().map(|s| s.0), Some(1), "the page's own segments follow"); + assert!(segments.iter().filter(|s| s.1 == 7).all(|s| s.3.first() == Some(&(0, false))), "{segments:?}"); + assert!(associations(c).iter().all(|page| *page == 1)); + shared += c.len(); + alone += encode_jbig2(&samples(picture), 1200, 300).unwrap().len(); + } + assert!(shared < alone, "shared {shared} bytes, alone {alone}"); + } + + /// Two pages alike place every mark from the shared dictionary and have none of their own; a + /// page with letters of its own as well keeps them in its own dictionary, referred to after the + /// shared one (its symbols' IDs follow), and the page with nothing shared has no stream. + #[test] + fn what_a_page_has_alone_stays_its_own() { + let (font, other) = (glyphs(30, 7), stemmed(&glyphs(20, 99))); + let same = text(900, 240, &font, 3); + let mut more = text(900, 240, &font, 4); + more.extend(text(900, 120, &other, 5)); + let (globals, coded) = shared_round_trip(&[same.clone(), same.clone()]); + assert!(!globals.is_empty()); + assert!(coded.iter().all(|c| segment_types(c) == [48, 7]), "{:?}", coded.iter().map(|c| segment_types(c)).collect::>()); + let with_more = |p: &Bitmap| p.iter().cloned().chain(std::iter::repeat_n(vec![0u8; 900], 120)).collect::(); + let (_, coded) = shared_round_trip(&[with_more(&same), more]); + assert_eq!(segment_types(&coded[1])[..3], [48, 0, 7]); + assert_eq!(segments(&coded[1])[2].3, [(0, false), (2, false)]); + let alone = text(900, 360, &other, 6); + let data: Vec> = [&same, &same, &alone].iter().map(|p| samples(p)).collect(); + let images: Vec<(&[u8], u32, u32)> = vec![(&data[0], 900, 240), (&data[1], 900, 240), (&data[2], 900, 360)]; + let (_, coded) = encode_shared(&images).unwrap(); + assert!(coded[0].is_some() && coded[1].is_some() && coded[2].is_none()); + } + + /// Images that share fewer than two marks share nothing; two are enough. + #[test] + fn images_with_nothing_in_common_share_nothing() { + let font = glyphs(30, 7); + let (a, b) = (samples(&text(600, 200, &font, 1)), samples(&text(600, 200, &stemmed(&font), 1))); + assert!(encode_shared(&[(&a, 600, 200), (&b, 600, 200)]).is_none()); + assert!(encode_shared(&[(&a, 600, 200)]).is_none(), "one image"); + assert!(encode_shared(&[]).is_none()); + // The two commonest letters of the first page, below the last line of the second. + let mut two = text(600, 200, &stemmed(&font), 1); + draw(&mut two, &font[0], 10, 180); + draw(&mut two, &font[1], 100, 180); + let two = samples(&two); + let (globals, coded) = encode_shared(&[(&a, 600, 200), (&two, 600, 200)]).unwrap(); + assert_eq!(exported(&globals), 2); + assert_eq!(decode_with(coded[1].as_ref().unwrap(), Some(&globals)), unpack(&two, 600, 200)); + } + + /// The images of a group are checked as `encode_jbig2_generic` checks one, and an image it + /// would refuse refuses the group. + #[test] + fn a_group_with_an_image_the_encoder_refuses_is_refused() { + let page = samples(&text(600, 200, &glyphs(30, 7), 1)); + assert!(matches!(encode_jbig2_shared(&[(&page, 600, 200), (&page[..10], 600, 200)]), Err(FilterError::Corrupt { .. }))); + assert!(matches!(encode_jbig2_shared(&[(&page, 600, 200), (&page, 0, 200)]), Err(FilterError::Unsupported(_)))); + assert!(encode_jbig2_shared(&[(&page, 600, 200), (&page, 600, 200)]).unwrap().is_some()); + } + + /// A group holds its distinct marks within what one image's are held to: past 65,536 a mark + /// first seen later isn't held, so isn't shared, while those held count every image they're + /// in. Ten marks of the first image are in two images after it, with 464 first seen there. + #[test] + fn past_the_marks_a_group_holds_later_ones_arent_shared() { + let (a, b) = (samples(&combs(0..40_000)), samples(&combs(40_000..65_536))); + let late = combs((0..10).chain(65_536..66_000).collect::>().into_iter()); + let (rows, late) = (late.len() as u32, samples(&late)); + let (columns, a_rows, b_rows) = (120 * 36, 40_000u32.div_ceil(120) * 4, 25_536u32.div_ceil(120) * 4); + let images = [(&a[..], columns, a_rows), (&b[..], columns, b_rows), (&late[..], columns, rows), (&late[..], columns, rows)]; + let (globals, coded) = encode_shared(&images).unwrap(); + assert_eq!(exported(&globals), 10); + for c in &coded[2..] { + assert_eq!(decode_with(c.as_ref().unwrap(), Some(&globals)), unpack(&late, columns, rows)); + } + } + + /// Past what one dictionary shares, it keeps the marks the most images hold: 20,000 shared, + /// the first 10,000 in all three images and the rest in two. The third image then places all + /// its marks from it, and the other two code the rest themselves (in a dictionary of their own + /// or a generic region, whichever is smaller). + #[test] + fn past_the_shared_limit_the_most_widely_held_marks_are_shared() { + let (both, first) = (combs(0..20_000), combs(0..10_000)); + let rows = both.len(); + let first: Bitmap = first.iter().cloned().chain(std::iter::repeat_n(vec![0u8; 120 * 36], rows - first.len())).collect(); + let (globals, coded) = shared_round_trip(&[both.clone(), both, first]); + assert_eq!(exported(&globals), MAX_SHARED_SYMBOLS); + assert_eq!(segment_types(&coded[2]), [48, 7]); + assert!(segment_types(&coded[0]).iter().any(|t| *t == 0 || *t == 39), "{:?}", segment_types(&coded[0])); + } + proptest::proptest! { + /// Glyphs scattered over pages of one font: whatever is shared, every page with a stream + /// decodes with the globals to its picture. + #[test] + fn shared_groups_round_trip( + columns in 1usize..200, + rows in 1usize..100, + shapes in 2usize..8, + n in 0usize..60, + pages in 2usize..5, + seed in proptest::num::u64::ANY, + ) { + let font = glyphs(shapes, seed); + let data: Vec> = (0..pages).map(|p| samples(&scatter(columns, rows, &font, seed ^ p as u64, n))).collect(); + let (c, r) = (columns as u32, rows as u32); + let images: Vec<(&[u8], u32, u32)> = data.iter().map(|d| (&d[..], c, r)).collect(); + if let Some((globals, coded)) = encode_shared(&images) { + for (d, coded) in data.iter().zip(coded) { + if let Some(coded) = coded { + proptest::prop_assert_eq!(decode_with(&coded, Some(&globals)), unpack(d, c, r)); + } + } + } + } + /// Glyphs anywhere, over the edges and each other, with stray pixels: every plan decodes to /// the picture, and `encode_jbig2` is never larger than the generic coding. #[test] @@ -1015,6 +1323,7 @@ mod tests { fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..5000, rows in 0u32..80) { let _ = encode_jbig2(&data, columns, rows); let _ = encode(&data, columns, rows); + let _ = encode_shared(&[(&data, columns, rows), (&data, columns, rows.saturating_sub(1))]); } } } diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 181f96ab66..0b50b0efe0 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -27,7 +27,8 @@ //! ``` //! //! 1-bit samples can be encoded as CCITT Group 4 ([`encode_g4`]) or lossless JBIG2 -//! ([`encode_jbig2`]). +//! ([`encode_jbig2`]), a group of images sharing their marks through `/JBIG2Globals` +//! ([`encode_jbig2_shared`]). //! //! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb //! defence) and never panics on malformed input. @@ -47,7 +48,7 @@ mod runlength; use std::borrow::Cow; pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt_within, encode_g4}; -pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2_within, encode_jbig2, encode_jbig2_generic}; +pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, SharedJbig2, decode_jbig2_within, encode_jbig2, encode_jbig2_generic, encode_jbig2_shared}; /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 6616e1b558..9acb076908 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -89,7 +89,15 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going alone when JBIG2 is asked for, is stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and often comes out larger, and then stays as it is. A JBIG2 image stored again refers to no - `/JBIG2Globals`, and one whose page isn't its size (PdfCraft's + `/JBIG2Globals` of its own. With JBIG2 asked for, the images stored as JBIG2, up to 16 at a + time, then share one symbol dictionary in a new `/JBIG2Globals` stream, holding the marks found + in more than one of them: those that come out smaller that way, when together they save more + than it takes. Only images that redaction can store again without it share one: image masks + and DeviceGray images named directly, of at most 64 Mi pixels (redaction's limit, + `pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS`); others are coded alone. + Redacting one of them stores the others of its group again without it, so the group keeps that + to 15 images, and each image's decode reads the dictionary of 16 at most. A + JBIG2 image whose page isn't its size (PdfCraft's renderer draws the whole page into the image) is left alone. With Retain, a resampled image keeps its kind of compression (CCITT, JBIG2, or Flate) and nothing else changes; an image of more bits is then left to the grey or colour settings. Every change is kept only when it is @@ -103,7 +111,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going is past the 80 million pixels above). - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when - smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2 + smaller, a dictionary shared by up to 16 images when that is smaller still. No lossy symbol substitution is used. Export, redaction and this step read JBIG2 images, with bounded shared globals. All of a run's images share one allowance of decoding (`pdfcraft_cos::ImageBudget`), filters, decoders and pixels alike, JPEG samples and merged pictures too: about diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index efff394a6f..41f893c71b 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1117,12 +1117,23 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i /// to be resampled, and so is JBIG2 alone when Group 4 is asked for; Group 3, Group 4 inside /// another filter, and JBIG2 otherwise are stored again like any other. With `Retain`, a resampled image keeps its kind of compression (CCITT, JBIG2, or /// Flate), and nothing else changes: an image of more bits stays as it is, for the grey or -/// colour settings. A JBIG2 image stored again refers to no `/JBIG2Globals`. +/// colour settings. A JBIG2 image stored again refers to no `/JBIG2Globals`; with `Jbig2`, [`share`] +/// may then have a group of them share one. /// /// The samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for /// black-and-white RGB, which becomes grey with one decode pair, and an indexed image's default /// decode array, which goes as the default at one bit. -fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSettings, budget: &mut ImageBudget) -> Option<(Stream, bool)> { +/// +/// Stored as JBIG2 with `Jbig2`, it comes with the 1-bit samples it was coded from, for [`share`] +/// to code again with the run's other JBIG2 images. +fn mono( + doc: &Document, + s: &Stream, + ppi: f64, + defaults: bool, + settings: &MonoSettings, + budget: &mut ImageBudget, +) -> Option<(Stream, bool, Option>)> { let d = &s.dict; let downsample = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && !d.contains(b"SMask"); let retain = settings.compression == MonoCompression::Retain; @@ -1206,7 +1217,106 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet let flate = ((resampled || packed_from_more) && !(retain && (ccitt || jbig2))).then(|| Stream::flate(dict, &data)); // Smaller as stored, counting the dictionary: a tiny image can lose by Group 4's parameters. let best = [g4, jbig, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?; - (best.0 < crate::stored_len(s)?).then_some((best.1, resampled)) + let shareable = settings.compression == MonoCompression::Jbig2 && best.1.dict.name(b"Filter") == Some(b"JBIG2Decode"); + (best.0 < crate::stored_len(s)?).then(|| (best.1, resampled, shareable.then_some(data))) +} + +/// The most images that share one symbol dictionary ([`share`]). Redaction stores again every +/// image that shares a redacted one's dictionary, and each image's decode reads all of it, so a +/// group keeps both to that many; most of what sharing saves comes with the first few images. +const SHARED_IMAGES: usize = 16; + +/// The most bytes of 1-bit samples a group holds: a group is coded before it would pass them +/// ([`hold_in`]). An image that shares has at most [`SHARED_PIXELS`], and as its rows are whole +/// bytes, at most that many bytes, which is this: one always fits a group with nothing in it. +const SHARED_BYTES: usize = 64 << 20; + +/// What the globals cost as an object of their own besides their dictionary and data: `N 0 obj`, +/// `stream`, `endstream` and `endobj`, a cross-reference entry, and an encrypted file's IV and +/// padding. A group is shared only when it saves more than that as well. +const NEW_OBJECT: usize = 96; + +/// Adds `image` to `group`, coding the group (`share`, which empties it) first when the image's +/// samples would take it past `cap` bytes, and after when it has [`SHARED_IMAGES`]. +fn hold_in(group: &mut Vec, image: Held, cap: usize, mut share: impl FnMut(&mut Vec)) { + if group.iter().map(|h| h.bits.len()).fold(image.bits.len(), usize::saturating_add) > cap { + share(group); + } + group.push(image); + if group.len() == SHARED_IMAGES { + share(group); + } +} + +/// The most pixels an image sharing a dictionary has: redaction's own limit on the images it +/// stores again (see [`shares`]). +const SHARED_PIXELS: u64 = pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS; + +/// Whether an image stored as JBIG2 (its dictionary `d`) may share a dictionary: only one that +/// redaction can store again without it, an image mask or a DeviceGray image, named directly, of +/// at most [`SHARED_PIXELS`]. Redacting one image of a group stores the others so, as the +/// dictionary can hold the shapes of marks under the area, and refuses the redaction for one it +/// can't; an image of another colour space, though redaction may take it, is coded alone. +fn shares(d: &Dict) -> bool { + let side = |key: &[u8]| d.int(key).and_then(|v| u64::try_from(v).ok()); + let within = side(b"Width").zip(side(b"Height")).is_some_and(|(w, h)| w.saturating_mul(h) <= SHARED_PIXELS); + within && (matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) || d.name(b"ColorSpace") == Some(b"DeviceGray")) +} + +/// An image the run stored as JBIG2, held to be stored again sharing a dictionary ([`share`]): its +/// 1-bit samples, `width` × `height`. +struct Held { + image: ObjRef, + bits: Vec, + width: u32, + height: u32, +} + +/// Stores the images of `group`, each JBIG2 already, again sharing one symbol dictionary in a +/// `/JBIG2Globals` stream ([`pdfcraft_filters::encode_jbig2_shared`]): those that come out smaller +/// that way, if together they save more than the globals take. The others stay as they are, and +/// so do all of them when that saves nothing. `report` counts the bytes as they end up, the globals +/// included. +fn share(doc: &mut Document, group: &mut Vec, report: &mut Report) { + let held = std::mem::take(group); + if held.len() < 2 { + return; + } + let images: Vec<(&[u8], u32, u32)> = held.iter().map(|h| (&h.bits[..], h.width, h.height)).collect(); + let Ok(Some(shared)) = pdfcraft_filters::encode_jbig2_shared(&images) else { return }; + let with_globals = |s: &Stream, coded: Vec, globals: ObjRef| { + let mut dict = s.dict.clone(); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(dict, coded) + }; + // Weighed naming the globals with the longest reference there is: the real one is no longer. + let longest = ObjRef { num: u32::MAX, generation: u16::MAX }; + let (mut chosen, mut saved) = (Vec::new(), 0usize); + for (h, coded) in held.iter().zip(shared.images) { + let Some(coded) = coded else { continue }; + let object = doc.get(h.image); + let Object::Stream(alone) = &*object else { continue }; + let shorter = crate::stored_len(alone).zip(crate::stored_len(&with_globals(alone, coded.clone(), longest))).filter(|(a, b)| b < a); + if let Some((before, after)) = shorter { + saved = saved.saturating_add(before - after); + chosen.push((h.image, coded)); + } + } + let globals = Stream::from_raw(Dict::new(), shared.globals); + if chosen.is_empty() || crate::stored_len(&globals).is_none_or(|takes| saved <= takes.saturating_add(NEW_OBJECT)) { + return; + } + report.image_bytes_after = report.image_bytes_after.saturating_add(globals.raw.len()); + let globals = doc.add(Object::Stream(globals)); + for (image, coded) in chosen { + let object = doc.get(image); + let Object::Stream(alone) = &*object else { continue }; + report.image_bytes_after = report.image_bytes_after.saturating_sub(alone.raw.len()).saturating_add(coded.len()); + let new = with_globals(alone, coded, globals); + doc.set(image, Object::Stream(new)); + } } /// One pass over the document's images, within `budget`. An image past it stays as it is, and is @@ -1239,6 +1349,13 @@ pub(crate) fn run( refs.sort_by_key(|(r, _)| r.num); let total = refs.len(); let mut jbig2 = false; + let mut group: Vec = Vec::new(); + // Holds a JBIG2 image for `share`, which codes the group once it is full. + let mut hold = |doc: &mut Document, image: ObjRef, size: (Option, Option), bits: Option>, report: &mut Report| { + let side = |v: Option| v.and_then(|v| u32::try_from(v).ok()); + let (Some(bits), Some(width), Some(height)) = (bits, side(size.0), side(size.1)) else { return }; + hold_in(&mut group, Held { image, bits, width, height }, SHARED_BYTES, |group| share(doc, group, report)); + }; for (done, (r, ppi)) in refs.into_iter().enumerate() { if !progress(Stage::Images { done, total }) { return Err(OptimizeError::Cancelled); @@ -1254,7 +1371,7 @@ pub(crate) fn run( Some(m) if packed_from_more => (Some(m), None), m => (None, m), }; - if let Some((new, resampled)) = mono { + if let Some((new, resampled, bits)) = mono { report.image_bytes_before += s.raw.len(); report.image_bytes_after += new.raw.len(); if resampled { @@ -1263,7 +1380,10 @@ pub(crate) fn run( report.images_recompressed += 1; } jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); + let size = (new.dict.int(b"Width"), new.dict.int(b"Height")); + let bits = bits.filter(|_| shares(&new.dict)); doc.set(r, Object::Stream(new)); + hold(doc, r, size, bits, report); continue; } let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)); @@ -1278,10 +1398,10 @@ pub(crate) fn run( }), }; let total = |new: &Stream, smask: Option<&(ObjRef, Stream)>| Some(crate::stored_len(new)?.saturating_add(smask_len(smask).unwrap_or(0))); - let packed_wins = packed.as_ref().is_some_and(|(p, _)| { + let packed_wins = packed.as_ref().is_some_and(|(p, _, _)| { processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b)) }); - if let Some((new, resampled)) = packed.filter(|_| packed_wins) { + if let Some((new, resampled, bits)) = packed.filter(|_| packed_wins) { jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); report.image_bytes_before += s.raw.len(); report.image_bytes_after += new.raw.len(); @@ -1290,7 +1410,10 @@ pub(crate) fn run( } else { report.images_recompressed += 1; } + let size = (new.dict.int(b"Width"), new.dict.int(b"Height")); + let bits = bits.filter(|_| shares(&new.dict)); doc.set(r, Object::Stream(new)); + hold(doc, r, size, bits, report); continue; } let Some((new, smask)) = processed else { @@ -1311,6 +1434,7 @@ pub(crate) fn run( doc.set(mr, Object::Stream(m)); } } + share(doc, &mut group, report); if !progress(Stage::Images { done: total, total }) { return Err(OptimizeError::Cancelled); } @@ -1338,6 +1462,58 @@ fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> { mod dimension_tests { use super::*; + /// A group is coded before an image's samples would take it past its limit, and once it has + /// [`SHARED_IMAGES`]: with room for 10 bytes, images of 4 go two to a group, never three, and + /// with room for 8, two of 4 fill one exactly; one of exactly the limit joins a group with + /// nothing in it. + #[test] + fn a_group_is_coded_before_its_samples_would_pass_the_limit() { + let held = |bytes: usize| Held { image: ObjRef { num: 1, generation: 0 }, bits: vec![0; bytes], width: 8, height: 8 }; + let mut coded: Vec> = Vec::new(); + let mut group = Vec::new(); + let mut code = |g: &mut Vec| coded.push(std::mem::take(g).iter().map(|h| h.bits.len()).collect()); + for _ in 0..5 { + hold_in(&mut group, held(4), 10, &mut code); + } + hold_in(&mut group, held(10), 10, &mut code); + assert_eq!(coded, [vec![4, 4], vec![4, 4], vec![4]]); + assert_eq!(group.iter().map(|h| h.bits.len()).collect::>(), [10]); + // Exactly the limit is within it: with room for 8, two images of 4 stay together. + let (mut count, mut group) = (0, Vec::new()); + let mut code = |g: &mut Vec| count += std::mem::take(g).len(); + hold_in(&mut group, held(4), 8, &mut code); + hold_in(&mut group, held(4), 8, &mut code); + assert_eq!((count, group.len()), (0, 2)); + let (mut count, mut group) = (0, Vec::new()); + for _ in 0..SHARED_IMAGES { + hold_in(&mut group, held(1), usize::MAX, |g| count += std::mem::take(g).len()); + } + assert_eq!((count, group.len()), (SHARED_IMAGES, 0), "coded on the 16th"); + } + + /// Only what redaction can store again without the dictionary shares one: an image mask, or + /// DeviceGray named directly, of at most 2^26 pixels. + #[test] + fn only_what_redaction_can_store_again_shares_a_dictionary() { + let dict = |entries: Vec<(&str, Object)>| { + let mut d = Dict::new(); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v); + } + d + }; + let sized = |w: i64, h: i64, cs: Object| dict(vec![("Width", Object::Int(w)), ("Height", Object::Int(h)), ("ColorSpace", cs)]); + assert!(shares(&sized(8192, 8192, Object::name("DeviceGray")))); + assert!(!shares(&sized(8192, 8193, Object::name("DeviceGray"))), "a row past 2^26 pixels"); + assert!(shares(&dict(vec![("Width", Object::Int(8)), ("Height", Object::Int(8)), ("ImageMask", Object::Bool(true))]))); + let device_n = + Object::Array(vec![Object::name("DeviceN"), Object::Array(vec![Object::name("Black")]), Object::name("DeviceGray"), Object::Null]); + assert!(!shares(&sized(8, 8, device_n))); + assert!(!shares(&sized(8, 8, Object::name("CalGray")))); + assert!(!shares(&sized(8, 8, Object::Ref(ObjRef { num: 3, generation: 0 }))), "a colour space by reference"); + assert!(!shares(&dict(vec![("ColorSpace", Object::name("DeviceGray"))])), "no size"); + } + #[test] fn pixel_reader_rejects_hostile_dimensions_before_decoding() { for (width, height, channels) in [(-1, 1, 1), (0, 1, 1), (i64::from(u32::MAX) + 101, 1, 1), (80_000_001, 1, 1), (2, 2, usize::MAX)] { diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index fe607bcd8b..1387cae528 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1951,8 +1951,17 @@ fn black_and_white_images_become_jbig2() { assert!(r.image_bytes_after < as_g4.image_bytes_after, "JBIG2 {} bytes, Group 4 {}", r.image_bytes_after, as_g4.image_bytes_after); let images: Vec = image_refs(&jbig2).into_iter().map(|i| stream(&jbig2, i)).collect(); assert_eq!(images.len(), 3); + // The same text three times: the grey image and the mask share one symbol dictionary, and the + // indexed image, which only those share, is coded alone. + let globals_of = |s: &Stream| s.dict.get(b"DecodeParms").and_then(Object::as_dict).map(|d| (d.len(), d.reference(b"JBIG2Globals"))); + let is_indexed = |s: &Stream| s.dict.get(b"ColorSpace").and_then(Object::as_array).is_some(); + let shared: Vec<_> = images.iter().filter(|s| !is_indexed(s)).map(globals_of).collect(); + assert!(shared.len() == 2 && shared[0].is_some_and(|(n, g)| n == 1 && g.is_some()) && shared[0] == shared[1], "{shared:?}"); for s in &images { - assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false)); + assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + if is_indexed(s) { + assert!(!s.dict.contains(b"DecodeParms")); + } } assert!(images.iter().any(|s| s.dict.get(b"Decode") == Some(&decode)), "the decode array stays"); let indexed_kept = @@ -1999,6 +2008,11 @@ fn a_catalog_version_given_by_reference_is_read() { /// `#` a 3 × 3 block, three columns apart with a wider gap between words, every copy of a letter /// the same to the pixel. fn rendered_text(w: u32, h: u32) -> Vec { + rendered_text_from(w, h, 0x2545_F491_4F6C_DD1D) +} + +/// [`rendered_text`], its letters picked from `seed`, which isn't 0. +fn rendered_text_from(w: u32, h: u32, mut seed: u64) -> Vec { const LETTERS: [&[&str]; 5] = [ &["##", "##", "##", "##", "##", "##", "##", "##", "##", "##"], &["......", ".####.", "##..##", "##..##", "##..##", "##..##", ".####."], @@ -2008,7 +2022,6 @@ fn rendered_text(w: u32, h: u32) -> Vec { ]; let stride = (w as usize).div_ceil(8); let mut data = vec![0xFF; stride * h as usize]; - let mut seed = 0x2545_F491_4F6C_DD1Du64; let mut next = move || { seed ^= seed << 13; seed ^= seed >> 7; @@ -2053,9 +2066,194 @@ fn rendered_text_becomes_jbig2_symbols() { assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); let generic = pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap(); assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, generic {}", s.raw.len(), generic.len()); + assert!(!s.dict.contains(b"DecodeParms"), "alone, it shares no dictionary"); assert!(render(&doc) == before, "the page looks the same"); } +/// Page `index` of `doc`, saved and opened again, rendered at scale 1. +fn render_page(doc: &Document, index: usize) -> Vec { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: index, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.rgba.to_vec() +} + +/// Text in a font of 26 letters, about 10 pt at 300 dpi: each letter 32 rows of a run from a stem +/// down its left side, of 2 to 24 pixels, every copy the same to the pixel; lines 44 rows apart, +/// the letters picked from `seed`, which isn't 0. +fn font_text(w: u32, h: u32, mut seed: u64) -> Vec { + let mut font_seed = 0x2545_F491_4F6C_DD1Du64; + let next = |seed: &mut u64| { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + *seed + }; + let letters: Vec> = (0..26).map(|_| (0..16).map(|_| 1 + (next(&mut font_seed) % 12) as usize).collect()).collect(); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for top in (8..(h as usize).saturating_sub(40)).step_by(44) { + let mut x = 8; + while x + 32 < w as usize { + let letter = &letters[(next(&mut seed) % 26) as usize]; + for (row, run) in letter.iter().enumerate() { + for y in [top + 2 * row, top + 2 * row + 1] { + for px in x..x + 2 * run { + data[y * stride + px / 8] &= !(0x80 >> (px % 8)); + } + } + } + x += 2 * letter.iter().max().unwrap() + 4 + if next(&mut seed) % 6 == 0 { 14 } else { 0 }; + } + } + data +} + +/// The `/JBIG2Globals` an image names in its `/DecodeParms`. +fn globals_of(doc: &Document, image: ObjRef) -> Option { + stream(doc, image).dict.get(b"DecodeParms")?.as_dict()?.reference(b"JBIG2Globals") +} + +/// Pages of text in one font, each letter the same to the pixel on every page, stored as JBIG2 +/// share one symbol dictionary (`/JBIG2Globals`): together they take less than each page coded +/// alone, and every page looks the same, the last one, whose crosses no other page has, placing +/// them from a dictionary of its own beside the shared one. The report counts the bytes they +/// take, the globals once. +#[test] +fn jbig2_pages_share_one_symbol_dictionary() { + let mut doc = Document::new_empty(); + let (w, h) = (600u32, 480u32); + let stride = (w as usize).div_ceil(8); + let pages: Vec> = (1..=4u64) + .map(|k| { + let mut text = font_text(w, h, k.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + if k == 4 { + // Crosses down the right margin, three pixels a stroke. + for top in (40..440).step_by(40) { + for (dx, dy) in (0..9).map(|i| (i, 4)).chain((0..9).map(|i| (4, i))) { + let (x, y) = (580 + dx, top + dy); + text[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + text + }) + .collect(); + let images: Vec = pages + .iter() + .map(|text| { + let im = mono_image(&mut doc, w, h, text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 600 0 0 480 6 300 cm /Im Do Q"); + im + }) + .collect(); + let before: Vec> = (0..pages.len()).map(|p| render_page(&doc, p)).collect(); + let alone: usize = pages.iter().map(|text| pdfcraft_filters::encode_jbig2(text, w, h).unwrap().len()).sum(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let report = optimize(&mut doc, &settings).unwrap(); + let globals = globals_of(&doc, images[0]).expect("the first page names the globals"); + assert!(images.iter().all(|im| globals_of(&doc, *im) == Some(globals)), "every page names the same globals"); + let shared = stream(&doc, globals).raw.len() + images.iter().map(|im| stream(&doc, *im).raw.len()).sum::(); + assert!(shared * 4 < alone * 3, "shared {shared} bytes, alone {alone}"); + assert_eq!(report.image_bytes_after, shared); + for (p, before) in before.iter().enumerate() { + assert!(render_page(&doc, p) == *before, "page {p} looks the same"); + let s = stream(&doc, images[p]); + assert_eq!(decoded_image(&doc, &s).unwrap(), pages[p], "page {p} decodes to its samples"); + } + // Optimized again, each page would come out larger alone than it is beside the globals: the + // sharing stays. + let streams: Vec = images.iter().map(|im| stream(&doc, *im)).collect(); + let again = optimize(&mut doc, &settings).unwrap(); + assert_eq!((again.images_recompressed, again.images_resampled), (0, 0)); + assert!(images.iter().zip(&streams).all(|(im, s)| stream(&doc, *im) == *s)); +} + +/// Images share a dictionary only where that saves more than it takes: two pages of five small +/// letters save less than their globals would take, and stay as they are coded alone. +#[test] +fn a_dictionary_that_saves_less_than_it_takes_isnt_shared() { + let mut doc = Document::new_empty(); + let (w, h) = (600u32, 480u32); + let images: Vec = (1..=2u64) + .map(|k| { + let text = rendered_text_from(w, h, k.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 600 0 0 480 6 300 cm /Im Do Q"); + im + }) + .collect(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let report = optimize(&mut doc, &settings).unwrap(); + assert_eq!(report.images_recompressed, 2); + for im in &images { + let s = stream(&doc, *im); + assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false)); + } + // Optimized again, they code to what they are, no smaller: they stay. + let streams: Vec = images.iter().map(|im| stream(&doc, *im)).collect(); + assert_eq!(optimize(&mut doc, &settings).unwrap().images_recompressed, 0); + assert!(images.iter().zip(&streams).all(|(im, s)| stream(&doc, *im) == *s)); +} + +/// Only JBIG2 asked for shares: under Retain, JBIG2 pages drawn at 600 ppi are averaged and stored +/// as JBIG2 again, each alone, as Retain changes nothing but their resolution. +#[test] +fn retained_jbig2_shares_nothing() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 960u32); + let images: Vec = (1..=4u64) + .map(|k| { + let text = font_text(w, h, k.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + let mut d = Dict::new(); + for (key, value) in [("Type", Object::name("XObject")), ("Subtype", Object::name("Image")), ("ColorSpace", Object::name("DeviceGray"))] { + d.set(key.as_bytes().to_vec(), value); + } + for (key, value) in [("Width", i64::from(w)), ("Height", i64::from(h)), ("BitsPerComponent", 1)] { + d.set(key.as_bytes().to_vec(), Object::Int(value)); + } + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let im = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_jbig2(&text, w, h).unwrap()))); + // 1200 samples over 144 pt: 600 ppi. + page(&mut doc, &[("Im", im)], "q 144 0 0 115.2 6 300 cm /Im Do Q"); + im + }) + .collect(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Retain, ..MonoSettings::REDUCE }, ..Settings::default() }; + assert_eq!(optimize(&mut doc, &settings).unwrap().images_resampled, 4); + for im in images { + let s = stream(&doc, im); + assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false)); + } +} + +/// Sixteen images at most share one dictionary, and two are enough: of eighteen, the first +/// sixteen share one and the other two another. +#[test] +fn sixteen_images_at_most_share_a_dictionary() { + let mut doc = Document::new_empty(); + let (w, h) = (600u32, 480u32); + let texts: Vec> = (1..=18u64).map(|k| font_text(w, h, k.wrapping_mul(0x9E37_79B9_7F4A_7C15))).collect(); + let images: Vec = texts + .iter() + .map(|text| { + let im = mono_image(&mut doc, w, h, text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 600 0 0 480 6 300 cm /Im Do Q"); + im + }) + .collect(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + let globals: Vec> = images.iter().map(|im| globals_of(&doc, *im)).collect(); + assert!(globals.iter().all(Option::is_some), "{globals:?}"); + assert!(globals[..16].iter().all(|g| *g == globals[0]) && globals[16..].iter().all(|g| *g == globals[16]), "{globals:?}"); + assert_ne!(globals[0], globals[16]); + for (k, (im, text)) in images.iter().zip(&texts).enumerate() { + assert!(decoded_image(&doc, &stream(&doc, *im)).unwrap() == *text, "image {k} decodes to its samples"); + } +} + /// A JBIG2 scan goes through Monochrome Images like any black-and-white image. Above 450 ppi it /// is averaged as its Flate copy is, to the same samples, and stored as each compression asks: /// Group 4 or JBIG2, or Flate when that is smaller, or under Retain JBIG2 again; alone or behind another diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 2e737e2baf..4b3c44b0fd 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -10,8 +10,9 @@ use pdfcraft_content::{Matrix, overlaps}; use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; -/// Most pixels an image may have to be cleared in place (larger ones are removed). -const MAX_PIXELS: u64 = 64 << 20; +/// Most pixels an image may have to be cleared in place (larger ones are removed), or to be stored +/// again without shared `/JBIG2Globals` (see [`without_globals`]). +const MAX_PIXELS: u64 = pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS; fn components(doc: &Document, s: &Stream) -> Option { let cs = doc.resolve(s.dict.get(b"ColorSpace")?); diff --git a/docs/roadmap.md b/docs/roadmap.md index f376f7cb00..d17893c1aa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -304,6 +304,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | M11: Optimize's JBIG2 images share a symbol dictionary (`/JBIG2Globals`), up to 16 at a time and when smaller (`pdfcraft_filters::encode_jbig2_shared`): 41–46% smaller on rendered text in proportional fonts, 10% in Courier | | 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 | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9ef459028b..822496df3b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7439,8 +7439,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] -evidence = ["crates/optimize/src/images.rs::flate_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::an_image_too_large_for_the_jbig2_decoder_stays_as_it_is", "crates/optimize/tests/optimize.rs::what_is_too_large_for_jbig2_is_stored_another_way", "crates/optimize/tests/optimize.rs::group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit"] -notes = "All of a run's images share one allowance of decoding, filters, decoders and pixels alike (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are, those the image pass read being counted in the report, which doc_optimize and doc_reduce return and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: images over 80 million pixels (left alone by the image steps, whatever their settings; Clean Up may still Flate an unfiltered or LZW one), inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples, JBIG2 past 65,535 samples on a side, where the decoder stops: such an image isn't stored as JBIG2 (the image step leaves a 1-bit one as it is unless it is averaged, and may store one packed from more bits as 1-bit Flate)." +evidence = ["crates/optimize/src/images.rs::flate_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::an_image_too_large_for_the_jbig2_decoder_stays_as_it_is", "crates/optimize/tests/optimize.rs::what_is_too_large_for_jbig2_is_stored_another_way", "crates/optimize/tests/optimize.rs::group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit", "crates/filters/src/jbig2/symbols.rs::pages_in_one_font_share_one_dictionary", "crates/filters/src/jbig2/symbols.rs::what_a_page_has_alone_stays_its_own", "crates/filters/src/jbig2/symbols.rs::past_the_shared_limit_the_most_widely_held_marks_are_shared", "crates/optimize/tests/optimize.rs::jbig2_pages_share_one_symbol_dictionary", "crates/optimize/tests/optimize.rs::a_dictionary_that_saves_less_than_it_takes_isnt_shared", "crates/optimize/tests/optimize.rs::sixteen_images_at_most_share_a_dictionary", "crates/engine/src/tests.rs::redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again", "crates/optimize/src/images.rs::only_what_redaction_can_store_again_shares_a_dictionary"] +notes = "All of a run's images share one allowance of decoding, filters, decoders and pixels alike (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are, those the image pass read being counted in the report, which doc_optimize and doc_reduce return and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export, up to 16 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it); lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: images over 80 million pixels (left alone by the image steps, whatever their settings; Clean Up may still Flate an unfiltered or LZW one), inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples, JBIG2 past 65,535 samples on a side, where the decoder stops: such an image isn't stored as JBIG2 (the image step leaves a 1-bit one as it is unless it is averaged, and may store one packed from more bits as 1-bit Flate)." [[feature]] id = "optimize.only-if-smaller" From c0cd40f2e189a25fa624d0b75a3255db439d7ad3 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Wed, 4 Nov 2026 13:51:40 +0100 Subject: [PATCH 62/63] M6: forms decodes field values, renamed calculations and detected pages within shared budgets (#6132) * M6: forms decodes values, renamed calculations and detected pages within shared budgets Part of #5755: the forms crate's four unbudgeted stream reads go through a `DecodeBudget`, so an operation's decoding is bounded however often the file names the same stream. - A reading of the form decodes its fields' value streams (/V, /DV) within one 64 MiB budget, as it already does their scripts. Each field got a fresh 1 GiB allowance, so 300 fields naming one stream decoded it 300 times. - Renaming a field rewrites the calculations that read it within one script budget; past it, a calculation is left as it is, like one that won't decode. - Field detection reads a page's content within one page's allowance (`DecodeBudget::page`), as the content walks do, however often /Contents lists a stream. - The PDF/A declaration is read from at most 8 MiB of XMP, as preflight's Standards panel reads it, and only once /NeedAppearances isn't already set. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M6: the forms README says which metadata declares PDF/A declares_pdfa reads pdfaid:part from XMP metadata that decodes within 8 MiB, as preflight's Standards panel reads it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M6: a test pins that metadata without pdfaid:part declares no PDF/A cargo-mutants found `==` turned to `!=` in declares_pdfa's search unpinned: no case had metadata within the cap that declares nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M6: field detection reads operators one at a time; the PDF/A check reads XMP as preflight does From the Resources review of 66415d737: - page_shapes collected every parsed operator, about 80 bytes for each decoded byte of dense content, so a page within its 256 MiB allowance could take some 20 GB. It now reads them one at a time (pdfcraft_content::ops) and drops the pieces once joined. - declares_pdfa decoded through a DecodeBudget, which counts every filter stage against the 8 MiB cap, so XMP stored through a chain of filters stopped declaring PDF/A while preflight's Standards panel, which caps each stage, still read it. It reads the metadata as preflight does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M6: say what declares PDF/A to forms, and why its metadata read stays direct From the Legal-forms review of c00e3d2fb: the README and doc comments said forms reads the declaration "as preflight's Standards panel reads it". The two share the 8 MiB cap, applied to each filter stage, but forms matches the bytes pdfaid:part and preflight the identifier by its namespace, as on main. The sweep's direct-/DecodeParms hit is answered at the site: preflight and organize read metadata the same way, and the readers move together. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * forms: rename a shared calculation once; keep unread defaults on Clear form; cap q in detection - A Calculate action that several fields share by reference is read and rewritten once, in place, so every calculation reads the new name and the script is decoded once, not skipped past the budget. - Clear form leaves a field alone when its /DV is a stream the value budget didn't reach, instead of emptying it. - Field detection keeps at most 256 saved graphics states; deeper q are counted so each Q still restores its own. - The dense-content test checks that the budget is spent; exhausted() was true from the start with no form visits. Co-Authored-By: Claude Opus 5.5 * forms: restore the matrix past the save cap; reset a value that wasn't read - Field detection keeps the matrix of the first save past MAX_SAVES and restores it when the last of them closes. A shape painted after a Q that would have undone a cm inside those saves is dropped, as the matrix there isn't known, instead of being reported scaled. - Clear form skips a field only when its value is known: one whose /V is a stream the value budget didn't reach is reset, not kept as if it equalled an empty default. Co-Authored-By: Claude Opus 5.5 * forms: keep detection's saved states in a Saves type shapes_within went past clippy's cognitive complexity limit (32/30) with the save-cap tracking inline. The same logic now lives in Saves (save, restore, moved, unknown). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/forms/README.md | 5 +- crates/forms/src/appearances.rs | 15 +++- crates/forms/src/author/edit.rs | 17 +++- crates/forms/src/detect.rs | 86 +++++++++++++++++-- crates/forms/src/read.rs | 26 ++++-- crates/forms/src/tests.rs | 143 ++++++++++++++++++++++++++++++++ crates/forms/src/values.rs | 26 +++++- 7 files changed, 298 insertions(+), 20 deletions(-) diff --git a/crates/forms/README.md b/crates/forms/README.md index ba9dc18f48..0c8f5ad9b9 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -50,7 +50,10 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table 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) and the form sets `/NeedAppearances`, so other viewers draw the field from its value with fonts - that have them. Not in a document that declares PDF/A, which forbids that flag (`declares_pdfa`). + that have them. Not in a document that declares PDF/A, which forbids that flag (`declares_pdfa`: + the bytes `pdfaid:part` in XMP metadata each filter stage of which decodes within 8 MiB, the cap + preflight's Standards panel reads it with; preflight reads the identifier by its namespace, so a + packet that gives that namespace another prefix declares PDF/A there and not here). `undrawn_fields` lists the marked fields; the engine reports each edit that draws one, and flattening refuses to make that `?` permanent. - Displaying a form that leaves its appearances to the viewer (`/NeedAppearances`, or a text or diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs index 64569fa3ca..02c1eec5bc 100644 --- a/crates/forms/src/appearances.rs +++ b/crates/forms/src/appearances.rs @@ -212,13 +212,24 @@ impl FieldAppearanceCache { } } -/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). +/// The most each filter stage of the XMP metadata stream may decode for its PDF/A declaration to +/// be read, the cap preflight's Standards panel reads it with: past it, metadata declares nothing +/// there either. +pub(crate) const MAX_XMP_BYTES: usize = 8 << 20; + +/// Whether the document's XMP metadata declares PDF/A conformance (the bytes `pdfaid:part`). +/// Metadata a filter stage of which decodes past [`MAX_XMP_BYTES`] doesn't. pub fn declares_pdfa(doc: &Document) -> bool { let Some(root) = doc.root() else { return false }; let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false }; let m = doc.resolve(&m); match &*m { - Object::Stream(s) => s.decoded().is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), + // Not a `DecodeBudget`, which counts every stage against one allowance: preflight bounds + // each stage, and the two must agree on metadata stored through a chain of filters. + // `/DecodeParms` is read only when direct, as preflight and organize read metadata too: + // PDF/A asks for it unfiltered, so only a file that breaks that reaches parameters here, + // and the readers go to `with_direct_parms` together or they disagree. + Object::Stream(s) => s.decoded_within(MAX_XMP_BYTES).is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), _ => false, } } diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 2d05600ae2..6bb62cfb73 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -583,10 +583,15 @@ fn apply_late_widget_props( } /// Name field `old` `new` in each field's Calculate script that reads it -/// ([`crate::af::rename_in_calculation`]). +/// ([`crate::af::rename_in_calculation`]). Script streams share one budget, as a reading of the +/// form's scripts does, however many fields name the same stream; past it, a script is left as +/// it is, like one that won't decode. A Calculate action several fields share by reference is +/// read and rewritten once, in place, so they all read the new name. fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &str) -> Result<(), FormError> { let aa_of = |doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()); + let mut scripts = crate::script_budget(); + let mut shared = std::collections::HashSet::new(); for f in all { // The script is the field's, or its only widget's when the field has none, as // `actions_of` reads it. @@ -595,12 +600,16 @@ fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &st _ => None, }; let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue }; + let by_ref = aa.get(b"C").and_then(Object::as_ref); + if by_ref.is_some_and(|r| !shared.insert(r)) { + continue; + } let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue }; let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() { Some(Object::String(s)) => s.to_text(), // A stream that isn't UTF-8 reads with replacement characters, which must not be // written back in place of what it holds. - Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) { + Some(Object::Stream(s)) => match scripts.decode(s).ok().and_then(|b| String::from_utf8(b).ok()) { Some(js) => js, None => continue, }, @@ -608,6 +617,10 @@ fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &st }; let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue }; action.set(b"JS".to_vec(), PdfString::text(&js)); + if let Some(r) = by_ref { + doc.update_dict(r, |d| *d = action)?; + continue; + } aa.set(b"C".to_vec(), Object::Dict(action)); doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; } diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index 386671f433..94ad7adb37 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -6,7 +6,7 @@ //! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan). use pdfcraft_content::{Matrix, Pieces}; -use pdfcraft_cos::{Document, Object}; +use pdfcraft_cos::{DecodeBudget, Document, Object}; pub mod raster; @@ -49,6 +49,60 @@ pub struct Candidate { /// points along its sides (#2942: a cell drawn with a midpoint on each vertical side). const MAX_RECT_POINTS: usize = 16; +/// Graphics states saved (`q`) and kept at once while detecting; deeper saves are counted, not kept. +const MAX_SAVES: usize = 256; + +/// The graphics states a page saves (`q`) as detection follows its matrix: at most [`MAX_SAVES`] +/// kept, deeper ones counted so each `Q` still closes its own. Of those not kept: the matrix when +/// the first was made, which closing the last restores; the shallowest a `cm` changed the matrix +/// in; and whether a `Q` has closed that one, so the matrix isn't known until the last closes. +struct Saves { + kept: Vec, + lost: usize, + lost_base: Matrix, + moved_at: usize, + unknown: bool, +} + +impl Default for Saves { + fn default() -> Self { + Saves { kept: Vec::new(), lost: 0, lost_base: Matrix::IDENTITY, moved_at: usize::MAX, unknown: false } + } +} + +impl Saves { + fn save(&mut self, ctm: Matrix) { + if self.kept.len() < MAX_SAVES && self.lost == 0 { + self.kept.push(ctm); + } else { + if self.lost == 0 { + self.lost_base = ctm; + } + self.lost = self.lost.saturating_add(1); + } + } + + fn restore(&mut self, ctm: &mut Matrix) { + if self.lost == 0 { + *ctm = self.kept.pop().unwrap_or(*ctm); + return; + } + self.lost -= 1; + if self.lost == 0 { + (*ctm, self.moved_at, self.unknown) = (self.lost_base, usize::MAX, false); + } else if self.lost < self.moved_at { + self.unknown = true; + } + } + + /// A `cm` changed the matrix at the current depth. + fn moved(&mut self) { + if self.lost > 0 { + self.moved_at = self.moved_at.min(self.lost); + } + } +} + /// The rectangles and horizontal segments a page draws (stroked or filled), in user space. #[derive(Default)] struct DetectionPath { @@ -125,7 +179,15 @@ impl DetectionPath { } } +/// The shapes page `page`'s content draws, its streams decoded within one page's allowance +/// ([`DecodeBudget::page`]), however often `/Contents` lists the same stream. pub fn page_shapes(doc: &Document, page: usize) -> Shapes { + shapes_within(doc, page, &mut DecodeBudget::page()) +} + +/// [`page_shapes`], decoding against `budget`: a stream `/Contents` lists again is charged again, +/// and one past what is left reads as empty. +pub(crate) fn shapes_within(doc: &Document, page: usize, budget: &mut DecodeBudget) -> Shapes { let mut out = Shapes::default(); let Some(p) = pdfcraft_model::pages(doc).into_iter().nth(page) else { return out }; let list: Vec = match p.dict.get(b"Contents").map(|c| doc.resolve(c)) { @@ -139,24 +201,36 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { let mut pieces = Vec::new(); for o in list { if let Object::Stream(s) = &*doc.resolve(&o) { - pieces.push(s.decoded().unwrap_or_default()); + pieces.push(budget.decode(s).unwrap_or_default()); } } let mut ctm = Matrix::IDENTITY; - let mut stack = Vec::new(); + let mut saves = Saves::default(); let mut path_boxes: Vec<[f64; 4]> = Vec::new(); let mut subpaths: Vec = Vec::new(); let mut path = DetectionPath::default(); let mut cur: Option<[f64; 2]> = None; - for op in Pieces::join(&pieces).parse().ops { + let joined = Pieces::join(&pieces); + drop(pieces); + // An operator at a time: dense content (`n n n …`) parsed whole holds about 80 bytes for each + // byte it decoded. + for op in pdfcraft_content::ops(joined.data()) { match op.op.as_slice() { - b"q" => stack.push(ctm), - b"Q" => ctm = stack.pop().unwrap_or(ctm), + b"q" => saves.save(ctm), + b"Q" => saves.restore(&mut ctm), b"cm" => { if let Some(m) = op.nums::<6>() { ctm = Matrix(m).then(&ctm); + saves.moved(); } } + // Painted where the matrix isn't known: dropped, as `n` drops a path. + b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" if saves.unknown => { + path_boxes.clear(); + subpaths.clear(); + path = DetectionPath::default(); + cur = None; + } b"re" => { path.finish(&mut subpaths); if let Some([x, y, w, h]) = op.nums::<4>() { diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index e689c0e813..34775ee75e 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -192,11 +192,17 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Decode out } -fn values_of(doc: &Document, o: Option<&Object>) -> Vec { +/// Text streams of field values (`/V`, `/DV`) one reading of the form may decode in all, however +/// many fields name the same stream. +pub(crate) const MAX_VALUE_BYTES: usize = 64 << 20; + +/// A field's value or default value (`/V`, `/DV`). A text stream is decoded against `budget`, and +/// reads as no value once it is spent, as one that won't decode does. +fn values_of(doc: &Document, o: Option<&Object>, budget: &mut DecodeBudget) -> Vec { let Some(o) = o else { return Vec::new() }; match &*doc.resolve(o) { Object::Array(a) => a.iter().filter_map(|x| text_of(&doc.resolve(x))).collect(), - Object::Stream(s) => s.decoded().ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(), + Object::Stream(s) => budget.decode(s).ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(), other => text_of(other).filter(|s| s != "Off" && !s.is_empty()).into_iter().collect(), } } @@ -345,7 +351,12 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { None => Inherited::default(), }; let mut out = Vec::new(); - let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), scripts: script_budget() }; + let mut seen = Seen { + fields: std::collections::HashSet::new(), + widgets: std::collections::HashSet::new(), + scripts: script_budget(), + values: DecodeBudget::new(MAX_VALUE_BYTES, 0), + }; if let Some(af) = &af { for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { if let Some(r) = f.as_ref() { @@ -416,13 +427,14 @@ fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet, /// A widget belongs to the first field that lists it, however often it is listed. widgets: std::collections::HashSet, scripts: DecodeBudget, + values: DecodeBudget, } /// Read the field at `r` and the fields under it into `out`, with what they inherit from @@ -549,8 +561,8 @@ fn walk( name: inh.name.clone(), obj: r, kind, - value: values_of(doc, inh.v.as_ref()), - default: values_of(doc, inh.dv.as_ref()), + value: values_of(doc, inh.v.as_ref(), &mut seen.values), + default: values_of(doc, inh.dv.as_ref(), &mut seen.values), flags: ff, max_len: inh.max_len, options, diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index cca4cb4881..38b5f5059f 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3575,6 +3575,149 @@ fn repeated_script_references_share_one_decode_budget() { assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts"); } +/// An unfiltered stream of `body`, as an object of [`document`]. +fn raw_stream(body: &str) -> String { + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()) +} + +/// 300 fields whose value is one 1 MiB text stream: a reading of the form decodes their values +/// within one budget, not 300 MiB. +#[test] +fn repeated_value_streams_share_one_decode_budget() { + let fields_list: String = (0..300).map(|i| format!("{} 0 R ", i + 3)).collect(); + let mut objs = vec![format!("<< /Type /Catalog /AcroForm << /Fields [{fields_list}] >> >>"), raw_stream(&"x".repeat(1 << 20))]; + objs.extend((0..300).map(|i| format!("<< /FT /Tx /T (f{i}) /V 2 0 R /DV 2 0 R >>"))); + let all = fields(&document(&objs)); + assert_eq!(all.len(), 300); + let read: usize = all.iter().flat_map(|f| f.value.iter().chain(&f.default)).map(String::len).sum(); + assert!(read > 0 && read <= crate::read::MAX_VALUE_BYTES, "{read} bytes of values"); + assert_eq!(all[0].value, ["x".repeat(1 << 20)], "the first field still reads its value"); +} + +/// Clear form leaves a field as it is when its default is a stream the value budget didn't reach: +/// 70 fields typed in, whose default is one 1 MiB text stream, keep a value, the first ones +/// their default and the rest what was typed, none of them emptied. +#[test] +fn clear_form_keeps_a_field_whose_default_was_not_read() { + let n = 70; + let fields_list: String = (0..n).map(|i| format!("{} 0 R ", i + 3)).collect(); + let mut objs = vec![format!("<< /Type /Catalog /AcroForm << /Fields [{fields_list}] >> >>"), raw_stream(&"d".repeat(1 << 20))]; + objs.extend((0..n).map(|i| format!("<< /FT /Tx /T (f{i}) /V (typed) /DV 2 0 R >>"))); + let mut doc = document(&objs); + reset(&mut doc, None).unwrap(); + let values: Vec = (0..n) + .map(|i| { + let field = doc.get(ObjRef::new(i as u32 + 3, 0)); + field.as_dict().and_then(|d| d.get(b"V")).and_then(|v| v.as_string().map(PdfString::to_text)).unwrap_or_default() + }) + .collect(); + assert!(values.iter().all(|v| !v.is_empty()), "no field is emptied"); + assert_eq!(values[0], "d".repeat(1 << 20), "the first field goes back to its default"); + assert_eq!(values[n - 1], "typed", "the last field, whose default wasn't read, keeps its value"); + // A value the reading didn't decode, with no default, is cleared: it isn't known to be empty. + let mut objs = vec![format!("<< /Type /Catalog /AcroForm << /Fields [{fields_list}] >> >>"), raw_stream(&"v".repeat(1 << 20))]; + objs.extend((0..n).map(|i| format!("<< /FT /Tx /T (f{i}) /V 2 0 R >>"))); + let mut doc = document(&objs); + reset(&mut doc, None).unwrap(); + let last = doc.get(ObjRef::new(n as u32 + 2, 0)); + assert!(last.as_dict().and_then(|d| d.get(b"V")).is_none_or(|v| !matches!(v, Object::Ref(_))), "the last field's unread value is cleared"); +} + +/// Renaming a field that 300 calculations read through one shared Calculate action, whose script +/// is a 256 KiB stream, decodes and rewrites that action once, in place: every calculation reads +/// the new name, and nothing is decoded or written 300 times. +#[test] +fn a_rename_rewrites_a_shared_calculation_once() { + let n = 300; + let refs = |from: usize| -> String { (from..from + n).map(|i| format!("{i} 0 R ")).collect() }; + let js = format!("AFSimple_Calculate(\"SUM\", new Array (\"a\"));{}", " ".repeat(256 << 10)); + let mut objs = 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(), + format!("<< /Type /Page /Parent 2 0 R /Annots [5 0 R {}] >>", refs(8)), + format!("<< /Fields [5 0 R {}] /DA (/Helv 0 Tf 0 g) >>", refs(8)), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (a) /Rect [50 700 250 720] /P 3 0 R >>".into(), + "<< /S /JavaScript /JS 7 0 R >>".into(), + raw_stream(&js), + ]; + objs.extend((0..n).map(|i| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (c{i}) /Rect [50 10 250 30] /P 3 0 R /AA << /C 6 0 R >> >>"))); + let mut doc = document(&objs); + set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap(); + let action = ObjRef::new(6, 0); + let renamed = (8..8 + n).all(|i| { + let field = doc.get(ObjRef::new(i as u32, 0)); + field.as_dict().and_then(|d| d.get(b"AA")).and_then(|aa| aa.as_dict()?.get(b"C")?.as_ref()) == Some(action) + }); + assert!(renamed, "every calculation still names the one shared action"); + let js = doc.get(action).as_dict().and_then(|d| d.get(b"JS")?.as_string().map(PdfString::to_text)).unwrap_or_default(); + assert!(js.contains("\"qty\"") && !js.contains("\"a\""), "the shared action reads the new name"); + assert!(js.len() <= MAX_SCRIPT_BYTES, "{} bytes written once", js.len()); +} + +/// A page whose `/Contents` lists one 1 MiB stream ten times is read within the budget given: +/// pieces past it read as empty, so their rectangles aren't found. +#[test] +fn page_shapes_decode_a_repeated_content_stream_within_the_budget() { + let piece = format!("%{}\n10 10 20 20 re S\n", "x".repeat(1 << 20)); + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Contents [{}] >>", "4 0 R ".repeat(10)), + raw_stream(&piece), + ]); + assert_eq!(crate::detect::page_shapes(&doc, 0).boxes.len(), 10, "one page's allowance reads them all"); + let mut budget = DecodeBudget::new(4 << 20, 0); + let boxes = crate::detect::shapes_within(&doc, 0, &mut budget).boxes.len(); + assert!((1..=4).contains(&boxes), "{boxes} rectangles read within 4 MiB"); + assert_eq!(budget.remaining(), 0, "the budget is spent"); +} + +/// Field detection keeps at most 256 saved graphics states, and each `Q` still restores its own +/// `q` past that: a scale set 300 saves deep is gone once they are all restored. +#[test] +fn page_shapes_restore_saves_past_the_cap() { + let content = format!("{} 2 0 0 2 0 0 cm {} 10 10 20 20 re S", "q ".repeat(300), "Q ".repeat(300)); + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".into(), + raw_stream(&content), + ]); + assert_eq!(crate::detect::page_shapes(&doc, 0).boxes, [[10.0, 10.0, 30.0, 30.0]]); + // A scale set past the cap and closed by its own `Q` leaves a matrix that can't be known until + // the last lost save closes: what is drawn in between isn't reported at the scaled place. + let content = format!("{} q 2 0 0 2 0 0 cm Q 10 10 20 20 re S {} 40 40 10 10 re S", "q ".repeat(257), "Q ".repeat(257)); + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".into(), + raw_stream(&content), + ]); + assert_eq!(crate::detect::page_shapes(&doc, 0).boxes, [[40.0, 40.0, 50.0, 50.0]]); +} + +/// The PDF/A declaration is read from metadata each filter stage of which decodes within +/// [`crate::appearances::MAX_XMP_BYTES`], as preflight decodes it: a field drawn with characters its +/// font lacks decodes no more than that a stage. +#[test] +fn a_pdfa_declaration_is_read_from_metadata_within_its_cap() { + let declared = |xmp: String| declares_pdfa(&document(&["<< /Type /Catalog /Metadata 2 0 R >>".into(), raw_stream(&xmp)])); + let pdfa = |padding: usize| format!("2{}", " ".repeat(padding)); + assert!(declared(pdfa(1 << 20))); + assert!(!declared(pdfa(crate::appearances::MAX_XMP_BYTES)), "metadata past the cap declares nothing"); + assert!(!declared("pdfaid".into()), "metadata without pdfaid:part declares nothing"); + // 3.5 MiB stored as hex, then Flate: its stages decode 7 and 3.5 MiB, each within the cap. + let xmp = pdfa(7 << 19); + let hex: Vec = xmp.bytes().flat_map(|b| [b"0123456789ABCDEF"[usize::from(b >> 4)], b"0123456789ABCDEF"[usize::from(b & 15)]]).collect(); + let mut stream = pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), &hex); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + let mut doc = Document::new_empty(); + let metadata = doc.add(Object::Stream(stream)); + let catalog = doc.add(Object::Dict([(b"Metadata".to_vec(), Object::Ref(metadata))].into_iter().collect())); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + assert!(declares_pdfa(&doc), "metadata through a chain of filters declares PDF/A, as preflight decodes it"); +} + /// A widget listed many times, by one field's `/Kids` or by a `/Kids` array several fields share, /// is read once: the first field to list it owns it. #[test] diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index e8615567b6..cf191c2240 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -470,7 +470,9 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc /// document that declares PDF/A, which forbids it: there the widget's mark /// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere. pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> { - if declares_pdfa(doc) || need_appearances(doc) { + // The form's own flag first: once it is set, the metadata needn't be decoded again for each + // field drawn after. + if need_appearances(doc) || declares_pdfa(doc) { return Ok(()); } let Some(root) = doc.root() else { return Ok(()) }; @@ -625,6 +627,22 @@ pub fn clear_password_values(doc: &mut Document) -> Result { Ok(targets.len()) } +/// Whether `field`'s `key` (`/V` or `/DV`, its own or inherited) is a stream with data, which +/// [`fields`] reads as empty once its value budget is spent. +fn unread_stream(doc: &Document, field: ObjRef, key: &[u8]) -> bool { + let mut node = Some(field); + for _ in 0..64 { + let Some(r) = node else { break }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { break }; + if let Some(value) = d.get(key) { + return matches!(&*doc.resolve(value), Object::Stream(s) if !s.raw.is_empty()); + } + node = d.reference(b"Parent"); + } + false +} + /// Acrobat's Clear form: every field (or the named ones) back to its default value. pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result { let all = fields(doc); @@ -638,7 +656,11 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result Date: Wed, 4 Nov 2026 13:51:47 +0100 Subject: [PATCH 63/63] M2: a JBIG2 refinement reads a reference right of it from its first column (#6048) A refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, ISO/IEC 14492 Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0. hayro-jbig2 emptied its buffers of the reference left of it and, at its first column, didn't load them again, so the refinement decoded to other pixels than were coded, or failed and lost the page. Vendored patch 15 (13 is #5999, 14 #6016): left of the reference the buffers keep the words of its first column each row starts with. With an offset of 0 or less nothing changes. Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/filters/src/jbig2/decode.rs | 51 +++++++++++++++++++ vendor/README.md | 2 +- .../src/decode/generic_refinement.rs | 36 ++++++------- vendor/hayro-jbig2/src/lib.rs | 43 ++++++++++++++++ 4 files changed, 113 insertions(+), 19 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index bcbbc29754..ddb70270d8 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -320,6 +320,57 @@ mod tests { assert_eq!(u64::MAX - work, 4 * 14); } + /// A refinement whose reference is offset right of it (GRREFERENCEDX above 0, as a refined text + /// instance has from a wider bitmap, ISO/IEC 14492 Table 12, and a refined symbol from an RDX + /// above 0, Table 18) reads the reference from its first column. Left of the reference the + /// decoder emptied what it had read of it, and at its first column didn't read it again, so its + /// first 32 columns read as 0: such a refinement decoded to other pixels than were coded, or + /// failed. Each stream is a refinement coded by the standard's procedures on a page of its own + /// size, and decodes to its picture, `#` for black. Vendored hayro-jbig2 patch 15. + #[test] + fn a_refinement_reads_a_reference_right_of_it_from_its_first_column() { + let cases: [(&str, &str, &[&str]); 4] = [ + ( + "a text instance of an 8 × 6 symbol refined 6 wider: 3 right, template 0", + "00000000300001000000130000000e000000060000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdd3dba48bdddde35ffac00000002062001010000002c0000000e000000060000000000000000040012ffffffff00000001a96b4a6b78b7fc094fadde1b3e881fffac", + &[".#.#.#....#...", "##.###....##.#", ".###...###..#.", "#.......#...#.", "#.####.#...#..", "#.##...###.#.."], + ), + ( + "refined 2 wider and 2 taller, RDX and RDY 1: 2 right, 2 down, template 1", + "00000000300001000000130000000a000000080000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdd9c52d3ea827a43ffac0000000206200101000000280000000a00000008000000000000000004801200000001a96c00cdac6dd5f6f848ce872411cfffac", + &["####.##...", "####..####", "####..##..", "...##...#.", "#......#..", "..#.....#.", "##....####", ".#........"], + ), + ( + "a symbol refined in a symbol dictionary, RDX 5, RDY −1, template 0 with its AT pixels moved", + "00000000300001000000130000000c000000060000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdda13efc4029efc95fffac000000020020010100000027000203fffdff02fefefefeff010100000001000000014fc98c352dd84a906f120ffb49793fffac00000003062002010000001b0000000c00000006000000000000000004001000000001a943ffac", + &["#.#.##.#.###", "..#.#.....##", "##..#...#...", "..##....##..", "##.#.##.####", ".#..#####..#"], + ), + ( + "a text instance of a 40 × 5 symbol refined 2 wider, RDX 1: 2 right, across a word, template 0", + "00000000300001000000130000002a0000000500000000000000004200000000000100000100000031000003fffdff02fefefe00000001000000015240f590d953ae3f4069dede672090d4d61b377906d3f41aafa4f77107ffac00000002062001010000003c0000002a000000050000000000000000040012ffffffff00000001a96c0b9386d4b140486bdc3673dbd031490e468c7f7813d19068ab04a79cbfffac", + &[ + "##.#.#..##.##.#####.#....#.#.#..#..######.", + ".#.#..###....#..##.#..#.####.#..#.###...##", + ".#.#......#....#####....#...###...#####...", + "#...##.##.......#...###.###...##..##.###.#", + "..#..###..###..#...#.......##.#........##.", + ], + ), + ]; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + for (form, stream, picture) in cases { + let (w, h) = (picture[0].len(), picture.len()); + // As the filter gives it: black as 0, each row padded to a byte with 0s. + let mut rows = vec![0u8; w.div_ceil(8) * h]; + for (y, row) in picture.iter().enumerate() { + for (x, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'.') { + rows[y * w.div_ceil(8) + x / 8] |= 0x80 >> (x % 8); + } + } + assert_eq!(decode_jbig2(&hex(stream), None, w as u32, h as u32, usize::MAX).unwrap(), rows, "{form}"); + } + } + /// The page has to be the image's size, and is bounded before it is decoded, being decoded /// whole: within `max` as rows of one bit, and within [`MAX_JBIG2_PIXELS`]. Globals are given /// to the decoder: ones it skips decode, ones that aren't JBIG2 are an error, as is damaged diff --git a/vendor/README.md b/vendor/README.md index 768c60bd8e..2c6d9be557 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | 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 | 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, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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); (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`; (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` | +| 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 | (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) | | 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 | diff --git a/vendor/hayro-jbig2/src/decode/generic_refinement.rs b/vendor/hayro-jbig2/src/decode/generic_refinement.rs index b89618ba68..cd730fd93f 100644 --- a/vendor/hayro-jbig2/src/decode/generic_refinement.rs +++ b/vendor/hayro-jbig2/src/decode/generic_refinement.rs @@ -264,25 +264,25 @@ impl<'a> RefinementContextGatherer<'a> { self.reg_cur = ContextGatherer::load_word(region, self.reg_y, new_start); } + // PdfCraft patch: left of the reference the buffers keep what `start_row` loaded, its words + // from column 0. The context takes in the reference a column to the right of the current + // one (Figures 12 and 13), so at column −1 it reads column 0, and from 0 on the buffers are + // the reference's own. They were zeroed here and, at column 0, not loaded again: with the + // reference offset right of the region (GRREFERENCEDX above 0, which a refined text + // instance or symbol has from a wider bitmap or an RDX above 0), its first 32 columns read + // as 0, and the region decoded to other pixels than were coded, or as garbage that failed. let ref_x_signed = (x as i32).wrapping_sub(self.reference_dx); - if ref_x_signed < 0 { - self.ref_cur_x = 0; - self.ref_m1 = 0; - self.ref_cur = 0; - self.ref_p1 = 0; - } else { - let ref_x = ref_x_signed as u32; - - if ref_x + 1 >= self.ref_cur_x + WORD_BITS || ref_x < self.ref_cur_x { - let new_start = ref_x.saturating_sub(1); - let ref_y = (self.reg_y as i32).wrapping_sub(self.reference_dy); - self.ref_cur_x = new_start; - self.ref_m1 = - ContextGatherer::load_word(reference, ref_y.wrapping_sub(1) as u32, new_start); - self.ref_cur = ContextGatherer::load_word(reference, ref_y as u32, new_start); - self.ref_p1 = - ContextGatherer::load_word(reference, ref_y.wrapping_add(1) as u32, new_start); - } + if let Ok(ref_x) = u32::try_from(ref_x_signed) + && (ref_x + 1 >= self.ref_cur_x + WORD_BITS || ref_x < self.ref_cur_x) + { + let new_start = ref_x.saturating_sub(1); + let ref_y = (self.reg_y as i32).wrapping_sub(self.reference_dy); + self.ref_cur_x = new_start; + self.ref_m1 = + ContextGatherer::load_word(reference, ref_y.wrapping_sub(1) as u32, new_start); + self.ref_cur = ContextGatherer::load_word(reference, ref_y as u32, new_start); + self.ref_p1 = + ContextGatherer::load_word(reference, ref_y.wrapping_add(1) as u32, new_start); } } diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index a307f53db4..e977ad72d6 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -1036,6 +1036,49 @@ mod tests { assert!(refused(&more), "one more isn't"); } + /// PdfCraft patch: a refinement whose reference is offset right of it reads the reference from + /// its first column, where it read the first 32 as 0. So that a context read wrong changes what + /// decodes, the refinement is large, and its picture comes from the hash it was coded from: a + /// 104 × 12 text instance refined from a 100 × 12 symbol 2 right of it. + #[test] + fn a_refinement_reads_a_reference_right_of_it_from_its_first_column() { + #[derive(Default)] + struct Rows(Vec>, Vec); + impl Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.1.push(u8::from(black)); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + for _ in 0..8 * chunks { + self.push_pixel(black); + } + } + fn next_line(&mut self) { + self.0.push(core::mem::take(&mut self.1)); + } + } + fn bit(seed: u32, x: u32, y: u32) -> u8 { + let mut h = x.wrapping_mul(0x9E37_79B1).wrapping_add(y.wrapping_mul(0x85EB_CA77)).wrapping_add(seed.wrapping_mul(0xC2B2_AE3D)); + h ^= h >> 15; + h = h.wrapping_mul(0x2C1B_3C6D); + h ^= h >> 12; + h = h.wrapping_mul(0x297A_2D39); + h ^= h >> 15; + ((h >> 7) & 1) as u8 + } + // About one pixel in eight: where a refinement differs from its reference. + let flipped = |seed: u32, x: u32, y: u32| bit(seed, x, y) & bit(seed + 1, x, y) & bit(seed + 2, x, y); + // A symbol of bit(2, ..), and a text instance refined from it, 4 wider with RDX 0. + const TEXT: &str = "0000000030000100000013000000680000000c000000000000000042000000000001000001000000b1000003fffdff02fefefe00000001000000013f525ad7118f669a303f1b11efa5210e9765b834b1831c650f3a3d325dbd7317379ea4fb573b09a20c362f065d8f9301e74cbbc50c69910641c0e0eea7c2fd1e831ca1459a943be55ed8e074b426ad7ff1e8d8ff6f1aa44684de5da4b4c3483911604412e00e96a0db6ec7103feb2bc4301ae98d3f9c23078ae72dc46f76e9dfea209d39187f1194f7b9285cd6e7b2dc7abb5def43ff0abe2df554fc55ffac0000000206200101000000af000000680000000c0000000000000000040012ffffffff00000001a96b5febebec5a68693b51f5db25ddf176d4aae89904a71d1690f12888e10b038db723ac59941f76c2844b739ce524d9563f4005e1edbabf2e323e8e9ac1a3b9060587c3010e7d6bcf90c00dbf9000a2f17b1d9b598e01b9158dc16a63f4388a6f647b99dc7118aa09e08d59fba1bc24e3a0f1ebb0cae636b6bedbb65b01945228d3e5d2980a0ad6290c13d2f34f5c861415ffac"; + let picture: Vec> = (0..12) + .map(|y| (0..104).map(|x| (if (2..102).contains(&x) { bit(2, x - 2, y) } else { 0 }) ^ flipped(20, x, y)).collect()) + .collect(); + let stream: Vec = (0..TEXT.len() / 2).map(|i| u8::from_str_radix(&TEXT[i * 2..i * 2 + 2], 16).unwrap()).collect(); + let mut rows = Rows::default(); + Image::new_embedded(&stream, None).unwrap().decode(&mut rows).unwrap(); + assert_eq!(rows.0, picture); + } + /// A dictionary being decoded has what the page may still hold, not all of it: refused only /// once it is stored, its copies would already have been made. #[test]