From 412fbb397ae0f8087369d5673a735f5b60778091 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Thu, 29 Oct 2026 00:50:10 -0400 Subject: [PATCH 001/124] 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 002/124] 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 003/124] 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 004/124] 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 005/124] 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 006/124] 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 007/124] 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 008/124] 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 009/124] 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 010/124] 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 011/124] 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 012/124] 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 013/124] 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 b31b40356dfcbd6c1170e1fb16e1f83f040867e8 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 17:54:15 +0530 Subject: [PATCH 014/124] fix(ui): prevent wheel lockup at edge in non-continuous view (#5713) --- crates/ui-egui/src/canvas/navigation.rs | 7 +++- crates/ui-egui/src/canvas/wheel_pager.rs | 5 +++ crates/ui-egui/tests/it/view.rs | 42 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 731b2c2586..98be96a72a 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -268,7 +268,11 @@ impl DocView { let Some(forward) = self.wheel.feed(unit, dy, phase, now, can_turn) else { return false }; let before = self.current; self.step_page(forward); - self.current != before + if self.current == before { + self.wheel.unturn(); + return false; + } + true } /// One wheel event in a non-continuous view whose page is taller than the window, where the @@ -288,6 +292,7 @@ impl DocView { let before = self.current; self.step_page(forward); if self.current == before { + self.wheel.unturn(); return false; } if !forward { diff --git a/crates/ui-egui/src/canvas/wheel_pager.rs b/crates/ui-egui/src/canvas/wheel_pager.rs index d12c445a8a..adabb8bee1 100644 --- a/crates/ui-egui/src/canvas/wheel_pager.rs +++ b/crates/ui-egui/src/canvas/wheel_pager.rs @@ -73,6 +73,11 @@ impl WheelPager { self.accum = 0.0; } + /// Reset any turned state when a turn did not happen (e.g. at document boundary). + pub(crate) fn unturn(&mut self) { + self.turned = None; + } + /// Whether the current gesture already turned a page: the rest of it must not pan the page /// just shown away from its top (or bottom). pub(crate) fn gesture_turned(&self) -> bool { diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 7f444bee0f..584cb064cb 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -520,6 +520,48 @@ fn single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does() ); } +#[test] +fn single_page_wheel_at_last_page_bottom_edge_can_scroll_back_up() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]); + h.state_mut().views[0].go_to_page(2); // Go to last page of PAGES (3 pages) + h.run_steps(20); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(4); + + // Scroll to the bottom edge of page 3 + let mut notches = 0; + let mut last = rect(&h, 2).expect("page 3 on screen").min.y; + let mut at_bottom = false; + while notches < 80 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + h.run_steps(12); + notches += 1; + if let Some(r) = rect(&h, 2) { + if (r.min.y - last).abs() < 0.01 { + at_bottom = true; + break; + } + last = r.min.y; + } + } + assert!(at_bottom, "reached bottom edge of last page"); + assert_eq!(h.state().views[0].current, 2, "remains on page 3"); + + // Push the wheel further against the bottom edge (cannot turn because it's the last page) + for _ in 0..5 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + h.run_steps(12); + } + let bottom_y = rect(&h, 2).expect("page 3 on screen").min.y; + + // Roll the wheel back up: the page must move away from the bottom edge (#5713) + h.event(wheel(MouseWheelUnit::Line, 1.0, TouchPhase::Move)); + h.run_steps(12); + let after_up_y = rect(&h, 2).expect("page 3 on screen").min.y; + assert!(after_up_y > bottom_y, "scrolling up moves the page away from the bottom edge: {after_up_y} > {bottom_y}"); +} + #[test] fn stepping_past_either_end_leaves_the_view_alone() { // Paging on past the first or last page must not jump the page back to its top. From 08af1dc39fe5ccff14f429f1087d1cc860d87ed1 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 2 Nov 2026 18:33:38 +0530 Subject: [PATCH 015/124] fix(ui): end a notch's turn when the next notch only pans (#5713) A wheel notch sets `turned`, and only a trackpad touch or a smooth burst cleared it. With a wheel that macOS reports in points, a notch that turned the page left `gesture_turned` set, so read_wheel swallowed every later notch on that page. The same happened at the last page's bottom edge. A notch that cannot turn now clears `turned`. The new tests use Point notches. The Line-unit test in b31b4035 passes without either fix, because egui's own ScrollArea still applies line scrolling. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/wheel_pager.rs | 3 ++ crates/ui-egui/tests/it/view.rs | 50 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/crates/ui-egui/src/canvas/wheel_pager.rs b/crates/ui-egui/src/canvas/wheel_pager.rs index adabb8bee1..c8ce69c57d 100644 --- a/crates/ui-egui/src/canvas/wheel_pager.rs +++ b/crates/ui-egui/src/canvas/wheel_pager.rs @@ -149,6 +149,9 @@ impl WheelPager { }; if notch { if !can_turn { + // A notch is a gesture of its own, so one that only pans ends the gesture a + // previous notch turned; otherwise `gesture_turned` keeps swallowing the wheel (#5713). + self.turned = None; return None; } // Every notch turns a page; a smooth tail of the same gesture won't add another. diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 584cb064cb..6f29132c28 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -562,6 +562,56 @@ fn single_page_wheel_at_last_page_bottom_edge_can_scroll_back_up() { assert!(after_up_y > bottom_y, "scrolling up moves the page away from the bottom edge: {after_up_y} > {bottom_y}"); } +/// One notch of a mouse wheel that macOS reports in points (a Logitech mouse sends about 14). +const POINT_NOTCH: f32 = 14.0; + +#[test] +fn single_page_point_notches_pan_the_page_a_notch_turned_to() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(20); + for _ in 0..120 { + h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + if h.state().views[0].current == 1 { + break; + } + } + assert_eq!(h.state().views[0].current, 1, "notches pan page 1, then turn to page 2"); + h.run_steps(20); + let top = rect(&h, 1).expect("page 2 on screen").min.y; + for _ in 0..3 { + h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + } + let after = rect(&h, 1).expect("page 2 on screen").min.y; + assert!(after < top, "notches pan page 2 down after the turn (#5713): {after} < {top}"); +} + +#[test] +fn single_page_point_notches_scroll_back_up_from_the_last_page_bottom() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]); + h.state_mut().views[0].go_to_page(2); + h.run_steps(20); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(4); + // Past the bottom: the extra notches push against the last page's edge. + for _ in 0..200 { + h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + } + assert_eq!(h.state().views[0].current, 2, "stays on the last page"); + let bottom = rect(&h, 2).expect("page 3 on screen").min.y; + for _ in 0..3 { + h.event(wheel(MouseWheelUnit::Point, POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + } + let after = rect(&h, 2).expect("page 3 on screen").min.y; + assert!(after > bottom + 1.0, "notches scroll back up from the last page's bottom (#5713): {after} > {bottom}"); +} + #[test] fn stepping_past_either_end_leaves_the_view_alone() { // Paging on past the first or last page must not jump the page back to its top. From 83b230ae730b9d8fe8e8d94972ea75d593a682ea Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 10:17:23 -0400 Subject: [PATCH 016/124] M12: PRC reader crate: container, bit primitives, schema (#1297) New standalone L0 crate pdfcraft-prc: the PRC container walk (file header, file-structure descriptions, per-section zlib inflation under a 512 MiB ceiling), the bit-level primitives of the compressed sections, and the schema interpreter a newer producer uses to describe fields it added (step and nesting ceilings). The Double prefix-code table is not in this commit: Double reads return UnknownDoubleCode, and the tests that need the table are ignored with that reason. Adapted from pdfcer (MIT, same author), crates/pdfcer-3d at 0c9742b5936232af89cd707138ef149dc1f4a660. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- Cargo.lock | 9 + crates/prc/Cargo.toml | 18 ++ crates/prc/README.md | 23 ++ crates/prc/src/acof.rs | 98 +++++++ crates/prc/src/bits.rs | 421 +++++++++++++++++++++++++++ crates/prc/src/container.rs | 325 +++++++++++++++++++++ crates/prc/src/error.rs | 38 +++ crates/prc/src/lib.rs | 26 ++ crates/prc/src/schema.rs | 525 ++++++++++++++++++++++++++++++++++ crates/prc/src/testw.rs | 101 +++++++ crates/prc/tests/container.rs | 114 ++++++++ crates/prc/tests/proptests.rs | 41 +++ xtask/src/layers.rs | 1 + 13 files changed, 1740 insertions(+) create mode 100644 crates/prc/Cargo.toml create mode 100644 crates/prc/README.md create mode 100644 crates/prc/src/acof.rs create mode 100644 crates/prc/src/bits.rs create mode 100644 crates/prc/src/container.rs create mode 100644 crates/prc/src/error.rs create mode 100644 crates/prc/src/lib.rs create mode 100644 crates/prc/src/schema.rs create mode 100644 crates/prc/src/testw.rs create mode 100644 crates/prc/tests/container.rs create mode 100644 crates/prc/tests/proptests.rs diff --git a/Cargo.lock b/Cargo.lock index d2f4dadad0..e5abfe499e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4551,6 +4551,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "pdfcraft-prc" +version = "0.15.0" +dependencies = [ + "flate2", + "proptest", + "thiserror 2.0.21", +] + [[package]] name = "pdfcraft-preflight" version = "0.17.0" diff --git a/crates/prc/Cargo.toml b/crates/prc/Cargo.toml new file mode 100644 index 0000000000..741c0ac5bd --- /dev/null +++ b/crates/prc/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pdfcraft-prc" +description = "PRC 3D model reader (ISO 14739-1): container, bit-level primitives, schema" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +flate2.workspace = true +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/prc/README.md b/crates/prc/README.md new file mode 100644 index 0000000000..0f8a01872f --- /dev/null +++ b/crates/prc/README.md @@ -0,0 +1,23 @@ +# pdfcraft-prc + +A reader for PRC (ISO 14739-1), the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries (ISO 32000-2 §13.6.3). + +- **Layer:** L0, standalone. No dependency on any other PdfCraft crate. +- **Licence:** MIT OR Apache-2.0. Inflation through `flate2` with the pure-Rust `miniz_oxide` backend. +- `#![forbid(unsafe_code)]`. Never panics on input: every read is bounded and returns `PrcError`. + +## API + +```rust +PrcFile::parse(data) -> Result // header, file structures, model file +PrcFile::parse_with_limit(data, max_inflated) -> Result +FileStructure::section(SectionKind) -> &[u8] // inflated globals / tree / tessellation / geometry / extra geometry +FileStructure::schema() -> Result // the globals section's leading schema +Schema::read(&mut BitReader) / Schema::skip_added_fields(entity_type, &mut BitReader) +BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_vbn, nbits_then_unsigned, string, float_as_bytes, double +``` + +- `MAX_INFLATED_BYTES` (512 MiB) bounds every section together; `TooLarge` reports the limit hit. +- Schema programs (how a newer producer describes fields it added) run under a step ceiling (2^22) and a nesting ceiling (64). +- `Unsupported` names constructs the reader cannot step over (schema pointer fields, `Value_CurveIs3D`). +- Not yet in the crate: the `Double` code table, so `BitReader::double` returns `UnknownDoubleCode`; the product tree and tessellation readers. diff --git a/crates/prc/src/acof.rs b/crates/prc/src/acof.rs new file mode 100644 index 0000000000..dff5d4a6f2 --- /dev/null +++ b/crates/prc/src/acof.rs @@ -0,0 +1,98 @@ +//! The prefix-code table behind the PRC `Double` [WD 11.17.2]. +//! +//! The table has 2077 rows: 29 frequent doubles and one row per IEEE binary64 exponent +//! (0..=2047). Each row is a code of 1 to 22 bits and the value it decodes to. +//! +//! The rows are not in this crate yet: until they are, [`lookup`] finds no code, so every +//! `Double` read fails with `UnknownDoubleCode` and nothing else changes. + +use std::collections::HashMap; +use std::sync::OnceLock; + +/// Rows a complete table has. +const ROWS: usize = 2077; + +/// The longest code, in bits. +pub(crate) const MAX_CODE_BITS: u32 = 22; + +/// What a code decodes to. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(not(test), expect(dead_code, reason = "built only by the table's rows, which are not in the crate yet"))] +pub(crate) enum Entry { + /// A frequent value, returned with the sign bit applied. + Double(f64), + /// The upper 32 bits of a binary64 (exponent set, mantissa zero); the mantissa follows + /// in the stream. + Exponent(u32), +} + +/// One row: code length, code, and what it decodes to. +type Row = (u32, u32, Entry); + +/// The table's rows. +const TABLE: &[Row] = &[]; + +fn key(nbits: u32, code: u32) -> u32 { + (nbits << 24) | code +} + +/// The rows keyed by `(nbits, code)`, or `None` when the table is incomplete or not prefix +/// free (then no `Double` decodes, rather than some decoding wrongly). +fn build(rows: &[Row]) -> Option> { + let mut map = HashMap::with_capacity(rows.len()); + for &(nbits, code, entry) in rows { + if nbits == 0 || nbits > MAX_CODE_BITS || code >> nbits != 0 || map.insert(key(nbits, code), entry).is_some() { + return None; + } + } + let prefix_free = map.keys().all(|&k| { + let (n, c) = (k >> 24, k & 0xff_ffff); + (1..n).all(|m| !map.contains_key(&key(m, c >> (n - m)))) + }); + (map.len() == ROWS && prefix_free).then_some(map) +} + +fn table() -> Option<&'static HashMap> { + static MAP: OnceLock>> = OnceLock::new(); + MAP.get_or_init(|| build(TABLE)).as_ref() +} + +/// The entry for an `nbits`-long code, if one exists. +pub(crate) fn lookup(nbits: u32, code: u32) -> Option { + table()?.get(&key(nbits, code)).copied() +} + +/// The `(nbits, code)` of IEEE exponent `e`'s row, the test writer's half of [`lookup`]. +#[cfg(test)] +pub(crate) fn exponent_code(e: u32) -> Option<(u32, u32)> { + table()?.iter().find(|(_, v)| **v == Entry::Exponent(e << 20)).map(|(&k, _)| (k >> 24, k & 0xff_ffff)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn an_incomplete_or_ambiguous_table_decodes_nothing() { + assert!(build(&[(2, 0b01, Entry::Double(0.0))]).is_none(), "one row is not the table"); + let rows: Vec = (0..ROWS as u32).map(|i| (22, i, Entry::Exponent(i))).collect(); + assert!(build(&rows).is_some(), "2077 distinct 22-bit codes are prefix free"); + let mut ambiguous = rows.clone(); + ambiguous[0] = (1, 0, Entry::Double(1.0)); // a prefix of every code starting 0 + assert!(build(&ambiguous).is_none()); + let mut too_long = rows; + too_long[0] = (2, 0b111, Entry::Double(1.0)); // a code wider than its length + assert!(build(&too_long).is_none()); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn the_table_is_complete() { + let t = table().unwrap(); + assert_eq!(t.len(), ROWS); + assert_eq!(t.values().filter(|e| matches!(e, Entry::Double(_))).count(), 29); + assert!((0..2048).all(|e| exponent_code(e).is_some())); + assert_eq!(lookup(2, 0b01), Some(Entry::Double(0.0))); + } +} diff --git a/crates/prc/src/bits.rs b/crates/prc/src/bits.rs new file mode 100644 index 0000000000..a94a16dc9a --- /dev/null +++ b/crates/prc/src/bits.rs @@ -0,0 +1,421 @@ +//! The bit-level reader for PRC compressed sections [WD 10.3, 11.1-11.17]. +//! +//! Values are consumed MSB-first from each inflated byte and straddle byte boundaries freely +//! [WD 10.3.1, 11.3]. Multi-byte integers are groups of eight bits, low group first. Two sign +//! conventions coexist: `Integer` is two's complement, the `*WithVariableBitNumber` types are +//! sign-magnitude. + +use crate::PrcError; +use crate::acof::{self, Entry}; + +/// A cursor over one inflated section. +#[derive(Debug, Clone)] +pub struct BitReader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> BitReader<'a> { + /// A reader positioned at the first bit of `data`. + pub fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + /// Bits consumed so far. + pub fn position(&self) -> usize { + self.pos + } + + /// Bits left before the end of the data. + pub fn remaining(&self) -> usize { + self.data.len().saturating_mul(8).saturating_sub(self.pos) + } + + /// Step over `n` bits of opaque data (`UserData` [WD 8.6]). + /// + /// # Errors + /// [`PrcError::Truncated`] when fewer than `n` bits remain; the cursor does not move. + pub fn skip_bits(&mut self, n: usize) -> Result<(), PrcError> { + if n > self.remaining() { + return Err(PrcError::Truncated("user data")); + } + self.pos = self.pos.saturating_add(n); + Ok(()) + } + + /// One bit; a PRC `Boolean` [WD 10.3.3]. + /// + /// # Errors + /// [`PrcError::Truncated`] at the end of the data. + pub fn bit(&mut self) -> Result { + let byte = self.data.get(self.pos / 8).ok_or(PrcError::Truncated("a bit"))?; + let b = (byte >> (7 - (self.pos % 8))) & 1 == 1; + // `pos / 8` indexed the data, so `pos + 1` is at most `8 * len`. + self.pos = self.pos.saturating_add(1); + Ok(b) + } + + /// `n` bits (at most 32), MSB-first; `UnsignedIntegerWithVariableBitNumber` [WD 11.13]. + /// + /// # Errors + /// [`PrcError::Truncated`] at the end of the data; [`PrcError::Malformed`] for `n > 32`. + pub fn bits(&mut self, n: u32) -> Result { + if n > 32 { + return Err(PrcError::Malformed(format!("{n}-bit field"))); + } + if self.remaining() < n as usize { + return Err(PrcError::Truncated("a bit field")); + } + let mut v: u64 = 0; + for _ in 0..n { + v = (v << 1) | u64::from(self.bit()?); + } + // n <= 32, so v fits. + Ok(u32::try_from(v).unwrap_or(u32::MAX)) + } + + fn byte(&mut self) -> Result { + Ok(u8::try_from(self.bits(8)?).unwrap_or(u8::MAX)) + } + + /// A `Character`: one byte [WD 10.3.4]. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn character(&mut self) -> Result { + self.byte() + } + + /// An `UnsignedInteger` [WD 11.10]: while a 1 bit precedes it, one more eight-bit group, + /// low group first. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups. + pub fn unsigned_integer(&mut self) -> Result { + let mut v: u32 = 0; + let mut shift = 0; + while self.bit()? { + if shift >= 32 { + return Err(PrcError::Malformed("UnsignedInteger over 32 bits".into())); + } + v |= u32::from(self.byte()?) << shift; + shift += 8; + } + Ok(v) + } + + /// An `Integer` [WD 11.11]: a 0 bit is zero; otherwise eight-bit groups, low group first, + /// each followed by a continue bit, sign-extended from the last group's top bit. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups. + pub fn integer(&mut self) -> Result { + if !self.bit()? { + return Ok(0); + } + let mut v: u32 = 0; + let mut shift = 0; + loop { + if shift >= 32 { + return Err(PrcError::Malformed("Integer over 32 bits".into())); + } + let group = self.byte()?; + v |= u32::from(group) << shift; + shift += 8; + if !self.bit()? { + if group & 0x80 != 0 && shift < 32 { + v |= u32::MAX << shift; + } + return Ok(v.cast_signed()); + } + } + } + + /// An `IntegerWithVariableBitNumber(n)` [WD 11.12]: a negative flag (1 = negative; the WD + /// prose says the opposite, its sample code and real files agree on this), then the + /// magnitude in `n - 1` bits. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] for `n > 33`. + pub fn integer_vbn(&mut self, n: u32) -> Result { + let Some(magnitude_bits) = n.checked_sub(1) else { + return Ok(0); + }; + let negative = self.bit()?; + let m = i64::from(self.bits(magnitude_bits)?); + Ok(if negative { -m } else { m }) + } + + /// A `DoubleWithVariableBitNumber(tolerance, n)` [WD 11.14]: a sign-magnitude quantum + /// count times `tolerance`. + /// + /// # Errors + /// As [`Self::integer_vbn`]. + pub fn double_vbn(&mut self, tolerance: f64, n: u32) -> Result { + Ok(self.integer_vbn(n)? as f64 * tolerance) + } + + /// `NumberOfBitsThenUnsignedInteger` [WD 11.15]: a 5-bit width, then the value in that + /// many bits. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn nbits_then_unsigned(&mut self) -> Result { + let n = self.bits(5)?; + self.bits(n) + } + + /// A `String` [WD 10.3.7, 11.4]: `None` for the null string, else UTF-8 (invalid + /// sequences replaced). + /// + /// # Errors + /// [`PrcError::Truncated`] when the byte count runs past the data. + pub fn string(&mut self) -> Result, PrcError> { + if !self.bit()? { + return Ok(None); + } + let len = self.unsigned_integer()? as usize; + if len.saturating_mul(8) > self.remaining() { + return Err(PrcError::Truncated("a String")); + } + let bytes = (0..len).map(|_| self.byte()).collect::, _>>()?; + Ok(Some(String::from_utf8_lossy(&bytes).into_owned())) + } + + /// `FloatAsBytes` [WD 11.5]: an IEEE binary32, least-significant byte first. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn float_as_bytes(&mut self) -> Result { + let b = [self.byte()?, self.byte()?, self.byte()?, self.byte()?]; + Ok(f32::from_le_bytes(b)) + } + + /// A `Double` [WD 11.17]: a prefix code from the Double table, then a sign bit and, for + /// an exponent code, a byte-wise mantissa with back-references. + /// + /// The two-bit zero code has no sign bit. Back-reference offsets 1 to 5 are all accepted, + /// distance 1 included (PDF Association pdf-issues #769). + /// + /// # Errors + /// [`PrcError::UnknownDoubleCode`], [`PrcError::Truncated`], or [`PrcError::Malformed`] + /// for a back-reference outside the value. + pub fn double(&mut self) -> Result { + let mut code = 0u32; + let mut entry = None; + for n in 1..=acof::MAX_CODE_BITS { + code = (code << 1) | u32::from(self.bit()?); + if let Some(e) = acof::lookup(n, code) { + if n == 2 && code == 0b01 { + return Ok(0.0); + } + entry = Some(e); + break; + } + } + let entry = entry.ok_or(PrcError::UnknownDoubleCode)?; + let negative = self.bit()?; + let upper = match entry { + Entry::Double(v) => return Ok(if negative { -v } else { v }), + Entry::Exponent(upper) => upper, + }; + let mut b = (u64::from(upper) << 32).to_le_bytes(); + if self.bit()? { + self.mantissa(&mut b)?; + } + let sign = if negative { 1u64 << 63 } else { 0 }; + Ok(f64::from_bits(u64::from_le_bytes(b) | sign)) + } + + /// Mantissa bits 51..0 into `b` (little-endian byte order): a nibble, then bytes 5 down + /// to 0, each a literal or a back-reference to a byte already set. + fn mantissa(&mut self, b: &mut [u8; 8]) -> Result<(), PrcError> { + let nibble = u8::try_from(self.bits(4)?).unwrap_or(0); + let [.., b6, _] = b; + *b6 |= nibble; + let at = |b: &[u8; 8], i: usize| b.get(i).copied().ok_or_else(|| PrcError::Malformed("Double back-reference past the value".into())); + for cbi in (0..=5usize).rev() { + let prev = at(b, cbi + 1)?; + let value = if self.bit()? { + self.byte()? + } else { + match self.bits(3)? { + // Fill this byte and every lower one with the previous byte. + 0 => { + b.iter_mut().take(cbi + 1).for_each(|x| *x = prev); + return Ok(()); + } + // As 0, but the lowest byte is a literal. + 6 => { + b.iter_mut().take(cbi + 1).skip(1).for_each(|x| *x = prev); + let low = self.byte()?; + let [b0, ..] = b; + *b0 = low; + return Ok(()); + } + off @ 1..=5 => at(b, cbi + off as usize)?, + _ => return Err(PrcError::Malformed("Double mantissa offset 7".into())), + } + }; + if let Some(x) = b.get_mut(cbi) { + *x = value; + } + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::testw::W; + + /// The code for IEEE exponent `e`. + fn exponent_code(e: u32) -> (u32, u32) { + acof::exponent_code(e).expect("the table has a row per exponent") + } + + fn read_double(w: &W) -> f64 { + BitReader::new(&w.bytes()).double().unwrap() + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn zero_is_the_two_bit_code_with_no_sign() { + let mut w = W::default(); + w.put(0b01, 2).put(0b1111_1111, 8); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.double().unwrap(), 0.0); + assert_eq!(r.position(), 2); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn exponent_with_literal_mantissa() { + let v = -1234.5678f64; + let le = v.to_bits().to_le_bytes(); + let e = u32::try_from((v.to_bits() >> 52) & 0x7ff).unwrap(); + let (n, c) = exponent_code(e); + let mut w = W::default(); + w.put(u64::from(c), n).put(1, 1).put(1, 1).put(u64::from(le[6] & 0x0f), 4); + for byte in le[..6].iter().rev() { + w.put(1, 1).put(u64::from(*byte), 8); + } + assert_eq!(read_double(&w), v); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn exponent_without_mantissa_is_a_power_of_two() { + let (n, c) = exponent_code(1023 + 3); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(0, 1); + assert_eq!(read_double(&w), 8.0); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn fill_back_reference_and_literal_low_byte() { + // Mantissa bytes 6..0 = 0x05, 0xAA, 0xAA (offset 1), 0xAA (offset 2), fill 0xAA to byte 1, byte 0 = 0x11. + let (n, c) = exponent_code(1023); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4); + w.put(1, 1).put(0xAA, 8); + w.put(0, 1).put(1, 3); + w.put(0, 1).put(2, 3); + w.put(0, 1).put(6, 3).put(0x11, 8); + assert_eq!(read_double(&w), f64::from_le_bytes([0x11, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xF5, 0x3F])); + + // Byte 5 literal, then fill 4..0. + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4); + w.put(1, 1).put(0x77, 8).put(0, 1).put(0, 3); + assert_eq!(read_double(&w), f64::from_le_bytes([0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0xF5, 0x3F])); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn a_back_reference_past_the_value_is_refused() { + let (n, c) = exponent_code(1023); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0, 4); + w.put(0, 1).put(5, 3).put(0, 16); // byte 5 + offset 5 = byte 10 + assert!(matches!(BitReader::new(&w.bytes()).double(), Err(PrcError::Malformed(_)))); + } + + #[test] + fn without_the_table_a_double_is_an_error_not_a_panic() { + let mut r = BitReader::new(&[0x40, 0, 0]); + assert_eq!(r.double(), Err(PrcError::UnknownDoubleCode)); + assert!(matches!(BitReader::new(&[0x40]).double(), Err(PrcError::Truncated(_)))); + } + + #[test] + fn integers_and_strings() { + let mut w = W::default(); + // UnsignedInteger 300 = groups 0x2C, 0x01. + w.put(1, 1).put(0x2C, 8).put(1, 1).put(0x01, 8).put(0, 1); + // Integer -2 = group 0xFE, stop. + w.put(1, 1).put(0xFE, 8).put(0, 1); + // Integer 200 = 0xC8 then 0x00 (top bit clear), stop. + w.put(1, 1).put(0xC8, 8).put(1, 1).put(0x00, 8).put(0, 1); + // IntegerWithVariableBitNumber(4) = -5. + w.put(1, 1).put(5, 3); + // String "hé". + w.put(1, 1).put(1, 1).put(3, 8).put(0, 1).put(0x68, 8).put(0xC3, 8).put(0xA9, 8); + // Null string. + w.put(0, 1); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.unsigned_integer().unwrap(), 300); + assert_eq!(r.integer().unwrap(), -2); + assert_eq!(r.integer().unwrap(), 200); + assert_eq!(r.integer_vbn(4).unwrap(), -5); + assert_eq!(r.string().unwrap().as_deref(), Some("hé")); + assert_eq!(r.string().unwrap(), None); + } + + #[test] + fn the_writer_round_trips_through_the_reader() { + let mut w = W::default(); + w.uint(0).uint(u32::MAX).int(-129).int(i32::MIN).int(i32::MAX).string(Some("glass")).string(None); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.unsigned_integer().unwrap(), 0); + assert_eq!(r.unsigned_integer().unwrap(), u32::MAX); + assert_eq!(r.integer().unwrap(), -129); + assert_eq!(r.integer().unwrap(), i32::MIN); + assert_eq!(r.integer().unwrap(), i32::MAX); + assert_eq!(r.string().unwrap().as_deref(), Some("glass")); + assert_eq!(r.string().unwrap(), None); + } + + #[test] + fn oversized_groups_and_widths_are_malformed() { + // Five UnsignedInteger groups. + let mut w = W::default(); + (0..5).for_each(|_| { + w.put(1, 1).put(0xFF, 8); + }); + w.put(0, 1); + assert!(matches!(BitReader::new(&w.bytes()).unsigned_integer(), Err(PrcError::Malformed(_)))); + assert!(matches!(BitReader::new(&[0; 8]).bits(33), Err(PrcError::Malformed(_)))); + assert!(matches!(BitReader::new(&[0; 8]).integer_vbn(34), Err(PrcError::Malformed(_)))); + assert_eq!(BitReader::new(&[]).integer_vbn(0), Ok(0)); + } + + #[test] + fn truncation_is_an_error_not_a_panic() { + let mut r = BitReader::new(&[0xFF]); + assert!(r.unsigned_integer().is_err()); + let mut r = BitReader::new(&[0xC0, 0x40]); + assert!(matches!(r.string(), Err(PrcError::Truncated(_)))); + let mut r = BitReader::new(&[0xAB]); + assert!(matches!(r.skip_bits(9), Err(PrcError::Truncated(_)))); + assert_eq!(r.position(), 0, "a refused skip does not move the cursor"); + r.skip_bits(8).unwrap(); + assert_eq!(r.remaining(), 0); + } +} diff --git a/crates/prc/src/container.rs b/crates/prc/src/container.rs new file mode 100644 index 0000000000..8a44921334 --- /dev/null +++ b/crates/prc/src/container.rs @@ -0,0 +1,325 @@ +//! The PRC container: file header, file-structure descriptions, section extents and +//! inflation [WD 6.1-6.2, 10.2]. +//! +//! Section extents are not stored: each section runs to the next one's start offset, and the +//! last one to the model file's start. Inflation stops at the zlib stream's end, so padding +//! between sections is tolerated. + +use std::io::Read; + +use crate::PrcError; +use crate::bits::BitReader; +use crate::schema::Schema; + +/// The PRC version this reader implements. +pub const PRC_READER_VERSION: u32 = 8137; + +/// The most file structures [`PrcFile::parse`] accepts. +pub const MAX_FILE_STRUCTURES: usize = 65_536; + +/// Ceiling on the total inflated size of every section in one PRC stream. +pub const MAX_INFLATED_BYTES: usize = 512 * 1024 * 1024; + +/// Sections per file structure [WD 6.1.2]: its header, then the five in [`SectionKind`] +/// order. +const SECTIONS: usize = 6; + +/// A 128-bit PRC identifier [WD 10.2.4]: four little-endian `u32`s. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct UniqueId(pub [u32; 4]); + +/// The compressed sections of one file structure, in file order [WD 6.1.2, 6.2.1]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SectionKind { + /// Starts with the file structure's schema, then its globals entity. + Globals, + /// The product-structure tree. + Tree, + /// Tessellated (mesh) data. + Tessellation, + /// Exact (B-rep) geometry. + Geometry, + /// Supplementary geometry. + ExtraGeometry, +} + +impl SectionKind { + /// Every kind, in file order. + pub const ALL: [SectionKind; 5] = + [SectionKind::Globals, SectionKind::Tree, SectionKind::Tessellation, SectionKind::Geometry, SectionKind::ExtraGeometry]; + + /// The section's name, as used in errors. + pub fn name(self) -> &'static str { + match self { + SectionKind::Globals => "globals", + SectionKind::Tree => "tree", + SectionKind::Tessellation => "tessellation", + SectionKind::Geometry => "geometry", + SectionKind::ExtraGeometry => "extra geometry", + } + } + + fn index(self) -> usize { + match self { + SectionKind::Globals => 0, + SectionKind::Tree => 1, + SectionKind::Tessellation => 2, + SectionKind::Geometry => 3, + SectionKind::ExtraGeometry => 4, + } + } +} + +/// The uncompressed file header [WD 6.1.1]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PrcHeader { + /// The oldest reader version the producer says can read the file. + pub min_version_for_read: u32, + /// The producer's PRC version. + pub authoring_version: u32, + /// The file's identifier. + pub file_id: UniqueId, + /// The producing application's identifier (all zero = unregistered). + pub application_id: UniqueId, +} + +/// One file structure: its uncompressed header fields and its five inflated sections +/// [WD 6.2]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct FileStructure { + /// The identifier; equals the file header's description of it. + pub id: UniqueId, + /// Per-structure reader version floor; may differ from the file's. + pub min_version_for_read: u32, + /// Per-structure producer version. + pub authoring_version: u32, + /// Picture payloads (JPEG, PNG, ...) carried uncompressed in the header, referenced by + /// index from picture entities [WD 6.2.2]. + pub pictures: Vec>, + sections: [Vec; 5], +} + +impl FileStructure { + /// The inflated bytes of one section. + pub fn section(&self, kind: SectionKind) -> &[u8] { + self.sections.get(kind.index()).map_or(&[], Vec::as_slice) + } + + /// The globals section's leading schema: the fields producers newer than this reader + /// added to each entity type [WD 6.3]. + /// + /// # Errors + /// As [`Schema::read`]. + pub fn schema(&self) -> Result { + Schema::read(&mut BitReader::new(self.section(SectionKind::Globals))) + } +} + +/// A parsed PRC stream: header, file structures and model-file section. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PrcFile { + /// The file header. + pub header: PrcHeader, + /// File structures in physical order (referenced before referencing). + pub file_structures: Vec, + /// The inflated model-file section: its schema, then the model file entity. + pub model_file: Vec, + /// The `UncompressedFiles` blocks after the file header [WD 6.1.1], which picture + /// entities may also reference. + pub uncompressed_files: Vec>, +} + +/// A little-endian byte cursor over the uncompressed parts of the stream. +struct Bytes<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Bytes<'a> { + fn take(&mut self, n: usize, what: &'static str) -> Result<&'a [u8], PrcError> { + let end = self.pos.checked_add(n).ok_or(PrcError::Truncated(what))?; + let s = self.data.get(self.pos..end).ok_or(PrcError::Truncated(what))?; + self.pos = end; + Ok(s) + } + + fn u32(&mut self, what: &'static str) -> Result { + let s = self.take(4, what)?; + let b: [u8; 4] = s.try_into().map_err(|_| PrcError::Truncated(what))?; + Ok(u32::from_le_bytes(b)) + } + + fn id(&mut self, what: &'static str) -> Result { + Ok(UniqueId([self.u32(what)?, self.u32(what)?, self.u32(what)?, self.u32(what)?])) + } + + fn magic(&mut self) -> Result<(), PrcError> { + match self.take(3, "magic") { + Ok(b"PRC") => Ok(()), + _ => Err(PrcError::NotPrc), + } + } + + /// `UncompressedBlock`s: a count, then size-prefixed blocks [WD 10.2.2-3]. + fn blocks(&mut self, what: &'static str) -> Result>, PrcError> { + let n = offset(self.u32(what)?); + // Each block needs at least its 4-byte size, so `n` is bounded by the data left. + if n > self.data.len().saturating_sub(self.pos) / 4 { + return Err(PrcError::Truncated(what)); + } + (0..n) + .map(|_| { + let len = offset(self.u32(what)?); + Ok(self.take(len, what)?.to_vec()) + }) + .collect() + } +} + +/// A stored `u32` offset or count as a `usize` (saturating on a 16-bit target, where no data +/// that large could exist). +fn offset(v: u32) -> usize { + usize::try_from(v).unwrap_or(usize::MAX) +} + +/// One file structure description of the file header [WD 6.2]: its id and the offsets of +/// its sections. +fn file_structure_description(r: &mut Bytes) -> Result<(UniqueId, Vec), PrcError> { + let id = r.id("file structure description")?; + if r.u32("file structure description")? != 0 { + return Err(PrcError::Malformed("file structure reserved field is not 0".into())); + } + let count = offset(r.u32("file structure description")?); + if count < SECTIONS { + return Err(PrcError::Malformed(format!("{count} sections in a file structure"))); + } + let mut offs = Vec::with_capacity(SECTIONS); + // Offsets past the six this reader uses are read and dropped; each read is bounded by + // the data, so a huge `count` ends in `Truncated`. + for i in 0..count { + let o = r.u32("section offset")?; + if i < SECTIONS { + offs.push(offset(o)); + } + } + Ok((id, offs)) +} + +/// Where one file structure's parts lie: its section offsets and the end of its last section. +struct Extent<'a> { + offs: &'a [usize], + last: usize, +} + +impl Extent<'_> { + fn bound(&self, i: usize) -> usize { + self.offs.get(i).copied().unwrap_or(self.last) + } +} + +/// The file structure `id` lying at `extent`: its header and its inflated sections, charged +/// to `budget`. +fn file_structure(data: &[u8], id: UniqueId, extent: &Extent, budget: &mut Budget) -> Result { + if extent.offs.windows(2).any(|w| w.first() > w.get(1)) || extent.bound(SECTIONS - 1) > extent.last { + return Err(PrcError::Malformed("file structure section offsets are not ascending".into())); + } + let header = data.get(..extent.bound(1)).ok_or(PrcError::Truncated("file structure header"))?; + let mut h = Bytes { data: header, pos: extent.bound(0) }; + h.magic()?; + let min_version_for_read = h.u32("file structure header")?; + let authoring_version = h.u32("file structure header")?; + if h.id("file structure header")? != id { + return Err(PrcError::Malformed("file structure id differs from its description".into())); + } + let _application = h.id("file structure header")?; + let pictures = h.blocks("file structure pictures")?; + let mut sections: [Vec; 5] = Default::default(); + for (i, (slot, kind)) in sections.iter_mut().zip(SectionKind::ALL).enumerate() { + let end = if i + 2 < SECTIONS { extent.bound(i + 2) } else { extent.last }; + *slot = budget.inflate(data, (extent.bound(i + 1), end), kind.name())?; + } + Ok(FileStructure { id, min_version_for_read, authoring_version, pictures, sections }) +} + +/// What is left of the inflation ceiling, and the ceiling itself for the error. +struct Budget { + left: usize, + limit: usize, +} + +impl Budget { + /// The zlib stream in `data[range]`, its size charged to the budget. + fn inflate(&mut self, data: &[u8], range: (usize, usize), section: &'static str) -> Result, PrcError> { + let (start, end) = range; + if start == end { + return Ok(Vec::new()); + } + let src = data.get(start..end).ok_or_else(|| PrcError::Malformed(format!("{section} section {start}..{end} is outside the data")))?; + let mut out = Vec::new(); + // One byte past the budget is enough to know it was exceeded. + let cap = u64::try_from(self.left).unwrap_or(u64::MAX).saturating_add(1); + flate2::read::ZlibDecoder::new(src).take(cap).read_to_end(&mut out).map_err(|e| PrcError::Inflate { section, reason: e.to_string() })?; + self.left = self.left.checked_sub(out.len()).ok_or(PrcError::TooLarge { limit: self.limit })?; + Ok(out) + } +} + +impl PrcFile { + /// Parse a PRC stream (the decoded data of a `/3D` stream with `/Subtype /PRC`). + /// + /// Newer `min_version_for_read` values are not refused: the fields read here are + /// unchanged across published versions, and the caller sees the versions in + /// [`PrcHeader`]. + /// + /// # Errors + /// [`PrcError::NotPrc`] without the magic; [`PrcError::Truncated`] or + /// [`PrcError::Malformed`] for a damaged header or offset table; [`PrcError::Inflate`] + /// for a section that is not zlib; [`PrcError::TooLarge`] past [`MAX_INFLATED_BYTES`] + /// in total. + /// + /// # Examples + /// ``` + /// use pdfcraft_prc::{PrcError, PrcFile}; + /// assert_eq!(PrcFile::parse(b"%PDF"), Err(PrcError::NotPrc)); + /// ``` + pub fn parse(data: &[u8]) -> Result { + Self::parse_with_limit(data, MAX_INFLATED_BYTES) + } + + /// [`Self::parse`] with a caller-chosen ceiling on the total inflated size, for callers + /// with less memory to spend than [`MAX_INFLATED_BYTES`]. + /// + /// # Errors + /// As [`Self::parse`]; [`PrcError::TooLarge`] carries `limit`. + pub fn parse_with_limit(data: &[u8], limit: usize) -> Result { + let mut r = Bytes { data, pos: 0 }; + r.magic()?; + let header = PrcHeader { + min_version_for_read: r.u32("file header")?, + authoring_version: r.u32("file header")?, + file_id: r.id("file header")?, + application_id: r.id("file header")?, + }; + let n_fs = offset(r.u32("file structure count")?); + if n_fs == 0 || n_fs > MAX_FILE_STRUCTURES { + return Err(PrcError::Malformed(format!("{n_fs} file structures"))); + } + let descriptions = (0..n_fs).map(|_| file_structure_description(&mut r)).collect::, _>>()?; + let mf_start = offset(r.u32("model file offsets")?); + let mf_end = offset(r.u32("model file offsets")?); + let uncompressed_files = r.blocks("uncompressed files")?; + if mf_end < mf_start { + return Err(PrcError::Malformed("model file ends before it starts".into())); + } + + let mut budget = Budget { left: limit, limit }; + let last = mf_start.min(data.len()); + let file_structures = + descriptions.iter().map(|(id, offs)| file_structure(data, *id, &Extent { offs, last }, &mut budget)).collect::, _>>()?; + let model_file = budget.inflate(data, (mf_start, mf_end.min(data.len())), "model file")?; + Ok(PrcFile { header, file_structures, model_file, uncompressed_files }) + } +} diff --git a/crates/prc/src/error.rs b/crates/prc/src/error.rs new file mode 100644 index 0000000000..eaad2bb6e1 --- /dev/null +++ b/crates/prc/src/error.rs @@ -0,0 +1,38 @@ +//! The crate's error type. + +/// Why a PRC stream could not be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum PrcError { + /// The data does not start with the `PRC` magic [WD 6.1.1]. + #[error("not a PRC stream: missing the \"PRC\" magic")] + NotPrc, + /// A value ran past the end of its byte or bit stream. + #[error("PRC data truncated while reading {0}")] + Truncated(&'static str), + /// A structural field holds a value the format forbids. + #[error("malformed PRC: {0}")] + Malformed(String), + /// No `Double` code of up to 22 bits matched [WD 11.17]. + #[error("PRC Double: no code matches the next 22 bits")] + UnknownDoubleCode, + /// A section is not a valid zlib stream. + #[error("PRC {section} section does not inflate: {reason}")] + Inflate { + /// The section's name. + section: &'static str, + /// The decoder's message. + reason: String, + }, + /// The inflated sections together exceed the ceiling ([`crate::MAX_INFLATED_BYTES`] or + /// the caller's limit). + #[error("PRC sections inflate past the {limit}-byte ceiling")] + TooLarge { + /// The ceiling that was hit. + limit: usize, + }, + /// A construct this reader does not decode; the part of the file holding it cannot be + /// read past. + #[error("PRC {0} is not supported")] + Unsupported(&'static str), +} diff --git a/crates/prc/src/lib.rs b/crates/prc/src/lib.rs new file mode 100644 index 0000000000..1a62f9f272 --- /dev/null +++ b/crates/prc/src/lib.rs @@ -0,0 +1,26 @@ +//! A reader for PRC, the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries +//! (ISO 32000-2 §13.6.3, ISO 14739-1). +//! +//! Layer L0, standalone: this crate depends on no other PdfCraft crate. It reads the +//! uncompressed container, inflates each section, and decodes the bit-level primitives and +//! the schema a newer producer uses to describe fields this reader does not know. +//! +//! Clause numbers in `[WD x.y]` refer to the PRC working draft, ISO/TC 171/SC 2 N570. +//! +//! Every read is bounded: inflation by [`MAX_INFLATED_BYTES`] (or a caller's limit), schema +//! programs by a step and depth ceiling. Damaged data is an error, never a panic. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod acof; +mod bits; +mod container; +mod error; +mod schema; +#[cfg(test)] +mod testw; + +pub use bits::BitReader; +pub use container::{FileStructure, MAX_FILE_STRUCTURES, MAX_INFLATED_BYTES, PRC_READER_VERSION, PrcFile, PrcHeader, SectionKind, UniqueId}; +pub use error::PrcError; +pub use schema::Schema; diff --git a/crates/prc/src/schema.rs b/crates/prc/src/schema.rs new file mode 100644 index 0000000000..a93554334b --- /dev/null +++ b/crates/prc/src/schema.rs @@ -0,0 +1,525 @@ +//! `FileStructureSchema` and its token interpreter [WD 6.3, 9]. +//! +//! A producer newer than the reader describes, per entity type, the fields it appended; the +//! reader runs that program after the fields it knows, to step over the rest [WD 9.1]. Token +//! values follow the WD 9.2 table as corrected by PDF Association pdf-issues #407 +//! (`Block_Start` = 19; 39 and 40 swallow one token). +//! +//! Choices the sources leave open, made here: +//! - `SimpleFor` reads its count as an `UnsignedInteger` [WD 9.3.17 pseudocode; pdf-issues +//! #575]: that read also decodes an `Integer`-written non-negative count, while a signed +//! read does not decode an unsigned one whose top group is 0x80 or higher. +//! - `Block_Version V` runs its block only when `V` is newer than +//! [`crate::PRC_READER_VERSION`]: an older block holds fields this reader already reads +//! natively [WD 9.3.20]. +//! - `Extent_1D` and `Extent_2D` read 2 and 4 `Double`s: `Interval` and `Domain` +//! [WD 8.2.2, 8.2.4]. +//! - Expression values are `i64`; a `Double` read as an operand truncates (NaN reads as 0). +//! - Variables are not block-scoped: a program that reuses an index after its block ends +//! reads the stale value instead of an error. +//! - Pointer tokens (12-14) and `Value_CurveIs3D` (28) need the exact-geometry reader and are +//! refused with [`PrcError::Unsupported`]. + +use std::collections::HashMap; + +use crate::PrcError; +use crate::bits::BitReader; + +/// Steps (tokens executed, loop iterations included) one +/// [`Schema::skip_added_fields`] call may take. +const MAX_STEPS: u64 = 1 << 22; + +/// Nesting depth of blocks, loops, expressions and `Father_Type` calls. +const MAX_DEPTH: u32 = 64; + +/// The per-entity-type programs at the start of a globals or model-file +/// bitstream [WD 6.3.1]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Schema { + programs: HashMap>, +} + +impl Schema { + /// Read a schema: `UnsignedInteger` count, then per entry the entity + /// type, a token count and the tokens [WD 6.3.2]. + /// + /// # Errors + /// [`PrcError::Truncated`] when a count runs past the data; + /// [`PrcError::Malformed`] for an entity type listed twice. + pub fn read(r: &mut BitReader<'_>) -> Result { + let n = r.unsigned_integer()? as usize; + // Every entry takes at least two bits. + if n > r.remaining() / 2 { + return Err(PrcError::Truncated("the schema")); + } + let mut programs = HashMap::new(); + for _ in 0..n { + let entity_type = r.unsigned_integer()?; + let len = r.unsigned_integer()? as usize; + if len > r.remaining() { + return Err(PrcError::Truncated("a schema program")); + } + let tokens = (0..len).map(|_| r.unsigned_integer()).collect::, _>>()?; + if programs.insert(entity_type, tokens).is_some() { + return Err(PrcError::Malformed(format!("schema lists entity type {entity_type} twice"))); + } + } + Ok(Self { programs }) + } + + /// True when no entity type has added fields (the common case: the file + /// was written at the version this reader implements). + pub fn is_empty(&self) -> bool { + self.programs.is_empty() + } + + /// The entity types the schema describes, unordered. + pub fn entity_types(&self) -> impl Iterator + '_ { + self.programs.keys().copied() + } + + /// Consume the fields a newer producer appended to `entity_type`, if the + /// schema describes it; a no-op otherwise. + /// + /// # Errors + /// [`PrcError::Unsupported`] for a token that needs exact geometry; + /// [`PrcError::Malformed`] for an ill-formed program or one past the step + /// or depth ceiling; any read error from `r`. + pub fn skip_added_fields(&self, entity_type: u32, r: &mut BitReader<'_>) -> Result<(), PrcError> { + let Some(program) = self.programs.get(&entity_type) else { + return Ok(()); + }; + let mut exec = Exec { schema: self, r, vars: HashMap::new(), loop_counter: 0, steps: 0 }; + exec.program(program, 0) + } +} + +struct Exec<'s, 'r, 'd> { + schema: &'s Schema, + r: &'r mut BitReader<'d>, + vars: HashMap, + loop_counter: i64, + steps: u64, +} + +fn malformed(what: &str) -> PrcError { + PrcError::Malformed(format!("schema program: {what}")) +} + +/// The token at `*pos`, advancing past it. +fn next(prog: &[u32], pos: &mut usize) -> Result { + let t = prog.get(*pos).copied().ok_or_else(|| malformed("ends mid-statement"))?; + *pos += 1; + Ok(t) +} + +impl Exec<'_, '_, '_> { + fn tick(&mut self, depth: u32) -> Result<(), PrcError> { + self.steps += 1; + if self.steps > MAX_STEPS { + return Err(malformed("step ceiling reached")); + } + if depth > MAX_DEPTH { + return Err(malformed("nesting ceiling reached")); + } + Ok(()) + } + + fn program(&mut self, prog: &[u32], depth: u32) -> Result<(), PrcError> { + let mut pos = 0; + while pos < prog.len() { + self.stmt(prog, &mut pos, true, depth)?; + } + Ok(()) + } + + /// One data read for tokens 0-5 and 7-11, returning its value as an + /// operand. + fn read(&mut self, token: u32) -> Result { + let doubles = match token { + 0 => return Ok(i64::from(self.r.bit()?)), + 1 => return Ok(self.r.double()? as i64), + 2 => return Ok(i64::from(self.r.character()?)), + 3 => return Ok(i64::from(self.r.unsigned_integer()?)), + 4 => return Ok(i64::from(self.r.integer()?)), + 5 => { + self.r.string()?; + return Ok(0); + } + 7 => 2, + 8 => 3, + 9 => 2, + 10 => 4, + 11 => 6, + _ => return Err(malformed("not a data token")), + }; + for _ in 0..doubles { + self.r.double()?; + } + Ok(0) + } + + /// Statements until `Block_End`, which is consumed. + fn block(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + loop { + if prog.get(*pos) == Some(&21) { + *pos += 1; + return Ok(()); + } + self.stmt(prog, pos, run, depth + 1)?; + } + } + + /// Run `body` (one statement at `*pos`) `count` times; `*pos` ends past it. + fn repeat(&mut self, prog: &[u32], pos: &mut usize, count: i64, run: bool, depth: u32) -> Result<(), PrcError> { + let start = *pos; + self.stmt(prog, pos, false, depth + 1)?; + if run { + let saved = self.loop_counter; + for i in 0..count.max(0) { + self.tick(depth)?; + self.loop_counter = i; + let mut p = start; + self.stmt(prog, &mut p, true, depth + 1)?; + } + self.loop_counter = saved; + } + Ok(()) + } + + /// One statement; with `run` false, only step over its tokens. + fn stmt(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + self.tick(depth)?; + match next(prog, pos)? { + t @ (0..=5 | 7..=11) => { + if run { + self.read(t)?; + } + } + 6 => self.parent_program(prog, pos, run, depth)?, + 12 => { + next(prog, pos)?; + if run { + return Err(PrcError::Unsupported("a schema pointer field")); + } + } + 13 | 14 => { + if run { + return Err(PrcError::Unsupported("a schema pointer field")); + } + } + 15 => { + let count = self.expr(prog, pos, run, depth + 1)?; + self.repeat(prog, pos, count, run, depth)?; + } + 16 => { + let count = if run { i64::from(self.r.unsigned_integer()?) } else { 0 }; + self.repeat(prog, pos, count, run, depth)?; + } + 17 => self.conditional(prog, pos, run, depth)?, + 19 => self.block(prog, pos, run, depth)?, + 20 => { + let version = next(prog, pos)?; + let newer = version > crate::PRC_READER_VERSION; + self.block(prog, pos, run && newer, depth)?; + } + 22 => { + let v = next(prog, pos)?; + if run { + self.vars.insert(v, 0); + } + } + 23 | 24 => { + let v = next(prog, pos)?; + let value = self.expr(prog, pos, run, depth + 1)?; + if run { + self.vars.insert(v, value); + } + } + 39 | 40 => { + next(prog, pos)?; + } + t => return Err(malformed(&format!("token {t} where a statement belongs"))), + } + Ok(()) + } + + /// Token 6: run the named type's own program, the fields it inherits. + fn parent_program(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + let parent = next(prog, pos)?; + if run && let Some(p) = self.schema.programs.get(&parent) { + self.tick(depth + 1)?; + let mut q = 0; + while q < p.len() { + self.stmt(p, &mut q, true, depth + 1)?; + } + } + Ok(()) + } + + /// Token 17: `if` with an optional token-18 `else`. + fn conditional(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + let cond = self.expr(prog, pos, run, depth + 1)? != 0; + self.stmt(prog, pos, run && cond, depth + 1)?; + if prog.get(*pos) == Some(&18) { + *pos += 1; + self.stmt(prog, pos, run && !cond, depth + 1)?; + } + Ok(()) + } + + /// One expression; with `run` false its value is 0 and nothing is read. + fn expr(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result { + self.tick(depth)?; + let t = next(prog, pos)?; + match t { + 0..=4 => { + if run { + self.read(t) + } else { + Ok(0) + } + } + 25 => { + let v = next(prog, pos)?; + if !run { + return Ok(0); + } + self.vars.get(&v).copied().ok_or_else(|| malformed(&format!("variable {v} read before it is set"))) + } + 26 => Ok(i64::from(next(prog, pos)?)), + 27 => Ok(self.loop_counter), + 28 => { + if run { + Err(PrcError::Unsupported("Value_CurveIs3D")) + } else { + Ok(0) + } + } + 29..=38 => { + let a = self.expr(prog, pos, run, depth + 1)?; + let b = self.expr(prog, pos, run, depth + 1)?; + if !run { + return Ok(0); + } + Ok(match t { + 29 => a.wrapping_mul(b), + 30 => a.checked_div(b).ok_or_else(|| malformed("division by zero"))?, + 31 => a.wrapping_add(b), + 32 => a.wrapping_sub(b), + 33 => i64::from(a < b), + 34 => i64::from(a <= b), + 35 => i64::from(a > b), + 36 => i64::from(a >= b), + 37 => i64::from(a == b), + _ => i64::from(a != b), + }) + } + t => Err(malformed(&format!("token {t} where a value belongs"))), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::testw::W; + + fn schema(entries: &[(u32, &[u32])]) -> W { + let mut w = W::default(); + w.uint(entries.len() as u32); + for (t, toks) in entries { + w.uint(*t).uint(toks.len() as u32); + for k in *toks { + w.uint(*k); + } + } + w + } + + fn run(entries: &[(u32, &[u32])], data: &W) -> (Result<(), PrcError>, usize) { + let mut w = schema(entries); + w.append(data); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let s = Schema::read(&mut r).unwrap(); + let start = r.position(); + let res = s.skip_added_fields(172, &mut r); + (res, r.position() - start) + } + + #[test] + fn integer_string_and_boolean_tokens_consume_their_fields() { + let mut d = W::default(); + d.bit(true).uint(300).int(-7).string(Some("ab")).put(0x41, 8); + let want = d.len(); + d.uint(99); // not part of the added fields + let (res, used) = run(&[(172, &[0, 3, 4, 5, 2])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn data_tokens_consume_their_fields() { + let mut d = W::default(); + d.bit(true).double(1.5).uint(300).int(-7).string(Some("ab")).double(2.0).double(3.0); + let want = d.len(); + d.uint(99); // not part of the added fields + let (res, used) = run(&[(172, &[0, 1, 3, 4, 5, 7])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn vector_extent_and_box_tokens_read_their_doubles() { + // Vec3, Extent1D, Extent2D, BBox: 3 + 2 + 4 + 6 Doubles. + let mut d = W::default(); + for i in 0..15 { + d.double(f64::from(i) + 0.5); + } + let want = d.len(); + d.uint(99); + let (res, used) = run(&[(172, &[8, 9, 10, 11])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn arithmetic_and_comparison_operators() { + // For (5 - 3) { UInt }, then If (2 < 2) { Bool } Else { Char }. + let mut d = W::default(); + d.uint(7).uint(8).put(0x41, 8); + let want = d.len(); + d.uint(99); + let prog = [15, 32, 26, 5, 26, 3, 3, 17, 33, 26, 2, 26, 2, 0, 18, 2]; + let (res, used) = run(&[(172, &prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn an_absent_type_is_a_no_op() { + let mut w = schema(&[(300, &[3])]); + w.uint(5); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let s = Schema::read(&mut r).unwrap(); + let at = r.position(); + s.skip_added_fields(172, &mut r).unwrap(); + assert_eq!(r.position(), at); + assert!(!s.is_empty()); + } + + #[test] + fn simple_for_reads_its_count_then_repeats_the_body() { + let mut d = W::default(); + d.int(3).uint(1).uint(2).uint(3); + let want = d.len(); + d.uint(77); + let (res, used) = run(&[(172, &[16, 3])], &d); + res.unwrap(); + assert_eq!(used, want); + + // A block body. + let mut d = W::default(); + d.int(2).bit(true).uint(9).bit(false).uint(8); + let want = d.len(); + let (res, used) = run(&[(172, &[16, 19, 0, 3, 21])], &d); + res.unwrap(); + assert_eq!(used, want); + + // A count whose top byte is 0x80 or higher, written either way. + for signed in [false, true] { + let mut d = W::default(); + if signed { + d.int(200); + } else { + d.uint(200); + } + (0..200).for_each(|_| { + d.bit(true); + }); + let want = d.len(); + d.uint(77); + let (res, used) = run(&[(172, &[16, 0])], &d); + res.unwrap(); + assert_eq!(used, want, "signed={signed}"); + } + } + + #[test] + fn if_else_and_variables_choose_the_branch_by_file_data() { + // DeclareAndSet v0 = ; if v0 > 1 { UInt } else { Character }. + let prog: &[u32] = &[24, 0, 3, 17, 35, 25, 0, 26, 1, 3, 18, 2]; + let mut d = W::default(); + d.uint(5).uint(4250); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + + let mut d = W::default(); + d.uint(0).put(0xAB, 8); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn for_counts_by_expression_and_exposes_the_counter() { + // For (2 * 2) { if counter == 2 { UInt } } => one UInt read. + let prog: &[u32] = &[15, 29, 26, 2, 26, 2, 17, 37, 27, 26, 2, 3]; + let mut d = W::default(); + d.uint(1234); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn version_blocks_run_only_when_newer_than_the_reader() { + let mut d = W::default(); + d.uint(42); + let want = d.len(); + let (res, used) = run(&[(172, &[20, 8137, 3, 21, 20, 9000, 3, 21])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn father_type_runs_the_parent_program_and_obsolete_tokens_swallow_one() { + let mut d = W::default(); + d.uint(1).uint(2); + let want = d.len(); + let (res, used) = run(&[(172, &[39, 3, 6, 50, 40, 8]), (50, &[3, 3])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn hostile_programs_are_refused_not_run_forever() { + let d = W::default(); + // For (1000000 * 1000000) { empty block }. + let prog: &[u32] = &[15, 29, 26, 1_000_000, 26, 1_000_000, 19, 21]; + assert!(matches!(run(&[(172, prog)], &d).0, Err(PrcError::Malformed(_)))); + // Father_Type recursing into itself. + assert!(matches!(run(&[(172, &[6, 172])], &d).0, Err(PrcError::Malformed(_)))); + // A pointer field. + assert!(matches!(run(&[(172, &[13])], &d).0, Err(PrcError::Unsupported(_)))); + // A stray Block_End, an unterminated block, a division by zero. + assert!(matches!(run(&[(172, &[21])], &d).0, Err(PrcError::Malformed(_)))); + assert!(matches!(run(&[(172, &[19, 0])], &d).0, Err(PrcError::Malformed(_)))); + assert!(matches!(run(&[(172, &[23, 0, 30, 26, 1, 26, 0])], &d).0, Err(PrcError::Malformed(_)))); + } + + #[test] + fn a_duplicated_entity_type_is_malformed() { + let bytes = schema(&[(172, &[3]), (172, &[3])]).bytes(); + assert!(matches!(Schema::read(&mut BitReader::new(&bytes)), Err(PrcError::Malformed(_)))); + } +} diff --git a/crates/prc/src/testw.rs b/crates/prc/src/testw.rs new file mode 100644 index 0000000000..4706a20ef8 --- /dev/null +++ b/crates/prc/src/testw.rs @@ -0,0 +1,101 @@ +//! Test-only PRC bit writer: the inverse of [`crate::BitReader`]. + +#![allow(clippy::expect_used)] + +use crate::acof; + +/// An MSB-first bit sink for building test streams. +#[derive(Debug, Default, Clone)] +pub(crate) struct W { + bits: Vec, +} + +impl W { + /// The low `n` bits of `v` (`n` ≤ 64), most significant first. + pub(crate) fn put(&mut self, v: u64, n: u32) -> &mut Self { + for i in (0..n).rev() { + self.bits.push((v >> i) & 1 == 1); + } + self + } + + /// One bit; a PRC `Boolean`. + pub(crate) fn bit(&mut self, b: bool) -> &mut Self { + self.bits.push(b); + self + } + + /// Bits written so far. + pub(crate) fn len(&self) -> usize { + self.bits.len() + } + + /// Every bit of `other`, unaligned. + pub(crate) fn append(&mut self, other: &Self) -> &mut Self { + self.bits.extend_from_slice(&other.bits); + self + } + + /// An `UnsignedInteger` [WD 11.10]. + pub(crate) fn uint(&mut self, mut v: u32) -> &mut Self { + while v != 0 { + self.bit(true).put(u64::from(v & 0xff), 8); + v >>= 8; + } + self.bit(false) + } + + /// An `Integer` [WD 11.11]: two's-complement groups, low first, each followed by a + /// continue bit. + pub(crate) fn int(&mut self, v: i32) -> &mut Self { + if v == 0 { + return self.bit(false); + } + self.bit(true); + let mut v = i64::from(v); + loop { + let group = v & 0xff; + self.put(group.cast_unsigned(), 8); + v >>= 8; + let done = (v == 0 && group & 0x80 == 0) || (v == -1 && group & 0x80 != 0); + self.bit(!done); + if done { + return self; + } + } + } + + /// A `String` [WD 11.4]: presence bit, byte count, raw UTF-8 bytes. + pub(crate) fn string(&mut self, s: Option<&str>) -> &mut Self { + let Some(s) = s else { + return self.bit(false); + }; + self.bit(true).uint(u32::try_from(s.len()).expect("a short test string")); + for b in s.bytes() { + self.put(u64::from(b), 8); + } + self + } + + /// A finite `Double` [WD 11.17]: zero as the two-bit zero code; anything else as its + /// exponent row, the sign, then the mantissa as literal bytes. + pub(crate) fn double(&mut self, v: f64) -> &mut Self { + if v == 0.0 { + return self.put(0b01, 2); + } + let bits = v.to_bits(); + let exponent = u32::try_from((bits >> 52) & 0x7ff).expect("11 bits"); + let (n, c) = acof::exponent_code(exponent).expect("the Double table has a row per exponent"); + let le = bits.to_le_bytes(); + self.put(u64::from(c), n).bit(v < 0.0).bit(true).put(u64::from(le[6] & 0x0f), 4); + for b in le[..6].iter().rev() { + self.bit(true).put(u64::from(*b), 8); + } + self + } + + /// The bits packed MSB-first, the last byte zero-padded. + pub(crate) fn bytes(&self) -> Vec { + self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect() + } +} diff --git a/crates/prc/tests/container.rs b/crates/prc/tests/container.rs new file mode 100644 index 0000000000..dc9970704b --- /dev/null +++ b/crates/prc/tests/container.rs @@ -0,0 +1,114 @@ +//! The PRC container walk [WD 6.1-6.2] against synthetic streams. + +#![allow(clippy::unwrap_used)] + +use std::io::Write; + +use pdfcraft_prc::{PrcError, PrcFile, SectionKind, UniqueId}; + +fn zlib(data: &[u8]) -> Vec { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() +} + +fn u32s(out: &mut Vec, vs: &[u32]) { + for v in vs { + out.extend_from_slice(&v.to_le_bytes()); + } +} + +/// One file structure with the given section payloads and model file. +fn build(sections: [&[u8]; 5], model: &[u8], padding: usize) -> Vec { + const HEADER_LEN: usize = 107; + let mut fs_header = b"PRC".to_vec(); + u32s(&mut fs_header, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 1, 3]); + fs_header.extend_from_slice(b"\xff\xd8\xff"); + + let mut body = fs_header.clone(); + let mut offs = vec![HEADER_LEN]; + for s in sections { + offs.push(HEADER_LEN + body.len()); + body.extend(zlib(s)); + body.extend(std::iter::repeat_n(0u8, padding)); + } + let mf_start = HEADER_LEN + body.len(); + body.extend(zlib(model)); + let mf_end = HEADER_LEN + body.len(); + + let mut out = b"PRC".to_vec(); + u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]); + u32s(&mut out, &[5, 6, 7, 8, 0, 6]); + u32s(&mut out, &offs.iter().map(|&o| o as u32).collect::>()); + u32s(&mut out, &[mf_start as u32, mf_end as u32, 0]); + assert_eq!(out.len(), HEADER_LEN); + out.extend(body); + out +} + +const SECTIONS: [&[u8]; 5] = [b"globals", b"tree", b"tess", b"", b"extra"]; + +#[test] +fn the_container_walk_inflates_every_section() { + for padding in [0, 3] { + let f = PrcFile::parse(&build(SECTIONS, b"model file", padding)).unwrap(); + assert_eq!(f.header.min_version_for_read, 8137); + assert_eq!(f.header.file_id, UniqueId([1, 2, 3, 4])); + assert_eq!(f.file_structures.len(), 1); + let fs = &f.file_structures[0]; + assert_eq!(fs.id, UniqueId([5, 6, 7, 8])); + assert_eq!(fs.pictures, vec![b"\xff\xd8\xff".to_vec()]); + for (kind, want) in SectionKind::ALL.into_iter().zip(SECTIONS) { + assert_eq!(fs.section(kind), want, "{}", kind.name()); + } + assert_eq!(f.model_file, b"model file"); + } +} + +#[test] +fn damaged_containers_are_refused_by_name() { + let good = build(SECTIONS, b"m", 0); + assert_eq!(PrcFile::parse(b"U3D\0").unwrap_err(), PrcError::NotPrc); + assert!(matches!(PrcFile::parse(&good[..40]), Err(PrcError::Truncated(_)))); + + // Reserved field (offset 63) not zero. + let mut bad = good.clone(); + bad[63] = 1; + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_)))); + + // File-structure id disagrees with its description (FS header id at 107+11). + let mut bad = good.clone(); + bad[118] ^= 0xff; + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_)))); + + // A section that is not zlib: corrupt the globals stream's header byte. + let globals = u32::from_le_bytes(good[75..79].try_into().unwrap()) as usize; + let mut bad = good.clone(); + bad[globals] = 0; + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Inflate { section: "globals", .. }))); + + // Offsets out of order. + let mut bad = good; + bad[75..79].copy_from_slice(&1u32.to_le_bytes()); + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_)))); +} + +#[test] +fn the_inflation_ceiling_is_reported_not_exceeded() { + let f = build([&[0u8; 600], &[0u8; 600], b"", b"", b""], b"", 0); + assert!(PrcFile::parse_with_limit(&f, 1200).is_ok()); + assert_eq!(PrcFile::parse_with_limit(&f, 1199).unwrap_err(), PrcError::TooLarge { limit: 1199 }); +} + +#[test] +fn an_offset_or_count_past_the_data_is_an_error_not_a_panic() { + let mut bad = build(SECTIONS, b"m", 0); + // The model file's start (header offset 95) far past the end, its end further still. + bad[95..99].copy_from_slice(&u32::MAX.to_le_bytes()); + bad[99..103].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(PrcFile::parse(&bad).is_err()); + // A section count that claims more offsets than the data holds. + let mut bad = build(SECTIONS, b"m", 0); + bad[67..71].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Truncated(_)))); +} diff --git a/crates/prc/tests/proptests.rs b/crates/prc/tests/proptests.rs new file mode 100644 index 0000000000..e33e095bb2 --- /dev/null +++ b/crates/prc/tests/proptests.rs @@ -0,0 +1,41 @@ +//! Arbitrary bytes through every reader: an error or a value, never a panic. + +use pdfcraft_prc::{BitReader, PrcFile, Schema}; +use proptest::prelude::*; + +proptest! { + #[test] + fn any_bytes_parse_or_fail_cleanly(data in proptest::collection::vec(any::(), 0..512), magic in any::()) { + let mut data = data; + if magic && data.len() >= 3 { + data[..3].copy_from_slice(b"PRC"); + } + let _ = PrcFile::parse_with_limit(&data, 1 << 20); + } + + #[test] + fn any_bits_read_or_fail_cleanly(data in proptest::collection::vec(any::(), 0..64), n in 0u32..40) { + let mut r = BitReader::new(&data); + let _ = r.unsigned_integer(); + let _ = r.integer(); + let _ = r.integer_vbn(n); + let _ = r.double_vbn(0.5, n); + let _ = r.nbits_then_unsigned(); + let _ = r.string(); + let _ = r.float_as_bytes(); + let _ = r.double(); + let _ = r.skip_bits(usize::try_from(n).unwrap_or(0)); + prop_assert!(r.position() <= data.len() * 8); + } + + #[test] + fn any_schema_runs_or_fails_cleanly(data in proptest::collection::vec(any::(), 0..256), entity in 0u32..4) { + let mut r = BitReader::new(&data); + if let Ok(schema) = Schema::read(&mut r) { + let types: Vec = schema.entity_types().chain([entity]).collect(); + for t in types { + let _ = schema.skip_added_fields(t, &mut r.clone()); + } + } + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index b961ebef66..4f59c1dc94 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -34,6 +34,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("filters", Class::Standalone(0)), ("crypt", Class::Standalone(0)), ("arlington", Class::Standalone(0)), + ("prc", Class::Standalone(0)), // L1 object layer ("cos", Class::Standalone(1)), // L2 core model From ab552430a7abce749b1ba6d0a959f5ff907fb2f1 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 10:22:49 -0400 Subject: [PATCH 017/124] M12: PRC reader: the Double code table from prc-rs (MIT) (#1297) Every PRC Double decodes through a fixed 2077-row prefix-code table (working draft 11.17.2). The rows come from prc-rs src/double.rs at 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d under its MIT licence, whose notice stays in the file header; ATTRIBUTION.toml records it. The table is a complete prefix code, so the empty-table stub test is replaced by a truncation test. The exponent lookup masks the 11 exponent bits, because the last exponent row's upper word is 0x7ff80000. Revert proof: with the table emptied, eight Double tests fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- ATTRIBUTION.md | 3 +- ATTRIBUTION.toml | 11 + crates/prc/README.md | 3 +- crates/prc/src/acof.rs | 15 +- crates/prc/src/acof/table.rs | 2113 ++++++++++++++++++++++++++++++++++ crates/prc/src/bits.rs | 13 +- crates/prc/src/schema.rs | 2 - 7 files changed, 2137 insertions(+), 23 deletions(-) create mode 100644 crates/prc/src/acof/table.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index fc88e8b664..dea7c69d36 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (289) +## In this repository (290) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -231,6 +231,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) | | `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) | | `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. | +| `crates/prc/src/acof/table.rs` | PRC Double prefix-code table (as a Rust table) | Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2) | MIT | Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header. | Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data. | | `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 | | `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 76f4cf40a8..16c2d266dc 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2437,6 +2437,17 @@ adobe_data = true usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree." sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2" +[[asset]] +path = "crates/prc/src/acof/table.rs" +title = "PRC Double prefix-code table (as a Rust table)" +author = "Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2)" +source = "Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header." +licence = "MIT" +licence_file = "crates/prc/src/acof/table.rs" +kind = "data" +usage = "Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data." +sha256 = "8e705c5cde6a3ef778e8aca69759e38845b228b3f7f12949e587b19855d2114c" + [[bundled]] crate = "epaint_default_fonts" version = "0.37.0" diff --git a/crates/prc/README.md b/crates/prc/README.md index 0f8a01872f..edb67bdad8 100644 --- a/crates/prc/README.md +++ b/crates/prc/README.md @@ -20,4 +20,5 @@ BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_ - `MAX_INFLATED_BYTES` (512 MiB) bounds every section together; `TooLarge` reports the limit hit. - Schema programs (how a newer producer describes fields it added) run under a step ceiling (2^22) and a nesting ceiling (64). - `Unsupported` names constructs the reader cannot step over (schema pointer fields, `Value_CurveIs3D`). -- Not yet in the crate: the `Double` code table, so `BitReader::double` returns `UnknownDoubleCode`; the product tree and tessellation readers. +- Not yet in the crate: the product tree and tessellation readers. +- The `Double` code table's rows come from [prc-rs](https://github.com/ralovich/prc-rs) (MIT); its notice is in `src/acof/table.rs`. diff --git a/crates/prc/src/acof.rs b/crates/prc/src/acof.rs index dff5d4a6f2..fc11a663a6 100644 --- a/crates/prc/src/acof.rs +++ b/crates/prc/src/acof.rs @@ -2,13 +2,13 @@ //! //! The table has 2077 rows: 29 frequent doubles and one row per IEEE binary64 exponent //! (0..=2047). Each row is a code of 1 to 22 bits and the value it decodes to. -//! -//! The rows are not in this crate yet: until they are, [`lookup`] finds no code, so every -//! `Double` read fails with `UnknownDoubleCode` and nothing else changes. +//! The rows come from prc-rs under its MIT licence (see [`table`]). use std::collections::HashMap; use std::sync::OnceLock; +mod table; + /// Rows a complete table has. const ROWS: usize = 2077; @@ -17,7 +17,6 @@ pub(crate) const MAX_CODE_BITS: u32 = 22; /// What a code decodes to. #[derive(Debug, Clone, Copy, PartialEq)] -#[cfg_attr(not(test), expect(dead_code, reason = "built only by the table's rows, which are not in the crate yet"))] pub(crate) enum Entry { /// A frequent value, returned with the sign bit applied. Double(f64), @@ -29,9 +28,6 @@ pub(crate) enum Entry { /// One row: code length, code, and what it decodes to. type Row = (u32, u32, Entry); -/// The table's rows. -const TABLE: &[Row] = &[]; - fn key(nbits: u32, code: u32) -> u32 { (nbits << 24) | code } @@ -54,7 +50,7 @@ fn build(rows: &[Row]) -> Option> { fn table() -> Option<&'static HashMap> { static MAP: OnceLock>> = OnceLock::new(); - MAP.get_or_init(|| build(TABLE)).as_ref() + MAP.get_or_init(|| build(table::TABLE)).as_ref() } /// The entry for an `nbits`-long code, if one exists. @@ -65,7 +61,7 @@ pub(crate) fn lookup(nbits: u32, code: u32) -> Option { /// The `(nbits, code)` of IEEE exponent `e`'s row, the test writer's half of [`lookup`]. #[cfg(test)] pub(crate) fn exponent_code(e: u32) -> Option<(u32, u32)> { - table()?.iter().find(|(_, v)| **v == Entry::Exponent(e << 20)).map(|(&k, _)| (k >> 24, k & 0xff_ffff)) + table()?.iter().find(|(_, v)| matches!(v, Entry::Exponent(hi) if (hi >> 20) & 0x7ff == e)).map(|(&k, _)| (k >> 24, k & 0xff_ffff)) } #[cfg(test)] @@ -87,7 +83,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn the_table_is_complete() { let t = table().unwrap(); assert_eq!(t.len(), ROWS); diff --git a/crates/prc/src/acof/table.rs b/crates/prc/src/acof/table.rs new file mode 100644 index 0000000000..28cff50a2e --- /dev/null +++ b/crates/prc/src/acof/table.rs @@ -0,0 +1,2113 @@ +//! The 2077 rows of the PRC `Double` prefix-code table [WD 11.17.2], as `(code length, code, entry)`. +//! +//! The rows are taken from prc-rs (, `src/double.rs` at commit +//! 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d), used under its MIT licence: +//! +//! Copyright (c) 2025,2026 Kristof Ralovich +//! +//! Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +//! documentation files (the "Software"), to deal in the Software without restriction, including without limitation +//! the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +//! to permit persons to whom the Software is furnished to do so, subject to the following conditions: +//! +//! The above copyright notice and this permission notice shall be included in all copies or substantial portions of +//! the Software. +//! +//! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO +//! THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +//! AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +//! TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +//! SOFTWARE. + +use super::{Entry, Row}; + +/// A frequent value: the binary64 with upper and lower words `hi`, `lo`. +const fn d(hi: u32, lo: u32) -> Entry { + Entry::Double(f64::from_bits(((hi as u64) << 32) | lo as u64)) +} + +/// An exponent row: the upper word of a binary64 whose mantissa follows in the stream. +const fn e(hi: u32) -> Entry { + Entry::Exponent(hi) +} + +#[rustfmt::skip] +pub(super) const TABLE: &[Row] = &[ + (2, 0x1, d(0x00000000, 0x00000000)), + (22, 0xd1d32, e(0x00000000)), + (22, 0xd1d33, e(0x00100000)), + (22, 0xf78d8, e(0x00200000)), + (22, 0xf78d9, e(0x00300000)), + (22, 0xf78da, e(0x00400000)), + (22, 0xf78db, e(0x00500000)), + (22, 0xf78dc, e(0x00600000)), + (22, 0xf78dd, e(0x00700000)), + (22, 0xf78de, e(0x00800000)), + (22, 0xf78df, e(0x00900000)), + (22, 0xf78e0, e(0x00a00000)), + (22, 0xf78e1, e(0x00b00000)), + (22, 0xf78e2, e(0x00c00000)), + (22, 0xf78e3, e(0x00d00000)), + (22, 0xf78e4, e(0x00e00000)), + (22, 0xf78e5, e(0x00f00000)), + (22, 0xf78e6, e(0x01000000)), + (22, 0xf78e7, e(0x01100000)), + (22, 0xf78e8, e(0x01200000)), + (22, 0xf78e9, e(0x01300000)), + (22, 0xf78ea, e(0x01400000)), + (22, 0xf78eb, e(0x01500000)), + (22, 0xf78ec, e(0x01600000)), + (22, 0xf78ed, e(0x01700000)), + (22, 0xf78ee, e(0x01800000)), + (22, 0xf78ef, e(0x01900000)), + (22, 0xf78f0, e(0x01a00000)), + (22, 0xf78f1, e(0x01b00000)), + (22, 0xf78f2, e(0x01c00000)), + (22, 0xf78f3, e(0x01d00000)), + (22, 0xf78f4, e(0x01e00000)), + (22, 0xf78f5, e(0x01f00000)), + (22, 0xf78f6, e(0x02000000)), + (22, 0xf78f7, e(0x02100000)), + (22, 0xf78f8, e(0x02200000)), + (22, 0xf78f9, e(0x02300000)), + (22, 0xf78fa, e(0x02400000)), + (22, 0xf78fb, e(0x02500000)), + (22, 0xf78fc, e(0x02600000)), + (22, 0xf78fd, e(0x02700000)), + (22, 0xf78fe, e(0x02800000)), + (22, 0xf78ff, e(0x02900000)), + (22, 0x3a8300, e(0x02a00000)), + (22, 0x3a8301, e(0x02b00000)), + (22, 0x3a8302, e(0x02c00000)), + (22, 0x3a8303, e(0x02d00000)), + (22, 0x3a8304, e(0x02e00000)), + (22, 0x3a8305, e(0x02f00000)), + (22, 0x3a8306, e(0x03000000)), + (22, 0x3a8307, e(0x03100000)), + (22, 0x3a8308, e(0x03200000)), + (22, 0x3a8309, e(0x03300000)), + (22, 0x3a830a, e(0x03400000)), + (22, 0x3a830b, e(0x03500000)), + (22, 0x3a830c, e(0x03600000)), + (22, 0x3a830d, e(0x03700000)), + (22, 0x3a830e, e(0x03800000)), + (22, 0x3a830f, e(0x03900000)), + (22, 0x3a8310, e(0x03a00000)), + (22, 0x3a8311, e(0x03b00000)), + (22, 0x3a8312, e(0x03c00000)), + (22, 0x3a8313, e(0x03d00000)), + (22, 0x3a8314, e(0x03e00000)), + (22, 0x3a8315, e(0x03f00000)), + (22, 0x3a8316, e(0x04000000)), + (22, 0x3a8317, e(0x04100000)), + (22, 0x3a8318, e(0x04200000)), + (22, 0x3a8319, e(0x04300000)), + (22, 0x3a831a, e(0x04400000)), + (22, 0x3a831b, e(0x04500000)), + (22, 0x3a831c, e(0x04600000)), + (22, 0x3a831d, e(0x04700000)), + (22, 0x3a831e, e(0x04800000)), + (22, 0x3a831f, e(0x04900000)), + (22, 0x3a8320, e(0x04a00000)), + (22, 0x3a8321, e(0x04b00000)), + (22, 0x3a8322, e(0x04c00000)), + (22, 0x3a8323, e(0x04d00000)), + (22, 0x3a8324, e(0x04e00000)), + (22, 0x3a8325, e(0x04f00000)), + (22, 0x3a8326, e(0x05000000)), + (22, 0x3a8327, e(0x05100000)), + (22, 0x3a8328, e(0x05200000)), + (22, 0x3a8329, e(0x05300000)), + (22, 0x3a832a, e(0x05400000)), + (22, 0x3a832b, e(0x05500000)), + (22, 0x3a832c, e(0x05600000)), + (22, 0x3a832d, e(0x05700000)), + (22, 0x3a832e, e(0x05800000)), + (22, 0x3a832f, e(0x05900000)), + (22, 0x3a8330, e(0x05a00000)), + (22, 0x3a8331, e(0x05b00000)), + (22, 0x3a8332, e(0x05c00000)), + (22, 0x3a8333, e(0x05d00000)), + (22, 0x3a8334, e(0x05e00000)), + (22, 0x3a8335, e(0x05f00000)), + (22, 0x3a8336, e(0x06000000)), + (22, 0x3a8337, e(0x06100000)), + (22, 0x3a8338, e(0x06200000)), + (22, 0x3a8339, e(0x06300000)), + (22, 0x3a833a, e(0x06400000)), + (22, 0x3a833b, e(0x06500000)), + (22, 0x3a833c, e(0x06600000)), + (22, 0x3a833d, e(0x06700000)), + (22, 0x3a833e, e(0x06800000)), + (22, 0x3a833f, e(0x06900000)), + (22, 0x3a8340, e(0x06a00000)), + (22, 0x3a8341, e(0x06b00000)), + (22, 0x3a8342, e(0x06c00000)), + (22, 0x3a8343, e(0x06d00000)), + (22, 0x3a8344, e(0x06e00000)), + (22, 0x3a8345, e(0x06f00000)), + (22, 0x3a8346, e(0x07000000)), + (22, 0x3a8347, e(0x07100000)), + (22, 0x3a8348, e(0x07200000)), + (22, 0x3a8349, e(0x07300000)), + (22, 0x3a834a, e(0x07400000)), + (22, 0x3a834b, e(0x07500000)), + (22, 0x3a834c, e(0x07600000)), + (22, 0x3a834d, e(0x07700000)), + (22, 0x3a834e, e(0x07800000)), + (22, 0x3a834f, e(0x07900000)), + (22, 0x3a8350, e(0x07a00000)), + (22, 0x3a8351, e(0x07b00000)), + (22, 0x3a8352, e(0x07c00000)), + (22, 0x3a8353, e(0x07d00000)), + (22, 0x3a8354, e(0x07e00000)), + (22, 0x3a8355, e(0x07f00000)), + (22, 0x3a8356, e(0x08000000)), + (22, 0x3a8357, e(0x08100000)), + (22, 0x3a8358, e(0x08200000)), + (22, 0x3a8359, e(0x08300000)), + (22, 0x3a835a, e(0x08400000)), + (22, 0x3a835b, e(0x08500000)), + (22, 0x3a835c, e(0x08600000)), + (22, 0x3a835d, e(0x08700000)), + (22, 0x3a835e, e(0x08800000)), + (22, 0x3a835f, e(0x08900000)), + (22, 0x3a8360, e(0x08a00000)), + (22, 0x3a8361, e(0x08b00000)), + (22, 0x3a8362, e(0x08c00000)), + (22, 0x3a8363, e(0x08d00000)), + (22, 0x3a8364, e(0x08e00000)), + (22, 0x3a8365, e(0x08f00000)), + (22, 0x3a8366, e(0x09000000)), + (22, 0x3a8367, e(0x09100000)), + (22, 0x3a8368, e(0x09200000)), + (22, 0x3a8369, e(0x09300000)), + (22, 0x3a836a, e(0x09400000)), + (22, 0x3a836b, e(0x09500000)), + (22, 0x3a836c, e(0x09600000)), + (22, 0x3a836d, e(0x09700000)), + (22, 0x3a836e, e(0x09800000)), + (22, 0x3a836f, e(0x09900000)), + (22, 0x3a8370, e(0x09a00000)), + (22, 0x3a8371, e(0x09b00000)), + (22, 0x3a8372, e(0x09c00000)), + (22, 0x3a8373, e(0x09d00000)), + (22, 0x3a8374, e(0x09e00000)), + (22, 0x3a8375, e(0x09f00000)), + (22, 0x3a8376, e(0x0a000000)), + (22, 0x3a8377, e(0x0a100000)), + (22, 0x3a8378, e(0x0a200000)), + (22, 0x3a8379, e(0x0a300000)), + (22, 0x3a837a, e(0x0a400000)), + (22, 0x3a837b, e(0x0a500000)), + (22, 0x3a837c, e(0x0a600000)), + (22, 0x3a837d, e(0x0a700000)), + (22, 0x3a837e, e(0x0a800000)), + (22, 0x3a837f, e(0x0a900000)), + (22, 0x3a8380, e(0x0aa00000)), + (22, 0x3a8381, e(0x0ab00000)), + (22, 0x3a8382, e(0x0ac00000)), + (22, 0x3a8383, e(0x0ad00000)), + (22, 0x3a8384, e(0x0ae00000)), + (22, 0x3a8385, e(0x0af00000)), + (22, 0x3a8386, e(0x0b000000)), + (22, 0x3a8387, e(0x0b100000)), + (22, 0x3a8388, e(0x0b200000)), + (22, 0x3a8389, e(0x0b300000)), + (22, 0x3a838a, e(0x0b400000)), + (22, 0x3a838b, e(0x0b500000)), + (22, 0x3a838c, e(0x0b600000)), + (22, 0x3a838d, e(0x0b700000)), + (22, 0x3a838e, e(0x0b800000)), + (22, 0x3a838f, e(0x0b900000)), + (22, 0x3a8390, e(0x0ba00000)), + (22, 0x3a8391, e(0x0bb00000)), + (22, 0x3a8392, e(0x0bc00000)), + (22, 0x3a8393, e(0x0bd00000)), + (22, 0x3a8394, e(0x0be00000)), + (22, 0x3a8395, e(0x0bf00000)), + (22, 0x3a8396, e(0x0c000000)), + (22, 0x3a8397, e(0x0c100000)), + (22, 0x3a8398, e(0x0c200000)), + (22, 0x3a8399, e(0x0c300000)), + (22, 0x3a839a, e(0x0c400000)), + (22, 0x3a839b, e(0x0c500000)), + (22, 0x3a839c, e(0x0c600000)), + (22, 0x3a839d, e(0x0c700000)), + (22, 0x3a839e, e(0x0c800000)), + (22, 0x3a839f, e(0x0c900000)), + (22, 0x3a83a0, e(0x0ca00000)), + (22, 0x3a83a1, e(0x0cb00000)), + (22, 0x3a83a2, e(0x0cc00000)), + (22, 0x3a83a3, e(0x0cd00000)), + (22, 0x3a83a4, e(0x0ce00000)), + (22, 0x3a83a5, e(0x0cf00000)), + (22, 0x3a83a6, e(0x0d000000)), + (22, 0x3a83a7, e(0x0d100000)), + (22, 0x3a83a8, e(0x0d200000)), + (22, 0x3a83a9, e(0x0d300000)), + (22, 0x3a83aa, e(0x0d400000)), + (22, 0x3a83ab, e(0x0d500000)), + (22, 0x3a83ac, e(0x0d600000)), + (22, 0x3a83ad, e(0x0d700000)), + (22, 0x3a83ae, e(0x0d800000)), + (22, 0x3a83af, e(0x0d900000)), + (22, 0x3a83b0, e(0x0da00000)), + (22, 0x3a83b1, e(0x0db00000)), + (22, 0x3a83b2, e(0x0dc00000)), + (22, 0x3a83b3, e(0x0dd00000)), + (22, 0x3a83b4, e(0x0de00000)), + (22, 0x3a83b5, e(0x0df00000)), + (22, 0x3a83b6, e(0x0e000000)), + (22, 0x3a83b7, e(0x0e100000)), + (22, 0x3a83b8, e(0x0e200000)), + (22, 0x3a83b9, e(0x0e300000)), + (22, 0x3a83ba, e(0x0e400000)), + (22, 0x3a83bb, e(0x0e500000)), + (22, 0x3a83bc, e(0x0e600000)), + (22, 0x3a83bd, e(0x0e700000)), + (22, 0x3a83be, e(0x0e800000)), + (22, 0x3a83bf, e(0x0e900000)), + (22, 0x3a83c0, e(0x0ea00000)), + (22, 0x3a83c1, e(0x0eb00000)), + (22, 0x3a83c2, e(0x0ec00000)), + (22, 0x3a83c3, e(0x0ed00000)), + (22, 0x3a83c4, e(0x0ee00000)), + (22, 0x3a83c5, e(0x0ef00000)), + (22, 0x3a83c6, e(0x0f000000)), + (22, 0x3a83c7, e(0x0f100000)), + (22, 0x3a83c8, e(0x0f200000)), + (22, 0x3a83c9, e(0x0f300000)), + (22, 0x3a83ca, e(0x0f400000)), + (22, 0x3a83cb, e(0x0f500000)), + (22, 0x3a83cc, e(0x0f600000)), + (22, 0x3a83cd, e(0x0f700000)), + (22, 0x3a83ce, e(0x0f800000)), + (22, 0x3a83cf, e(0x0f900000)), + (22, 0x3a83d0, e(0x0fa00000)), + (22, 0x3a83d1, e(0x0fb00000)), + (22, 0x3a83d2, e(0x0fc00000)), + (22, 0x3a83d3, e(0x0fd00000)), + (22, 0x3a83d4, e(0x0fe00000)), + (22, 0x3a83d5, e(0x0ff00000)), + (22, 0x3a83d6, e(0x10000000)), + (22, 0x3a83d7, e(0x10100000)), + (22, 0x3a83d8, e(0x10200000)), + (22, 0x3a83d9, e(0x10300000)), + (22, 0x3a83da, e(0x10400000)), + (22, 0x3a83db, e(0x10500000)), + (22, 0x3a83dc, e(0x10600000)), + (22, 0x3a83dd, e(0x10700000)), + (22, 0x3a83de, e(0x10800000)), + (22, 0x3a83df, e(0x10900000)), + (22, 0x3a83e0, e(0x10a00000)), + (22, 0x3a83e1, e(0x10b00000)), + (22, 0x3a83e2, e(0x10c00000)), + (22, 0x3a83e3, e(0x10d00000)), + (22, 0x3a83e4, e(0x10e00000)), + (22, 0x3a83e5, e(0x10f00000)), + (22, 0x3a83e6, e(0x11000000)), + (22, 0x3a83e7, e(0x11100000)), + (22, 0x3a83e8, e(0x11200000)), + (22, 0x3a83e9, e(0x11300000)), + (22, 0x3a83ea, e(0x11400000)), + (22, 0x3a83eb, e(0x11500000)), + (22, 0x3a83ec, e(0x11600000)), + (22, 0x3a83ed, e(0x11700000)), + (22, 0x3a83ee, e(0x11800000)), + (22, 0x3a83ef, e(0x11900000)), + (22, 0x3a83f0, e(0x11a00000)), + (22, 0x3a83f1, e(0x11b00000)), + (22, 0x3a83f2, e(0x11c00000)), + (22, 0x3a83f3, e(0x11d00000)), + (22, 0x3a83f4, e(0x11e00000)), + (22, 0x3a83f5, e(0x11f00000)), + (22, 0x3a83f6, e(0x12000000)), + (22, 0x3a83f7, e(0x12100000)), + (22, 0x3a83f8, e(0x12200000)), + (22, 0x3a83f9, e(0x12300000)), + (22, 0x3a83fa, e(0x12400000)), + (22, 0x3a83fb, e(0x12500000)), + (22, 0x3a83fc, e(0x12600000)), + (22, 0x3a83fd, e(0x12700000)), + (22, 0x3a83fe, e(0x12800000)), + (22, 0x3a83ff, e(0x12900000)), + (22, 0x3a8400, e(0x12a00000)), + (22, 0x3a8401, e(0x12b00000)), + (22, 0x3a8402, e(0x12c00000)), + (22, 0x3a8403, e(0x12d00000)), + (22, 0x3a8404, e(0x12e00000)), + (22, 0x3a8405, e(0x12f00000)), + (22, 0x3a8406, e(0x13000000)), + (22, 0x3a8407, e(0x13100000)), + (22, 0x3a8408, e(0x13200000)), + (22, 0x3a8409, e(0x13300000)), + (22, 0x3a840a, e(0x13400000)), + (22, 0x3a840b, e(0x13500000)), + (22, 0x3a840c, e(0x13600000)), + (22, 0x3a840d, e(0x13700000)), + (22, 0x3a840e, e(0x13800000)), + (22, 0x3a840f, e(0x13900000)), + (22, 0x3a8410, e(0x13a00000)), + (22, 0x3a8411, e(0x13b00000)), + (22, 0x3a8412, e(0x13c00000)), + (22, 0x3a8413, e(0x13d00000)), + (22, 0x3a8414, e(0x13e00000)), + (22, 0x3a8415, e(0x13f00000)), + (22, 0x3a8416, e(0x14000000)), + (22, 0x3a8417, e(0x14100000)), + (22, 0x3a8418, e(0x14200000)), + (22, 0x3a8419, e(0x14300000)), + (22, 0x3a841a, e(0x14400000)), + (22, 0x3a841b, e(0x14500000)), + (22, 0x3a841c, e(0x14600000)), + (22, 0x3a841d, e(0x14700000)), + (22, 0x3a841e, e(0x14800000)), + (22, 0x3a841f, e(0x14900000)), + (22, 0x3a8420, e(0x14a00000)), + (22, 0x3a8421, e(0x14b00000)), + (22, 0x3a8422, e(0x14c00000)), + (22, 0x3a8423, e(0x14d00000)), + (22, 0x3a8424, e(0x14e00000)), + (22, 0x3a8425, e(0x14f00000)), + (22, 0x3a8426, e(0x15000000)), + (22, 0x3a8427, e(0x15100000)), + (22, 0x3a8428, e(0x15200000)), + (22, 0x3a8429, e(0x15300000)), + (22, 0x3a842a, e(0x15400000)), + (22, 0x3a842b, e(0x15500000)), + (22, 0x3a842c, e(0x15600000)), + (22, 0x3a842d, e(0x15700000)), + (22, 0x3a842e, e(0x15800000)), + (22, 0x3a842f, e(0x15900000)), + (22, 0x3a8430, e(0x15a00000)), + (22, 0x3a8431, e(0x15b00000)), + (22, 0x3a8432, e(0x15c00000)), + (22, 0x3a8433, e(0x15d00000)), + (22, 0x3a8434, e(0x15e00000)), + (22, 0x3a8435, e(0x15f00000)), + (22, 0x3a8436, e(0x16000000)), + (22, 0x3a8437, e(0x16100000)), + (22, 0x3a8438, e(0x16200000)), + (22, 0x3a8439, e(0x16300000)), + (22, 0x3a843a, e(0x16400000)), + (22, 0x3a843b, e(0x16500000)), + (22, 0x3a843c, e(0x16600000)), + (22, 0x3a843d, e(0x16700000)), + (22, 0x3a843e, e(0x16800000)), + (22, 0x3a843f, e(0x16900000)), + (22, 0x3a8440, e(0x16a00000)), + (22, 0x3a8441, e(0x16b00000)), + (22, 0x3a8442, e(0x16c00000)), + (22, 0x3a8443, e(0x16d00000)), + (22, 0x3a8444, e(0x16e00000)), + (22, 0x3a8445, e(0x16f00000)), + (22, 0x3a8446, e(0x17000000)), + (22, 0x3a8447, e(0x17100000)), + (22, 0x3a8448, e(0x17200000)), + (22, 0x3a8449, e(0x17300000)), + (22, 0x3a844a, e(0x17400000)), + (22, 0x3a844b, e(0x17500000)), + (22, 0x3a844c, e(0x17600000)), + (22, 0x3a844d, e(0x17700000)), + (22, 0x3a844e, e(0x17800000)), + (22, 0x3a844f, e(0x17900000)), + (22, 0x3a8450, e(0x17a00000)), + (22, 0x3a8451, e(0x17b00000)), + (22, 0x3a8452, e(0x17c00000)), + (22, 0x3a8453, e(0x17d00000)), + (22, 0x3a8454, e(0x17e00000)), + (22, 0x3a8455, e(0x17f00000)), + (22, 0x3a8456, e(0x18000000)), + (22, 0x3a8457, e(0x18100000)), + (22, 0x3a8458, e(0x18200000)), + (22, 0x3a8459, e(0x18300000)), + (22, 0x3a845a, e(0x18400000)), + (22, 0x3a845b, e(0x18500000)), + (22, 0x3a845c, e(0x18600000)), + (22, 0x3a845d, e(0x18700000)), + (22, 0x3a845e, e(0x18800000)), + (22, 0x3a845f, e(0x18900000)), + (22, 0x3a8460, e(0x18a00000)), + (22, 0x3a8461, e(0x18b00000)), + (22, 0x3a8462, e(0x18c00000)), + (22, 0x3a8463, e(0x18d00000)), + (22, 0x3a8464, e(0x18e00000)), + (22, 0x3a8465, e(0x18f00000)), + (22, 0x3a8466, e(0x19000000)), + (22, 0x3a8467, e(0x19100000)), + (22, 0x3a8468, e(0x19200000)), + (22, 0x3a8469, e(0x19300000)), + (22, 0x3a846a, e(0x19400000)), + (22, 0x3a846b, e(0x19500000)), + (22, 0x3a846c, e(0x19600000)), + (22, 0x3a846d, e(0x19700000)), + (22, 0x3a846e, e(0x19800000)), + (22, 0x3a846f, e(0x19900000)), + (22, 0x3a8470, e(0x19a00000)), + (22, 0x3a8471, e(0x19b00000)), + (22, 0x3a8472, e(0x19c00000)), + (22, 0x3a8473, e(0x19d00000)), + (22, 0x3a8474, e(0x19e00000)), + (22, 0x3a8475, e(0x19f00000)), + (22, 0x3a8476, e(0x1a000000)), + (22, 0x3a8477, e(0x1a100000)), + (22, 0x3a8478, e(0x1a200000)), + (22, 0x3a8479, e(0x1a300000)), + (22, 0x3a847a, e(0x1a400000)), + (22, 0x3a847b, e(0x1a500000)), + (22, 0x3a847c, e(0x1a600000)), + (22, 0x3a847d, e(0x1a700000)), + (22, 0x3a847e, e(0x1a800000)), + (22, 0x3a847f, e(0x1a900000)), + (22, 0x3a8480, e(0x1aa00000)), + (22, 0x3a8481, e(0x1ab00000)), + (22, 0x3a8482, e(0x1ac00000)), + (22, 0x3a8483, e(0x1ad00000)), + (22, 0x3a8484, e(0x1ae00000)), + (22, 0x3a8485, e(0x1af00000)), + (22, 0x3a8486, e(0x1b000000)), + (22, 0x3a8487, e(0x1b100000)), + (22, 0x3a8488, e(0x1b200000)), + (22, 0x3a8489, e(0x1b300000)), + (22, 0x3a848a, e(0x1b400000)), + (22, 0x3a848b, e(0x1b500000)), + (22, 0x3a848c, e(0x1b600000)), + (22, 0x3a848d, e(0x1b700000)), + (22, 0x3a848e, e(0x1b800000)), + (22, 0x3a848f, e(0x1b900000)), + (22, 0x3a8490, e(0x1ba00000)), + (22, 0x3a8491, e(0x1bb00000)), + (22, 0x3a8492, e(0x1bc00000)), + (22, 0x3a8493, e(0x1bd00000)), + (22, 0x3a8494, e(0x1be00000)), + (22, 0x3a8495, e(0x1bf00000)), + (22, 0x3a8496, e(0x1c000000)), + (22, 0x3a8497, e(0x1c100000)), + (22, 0x3a8498, e(0x1c200000)), + (22, 0x3a8499, e(0x1c300000)), + (22, 0x3a849a, e(0x1c400000)), + (22, 0x3a849b, e(0x1c500000)), + (22, 0x3a849c, e(0x1c600000)), + (22, 0x3a849d, e(0x1c700000)), + (22, 0x3a849e, e(0x1c800000)), + (22, 0x3a849f, e(0x1c900000)), + (22, 0x3a84a0, e(0x1ca00000)), + (22, 0x3a84a1, e(0x1cb00000)), + (22, 0x3a84a2, e(0x1cc00000)), + (22, 0x3a84a3, e(0x1cd00000)), + (22, 0x3a84a4, e(0x1ce00000)), + (22, 0x3a84a5, e(0x1cf00000)), + (22, 0x3a84a6, e(0x1d000000)), + (22, 0x3a84a7, e(0x1d100000)), + (22, 0x3a84a8, e(0x1d200000)), + (22, 0x3a84a9, e(0x1d300000)), + (22, 0x3a84aa, e(0x1d400000)), + (22, 0x3a84ab, e(0x1d500000)), + (22, 0x3a84ac, e(0x1d600000)), + (22, 0x3a84ad, e(0x1d700000)), + (22, 0x3a84ae, e(0x1d800000)), + (22, 0x3a84af, e(0x1d900000)), + (22, 0x3a84b0, e(0x1da00000)), + (22, 0x3a84b1, e(0x1db00000)), + (22, 0x3a84b2, e(0x1dc00000)), + (22, 0x3a84b3, e(0x1dd00000)), + (22, 0x3a84b4, e(0x1de00000)), + (22, 0x3a84b5, e(0x1df00000)), + (22, 0x3a84b6, e(0x1e000000)), + (22, 0x3a84b7, e(0x1e100000)), + (22, 0x3a84b8, e(0x1e200000)), + (22, 0x3a84b9, e(0x1e300000)), + (22, 0x3a84ba, e(0x1e400000)), + (22, 0x3a84bb, e(0x1e500000)), + (22, 0x3a84bc, e(0x1e600000)), + (22, 0x3a84bd, e(0x1e700000)), + (22, 0x3a84be, e(0x1e800000)), + (22, 0x3a84bf, e(0x1e900000)), + (22, 0x3a84c0, e(0x1ea00000)), + (22, 0x3a84c1, e(0x1eb00000)), + (22, 0x3a84c2, e(0x1ec00000)), + (22, 0x3a84c3, e(0x1ed00000)), + (22, 0x3a84c4, e(0x1ee00000)), + (22, 0x3a84c5, e(0x1ef00000)), + (22, 0x3a84c6, e(0x1f000000)), + (22, 0x3a84c7, e(0x1f100000)), + (22, 0x3a84c8, e(0x1f200000)), + (22, 0x3a84c9, e(0x1f300000)), + (22, 0x3a84ca, e(0x1f400000)), + (22, 0x3a84cb, e(0x1f500000)), + (22, 0x3a84cc, e(0x1f600000)), + (22, 0x3a84cd, e(0x1f700000)), + (22, 0x3a84ce, e(0x1f800000)), + (22, 0x3a84cf, e(0x1f900000)), + (22, 0x3a84d0, e(0x1fa00000)), + (22, 0x3a84d1, e(0x1fb00000)), + (22, 0x3a84d2, e(0x1fc00000)), + (22, 0x3a84d3, e(0x1fd00000)), + (22, 0x3a84d4, e(0x1fe00000)), + (22, 0x3a84d5, e(0x1ff00000)), + (22, 0x3a84d6, e(0x20000000)), + (22, 0x3a84d7, e(0x20100000)), + (22, 0x3a84d8, e(0x20200000)), + (22, 0x3a84d9, e(0x20300000)), + (22, 0x3a84da, e(0x20400000)), + (22, 0x3a84db, e(0x20500000)), + (22, 0x3a84dc, e(0x20600000)), + (22, 0x3a84dd, e(0x20700000)), + (22, 0x3a84de, e(0x20800000)), + (22, 0x3a84df, e(0x20900000)), + (22, 0x3a84e0, e(0x20a00000)), + (22, 0x3a84e1, e(0x20b00000)), + (22, 0x3a84e2, e(0x20c00000)), + (22, 0x3a84e3, e(0x20d00000)), + (22, 0x3a84e4, e(0x20e00000)), + (22, 0x3a84e5, e(0x20f00000)), + (22, 0x3a84e6, e(0x21000000)), + (22, 0x3a84e7, e(0x21100000)), + (22, 0x3a84e8, e(0x21200000)), + (22, 0x3a84e9, e(0x21300000)), + (22, 0x3a84ea, e(0x21400000)), + (22, 0x3a84eb, e(0x21500000)), + (22, 0x3a84ec, e(0x21600000)), + (22, 0x3a84ed, e(0x21700000)), + (22, 0x3a84ee, e(0x21800000)), + (22, 0x3a84ef, e(0x21900000)), + (22, 0x3a84f0, e(0x21a00000)), + (22, 0x3a84f1, e(0x21b00000)), + (22, 0x3a84f2, e(0x21c00000)), + (22, 0x3a84f3, e(0x21d00000)), + (22, 0x3a84f4, e(0x21e00000)), + (22, 0x3a84f5, e(0x21f00000)), + (22, 0x3a84f6, e(0x22000000)), + (22, 0x3a84f7, e(0x22100000)), + (22, 0x3a84f8, e(0x22200000)), + (22, 0x3a84f9, e(0x22300000)), + (22, 0x3a84fa, e(0x22400000)), + (22, 0x3a84fb, e(0x22500000)), + (22, 0x3a84fc, e(0x22600000)), + (22, 0x3a84fd, e(0x22700000)), + (22, 0x3a84fe, e(0x22800000)), + (22, 0x3a84ff, e(0x22900000)), + (22, 0x3a8500, e(0x22a00000)), + (22, 0x3a8501, e(0x22b00000)), + (22, 0x3a8502, e(0x22c00000)), + (22, 0x3a8503, e(0x22d00000)), + (22, 0x3a8504, e(0x22e00000)), + (22, 0x3a8505, e(0x22f00000)), + (22, 0x3a8506, e(0x23000000)), + (22, 0x3a8507, e(0x23100000)), + (22, 0x3a8508, e(0x23200000)), + (22, 0x3a8509, e(0x23300000)), + (22, 0x3a850a, e(0x23400000)), + (22, 0x3a850b, e(0x23500000)), + (22, 0x3a850c, e(0x23600000)), + (22, 0x3a850d, e(0x23700000)), + (22, 0x3a850e, e(0x23800000)), + (22, 0x3a850f, e(0x23900000)), + (22, 0x3a8510, e(0x23a00000)), + (22, 0x3a8511, e(0x23b00000)), + (22, 0x3a8512, e(0x23c00000)), + (22, 0x3a8513, e(0x23d00000)), + (22, 0x3a8514, e(0x23e00000)), + (22, 0x3a8515, e(0x23f00000)), + (22, 0x3a8516, e(0x24000000)), + (22, 0x3a8517, e(0x24100000)), + (22, 0x3a8518, e(0x24200000)), + (22, 0x3a8519, e(0x24300000)), + (22, 0x3a851a, e(0x24400000)), + (22, 0x3a851b, e(0x24500000)), + (22, 0x3a851c, e(0x24600000)), + (22, 0x3a851d, e(0x24700000)), + (22, 0x3a851e, e(0x24800000)), + (22, 0x3a851f, e(0x24900000)), + (22, 0x3a8520, e(0x24a00000)), + (22, 0x3a8521, e(0x24b00000)), + (22, 0x3a8522, e(0x24c00000)), + (22, 0x3a8523, e(0x24d00000)), + (22, 0x3a8524, e(0x24e00000)), + (22, 0x3a8525, e(0x24f00000)), + (22, 0x3a8526, e(0x25000000)), + (22, 0x3a8527, e(0x25100000)), + (22, 0x3a8528, e(0x25200000)), + (22, 0x3a8529, e(0x25300000)), + (22, 0x3a852a, e(0x25400000)), + (22, 0x3a852b, e(0x25500000)), + (22, 0x3a852c, e(0x25600000)), + (22, 0x3a852d, e(0x25700000)), + (22, 0x3a852e, e(0x25800000)), + (22, 0x3a852f, e(0x25900000)), + (22, 0x3a8530, e(0x25a00000)), + (22, 0x3a8531, e(0x25b00000)), + (22, 0x3a8532, e(0x25c00000)), + (22, 0x3a8533, e(0x25d00000)), + (22, 0x3a8534, e(0x25e00000)), + (22, 0x3a8535, e(0x25f00000)), + (22, 0x3a8536, e(0x26000000)), + (22, 0x3a8537, e(0x26100000)), + (22, 0x3a8538, e(0x26200000)), + (22, 0x3a8539, e(0x26300000)), + (22, 0x3a853a, e(0x26400000)), + (22, 0x3a853b, e(0x26500000)), + (22, 0x3a853c, e(0x26600000)), + (22, 0x3a853d, e(0x26700000)), + (22, 0x3a853e, e(0x26800000)), + (22, 0x3a853f, e(0x26900000)), + (22, 0x3a8540, e(0x26a00000)), + (22, 0x3a8541, e(0x26b00000)), + (22, 0x3a8542, e(0x26c00000)), + (22, 0x3a8543, e(0x26d00000)), + (22, 0x3a8544, e(0x26e00000)), + (22, 0x3a8545, e(0x26f00000)), + (22, 0x3a8546, e(0x27000000)), + (22, 0x3a8547, e(0x27100000)), + (22, 0x3a8548, e(0x27200000)), + (22, 0x3a8549, e(0x27300000)), + (22, 0x3a854a, e(0x27400000)), + (22, 0x3a854b, e(0x27500000)), + (22, 0x3a854c, e(0x27600000)), + (22, 0x3a854d, e(0x27700000)), + (22, 0x3a854e, e(0x27800000)), + (22, 0x3a854f, e(0x27900000)), + (22, 0x3a8550, e(0x27a00000)), + (22, 0x3a8551, e(0x27b00000)), + (22, 0x3a8552, e(0x27c00000)), + (22, 0x3a8553, e(0x27d00000)), + (22, 0x3a8554, e(0x27e00000)), + (22, 0x3a8555, e(0x27f00000)), + (22, 0x3a8556, e(0x28000000)), + (22, 0x3a8557, e(0x28100000)), + (22, 0x3a8558, e(0x28200000)), + (22, 0x3a8559, e(0x28300000)), + (22, 0x3a855a, e(0x28400000)), + (22, 0x3a855b, e(0x28500000)), + (22, 0x3a855c, e(0x28600000)), + (22, 0x3a855d, e(0x28700000)), + (22, 0x3a855e, e(0x28800000)), + (22, 0x3a855f, e(0x28900000)), + (22, 0x3a8560, e(0x28a00000)), + (22, 0x3a8561, e(0x28b00000)), + (22, 0x3a8562, e(0x28c00000)), + (22, 0x3a8563, e(0x28d00000)), + (22, 0x3a8564, e(0x28e00000)), + (22, 0x3a8565, e(0x28f00000)), + (22, 0x3a8566, e(0x29000000)), + (22, 0x3a8567, e(0x29100000)), + (22, 0x3a8568, e(0x29200000)), + (22, 0x3a8569, e(0x29300000)), + (22, 0x3a856a, e(0x29400000)), + (22, 0x3a856b, e(0x29500000)), + (22, 0x3a856c, e(0x29600000)), + (22, 0x3a856d, e(0x29700000)), + (22, 0x3a856e, e(0x29800000)), + (22, 0x3a856f, e(0x29900000)), + (22, 0x3a8570, e(0x29a00000)), + (22, 0x3a8571, e(0x29b00000)), + (22, 0x3a8572, e(0x29c00000)), + (22, 0x3a8573, e(0x29d00000)), + (22, 0x3a8574, e(0x29e00000)), + (22, 0x3a8575, e(0x29f00000)), + (22, 0x3a8576, e(0x2a000000)), + (22, 0x3a8577, e(0x2a100000)), + (22, 0x3a8578, e(0x2a200000)), + (22, 0x3a8579, e(0x2a300000)), + (22, 0x3a857a, e(0x2a400000)), + (22, 0x3a857b, e(0x2a500000)), + (22, 0x3a857c, e(0x2a600000)), + (22, 0x3a857d, e(0x2a700000)), + (22, 0x3a857e, e(0x2a800000)), + (22, 0x3a857f, e(0x2a900000)), + (22, 0x3a8580, e(0x2aa00000)), + (22, 0x3a8581, e(0x2ab00000)), + (22, 0x3a8582, e(0x2ac00000)), + (22, 0x3a8583, e(0x2ad00000)), + (22, 0x3a8584, e(0x2ae00000)), + (22, 0x3a8585, e(0x2af00000)), + (22, 0x3a8586, e(0x2b000000)), + (22, 0x3a8587, e(0x2b100000)), + (22, 0x3a8588, e(0x2b200000)), + (22, 0x3a8589, e(0x2b300000)), + (22, 0x3a858a, e(0x2b400000)), + (22, 0x3a858b, e(0x2b500000)), + (22, 0x3a858c, e(0x2b600000)), + (22, 0x3a858d, e(0x2b700000)), + (22, 0x3a858e, e(0x2b800000)), + (22, 0x3a858f, e(0x2b900000)), + (22, 0x3a8590, e(0x2ba00000)), + (22, 0x3a8591, e(0x2bb00000)), + (22, 0x3a8592, e(0x2bc00000)), + (22, 0x3a8593, e(0x2bd00000)), + (22, 0x3a8594, e(0x2be00000)), + (22, 0x3a8595, e(0x2bf00000)), + (22, 0x3a8596, e(0x2c000000)), + (22, 0x3a8597, e(0x2c100000)), + (22, 0x3a8598, e(0x2c200000)), + (22, 0x3a8599, e(0x2c300000)), + (22, 0x3a859a, e(0x2c400000)), + (22, 0x3a859b, e(0x2c500000)), + (22, 0x3a859c, e(0x2c600000)), + (22, 0x3a859d, e(0x2c700000)), + (22, 0x3a859e, e(0x2c800000)), + (22, 0x3a859f, e(0x2c900000)), + (22, 0x3a85a0, e(0x2ca00000)), + (22, 0x3a85a1, e(0x2cb00000)), + (22, 0x3a85a2, e(0x2cc00000)), + (22, 0x3a85a3, e(0x2cd00000)), + (22, 0x3a85a4, e(0x2ce00000)), + (22, 0x3a85a5, e(0x2cf00000)), + (22, 0x3a85a6, e(0x2d000000)), + (22, 0x3a85a7, e(0x2d100000)), + (22, 0x3a85a8, e(0x2d200000)), + (22, 0x3a85a9, e(0x2d300000)), + (22, 0x3a85aa, e(0x2d400000)), + (22, 0x3a85ab, e(0x2d500000)), + (22, 0x3a85ac, e(0x2d600000)), + (22, 0x3a85ad, e(0x2d700000)), + (22, 0x3a85ae, e(0x2d800000)), + (22, 0x3a85af, e(0x2d900000)), + (22, 0x3a85b0, e(0x2da00000)), + (22, 0x3a85b1, e(0x2db00000)), + (22, 0x3a85b2, e(0x2dc00000)), + (22, 0x3a85b3, e(0x2dd00000)), + (22, 0x3a85b4, e(0x2de00000)), + (22, 0x3a85b5, e(0x2df00000)), + (22, 0x3a85b6, e(0x2e000000)), + (22, 0x3a85b7, e(0x2e100000)), + (22, 0x3a85b8, e(0x2e200000)), + (22, 0x3a85b9, e(0x2e300000)), + (22, 0x3a85ba, e(0x2e400000)), + (22, 0x3a85bb, e(0x2e500000)), + (22, 0x3a85bc, e(0x2e600000)), + (22, 0x3a85bd, e(0x2e700000)), + (22, 0x3a85be, e(0x2e800000)), + (22, 0x3a85bf, e(0x2e900000)), + (22, 0x3a85c0, e(0x2ea00000)), + (22, 0x3a85c1, e(0x2eb00000)), + (22, 0x3a85c2, e(0x2ec00000)), + (22, 0x3a85c3, e(0x2ed00000)), + (22, 0x3a85c4, e(0x2ee00000)), + (22, 0x3a85c5, e(0x2ef00000)), + (22, 0x3a85c6, e(0x2f000000)), + (22, 0x3a85c7, e(0x2f100000)), + (22, 0x3a85c8, e(0x2f200000)), + (22, 0x3a85c9, e(0x2f300000)), + (22, 0x3a85ca, e(0x2f400000)), + (22, 0x3a85cb, e(0x2f500000)), + (22, 0x3a85cc, e(0x2f600000)), + (22, 0x3a85cd, e(0x2f700000)), + (22, 0x3a85ce, e(0x2f800000)), + (22, 0x3a85cf, e(0x2f900000)), + (22, 0x3a85d0, e(0x2fa00000)), + (22, 0x3a85d1, e(0x2fb00000)), + (22, 0x3a85d2, e(0x2fc00000)), + (22, 0x3a85d3, e(0x2fd00000)), + (22, 0x3a85d4, e(0x2fe00000)), + (22, 0x3a85d5, e(0x2ff00000)), + (22, 0x3a85d6, e(0x30000000)), + (22, 0x3a85d7, e(0x30100000)), + (22, 0x3a85d8, e(0x30200000)), + (22, 0x3a85d9, e(0x30300000)), + (22, 0x3a85da, e(0x30400000)), + (22, 0x3a85db, e(0x30500000)), + (22, 0x3a85dc, e(0x30600000)), + (22, 0x3a85dd, e(0x30700000)), + (22, 0x3a85de, e(0x30800000)), + (22, 0x3a85df, e(0x30900000)), + (22, 0x3a85e0, e(0x30a00000)), + (22, 0x3a85e1, e(0x30b00000)), + (22, 0x3a85e2, e(0x30c00000)), + (22, 0x3a85e3, e(0x30d00000)), + (22, 0x3a85e4, e(0x30e00000)), + (22, 0x3a85e5, e(0x30f00000)), + (22, 0x3a85e6, e(0x31000000)), + (22, 0x3a85e7, e(0x31100000)), + (22, 0x3a85e8, e(0x31200000)), + (22, 0x3a85e9, e(0x31300000)), + (22, 0x3a85ea, e(0x31400000)), + (22, 0x3a85eb, e(0x31500000)), + (22, 0x3a85ec, e(0x31600000)), + (22, 0x3a85ed, e(0x31700000)), + (22, 0x3a85ee, e(0x31800000)), + (22, 0x3a85ef, e(0x31900000)), + (22, 0x3a85f0, e(0x31a00000)), + (22, 0x3a85f1, e(0x31b00000)), + (22, 0x3a85f2, e(0x31c00000)), + (22, 0x3a85f3, e(0x31d00000)), + (22, 0x3a85f4, e(0x31e00000)), + (22, 0x3a85f5, e(0x31f00000)), + (22, 0x3a85f6, e(0x32000000)), + (22, 0x3a85f7, e(0x32100000)), + (22, 0x3a85f8, e(0x32200000)), + (22, 0x3a85f9, e(0x32300000)), + (22, 0x3a85fa, e(0x32400000)), + (22, 0x3a85fb, e(0x32500000)), + (22, 0x3a85fc, e(0x32600000)), + (22, 0x3a85fd, e(0x32700000)), + (22, 0x3a85fe, e(0x32800000)), + (22, 0x3a85ff, e(0x32900000)), + (22, 0x3a8600, e(0x32a00000)), + (22, 0x3a8601, e(0x32b00000)), + (22, 0x3a8602, e(0x32c00000)), + (22, 0x3a8603, e(0x32d00000)), + (22, 0x3a8604, e(0x32e00000)), + (22, 0x3a8605, e(0x32f00000)), + (22, 0x3a8606, e(0x33000000)), + (22, 0x3a8607, e(0x33100000)), + (22, 0x3a8608, e(0x33200000)), + (22, 0x3a8609, e(0x33300000)), + (22, 0x3a860a, e(0x33400000)), + (22, 0x3a860b, e(0x33500000)), + (22, 0x3a860c, e(0x33600000)), + (22, 0x3a860d, e(0x33700000)), + (22, 0x3a860e, e(0x33800000)), + (22, 0x3a860f, e(0x33900000)), + (22, 0x3a8610, e(0x33a00000)), + (22, 0x3a8611, e(0x33b00000)), + (22, 0x3a8612, e(0x33c00000)), + (22, 0x3a8613, e(0x33d00000)), + (22, 0x3a8614, e(0x33e00000)), + (22, 0x3a8615, e(0x33f00000)), + (22, 0x3a8616, e(0x34000000)), + (22, 0x3a8617, e(0x34100000)), + (22, 0x3a8618, e(0x34200000)), + (22, 0x3a8619, e(0x34300000)), + (22, 0x3a861a, e(0x34400000)), + (22, 0x3a861b, e(0x34500000)), + (22, 0x3a861c, e(0x34600000)), + (22, 0x3a861d, e(0x34700000)), + (22, 0x3a861e, e(0x34800000)), + (22, 0x3a861f, e(0x34900000)), + (22, 0x3a8620, e(0x34a00000)), + (22, 0x3a8621, e(0x34b00000)), + (22, 0x3a8622, e(0x34c00000)), + (22, 0x3a8623, e(0x34d00000)), + (22, 0x3a8624, e(0x34e00000)), + (22, 0x3a8625, e(0x34f00000)), + (22, 0x3a8626, e(0x35000000)), + (22, 0x3a8627, e(0x35100000)), + (22, 0x3a8628, e(0x35200000)), + (22, 0x3a8629, e(0x35300000)), + (18, 0xf787, e(0x35400000)), + (18, 0xd1d4, e(0x35500000)), + (19, 0x77f9f, e(0x35600000)), + (18, 0x3b8fb, e(0x35700000)), + (19, 0x1ef1a, e(0x35800000)), + (21, 0x1d4315, e(0x35900000)), + (16, 0x3de2, e(0x35a00000)), + (22, 0x3a862c, e(0x35b00000)), + (22, 0x3a862d, e(0x35c00000)), + (22, 0x3a862e, e(0x35d00000)), + (22, 0x3a862f, e(0x35e00000)), + (22, 0x3a8630, e(0x35f00000)), + (22, 0x3a8631, e(0x36000000)), + (22, 0x3a8632, e(0x36100000)), + (22, 0x3a8633, e(0x36200000)), + (22, 0x3a8634, e(0x36300000)), + (22, 0x3a8635, e(0x36400000)), + (22, 0x3a8636, e(0x36500000)), + (22, 0x3a8637, e(0x36600000)), + (22, 0x3a8638, e(0x36700000)), + (21, 0x1d431d, e(0x36800000)), + (22, 0x3a8639, e(0x36900000)), + (22, 0x3a863c, e(0x36a00000)), + (16, 0x3de0, e(0x36b00000)), + (18, 0x3a95e, e(0x36c00000)), + (21, 0x1d431f, e(0x36d00000)), + (22, 0x3a863d, e(0x36e00000)), + (22, 0x3a8640, e(0x36f00000)), + (20, 0x34749, e(0x37000000)), + (20, 0x3474d, e(0x37100000)), + (22, 0x3a8641, e(0x37200000)), + (22, 0x3a8642, e(0x37300000)), + (22, 0x3a8643, e(0x37400000)), + (22, 0x3a8644, e(0x37500000)), + (22, 0x3a8645, e(0x37600000)), + (22, 0x3a8646, e(0x37700000)), + (22, 0x3a8647, e(0x37800000)), + (22, 0x3a8648, e(0x37900000)), + (22, 0x3a8649, e(0x37a00000)), + (22, 0x3a864a, e(0x37b00000)), + (22, 0x3a864b, e(0x37c00000)), + (22, 0x3a864c, e(0x37d00000)), + (22, 0x3a864d, e(0x37e00000)), + (22, 0x3a864e, e(0x37f00000)), + (22, 0x3a864f, e(0x38000000)), + (22, 0x3a8650, e(0x38100000)), + (22, 0x3a8651, e(0x38200000)), + (22, 0x3a8652, e(0x38300000)), + (22, 0x3a8653, e(0x38400000)), + (22, 0x3a8654, e(0x38500000)), + (22, 0x3a8655, e(0x38600000)), + (22, 0x3a8656, e(0x38700000)), + (22, 0x3a8657, e(0x38800000)), + (22, 0x3a8658, e(0x38900000)), + (22, 0x3a8659, e(0x38a00000)), + (22, 0x3a865a, e(0x38b00000)), + (22, 0x3a865b, e(0x38c00000)), + (22, 0x3a865c, e(0x38d00000)), + (22, 0x3a865d, e(0x38e00000)), + (21, 0x1d432f, e(0x38f00000)), + (22, 0x3a8660, e(0x39000000)), + (22, 0x3a8661, e(0x39100000)), + (22, 0x3a8662, e(0x39200000)), + (21, 0x1d4332, e(0x39300000)), + (18, 0xd1d5, e(0x39400000)), + (18, 0x3bfda, e(0x39500000)), + (15, 0x7528, e(0x39600000)), + (15, 0x7529, e(0x39700000)), + (18, 0x3a95f, e(0x39800000)), + (17, 0x1d434, e(0x39900000)), + (19, 0x750cd, e(0x39a00000)), + (18, 0x3a867, e(0x39b00000)), + (19, 0x771fd, e(0x39c00000)), + (15, 0x7764, e(0x39d00000)), + (20, 0xee3f9, e(0x39e00000)), + (18, 0x3bfdb, e(0x39f00000)), + (16, 0xeff7, e(0x3a000000)), + (18, 0xd1d6, e(0x3a100000)), + (22, 0x3a8663, e(0x3a200000)), + (22, 0x3a8666, e(0x3a300000)), + (18, 0x3b8fa, e(0x3a400000)), + (17, 0x1d435, e(0x3a500000)), + (17, 0x1dfe4, e(0x3a600000)), + (19, 0x750d8, e(0x3a700000)), + (18, 0x3a95b, e(0x3a800000)), + (19, 0x77f9e, e(0x3a900000)), + (19, 0x750d9, e(0x3aa00000)), + (18, 0xd1d7, e(0x3ab00000)), + (18, 0x3b8ff, e(0x3ac00000)), + (17, 0x1dc7c, e(0x3ad00000)), + (19, 0x750da, e(0x3ae00000)), + (17, 0x7bc2, e(0x3af00000)), + (18, 0x3bfca, e(0x3b000000)), + (19, 0x1a3a5, e(0x3b100000)), + (17, 0x1d4ac, e(0x3b200000)), + (18, 0x3a86e, e(0x3b300000)), + (17, 0x1d438, e(0x3b400000)), + (18, 0x3bfcb, e(0x3b500000)), + (19, 0x1a3a7, e(0x3b600000)), + (17, 0x1d4ae, e(0x3b700000)), + (18, 0x3bfce, e(0x3b800000)), + (18, 0xf78c, e(0x3b900000)), + (17, 0x1dfec, e(0x3ba00000)), + (17, 0x1d439, e(0x3bb00000)), + (17, 0x68e8, e(0x3bc00000)), + (18, 0xf786, e(0x3bd00000)), + (15, 0x771e, e(0x3be00000)), + (17, 0x1dfe6, e(0x3bf00000)), + (15, 0x77f8, e(0x3c000000)), + (14, 0x3bb3, e(0x3c100000)), + (14, 0x3b8e, e(0x3c200000)), + (14, 0x3a82, e(0x3c300000)), + (14, 0x3b96, e(0x3c400000)), + (13, 0x68f, e(0x3c500000)), + (12, 0x3d4, e(0x3c600000)), + (13, 0x1dca, e(0x3c700000)), + (12, 0x346, e(0x3c800000)), + (12, 0xee7, e(0x3c900000)), + (12, 0xeea, e(0x3ca00000)), + (11, 0x1ed, e(0x3cb00000)), + (12, 0x3df, e(0x3cc00000)), + (11, 0x1a2, e(0x3cd00000)), + (11, 0x56f, e(0x3ce00000)), + (11, 0xb9, e(0x3cf00000)), + (13, 0x7b2, e(0x3d000000)), + (13, 0x1dd8, e(0x3d100000)), + (13, 0x15ba, e(0x3d200000)), + (12, 0xee6, e(0x3d300000)), + (13, 0x15b8, e(0x3d400000)), + (14, 0xf79, e(0x3d500000)), + (14, 0x3a81, e(0x3d600000)), + (14, 0xd1c, e(0x3d700000)), + (15, 0x7765, e(0x3d800000)), + (14, 0xf54, e(0x3d900000)), + (13, 0x15b9, e(0x3da00000)), + (13, 0x7ab, e(0x3db00000)), + (15, 0x7500, e(0x3dc00000)), + (15, 0x1eaa, e(0x3dd00000)), + (15, 0x7501, e(0x3de00000)), + (15, 0x1eab, e(0x3df00000)), + (14, 0x3b97, e(0x3e000000)), + (15, 0x752a, e(0x3e100000)), + (15, 0x77fa, e(0x3e200000)), + (10, 0xf4, d(0x3e35798e, 0xe2308c3a)), + (14, 0x3a93, e(0x3e300000)), + (11, 0x77c, d(0x3e45798e, 0xe2308c3a)), + (13, 0x1dc6, e(0x3e400000)), + (13, 0x7bd, e(0x3e500000)), + (13, 0x1dff, e(0x3e600000)), + (12, 0xefe, e(0x3e700000)), + (8, 0xaf, d(0x3e8ad7f2, 0x9abcaf4a)), + (12, 0xeed, e(0x3e800000)), + (11, 0xb8, e(0x3e900000)), + (12, 0x3d8, e(0x3ea00000)), + (11, 0x1eb, e(0x3eb00000)), + (9, 0x1d2, d(0x3ec0c6f7, 0xa0b5ed8e)), + (13, 0x1d4b, e(0x3ec00000)), + (13, 0x7b3, e(0x3ed00000)), + (10, 0x5d, e(0x3ee00000)), + (12, 0xeeb, e(0x3ef00000)), + (11, 0x1ee, e(0x3f000000)), + (10, 0x5f, e(0x3f100000)), + (10, 0x2b6, e(0x3f200000)), + (9, 0x1de, e(0x3f300000)), + (10, 0xd0, d(0x3f454c98, 0x5f06f694)), + (6, 0x9, d(0x3f4a36e2, 0xeb1c432d)), + (8, 0xe8, e(0x3f400000)), + (4, 0xf, d(0x3f50624d, 0xd2f1a9fc)), + (8, 0xae, e(0x3f500000)), + (5, 0x16, d(0x3f60624d, 0xd2f1a9fc)), + (7, 0x1b, e(0x3f600000)), + (7, 0x76, e(0x3f700000)), + (7, 0xa, e(0x3f800000)), + (6, 0x8, e(0x3f900000)), + (6, 0xe, e(0x3fa00000)), + (11, 0x751, d(0x3fbe69ad, 0x42c3c9ee)), + (6, 0x4, e(0x3fb00000)), + (6, 0xc, e(0x3fc00000)), + (5, 0x3, e(0x3fd00000)), + (11, 0x777, d(0x3fe00000, 0x00000000)), + (9, 0x1d6, d(0x3fefffff, 0xf8000002)), + (4, 0x8, e(0x3fe00000)), + (4, 0x0, d(0x3ff00000, 0x00000000)), + (5, 0x13, e(0x3ff00000)), + (5, 0x1b, e(0x40000000)), + (9, 0x15a, d(0x401921fb, 0x54442d18)), + (5, 0x17, e(0x40100000)), + (5, 0x12, e(0x40200000)), + (11, 0x774, d(0x4035ee14, 0x80000000)), + (5, 0x19, e(0x40300000)), + (9, 0x1d3, d(0x404ca5dc, 0x1a63c1f8)), + (5, 0x1a, e(0x40400000)), + (11, 0x77e, d(0x405bb32f, 0xe0000000)), + (10, 0x5e, d(0x405c332f, 0xe0000000)), + (5, 0x18, e(0x40500000)), + (9, 0x1d7, d(0x40668000, 0x00000000)), + (5, 0x1c, e(0x40600000)), + (9, 0x1d5, d(0x40768000, 0x00000000)), + (5, 0x14, e(0x40700000)), + (11, 0x77d, d(0x408f4000, 0x00000000)), + (5, 0x5, e(0x40800000)), + (10, 0xd2, d(0x409233ff, 0xffffffff)), + (8, 0x3c, d(0x40923400, 0x00000000)), + (11, 0x753, d(0x40923400, 0x00000001)), + (10, 0xd3, d(0x4092abff, 0xffffffff)), + (8, 0x35, d(0x4092ac00, 0x00000000)), + (11, 0x770, d(0x4092ac00, 0x00000001)), + (8, 0x16, e(0x40900000)), + (12, 0xee2, e(0x40a00000)), + (12, 0xee4, e(0x40b00000)), + (7, 0x1f, d(0x40c81c80, 0x00000000)), + (8, 0xac, e(0x40c00000)), + (13, 0x15bb, e(0x40d00000)), + (22, 0x3a8667, e(0x40e00000)), + (22, 0x3a86d8, e(0x40f00000)), + (22, 0x3a86d9, e(0x41000000)), + (22, 0x3a86da, e(0x41100000)), + (17, 0x1dc7e, e(0x41200000)), + (22, 0x3a86db, e(0x41300000)), + (22, 0x3a86dc, e(0x41400000)), + (22, 0x3a86dd, e(0x41500000)), + (22, 0x3a86de, e(0x41600000)), + (22, 0x3a86df, e(0x41700000)), + (22, 0x3a86f0, e(0x41800000)), + (22, 0x3a86f1, e(0x41900000)), + (22, 0x3a86f2, e(0x41a00000)), + (22, 0x3a86f3, e(0x41b00000)), + (6, 0x2a, d(0x41cdcd64, 0xff800000)), + (22, 0x3a86f4, e(0x41c00000)), + (22, 0x3a86f5, e(0x41d00000)), + (22, 0x3a86f6, e(0x41e00000)), + (22, 0x3a86f7, e(0x41f00000)), + (22, 0x3a86f8, e(0x42000000)), + (22, 0x3a86f9, e(0x42100000)), + (22, 0x3a86fa, e(0x42200000)), + (22, 0x3a86fb, e(0x42300000)), + (22, 0x3a86fc, e(0x42400000)), + (22, 0x3a86fd, e(0x42500000)), + (22, 0x3a86fe, e(0x42600000)), + (22, 0x3a86ff, e(0x42700000)), + (22, 0x3a8740, e(0x42800000)), + (22, 0x3a8741, e(0x42900000)), + (22, 0x3a8742, e(0x42a00000)), + (22, 0x3a8743, e(0x42b00000)), + (22, 0x3a8744, e(0x42c00000)), + (22, 0x3a8745, e(0x42d00000)), + (22, 0x3a8746, e(0x42e00000)), + (22, 0x3a8747, e(0x42f00000)), + (22, 0x3a8748, e(0x43000000)), + (22, 0x3a8749, e(0x43100000)), + (22, 0x3a874a, e(0x43200000)), + (22, 0x3a874b, e(0x43300000)), + (22, 0x3a874c, e(0x43400000)), + (22, 0x3a874d, e(0x43500000)), + (22, 0x3a874e, e(0x43600000)), + (22, 0x3a874f, e(0x43700000)), + (22, 0x3a8750, e(0x43800000)), + (22, 0x3a8751, e(0x43900000)), + (22, 0x3a8752, e(0x43a00000)), + (22, 0x3a8753, e(0x43b00000)), + (22, 0x3a8754, e(0x43c00000)), + (22, 0x3a8755, e(0x43d00000)), + (22, 0x3a8756, e(0x43e00000)), + (22, 0x3a8757, e(0x43f00000)), + (22, 0x3a8758, e(0x44000000)), + (15, 0x1a3b, e(0x44100000)), + (22, 0x3a8759, e(0x44200000)), + (22, 0x3a875a, e(0x44300000)), + (22, 0x3a875b, e(0x44400000)), + (22, 0x3a875c, e(0x44500000)), + (22, 0x3a875d, e(0x44600000)), + (22, 0x3a875e, e(0x44700000)), + (22, 0x3a875f, e(0x44800000)), + (22, 0x3a8760, e(0x44900000)), + (22, 0x3a8761, e(0x44a00000)), + (22, 0x3a8762, e(0x44b00000)), + (22, 0x3a8763, e(0x44c00000)), + (22, 0x3a8764, e(0x44d00000)), + (22, 0x3a8765, e(0x44e00000)), + (22, 0x3a8766, e(0x44f00000)), + (22, 0x3a8767, e(0x45000000)), + (22, 0x3a8768, e(0x45100000)), + (22, 0x3a8769, e(0x45200000)), + (22, 0x3a876a, e(0x45300000)), + (22, 0x3a876b, e(0x45400000)), + (22, 0x3a876c, e(0x45500000)), + (22, 0x3a876d, e(0x45600000)), + (22, 0x3a876e, e(0x45700000)), + (22, 0x3a876f, e(0x45800000)), + (22, 0x3a8770, e(0x45900000)), + (22, 0x3a8771, e(0x45a00000)), + (22, 0x3a8772, e(0x45b00000)), + (22, 0x3a8773, e(0x45c00000)), + (22, 0x3a8774, e(0x45d00000)), + (22, 0x3a8775, e(0x45e00000)), + (22, 0x3a8776, e(0x45f00000)), + (22, 0x3a8777, e(0x46000000)), + (22, 0x3a8778, e(0x46100000)), + (22, 0x3a8779, e(0x46200000)), + (22, 0x3a877a, e(0x46300000)), + (22, 0x3a877b, e(0x46400000)), + (22, 0x3a877c, e(0x46500000)), + (22, 0x3a877d, e(0x46600000)), + (22, 0x3a877e, e(0x46700000)), + (22, 0x3a877f, e(0x46800000)), + (22, 0x3a8780, e(0x46900000)), + (22, 0x3a8781, e(0x46a00000)), + (22, 0x3a8782, e(0x46b00000)), + (22, 0x3a8783, e(0x46c00000)), + (22, 0x3a8784, e(0x46d00000)), + (22, 0x3a8785, e(0x46e00000)), + (22, 0x3a8786, e(0x46f00000)), + (22, 0x3a8787, e(0x47000000)), + (22, 0x3a8788, e(0x47100000)), + (22, 0x3a8789, e(0x47200000)), + (22, 0x3a878a, e(0x47300000)), + (22, 0x3a878b, e(0x47400000)), + (22, 0x3a878c, e(0x47500000)), + (22, 0x3a878d, e(0x47600000)), + (22, 0x3a878e, e(0x47700000)), + (22, 0x3a878f, e(0x47800000)), + (22, 0x3a8790, e(0x47900000)), + (22, 0x3a8791, e(0x47a00000)), + (22, 0x3a8792, e(0x47b00000)), + (22, 0x3a8793, e(0x47c00000)), + (22, 0x3a8794, e(0x47d00000)), + (22, 0x3a8795, e(0x47e00000)), + (22, 0x3a8796, e(0x47f00000)), + (22, 0x3a8797, e(0x48000000)), + (22, 0x3a8798, e(0x48100000)), + (22, 0x3a8799, e(0x48200000)), + (22, 0x3a879a, e(0x48300000)), + (22, 0x3a879b, e(0x48400000)), + (22, 0x3a879c, e(0x48500000)), + (22, 0x3a879d, e(0x48600000)), + (22, 0x3a879e, e(0x48700000)), + (22, 0x3a879f, e(0x48800000)), + (22, 0x3a87a0, e(0x48900000)), + (22, 0x3a87a1, e(0x48a00000)), + (22, 0x3a87a2, e(0x48b00000)), + (22, 0x3a87a3, e(0x48c00000)), + (22, 0x3a87a4, e(0x48d00000)), + (22, 0x3a87a5, e(0x48e00000)), + (22, 0x3a87a6, e(0x48f00000)), + (22, 0x3a87a7, e(0x49000000)), + (22, 0x3a87a8, e(0x49100000)), + (22, 0x3a87a9, e(0x49200000)), + (22, 0x3a87aa, e(0x49300000)), + (22, 0x3a87ab, e(0x49400000)), + (22, 0x3a87ac, e(0x49500000)), + (22, 0x3a87ad, e(0x49600000)), + (22, 0x3a87ae, e(0x49700000)), + (22, 0x3a87af, e(0x49800000)), + (22, 0x3a87b0, e(0x49900000)), + (22, 0x3a87b1, e(0x49a00000)), + (22, 0x3a87b2, e(0x49b00000)), + (22, 0x3a87b3, e(0x49c00000)), + (22, 0x3a87b4, e(0x49d00000)), + (22, 0x3a87b5, e(0x49e00000)), + (22, 0x3a87b6, e(0x49f00000)), + (22, 0x3a87b7, e(0x4a000000)), + (22, 0x3a87b8, e(0x4a100000)), + (22, 0x3a87b9, e(0x4a200000)), + (22, 0x3a87ba, e(0x4a300000)), + (22, 0x3a87bb, e(0x4a400000)), + (22, 0x3a87bc, e(0x4a500000)), + (22, 0x3a87bd, e(0x4a600000)), + (22, 0x3a87be, e(0x4a700000)), + (22, 0x3a87bf, e(0x4a800000)), + (22, 0x3a87c0, e(0x4a900000)), + (22, 0x3a87c1, e(0x4aa00000)), + (22, 0x3a87c2, e(0x4ab00000)), + (22, 0x3a87c3, e(0x4ac00000)), + (22, 0x3a87c4, e(0x4ad00000)), + (22, 0x3a87c5, e(0x4ae00000)), + (22, 0x3a87c6, e(0x4af00000)), + (22, 0x3a87c7, e(0x4b000000)), + (22, 0x3a87c8, e(0x4b100000)), + (22, 0x3a87c9, e(0x4b200000)), + (22, 0x3a87ca, e(0x4b300000)), + (22, 0x3a87cb, e(0x4b400000)), + (22, 0x3a87cc, e(0x4b500000)), + (22, 0x3a87cd, e(0x4b600000)), + (22, 0x3a87ce, e(0x4b700000)), + (22, 0x3a87cf, e(0x4b800000)), + (22, 0x3a87d0, e(0x4b900000)), + (22, 0x3a87d1, e(0x4ba00000)), + (22, 0x3a87d2, e(0x4bb00000)), + (22, 0x3a87d3, e(0x4bc00000)), + (22, 0x3a87d4, e(0x4bd00000)), + (22, 0x3a87d5, e(0x4be00000)), + (22, 0x3a87d6, e(0x4bf00000)), + (22, 0x3a87d7, e(0x4c000000)), + (22, 0x3a87d8, e(0x4c100000)), + (22, 0x3a87d9, e(0x4c200000)), + (22, 0x3a87da, e(0x4c300000)), + (22, 0x3a87db, e(0x4c400000)), + (22, 0x3a87dc, e(0x4c500000)), + (22, 0x3a87dd, e(0x4c600000)), + (22, 0x3a87de, e(0x4c700000)), + (22, 0x3a87df, e(0x4c800000)), + (22, 0x3a87e0, e(0x4c900000)), + (22, 0x3a87e1, e(0x4ca00000)), + (22, 0x3a87e2, e(0x4cb00000)), + (22, 0x3a87e3, e(0x4cc00000)), + (22, 0x3a87e4, e(0x4cd00000)), + (22, 0x3a87e5, e(0x4ce00000)), + (22, 0x3a87e6, e(0x4cf00000)), + (22, 0x3a87e7, e(0x4d000000)), + (22, 0x3a87e8, e(0x4d100000)), + (22, 0x3a87e9, e(0x4d200000)), + (22, 0x3a87ea, e(0x4d300000)), + (22, 0x3a87eb, e(0x4d400000)), + (22, 0x3a87ec, e(0x4d500000)), + (22, 0x3a87ed, e(0x4d600000)), + (22, 0x3a87ee, e(0x4d700000)), + (22, 0x3a87ef, e(0x4d800000)), + (22, 0x3a87f0, e(0x4d900000)), + (22, 0x3a87f1, e(0x4da00000)), + (22, 0x3a87f2, e(0x4db00000)), + (22, 0x3a87f3, e(0x4dc00000)), + (22, 0x3a87f4, e(0x4dd00000)), + (22, 0x3a87f5, e(0x4de00000)), + (22, 0x3a87f6, e(0x4df00000)), + (22, 0x3a87f7, e(0x4e000000)), + (22, 0x3a87f8, e(0x4e100000)), + (22, 0x3a87f9, e(0x4e200000)), + (22, 0x3a87fa, e(0x4e300000)), + (22, 0x3a87fb, e(0x4e400000)), + (22, 0x3a87fc, e(0x4e500000)), + (22, 0x3a87fd, e(0x4e600000)), + (22, 0x3a87fe, e(0x4e700000)), + (22, 0x3a87ff, e(0x4e800000)), + (22, 0x3a9000, e(0x4e900000)), + (22, 0x3a9001, e(0x4ea00000)), + (22, 0x3a9002, e(0x4eb00000)), + (22, 0x3a9003, e(0x4ec00000)), + (22, 0x3a9004, e(0x4ed00000)), + (22, 0x3a9005, e(0x4ee00000)), + (22, 0x3a9006, e(0x4ef00000)), + (22, 0x3a9007, e(0x4f000000)), + (22, 0x3a9008, e(0x4f100000)), + (22, 0x3a9009, e(0x4f200000)), + (22, 0x3a900a, e(0x4f300000)), + (22, 0x3a900b, e(0x4f400000)), + (22, 0x3a900c, e(0x4f500000)), + (22, 0x3a900d, e(0x4f600000)), + (22, 0x3a900e, e(0x4f700000)), + (22, 0x3a900f, e(0x4f800000)), + (22, 0x3a9010, e(0x4f900000)), + (22, 0x3a9011, e(0x4fa00000)), + (22, 0x3a9012, e(0x4fb00000)), + (22, 0x3a9013, e(0x4fc00000)), + (22, 0x3a9014, e(0x4fd00000)), + (22, 0x3a9015, e(0x4fe00000)), + (22, 0x3a9016, e(0x4ff00000)), + (22, 0x3a9017, e(0x50000000)), + (22, 0x3a9018, e(0x50100000)), + (22, 0x3a9019, e(0x50200000)), + (22, 0x3a901a, e(0x50300000)), + (22, 0x3a901b, e(0x50400000)), + (22, 0x3a901c, e(0x50500000)), + (22, 0x3a901d, e(0x50600000)), + (22, 0x3a901e, e(0x50700000)), + (22, 0x3a901f, e(0x50800000)), + (22, 0x3a9020, e(0x50900000)), + (22, 0x3a9021, e(0x50a00000)), + (22, 0x3a9022, e(0x50b00000)), + (22, 0x3a9023, e(0x50c00000)), + (22, 0x3a9024, e(0x50d00000)), + (22, 0x3a9025, e(0x50e00000)), + (22, 0x3a9026, e(0x50f00000)), + (22, 0x3a9027, e(0x51000000)), + (22, 0x3a9028, e(0x51100000)), + (22, 0x3a9029, e(0x51200000)), + (22, 0x3a902a, e(0x51300000)), + (22, 0x3a902b, e(0x51400000)), + (22, 0x3a902c, e(0x51500000)), + (22, 0x3a902d, e(0x51600000)), + (22, 0x3a902e, e(0x51700000)), + (22, 0x3a902f, e(0x51800000)), + (22, 0x3a9030, e(0x51900000)), + (22, 0x3a9031, e(0x51a00000)), + (22, 0x3a9032, e(0x51b00000)), + (22, 0x3a9033, e(0x51c00000)), + (22, 0x3a9034, e(0x51d00000)), + (22, 0x3a9035, e(0x51e00000)), + (22, 0x3a9036, e(0x51f00000)), + (22, 0x3a9037, e(0x52000000)), + (22, 0x3a9038, e(0x52100000)), + (22, 0x3a9039, e(0x52200000)), + (22, 0x3a903a, e(0x52300000)), + (22, 0x3a903b, e(0x52400000)), + (22, 0x3a903c, e(0x52500000)), + (22, 0x3a903d, e(0x52600000)), + (22, 0x3a903e, e(0x52700000)), + (22, 0x3a903f, e(0x52800000)), + (22, 0x3a9040, e(0x52900000)), + (22, 0x3a9041, e(0x52a00000)), + (22, 0x3a9042, e(0x52b00000)), + (22, 0x3a9043, e(0x52c00000)), + (22, 0x3a9044, e(0x52d00000)), + (22, 0x3a9045, e(0x52e00000)), + (22, 0x3a9046, e(0x52f00000)), + (22, 0x3a9047, e(0x53000000)), + (22, 0x3a9048, e(0x53100000)), + (22, 0x3a9049, e(0x53200000)), + (22, 0x3a904a, e(0x53300000)), + (22, 0x3a904b, e(0x53400000)), + (22, 0x3a904c, e(0x53500000)), + (22, 0x3a904d, e(0x53600000)), + (22, 0x3a904e, e(0x53700000)), + (22, 0x3a904f, e(0x53800000)), + (22, 0x3a9050, e(0x53900000)), + (22, 0x3a9051, e(0x53a00000)), + (22, 0x3a9052, e(0x53b00000)), + (22, 0x3a9053, e(0x53c00000)), + (22, 0x3a9054, e(0x53d00000)), + (22, 0x3a9055, e(0x53e00000)), + (22, 0x3a9056, e(0x53f00000)), + (22, 0x3a9057, e(0x54000000)), + (22, 0x3a9058, e(0x54100000)), + (22, 0x3a9059, e(0x54200000)), + (22, 0x3a905a, e(0x54300000)), + (22, 0x3a905b, e(0x54400000)), + (22, 0x3a905c, e(0x54500000)), + (22, 0x3a905d, e(0x54600000)), + (22, 0x3a905e, e(0x54700000)), + (22, 0x3a905f, e(0x54800000)), + (22, 0x3a9060, e(0x54900000)), + (22, 0x3a9061, e(0x54a00000)), + (22, 0x3a9062, e(0x54b00000)), + (22, 0x3a9063, e(0x54c00000)), + (22, 0x3a9064, e(0x54d00000)), + (22, 0x3a9065, e(0x54e00000)), + (22, 0x3a9066, e(0x54f00000)), + (22, 0x3a9067, e(0x55000000)), + (22, 0x3a9068, e(0x55100000)), + (22, 0x3a9069, e(0x55200000)), + (22, 0x3a906a, e(0x55300000)), + (22, 0x3a906b, e(0x55400000)), + (22, 0x3a906c, e(0x55500000)), + (22, 0x3a906d, e(0x55600000)), + (22, 0x3a906e, e(0x55700000)), + (22, 0x3a906f, e(0x55800000)), + (22, 0x3a9070, e(0x55900000)), + (22, 0x3a9071, e(0x55a00000)), + (22, 0x3a9072, e(0x55b00000)), + (22, 0x3a9073, e(0x55c00000)), + (22, 0x3a9074, e(0x55d00000)), + (22, 0x3a9075, e(0x55e00000)), + (22, 0x3a9076, e(0x55f00000)), + (22, 0x3a9077, e(0x56000000)), + (22, 0x3a9078, e(0x56100000)), + (22, 0x3a9079, e(0x56200000)), + (22, 0x3a907a, e(0x56300000)), + (22, 0x3a907b, e(0x56400000)), + (22, 0x3a907c, e(0x56500000)), + (22, 0x3a907d, e(0x56600000)), + (22, 0x3a907e, e(0x56700000)), + (22, 0x3a907f, e(0x56800000)), + (22, 0x3a9080, e(0x56900000)), + (22, 0x3a9081, e(0x56a00000)), + (22, 0x3a9082, e(0x56b00000)), + (22, 0x3a9083, e(0x56c00000)), + (22, 0x3a9084, e(0x56d00000)), + (22, 0x3a9085, e(0x56e00000)), + (22, 0x3a9086, e(0x56f00000)), + (22, 0x3a9087, e(0x57000000)), + (22, 0x3a9088, e(0x57100000)), + (22, 0x3a9089, e(0x57200000)), + (22, 0x3a908a, e(0x57300000)), + (22, 0x3a908b, e(0x57400000)), + (22, 0x3a908c, e(0x57500000)), + (22, 0x3a908d, e(0x57600000)), + (22, 0x3a908e, e(0x57700000)), + (22, 0x3a908f, e(0x57800000)), + (22, 0x3a9090, e(0x57900000)), + (22, 0x3a9091, e(0x57a00000)), + (22, 0x3a9092, e(0x57b00000)), + (22, 0x3a9093, e(0x57c00000)), + (22, 0x3a9094, e(0x57d00000)), + (22, 0x3a9095, e(0x57e00000)), + (22, 0x3a9096, e(0x57f00000)), + (22, 0x3a9097, e(0x58000000)), + (22, 0x3a9098, e(0x58100000)), + (22, 0x3a9099, e(0x58200000)), + (22, 0x3a909a, e(0x58300000)), + (22, 0x3a909b, e(0x58400000)), + (22, 0x3a909c, e(0x58500000)), + (22, 0x3a909d, e(0x58600000)), + (22, 0x3a909e, e(0x58700000)), + (22, 0x3a909f, e(0x58800000)), + (22, 0x3a90a0, e(0x58900000)), + (22, 0x3a90a1, e(0x58a00000)), + (22, 0x3a90a2, e(0x58b00000)), + (22, 0x3a90a3, e(0x58c00000)), + (22, 0x3a90a4, e(0x58d00000)), + (22, 0x3a90a5, e(0x58e00000)), + (22, 0x3a90a6, e(0x58f00000)), + (22, 0x3a90a7, e(0x59000000)), + (22, 0x3a90a8, e(0x59100000)), + (22, 0x3a90a9, e(0x59200000)), + (22, 0x3a90aa, e(0x59300000)), + (22, 0x3a90ab, e(0x59400000)), + (22, 0x3a90ac, e(0x59500000)), + (22, 0x3a90ad, e(0x59600000)), + (22, 0x3a90ae, e(0x59700000)), + (22, 0x3a90af, e(0x59800000)), + (22, 0x3a90b0, e(0x59900000)), + (22, 0x3a90b1, e(0x59a00000)), + (22, 0x3a90b2, e(0x59b00000)), + (22, 0x3a90b3, e(0x59c00000)), + (22, 0x3a90b4, e(0x59d00000)), + (22, 0x3a90b5, e(0x59e00000)), + (22, 0x3a90b6, e(0x59f00000)), + (22, 0x3a90b7, e(0x5a000000)), + (22, 0x3a90b8, e(0x5a100000)), + (22, 0x3a90b9, e(0x5a200000)), + (22, 0x3a90ba, e(0x5a300000)), + (22, 0x3a90bb, e(0x5a400000)), + (22, 0x3a90bc, e(0x5a500000)), + (22, 0x3a90bd, e(0x5a600000)), + (22, 0x3a90be, e(0x5a700000)), + (22, 0x3a90bf, e(0x5a800000)), + (22, 0x3a90c0, e(0x5a900000)), + (22, 0x3a90c1, e(0x5aa00000)), + (22, 0x3a90c2, e(0x5ab00000)), + (22, 0x3a90c3, e(0x5ac00000)), + (22, 0x3a90c4, e(0x5ad00000)), + (22, 0x3a90c5, e(0x5ae00000)), + (22, 0x3a90c6, e(0x5af00000)), + (22, 0x3a90c7, e(0x5b000000)), + (22, 0x3a90c8, e(0x5b100000)), + (22, 0x3a90c9, e(0x5b200000)), + (22, 0x3a90ca, e(0x5b300000)), + (22, 0x3a90cb, e(0x5b400000)), + (22, 0x3a90cc, e(0x5b500000)), + (22, 0x3a90cd, e(0x5b600000)), + (22, 0x3a90ce, e(0x5b700000)), + (22, 0x3a90cf, e(0x5b800000)), + (22, 0x3a90d0, e(0x5b900000)), + (22, 0x3a90d1, e(0x5ba00000)), + (22, 0x3a90d2, e(0x5bb00000)), + (22, 0x3a90d3, e(0x5bc00000)), + (22, 0x3a90d4, e(0x5bd00000)), + (22, 0x3a90d5, e(0x5be00000)), + (22, 0x3a90d6, e(0x5bf00000)), + (22, 0x3a90d7, e(0x5c000000)), + (22, 0x3a90d8, e(0x5c100000)), + (22, 0x3a90d9, e(0x5c200000)), + (22, 0x3a90da, e(0x5c300000)), + (22, 0x3a90db, e(0x5c400000)), + (22, 0x3a90dc, e(0x5c500000)), + (22, 0x3a90dd, e(0x5c600000)), + (22, 0x3a90de, e(0x5c700000)), + (22, 0x3a90df, e(0x5c800000)), + (22, 0x3a90e0, e(0x5c900000)), + (22, 0x3a90e1, e(0x5ca00000)), + (22, 0x3a90e2, e(0x5cb00000)), + (22, 0x3a90e3, e(0x5cc00000)), + (22, 0x3a90e4, e(0x5cd00000)), + (22, 0x3a90e5, e(0x5ce00000)), + (22, 0x3a90e6, e(0x5cf00000)), + (22, 0x3a90e7, e(0x5d000000)), + (22, 0x3a90e8, e(0x5d100000)), + (22, 0x3a90e9, e(0x5d200000)), + (22, 0x3a90ea, e(0x5d300000)), + (22, 0x3a90eb, e(0x5d400000)), + (22, 0x3a90ec, e(0x5d500000)), + (22, 0x3a90ed, e(0x5d600000)), + (22, 0x3a90ee, e(0x5d700000)), + (22, 0x3a90ef, e(0x5d800000)), + (22, 0x3a90f0, e(0x5d900000)), + (22, 0x3a90f1, e(0x5da00000)), + (22, 0x3a90f2, e(0x5db00000)), + (22, 0x3a90f3, e(0x5dc00000)), + (22, 0x3a90f4, e(0x5dd00000)), + (22, 0x3a90f5, e(0x5de00000)), + (22, 0x3a90f6, e(0x5df00000)), + (22, 0x3a90f7, e(0x5e000000)), + (22, 0x3a90f8, e(0x5e100000)), + (22, 0x3a90f9, e(0x5e200000)), + (22, 0x3a90fa, e(0x5e300000)), + (22, 0x3a90fb, e(0x5e400000)), + (22, 0x3a90fc, e(0x5e500000)), + (22, 0x3a90fd, e(0x5e600000)), + (22, 0x3a90fe, e(0x5e700000)), + (22, 0x3a90ff, e(0x5e800000)), + (22, 0x3a9100, e(0x5e900000)), + (22, 0x3a9101, e(0x5ea00000)), + (22, 0x3a9102, e(0x5eb00000)), + (22, 0x3a9103, e(0x5ec00000)), + (22, 0x3a9104, e(0x5ed00000)), + (22, 0x3a9105, e(0x5ee00000)), + (22, 0x3a9106, e(0x5ef00000)), + (22, 0x3a9107, e(0x5f000000)), + (22, 0x3a9108, e(0x5f100000)), + (22, 0x3a9109, e(0x5f200000)), + (22, 0x3a910a, e(0x5f300000)), + (22, 0x3a910b, e(0x5f400000)), + (22, 0x3a910c, e(0x5f500000)), + (22, 0x3a910d, e(0x5f600000)), + (22, 0x3a910e, e(0x5f700000)), + (22, 0x3a910f, e(0x5f800000)), + (22, 0x3a9110, e(0x5f900000)), + (22, 0x3a9111, e(0x5fa00000)), + (22, 0x3a9112, e(0x5fb00000)), + (22, 0x3a9113, e(0x5fc00000)), + (22, 0x3a9114, e(0x5fd00000)), + (22, 0x3a9115, e(0x5fe00000)), + (22, 0x3a9116, e(0x5ff00000)), + (22, 0x3a9117, e(0x60000000)), + (22, 0x3a9118, e(0x60100000)), + (22, 0x3a9119, e(0x60200000)), + (22, 0x3a911a, e(0x60300000)), + (22, 0x3a911b, e(0x60400000)), + (22, 0x3a911c, e(0x60500000)), + (22, 0x3a911d, e(0x60600000)), + (22, 0x3a911e, e(0x60700000)), + (22, 0x3a911f, e(0x60800000)), + (22, 0x3a9120, e(0x60900000)), + (22, 0x3a9121, e(0x60a00000)), + (22, 0x3a9122, e(0x60b00000)), + (22, 0x3a9123, e(0x60c00000)), + (22, 0x3a9124, e(0x60d00000)), + (22, 0x3a9125, e(0x60e00000)), + (22, 0x3a9126, e(0x60f00000)), + (22, 0x3a9127, e(0x61000000)), + (22, 0x3a9128, e(0x61100000)), + (22, 0x3a9129, e(0x61200000)), + (22, 0x3a912a, e(0x61300000)), + (22, 0x3a912b, e(0x61400000)), + (22, 0x3a912c, e(0x61500000)), + (22, 0x3a912d, e(0x61600000)), + (22, 0x3a912e, e(0x61700000)), + (22, 0x3a912f, e(0x61800000)), + (22, 0x3a9130, e(0x61900000)), + (22, 0x3a9131, e(0x61a00000)), + (22, 0x3a9132, e(0x61b00000)), + (22, 0x3a9133, e(0x61c00000)), + (22, 0x3a9134, e(0x61d00000)), + (22, 0x3a9135, e(0x61e00000)), + (22, 0x3a9136, e(0x61f00000)), + (22, 0x3a9137, e(0x62000000)), + (22, 0x3a9138, e(0x62100000)), + (22, 0x3a9139, e(0x62200000)), + (22, 0x3a913a, e(0x62300000)), + (22, 0x3a913b, e(0x62400000)), + (22, 0x3a913c, e(0x62500000)), + (22, 0x3a913d, e(0x62600000)), + (22, 0x3a913e, e(0x62700000)), + (22, 0x3a913f, e(0x62800000)), + (22, 0x3a9140, e(0x62900000)), + (22, 0x3a9141, e(0x62a00000)), + (22, 0x3a9142, e(0x62b00000)), + (22, 0x3a9143, e(0x62c00000)), + (22, 0x3a9144, e(0x62d00000)), + (22, 0x3a9145, e(0x62e00000)), + (22, 0x3a9146, e(0x62f00000)), + (22, 0x3a9147, e(0x63000000)), + (22, 0x3a9148, e(0x63100000)), + (22, 0x3a9149, e(0x63200000)), + (22, 0x3a914a, e(0x63300000)), + (22, 0x3a914b, e(0x63400000)), + (22, 0x3a914c, e(0x63500000)), + (22, 0x3a914d, e(0x63600000)), + (22, 0x3a914e, e(0x63700000)), + (22, 0x3a914f, e(0x63800000)), + (22, 0x3a9150, e(0x63900000)), + (22, 0x3a9151, e(0x63a00000)), + (22, 0x3a9152, e(0x63b00000)), + (22, 0x3a9153, e(0x63c00000)), + (22, 0x3a9154, e(0x63d00000)), + (22, 0x3a9155, e(0x63e00000)), + (22, 0x3a9156, e(0x63f00000)), + (22, 0x3a9157, e(0x64000000)), + (22, 0x3a9158, e(0x64100000)), + (22, 0x3a9159, e(0x64200000)), + (22, 0x3a915a, e(0x64300000)), + (22, 0x3a915b, e(0x64400000)), + (22, 0x3a915c, e(0x64500000)), + (22, 0x3a915d, e(0x64600000)), + (22, 0x3a915e, e(0x64700000)), + (22, 0x3a915f, e(0x64800000)), + (22, 0x3a9160, e(0x64900000)), + (22, 0x3a9161, e(0x64a00000)), + (22, 0x3a9162, e(0x64b00000)), + (22, 0x3a9163, e(0x64c00000)), + (22, 0x3a9164, e(0x64d00000)), + (22, 0x3a9165, e(0x64e00000)), + (22, 0x3a9166, e(0x64f00000)), + (22, 0x3a9167, e(0x65000000)), + (22, 0x3a9168, e(0x65100000)), + (22, 0x3a9169, e(0x65200000)), + (22, 0x3a916a, e(0x65300000)), + (22, 0x3a916b, e(0x65400000)), + (22, 0x3a916c, e(0x65500000)), + (22, 0x3a916d, e(0x65600000)), + (22, 0x3a916e, e(0x65700000)), + (22, 0x3a916f, e(0x65800000)), + (22, 0x3a9170, e(0x65900000)), + (22, 0x3a9171, e(0x65a00000)), + (22, 0x3a9172, e(0x65b00000)), + (22, 0x3a9173, e(0x65c00000)), + (22, 0x3a9174, e(0x65d00000)), + (22, 0x3a9175, e(0x65e00000)), + (22, 0x3a9176, e(0x65f00000)), + (22, 0x3a9177, e(0x66000000)), + (22, 0x3a9178, e(0x66100000)), + (22, 0x3a9179, e(0x66200000)), + (22, 0x3a917a, e(0x66300000)), + (22, 0x3a917b, e(0x66400000)), + (22, 0x3a917c, e(0x66500000)), + (22, 0x3a917d, e(0x66600000)), + (22, 0x3a917e, e(0x66700000)), + (22, 0x3a917f, e(0x66800000)), + (22, 0x3a9180, e(0x66900000)), + (22, 0x3a9181, e(0x66a00000)), + (22, 0x3a9182, e(0x66b00000)), + (22, 0x3a9183, e(0x66c00000)), + (22, 0x3a9184, e(0x66d00000)), + (22, 0x3a9185, e(0x66e00000)), + (22, 0x3a9186, e(0x66f00000)), + (22, 0x3a9187, e(0x67000000)), + (22, 0x3a9188, e(0x67100000)), + (22, 0x3a9189, e(0x67200000)), + (22, 0x3a918a, e(0x67300000)), + (22, 0x3a918b, e(0x67400000)), + (22, 0x3a918c, e(0x67500000)), + (22, 0x3a918d, e(0x67600000)), + (22, 0x3a918e, e(0x67700000)), + (22, 0x3a918f, e(0x67800000)), + (22, 0x3a9190, e(0x67900000)), + (22, 0x3a9191, e(0x67a00000)), + (22, 0x3a9192, e(0x67b00000)), + (22, 0x3a9193, e(0x67c00000)), + (22, 0x3a9194, e(0x67d00000)), + (22, 0x3a9195, e(0x67e00000)), + (22, 0x3a9196, e(0x67f00000)), + (22, 0x3a9197, e(0x68000000)), + (22, 0x3a9198, e(0x68100000)), + (22, 0x3a9199, e(0x68200000)), + (22, 0x3a919a, e(0x68300000)), + (22, 0x3a919b, e(0x68400000)), + (22, 0x3a919c, e(0x68500000)), + (22, 0x3a919d, e(0x68600000)), + (22, 0x3a919e, e(0x68700000)), + (22, 0x3a919f, e(0x68800000)), + (22, 0x3a91a0, e(0x68900000)), + (22, 0x3a91a1, e(0x68a00000)), + (22, 0x3a91a2, e(0x68b00000)), + (22, 0x3a91a3, e(0x68c00000)), + (22, 0x3a91a4, e(0x68d00000)), + (22, 0x3a91a5, e(0x68e00000)), + (22, 0x3a91a6, e(0x68f00000)), + (22, 0x3a91a7, e(0x69000000)), + (22, 0x3a91a8, e(0x69100000)), + (22, 0x3a91a9, e(0x69200000)), + (22, 0x3a91aa, e(0x69300000)), + (22, 0x3a91ab, e(0x69400000)), + (22, 0x3a91ac, e(0x69500000)), + (22, 0x3a91ad, e(0x69600000)), + (22, 0x3a91ae, e(0x69700000)), + (22, 0x3a91af, e(0x69800000)), + (22, 0x3a91b0, e(0x69900000)), + (22, 0x3a91b1, e(0x69a00000)), + (22, 0x3a91b2, e(0x69b00000)), + (22, 0x3a91b3, e(0x69c00000)), + (22, 0x3a91b4, e(0x69d00000)), + (22, 0x3a91b5, e(0x69e00000)), + (22, 0x3a91b6, e(0x69f00000)), + (22, 0x3a91b7, e(0x6a000000)), + (22, 0x3a91b8, e(0x6a100000)), + (22, 0x3a91b9, e(0x6a200000)), + (22, 0x3a91ba, e(0x6a300000)), + (22, 0x3a91bb, e(0x6a400000)), + (22, 0x3a91bc, e(0x6a500000)), + (22, 0x3a91bd, e(0x6a600000)), + (22, 0x3a91be, e(0x6a700000)), + (22, 0x3a91bf, e(0x6a800000)), + (22, 0x3a91c0, e(0x6a900000)), + (22, 0x3a91c1, e(0x6aa00000)), + (22, 0x3a91c2, e(0x6ab00000)), + (22, 0x3a91c3, e(0x6ac00000)), + (22, 0x3a91c4, e(0x6ad00000)), + (22, 0x3a91c5, e(0x6ae00000)), + (22, 0x3a91c6, e(0x6af00000)), + (22, 0x3a91c7, e(0x6b000000)), + (22, 0x3a91c8, e(0x6b100000)), + (22, 0x3a91c9, e(0x6b200000)), + (22, 0x3a91ca, e(0x6b300000)), + (22, 0x3a91cb, e(0x6b400000)), + (22, 0x3a91cc, e(0x6b500000)), + (22, 0x3a91cd, e(0x6b600000)), + (22, 0x3a91ce, e(0x6b700000)), + (22, 0x3a91cf, e(0x6b800000)), + (22, 0x3a91d0, e(0x6b900000)), + (22, 0x3a91d1, e(0x6ba00000)), + (22, 0x3a91d2, e(0x6bb00000)), + (22, 0x3a91d3, e(0x6bc00000)), + (22, 0x3a91d4, e(0x6bd00000)), + (22, 0x3a91d5, e(0x6be00000)), + (22, 0x3a91d6, e(0x6bf00000)), + (22, 0x3a91d7, e(0x6c000000)), + (22, 0x3a91d8, e(0x6c100000)), + (22, 0x3a91d9, e(0x6c200000)), + (22, 0x3a91da, e(0x6c300000)), + (22, 0x3a91db, e(0x6c400000)), + (22, 0x3a91dc, e(0x6c500000)), + (22, 0x3a91dd, e(0x6c600000)), + (22, 0x3a91de, e(0x6c700000)), + (22, 0x3a91df, e(0x6c800000)), + (22, 0x3a91e0, e(0x6c900000)), + (22, 0x3a91e1, e(0x6ca00000)), + (22, 0x3a91e2, e(0x6cb00000)), + (22, 0x3a91e3, e(0x6cc00000)), + (22, 0x3a91e4, e(0x6cd00000)), + (22, 0x3a91e5, e(0x6ce00000)), + (22, 0x3a91e6, e(0x6cf00000)), + (22, 0x3a91e7, e(0x6d000000)), + (22, 0x3a91e8, e(0x6d100000)), + (22, 0x3a91e9, e(0x6d200000)), + (22, 0x3a91ea, e(0x6d300000)), + (22, 0x3a91eb, e(0x6d400000)), + (22, 0x3a91ec, e(0x6d500000)), + (22, 0x3a91ed, e(0x6d600000)), + (22, 0x3a91ee, e(0x6d700000)), + (22, 0x3a91ef, e(0x6d800000)), + (22, 0x3a91f0, e(0x6d900000)), + (22, 0x3a91f1, e(0x6da00000)), + (22, 0x3a91f2, e(0x6db00000)), + (22, 0x3a91f3, e(0x6dc00000)), + (22, 0x3a91f4, e(0x6dd00000)), + (22, 0x3a91f5, e(0x6de00000)), + (22, 0x3a91f6, e(0x6df00000)), + (22, 0x3a91f7, e(0x6e000000)), + (22, 0x3a91f8, e(0x6e100000)), + (22, 0x3a91f9, e(0x6e200000)), + (22, 0x3a91fa, e(0x6e300000)), + (22, 0x3a91fb, e(0x6e400000)), + (22, 0x3a91fc, e(0x6e500000)), + (22, 0x3a91fd, e(0x6e600000)), + (22, 0x3a91fe, e(0x6e700000)), + (22, 0x3a91ff, e(0x6e800000)), + (22, 0x3a9200, e(0x6e900000)), + (22, 0x3a9201, e(0x6ea00000)), + (22, 0x3a9202, e(0x6eb00000)), + (22, 0x3a9203, e(0x6ec00000)), + (22, 0x3a9204, e(0x6ed00000)), + (22, 0x3a9205, e(0x6ee00000)), + (22, 0x3a9206, e(0x6ef00000)), + (22, 0x3a9207, e(0x6f000000)), + (22, 0x3a9208, e(0x6f100000)), + (22, 0x3a9209, e(0x6f200000)), + (22, 0x3a920a, e(0x6f300000)), + (22, 0x3a920b, e(0x6f400000)), + (22, 0x3a920c, e(0x6f500000)), + (22, 0x3a920d, e(0x6f600000)), + (22, 0x3a920e, e(0x6f700000)), + (22, 0x3a920f, e(0x6f800000)), + (22, 0x3a9210, e(0x6f900000)), + (22, 0x3a9211, e(0x6fa00000)), + (22, 0x3a9212, e(0x6fb00000)), + (22, 0x3a9213, e(0x6fc00000)), + (22, 0x3a9214, e(0x6fd00000)), + (22, 0x3a9215, e(0x6fe00000)), + (22, 0x3a9216, e(0x6ff00000)), + (22, 0x3a9217, e(0x70000000)), + (22, 0x3a9218, e(0x70100000)), + (22, 0x3a9219, e(0x70200000)), + (22, 0x3a921a, e(0x70300000)), + (22, 0x3a921b, e(0x70400000)), + (22, 0x3a921c, e(0x70500000)), + (22, 0x3a921d, e(0x70600000)), + (22, 0x3a921e, e(0x70700000)), + (22, 0x3a921f, e(0x70800000)), + (22, 0x3a9220, e(0x70900000)), + (22, 0x3a9221, e(0x70a00000)), + (22, 0x3a9222, e(0x70b00000)), + (22, 0x3a9223, e(0x70c00000)), + (22, 0x3a9224, e(0x70d00000)), + (22, 0x3a9225, e(0x70e00000)), + (22, 0x3a9226, e(0x70f00000)), + (22, 0x3a9227, e(0x71000000)), + (22, 0x3a9228, e(0x71100000)), + (22, 0x3a9229, e(0x71200000)), + (22, 0x3a922a, e(0x71300000)), + (22, 0x3a922b, e(0x71400000)), + (22, 0x3a922c, e(0x71500000)), + (22, 0x3a922d, e(0x71600000)), + (22, 0x3a922e, e(0x71700000)), + (22, 0x3a922f, e(0x71800000)), + (22, 0x3a9230, e(0x71900000)), + (22, 0x3a9231, e(0x71a00000)), + (22, 0x3a9232, e(0x71b00000)), + (22, 0x3a9233, e(0x71c00000)), + (22, 0x3a9234, e(0x71d00000)), + (22, 0x3a9235, e(0x71e00000)), + (22, 0x3a9236, e(0x71f00000)), + (22, 0x3a9237, e(0x72000000)), + (22, 0x3a9238, e(0x72100000)), + (22, 0x3a9239, e(0x72200000)), + (22, 0x3a923a, e(0x72300000)), + (22, 0x3a923b, e(0x72400000)), + (22, 0x3a923c, e(0x72500000)), + (22, 0x3a923d, e(0x72600000)), + (22, 0x3a923e, e(0x72700000)), + (22, 0x3a923f, e(0x72800000)), + (22, 0x3a9240, e(0x72900000)), + (22, 0x3a9241, e(0x72a00000)), + (22, 0x3a9242, e(0x72b00000)), + (22, 0x3a9243, e(0x72c00000)), + (22, 0x3a9244, e(0x72d00000)), + (22, 0x3a9245, e(0x72e00000)), + (22, 0x3a9246, e(0x72f00000)), + (22, 0x3a9247, e(0x73000000)), + (22, 0x3a9248, e(0x73100000)), + (22, 0x3a9249, e(0x73200000)), + (22, 0x3a924a, e(0x73300000)), + (22, 0x3a924b, e(0x73400000)), + (22, 0x3a924c, e(0x73500000)), + (22, 0x3a924d, e(0x73600000)), + (22, 0x3a924e, e(0x73700000)), + (22, 0x3a924f, e(0x73800000)), + (22, 0x3a9250, e(0x73900000)), + (22, 0x3a9251, e(0x73a00000)), + (22, 0x3a9252, e(0x73b00000)), + (22, 0x3a9253, e(0x73c00000)), + (22, 0x3a9254, e(0x73d00000)), + (22, 0x3a9255, e(0x73e00000)), + (22, 0x3a9256, e(0x73f00000)), + (22, 0x3a9257, e(0x74000000)), + (22, 0x3a9258, e(0x74100000)), + (22, 0x3a9259, e(0x74200000)), + (22, 0x3a925a, e(0x74300000)), + (22, 0x3a925b, e(0x74400000)), + (22, 0x3a925c, e(0x74500000)), + (22, 0x3a925d, e(0x74600000)), + (22, 0x3a925e, e(0x74700000)), + (22, 0x3a925f, e(0x74800000)), + (22, 0x3a9260, e(0x74900000)), + (22, 0x3a9261, e(0x74a00000)), + (22, 0x3a9262, e(0x74b00000)), + (22, 0x3a9263, e(0x74c00000)), + (22, 0x3a9264, e(0x74d00000)), + (22, 0x3a9265, e(0x74e00000)), + (22, 0x3a9266, e(0x74f00000)), + (22, 0x3a9267, e(0x75000000)), + (22, 0x3a9268, e(0x75100000)), + (22, 0x3a9269, e(0x75200000)), + (22, 0x3a926a, e(0x75300000)), + (22, 0x3a926b, e(0x75400000)), + (22, 0x3a926c, e(0x75500000)), + (22, 0x3a926d, e(0x75600000)), + (22, 0x3a926e, e(0x75700000)), + (22, 0x3a926f, e(0x75800000)), + (22, 0x3a9270, e(0x75900000)), + (22, 0x3a9271, e(0x75a00000)), + (22, 0x3a9272, e(0x75b00000)), + (22, 0x3a9273, e(0x75c00000)), + (22, 0x3a9274, e(0x75d00000)), + (22, 0x3a9275, e(0x75e00000)), + (22, 0x3a9276, e(0x75f00000)), + (22, 0x3a9277, e(0x76000000)), + (22, 0x3a9278, e(0x76100000)), + (22, 0x3a9279, e(0x76200000)), + (22, 0x3a927a, e(0x76300000)), + (22, 0x3a927b, e(0x76400000)), + (22, 0x3a927c, e(0x76500000)), + (22, 0x3a927d, e(0x76600000)), + (22, 0x3a927e, e(0x76700000)), + (22, 0x3a927f, e(0x76800000)), + (22, 0x3a9280, e(0x76900000)), + (22, 0x3a9281, e(0x76a00000)), + (22, 0x3a9282, e(0x76b00000)), + (22, 0x3a9283, e(0x76c00000)), + (22, 0x3a9284, e(0x76d00000)), + (22, 0x3a9285, e(0x76e00000)), + (22, 0x3a9286, e(0x76f00000)), + (22, 0x3a9287, e(0x77000000)), + (22, 0x3a9288, e(0x77100000)), + (22, 0x3a9289, e(0x77200000)), + (22, 0x3a928a, e(0x77300000)), + (22, 0x3a928b, e(0x77400000)), + (22, 0x3a928c, e(0x77500000)), + (22, 0x3a928d, e(0x77600000)), + (22, 0x3a928e, e(0x77700000)), + (22, 0x3a928f, e(0x77800000)), + (22, 0x3a9290, e(0x77900000)), + (22, 0x3a9291, e(0x77a00000)), + (22, 0x3a9292, e(0x77b00000)), + (22, 0x3a9293, e(0x77c00000)), + (22, 0x3a9294, e(0x77d00000)), + (22, 0x3a9295, e(0x77e00000)), + (22, 0x3a9296, e(0x77f00000)), + (22, 0x3a9297, e(0x78000000)), + (22, 0x3a9298, e(0x78100000)), + (22, 0x3a9299, e(0x78200000)), + (22, 0x3a929a, e(0x78300000)), + (22, 0x3a929b, e(0x78400000)), + (22, 0x3a929c, e(0x78500000)), + (22, 0x3a929d, e(0x78600000)), + (22, 0x3a929e, e(0x78700000)), + (22, 0x3a929f, e(0x78800000)), + (22, 0x3a92a0, e(0x78900000)), + (22, 0x3a92a1, e(0x78a00000)), + (22, 0x3a92a2, e(0x78b00000)), + (22, 0x3a92a3, e(0x78c00000)), + (22, 0x3a92a4, e(0x78d00000)), + (22, 0x3a92a5, e(0x78e00000)), + (22, 0x3a92a6, e(0x78f00000)), + (22, 0x3a92a7, e(0x79000000)), + (22, 0x3a92a8, e(0x79100000)), + (22, 0x3a92a9, e(0x79200000)), + (22, 0x3a92aa, e(0x79300000)), + (22, 0x3a92ab, e(0x79400000)), + (22, 0x3a92ac, e(0x79500000)), + (22, 0x3a92ad, e(0x79600000)), + (22, 0x3a92ae, e(0x79700000)), + (22, 0x3a92af, e(0x79800000)), + (22, 0x3a92b0, e(0x79900000)), + (22, 0x3a92b1, e(0x79a00000)), + (22, 0x3a92b2, e(0x79b00000)), + (22, 0x3a92b3, e(0x79c00000)), + (22, 0x3a92b4, e(0x79d00000)), + (22, 0x3a92b5, e(0x79e00000)), + (22, 0x3a92b6, e(0x79f00000)), + (22, 0x3a92b7, e(0x7a000000)), + (22, 0x3a92b8, e(0x7a100000)), + (22, 0x3a92b9, e(0x7a200000)), + (22, 0x3a92ba, e(0x7a300000)), + (22, 0x3a92bb, e(0x7a400000)), + (22, 0x3a92bc, e(0x7a500000)), + (22, 0x3a92bd, e(0x7a600000)), + (22, 0x3a92be, e(0x7a700000)), + (22, 0x3a92bf, e(0x7a800000)), + (22, 0x3a92c0, e(0x7a900000)), + (22, 0x3a92c1, e(0x7aa00000)), + (22, 0x3a92c2, e(0x7ab00000)), + (22, 0x3a92c3, e(0x7ac00000)), + (22, 0x3a92c4, e(0x7ad00000)), + (22, 0x3a92c5, e(0x7ae00000)), + (22, 0x3a92c6, e(0x7af00000)), + (22, 0x3a92c7, e(0x7b000000)), + (22, 0x3a92c8, e(0x7b100000)), + (22, 0x3a92c9, e(0x7b200000)), + (22, 0x3a92ca, e(0x7b300000)), + (22, 0x3a92cb, e(0x7b400000)), + (22, 0x3a92cc, e(0x7b500000)), + (22, 0x3a92cd, e(0x7b600000)), + (22, 0x3a92ce, e(0x7b700000)), + (22, 0x3a92cf, e(0x7b800000)), + (22, 0x3a92d0, e(0x7b900000)), + (22, 0x3a92d1, e(0x7ba00000)), + (22, 0x3a92d2, e(0x7bb00000)), + (22, 0x3a92d3, e(0x7bc00000)), + (22, 0x3a92d4, e(0x7bd00000)), + (22, 0x3a92d5, e(0x7be00000)), + (22, 0x3a92d6, e(0x7bf00000)), + (22, 0x3a92d7, e(0x7c000000)), + (22, 0x3a92d8, e(0x7c100000)), + (22, 0x3a92d9, e(0x7c200000)), + (22, 0x3a92da, e(0x7c300000)), + (22, 0x3a92db, e(0x7c400000)), + (22, 0x3a92dc, e(0x7c500000)), + (22, 0x3a92dd, e(0x7c600000)), + (22, 0x3a92de, e(0x7c700000)), + (22, 0x3a92df, e(0x7c800000)), + (22, 0x3a92e0, e(0x7c900000)), + (22, 0x3a92e1, e(0x7ca00000)), + (22, 0x3a92e2, e(0x7cb00000)), + (22, 0x3a92e3, e(0x7cc00000)), + (22, 0x3a92e4, e(0x7cd00000)), + (22, 0x3a92e5, e(0x7ce00000)), + (22, 0x3a92e6, e(0x7cf00000)), + (22, 0x3a92e7, e(0x7d000000)), + (22, 0x3a92e8, e(0x7d100000)), + (22, 0x3a92e9, e(0x7d200000)), + (22, 0x3a92ea, e(0x7d300000)), + (22, 0x3a92eb, e(0x7d400000)), + (22, 0x3a92ec, e(0x7d500000)), + (22, 0x3a92ed, e(0x7d600000)), + (22, 0x3a92ee, e(0x7d700000)), + (22, 0x3a92ef, e(0x7d800000)), + (22, 0x3a92f0, e(0x7d900000)), + (22, 0x3a92f1, e(0x7da00000)), + (22, 0x3a92f2, e(0x7db00000)), + (22, 0x3a92f3, e(0x7dc00000)), + (22, 0x3a92f4, e(0x7dd00000)), + (22, 0x3a92f5, e(0x7de00000)), + (22, 0x3a92f6, e(0x7df00000)), + (22, 0x3a92f7, e(0x7e000000)), + (22, 0x3a92f8, e(0x7e100000)), + (22, 0x3a92f9, e(0x7e200000)), + (22, 0x3a92fa, e(0x7e300000)), + (22, 0x3a92fb, e(0x7e400000)), + (22, 0x3a92fc, e(0x7e500000)), + (22, 0x3a92fd, e(0x7e600000)), + (22, 0x3a92fe, e(0x7e700000)), + (22, 0x3a92ff, e(0x7e800000)), + (22, 0x3a95a0, e(0x7e900000)), + (22, 0x3a95a1, e(0x7ea00000)), + (22, 0x3a95a2, e(0x7eb00000)), + (22, 0x3a95a3, e(0x7ec00000)), + (22, 0x3a95a4, e(0x7ed00000)), + (22, 0x3a95a5, e(0x7ee00000)), + (22, 0x3a95a6, e(0x7ef00000)), + (22, 0x3a95a7, e(0x7f000000)), + (22, 0x3a95a8, e(0x7f100000)), + (22, 0x3a95a9, e(0x7f200000)), + (22, 0x3a95aa, e(0x7f300000)), + (22, 0x3a95ab, e(0x7f400000)), + (22, 0x3a95ac, e(0x7f500000)), + (22, 0x3a95ad, e(0x7f600000)), + (22, 0x3a95ae, e(0x7f700000)), + (22, 0x3a95af, e(0x7f800000)), + (22, 0x3b8fe0, e(0x7f900000)), + (22, 0x3b8fe1, e(0x7fa00000)), + (22, 0x3b8fe2, e(0x7fb00000)), + (22, 0x3b8fe3, e(0x7fc00000)), + (21, 0x68e90, e(0x7fd00000)), + (21, 0x68e91, e(0x7fe00000)), + (21, 0x68e98, e(0x7ff80000)), +]; diff --git a/crates/prc/src/bits.rs b/crates/prc/src/bits.rs index a94a16dc9a..b4a2ff352b 100644 --- a/crates/prc/src/bits.rs +++ b/crates/prc/src/bits.rs @@ -281,7 +281,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn zero_is_the_two_bit_code_with_no_sign() { let mut w = W::default(); w.put(0b01, 2).put(0b1111_1111, 8); @@ -292,7 +291,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn exponent_with_literal_mantissa() { let v = -1234.5678f64; let le = v.to_bits().to_le_bytes(); @@ -307,7 +305,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn exponent_without_mantissa_is_a_power_of_two() { let (n, c) = exponent_code(1023 + 3); let mut w = W::default(); @@ -316,7 +313,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn fill_back_reference_and_literal_low_byte() { // Mantissa bytes 6..0 = 0x05, 0xAA, 0xAA (offset 1), 0xAA (offset 2), fill 0xAA to byte 1, byte 0 = 0x11. let (n, c) = exponent_code(1023); @@ -336,7 +332,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn a_back_reference_past_the_value_is_refused() { let (n, c) = exponent_code(1023); let mut w = W::default(); @@ -346,10 +341,10 @@ mod tests { } #[test] - fn without_the_table_a_double_is_an_error_not_a_panic() { - let mut r = BitReader::new(&[0x40, 0, 0]); - assert_eq!(r.double(), Err(PrcError::UnknownDoubleCode)); - assert!(matches!(BitReader::new(&[0x40]).double(), Err(PrcError::Truncated(_)))); + fn a_truncated_double_is_an_error_not_a_panic() { + // No code is 8 bits or shorter and a prefix of 0x17, so one byte ends inside a code. + assert!(matches!(BitReader::new(&[0x17]).double(), Err(PrcError::Truncated(_)))); + assert!(matches!(BitReader::new(&[]).double(), Err(PrcError::Truncated(_)))); } #[test] diff --git a/crates/prc/src/schema.rs b/crates/prc/src/schema.rs index a93554334b..686cc8e056 100644 --- a/crates/prc/src/schema.rs +++ b/crates/prc/src/schema.rs @@ -361,7 +361,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn data_tokens_consume_their_fields() { let mut d = W::default(); d.bit(true).double(1.5).uint(300).int(-7).string(Some("ab")).double(2.0).double(3.0); @@ -373,7 +372,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn vector_extent_and_box_tokens_read_their_doubles() { // Vec3, Extent1D, Extent2D, BBox: 3 + 2 + 4 + 6 Doubles. let mut d = W::default(); From 7f302667900b2f3a33b672dd491cadbe0e606a2d Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 10:34:43 -0400 Subject: [PATCH 018/124] M12: PRC reader: tessellation and arrays (#1297) Read the tessellation section of a PRC file structure: FileStructure::tessellations() returns each entity as a Tessellation. - TESS_3D becomes a TriangleMesh: positions, triangles from the triangle/fan/strip blocks (one-normal and textured variants included), one triangle range per face, stored normals and texture coordinates with per-corner indices, and per-triangle line-attribute styles. - TESS_3D_Wire becomes polylines (closing and continuous flags); vertex colours are read past, sized by points (closing point included). - TESS_Markup is read past. - TESS_3D_Compressed is read field by field to its end so the entities after it decode; its triangles are not rebuilt (later step). - The compressed Character/Short/Integer/Indice arrays and the Huffman container they use (WD 11.6-11.9, 12.2) live in arrays.rs; the base fields every entity shares (attributes, name, user data) in entity.rs. Never-panic hardening beyond the source this is adapted from: - Huffman symbol width must be 1..=16; the leaf table and element count are bounded by the payload before anything is allocated. - A face may carry at most 8 texture-coordinate sets (an unbounded count allocated per set and padded per triangle). - Every count is checked against the remaining bits; indice sums and deltas use checked arithmetic. A few reading choices (Huffman bit order and code-length width, indice running sums, 64-bit attribute order, three version gates, the is_segment_color bit, the compressed field order) follow prc-rs (MIT); each is marked `// prc-rs:` in the source. Tests are synthetic, written with the crate's test bit writer. Each fix was reverted and its test seen failing (17 reversions). Adapted from pdfcer (MIT, same author), pdfcer-3d at 0c9742b5936232af89cd707138ef149dc1f4a660. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc/README.md | 9 +- crates/prc/src/arrays.rs | 336 +++++++++++++++++ crates/prc/src/entity.rs | 121 ++++++ crates/prc/src/lib.rs | 4 + crates/prc/src/tess.rs | 419 +++++++++++++++++++++ crates/prc/src/tess/tests.rs | 581 +++++++++++++++++++++++++++++ crates/prc/src/tess/triangulate.rs | 258 +++++++++++++ crates/prc/src/testw.rs | 65 ++++ 8 files changed, 1792 insertions(+), 1 deletion(-) create mode 100644 crates/prc/src/arrays.rs create mode 100644 crates/prc/src/entity.rs create mode 100644 crates/prc/src/tess.rs create mode 100644 crates/prc/src/tess/tests.rs create mode 100644 crates/prc/src/tess/triangulate.rs diff --git a/crates/prc/README.md b/crates/prc/README.md index edb67bdad8..cb3865d3fd 100644 --- a/crates/prc/README.md +++ b/crates/prc/README.md @@ -13,6 +13,7 @@ PrcFile::parse(data) -> Result // header, fil PrcFile::parse_with_limit(data, max_inflated) -> Result FileStructure::section(SectionKind) -> &[u8] // inflated globals / tree / tessellation / geometry / extra geometry FileStructure::schema() -> Result // the globals section's leading schema +FileStructure::tessellations() -> Result, PrcError> // meshes, wires, markups, compressed meshes Schema::read(&mut BitReader) / Schema::skip_added_fields(entity_type, &mut BitReader) BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_vbn, nbits_then_unsigned, string, float_as_bytes, double ``` @@ -20,5 +21,11 @@ BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_ - `MAX_INFLATED_BYTES` (512 MiB) bounds every section together; `TooLarge` reports the limit hit. - Schema programs (how a newer producer describes fields it added) run under a step ceiling (2^22) and a nesting ceiling (64). - `Unsupported` names constructs the reader cannot step over (schema pointer fields, `Value_CurveIs3D`). -- Not yet in the crate: the product tree and tessellation readers. +- `Tessellation::Mesh` is a `TriangleMesh`: positions, triangles, one triangle range per face, the stored normals and + texture coordinates with each triangle's corner indices into them, and per-triangle line-attribute styles. + `Wire` is polylines; `Markup` is read past. +- `Tessellation::Compressed` is read to its end so later entities decode; its triangles are not rebuilt yet. +- A face may carry at most 8 texture-coordinate sets; more is `Malformed`. Optimised vertex colours are `Unsupported`. +- Not yet in the crate: the product tree and rebuilding compressed meshes. - The `Double` code table's rows come from [prc-rs](https://github.com/ralovich/prc-rs) (MIT); its notice is in `src/acof/table.rs`. + A few reading choices also follow prc-rs; each is marked `// prc-rs:` in the source. diff --git a/crates/prc/src/arrays.rs b/crates/prc/src/arrays.rs new file mode 100644 index 0000000000..da6c52521b --- /dev/null +++ b/crates/prc/src/arrays.rs @@ -0,0 +1,336 @@ +//! The array primitives of compressed tessellation [WD 11.6-11.9] and the Huffman payload +//! they may carry [WD 12.2]. +//! +//! The WD gives only the Huffman writer. The reader decodes what producers write: each +//! leaf's code length is read in the width the payload's 8-bit field states (the WD writes a +//! fixed 8 bits), stored codes carry a leading root bit, and the payload is read LSB-first +//! although the stream around it is MSB-first. + +use std::collections::HashMap; + +use crate::PrcError; +use crate::bits::BitReader; + +/// Longest Huffman code [WD 12.2]. +const MAX_CODE_BITS: u32 = 32; + +/// Widest Huffman symbol a caller asks for: the arrays hold 2- to 16-bit values. +const MAX_SYMBOL_BITS: u32 = 16; + +fn malformed(what: &str) -> PrcError { + PrcError::Malformed(what.to_owned()) +} + +/// An LSB-first cursor over a Huffman payload. +struct Lsb<'a> { + data: &'a [u8], + pos: usize, + end: usize, +} + +impl Lsb<'_> { + fn bit(&mut self) -> Result { + if self.pos >= self.end { + return Err(PrcError::Truncated("a Huffman payload")); + } + let byte = self.data.get(self.pos / 8).ok_or(PrcError::Truncated("a Huffman payload"))?; + let b = u32::from((byte >> (self.pos % 8)) & 1); + self.pos += 1; + Ok(b) + } + + /// `n` ≤ 32 bits, the first read being the least significant. + fn bits(&mut self, n: u32) -> Result { + let mut v = 0u32; + for i in 0..n.min(32) { + v |= self.bit()? << i; + } + Ok(v) + } + + fn remaining(&self) -> usize { + self.end.saturating_sub(self.pos) + } +} + +/// The low `n` (1..=16) bits of `v` as a symbol, two's complement when `signed`. +fn symbol(v: u32, n: u32, signed: bool) -> i32 { + let v = i64::from(v); + let wide = if signed && n > 0 && v & (1 << (n - 1)) != 0 { v - (1i64 << n) } else { v }; + // n <= 16, so `wide` is within ±2^16. + i32::try_from(wide).unwrap_or(0) +} + +/// The Huffman container of a compressed Character/Short array [WD 11.6, 11.7, 12.2]: +/// symbols are `nbits` wide, sign-extended when `signed`. +fn huffman(r: &mut BitReader<'_>, nbits: u32, signed: bool) -> Result, PrcError> { + if nbits == 0 || nbits > MAX_SYMBOL_BITS { + return Err(malformed("Huffman symbol width")); + } + let words = r.unsigned_integer()? as usize; + if words == 0 { + return Ok(Vec::new()); + } + if words.saturating_mul(32) > r.remaining() { + return Err(PrcError::Truncated("a Huffman payload")); + } + // words * 32 bits remain, so words * 4 bytes cannot overflow. + let blob = (0..words * 4).map(|_| r.character()).collect::, _>>()?; + let last = r.unsigned_integer()?; + if last > 32 { + return Err(malformed("Huffman last-word bit count above 32")); + } + let end = (words - 1) * 32 + last as usize; + let mut b = Lsb { data: &blob, pos: 0, end }; + let codes = huffman_leaves(&mut b, nbits, signed)?; + if codes.is_empty() { + return Ok(Vec::new()); + } + let count = b.bits(32)? as usize; + // Every element is at least one bit. + if count > b.remaining() { + return Err(PrcError::Truncated("a Huffman element count")); + } + let mut out = Vec::with_capacity(count); + for _ in 0..count { + out.push(huffman_symbol(&mut b, &codes)?); + } + Ok(out) +} + +/// The leaf table: `(code length, stored code)` to symbol. +fn huffman_leaves(b: &mut Lsb<'_>, nbits: u32, signed: bool) -> Result, PrcError> { + let leaves = b.bits(nbits + 1)?; + if leaves == 0 { + return Ok(HashMap::new()); + } + // prc-rs: the code-length width is read from the payload, not fixed at 8 bits. + let len_width = b.bits(8)?; + if len_width == 0 || len_width > MAX_CODE_BITS { + return Err(malformed("Huffman code-length width")); + } + // Every leaf is at least nbits + 2 bits, so the payload bounds the table. + if (leaves as usize).saturating_mul((nbits + 2) as usize) > b.remaining() { + return Err(PrcError::Truncated("a Huffman payload")); + } + let mut codes = HashMap::new(); + for _ in 0..leaves { + let s = symbol(b.bits(nbits)?, nbits, signed); + let len = b.bits(len_width)?; + if len == 0 || len > MAX_CODE_BITS { + return Err(malformed("Huffman code length")); + } + let code = b.bits(len)?; + if codes.insert((len, code), s).is_some() { + return Err(malformed("duplicate Huffman code")); + } + } + Ok(codes) +} + +/// One element: code bits, most significant first, until a leaf matches. +fn huffman_symbol(b: &mut Lsb<'_>, codes: &HashMap<(u32, u32), i32>) -> Result { + // prc-rs: the stored code's first bit read is its most significant (the root bit). + let (mut code, mut len) = (0u32, 0u32); + while len < MAX_CODE_BITS { + code = (code << 1) | b.bit()?; + len += 1; + if let Some(&s) = codes.get(&(len, code)) { + return Ok(s); + } + } + Err(malformed("a Huffman code matching no leaf")) +} + +/// A `CharacterArray(nbits)` [WD 11.6]. `compressed` is `None` when the flag is written, +/// else the caller's implicit value. Uncompressed elements are whole bytes, sign-extended +/// from 8 bits when `signed`. +pub(crate) fn character_array(r: &mut BitReader<'_>, nbits: u32, compressed: Option, signed: bool) -> Result, PrcError> { + let compressed = match compressed { + Some(c) => c, + None => r.bit()?, + }; + if compressed { + return huffman(r, nbits, signed); + } + let n = r.unsigned_integer()? as usize; + if n.saturating_mul(8) > r.remaining() { + return Err(PrcError::Truncated("a character array")); + } + (0..n) + .map(|_| { + let c = r.character()?; + Ok(if signed { i32::from(c.cast_signed()) } else { i32::from(c) }) + }) + .collect() +} + +/// A `ShortArray(nbits)` [WD 11.7]: uncompressed elements are a low then a high byte, read +/// unsigned. +pub(crate) fn short_array(r: &mut BitReader<'_>, nbits: u32) -> Result, PrcError> { + if r.bit()? { + return huffman(r, nbits, false); + } + let n = r.unsigned_integer()? as usize; + if n.saturating_mul(16) > r.remaining() { + return Err(PrcError::Truncated("a short array")); + } + (0..n) + .map(|_| { + let lo = r.character()?; + let hi = r.character()?; + Ok(i32::from(u16::from_le_bytes([lo, hi]))) + }) + .collect() +} + +/// A `CompressedIntegerArray` [WD 11.8]: a CharacterArray(6) of widths, then one +/// `IntegerWithVariableBitNumber` per width. +pub(crate) fn compressed_integer_array(r: &mut BitReader<'_>) -> Result, PrcError> { + let widths = character_array(r, 6, None, false)?; + widths + .into_iter() + .map(|w| { + let w = u32::try_from(w).map_err(|_| malformed("integer width"))?; + r.integer_vbn(w) + }) + .collect() +} + +/// A `CompressedIndiceArray` [WD 11.9], read as producers write it: the stored signed 6-bit +/// values are width differences summed into each element's width, and each element is a +/// delta from the previous (the WD's own reading desyncs from the third element). Indices +/// are never negative. +pub(crate) fn compressed_indice_array(r: &mut BitReader<'_>, compressed: Option) -> Result, PrcError> { + let diffs = character_array(r, 6, compressed, true)?; + let mut out = Vec::with_capacity(diffs.len()); + let (mut width, mut prev) = (0i64, 0i64); + for d in diffs { + // prc-rs: widths are running sums of the stored differences, values running deltas. + width = width.checked_add(i64::from(d)).ok_or_else(|| malformed("indice width"))?; + let w = u32::try_from(width).map_err(|_| malformed("indice width"))?; + let v = prev.checked_add(r.integer_vbn(w)?).ok_or_else(|| malformed("indice delta"))?; + out.push(u32::try_from(v).map_err(|_| malformed("an index outside 0..2^32"))?); + prev = v; + } + Ok(out) +} + +/// An `UncompressedBoolArray` of `n` raw bits (no length prefix). +pub(crate) fn bool_array(r: &mut BitReader<'_>, n: usize) -> Result, PrcError> { + if n > r.remaining() { + return Err(PrcError::Truncated("a boolean array")); + } + (0..n).map(|_| r.bit()).collect() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::testw::W; + + fn read(w: &W, f: impl FnOnce(&mut BitReader<'_>) -> Result) -> T { + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let v = f(&mut r).unwrap(); + assert_eq!(r.position(), w.len(), "bits consumed"); + v + } + + #[test] + fn huffman_decodes_the_wd_example_with_root_bits() { + // WD 12.2's input {1,3,5,7,7,9,5,3,3}; codes carry the root bit. + let leaves = [(3, 0b100, 3), (7, 0b101, 3), (5, 0b110, 3), (1, 0b1110, 4), (9, 0b1111, 4)]; + let values = [1, 3, 5, 7, 7, 9, 5, 3, 3]; + let mut w = W::default(); + w.bit(true).huffman(4, 3, &leaves, &values); + assert_eq!(read(&w, |r| character_array(r, 4, None, false)), values); + } + + #[test] + fn huffman_symbols_sign_extend_only_when_signed() { + let leaves = [(0b111110, 0b10, 2), (1, 0b11, 2)]; + let mut w = W::default(); + w.huffman(6, 2, &leaves, &[62, 1, 62]); + assert_eq!(read(&w, |r| huffman(r, 6, true)), [-2, 1, -2]); + assert_eq!(read(&w, |r| huffman(r, 6, false)), [62, 1, 62]); + } + + #[test] + fn huffman_rejects_codes_that_match_no_leaf_and_empty_payloads_are_empty() { + let mut bad = W::default(); + bad.huffman_raw(2, 2, &[(1, 0b10, 2)], &[(0b11, 2)]); + let bytes = bad.bytes(); + assert!(matches!(huffman(&mut BitReader::new(&bytes), 2, false), Err(PrcError::Malformed(_) | PrcError::Truncated(_)))); + let mut empty = W::default(); + empty.uint(0); + assert!(read(&empty, |r| huffman(r, 2, false)).is_empty()); + } + + #[test] + fn a_huffman_element_count_past_the_payload_is_truncated_not_allocated() { + // One leaf, then an element count of 2^32 - 1 with no elements behind it. + let mut w = W::default(); + w.huffman_count(2, 2, &[(1, 0b10, 2)], u32::MAX); + let bytes = w.bytes(); + assert!(matches!(huffman(&mut BitReader::new(&bytes), 2, false), Err(PrcError::Truncated("a Huffman element count")))); + } + + #[test] + fn huffman_widths_out_of_range_are_errors() { + // Well-formed payloads at each width, which only the width check refuses. + for nbits in [0, 17] { + let mut w = W::default(); + w.huffman(nbits, 2, &[(1, 0b10, 2)], &[1]); + let bytes = w.bytes(); + assert!(matches!(huffman(&mut BitReader::new(&bytes), nbits, false), Err(PrcError::Malformed(_))), "{nbits}"); + } + } + + #[test] + fn uncompressed_arrays() { + let mut w = W::default(); + w.bit(false).uint(2).put(0xfe, 8).put(3, 8); + assert_eq!(read(&w, |r| character_array(r, 6, None, true)), [-2, 3]); + let mut s = W::default(); + s.bit(false).uint(1).put(0x34, 8).put(0x12, 8); + assert_eq!(read(&s, |r| short_array(r, 16)), [0x1234]); + } + + #[test] + fn integer_array_reads_one_width_per_value() { + let mut w = W::default(); + w.bit(false).uint(3).put(3, 8).put(0, 8).put(4, 8); + w.bit(true).put(3, 2); // -3 in 3 bits + w.bit(false).put(5, 3); // 5 in 4 bits + assert_eq!(read(&w, compressed_integer_array), [-3, 0, 5]); + } + + #[test] + fn indice_array_sums_widths_and_deltas() { + // Values 5, 6, 4: widths 4, 2, 3 stored as diffs 4, -2, +1. + let mut w = W::default(); + w.bit(false).uint(3).put(4, 8).put(0xfe, 8).put(1, 8); + w.bit(false).put(5, 3); // 5 + w.bit(false).put(1, 1); // +1 + w.bit(true).put(2, 2); // -2 + assert_eq!(read(&w, |r| compressed_indice_array(r, None)), [5, 6, 4]); + // The implicit-flag form reads no flag bit. + let mut i = W::default(); + i.uint(1).put(2, 8).bit(false).put(1, 1); + assert_eq!(read(&i, |r| compressed_indice_array(r, Some(false))), [1]); + // A negative running index is damage. + let mut n = W::default(); + n.bit(false).uint(1).put(2, 8).bit(true).put(1, 1); + let bytes = n.bytes(); + assert!(compressed_indice_array(&mut BitReader::new(&bytes), None).is_err()); + } + + #[test] + fn a_bool_array_longer_than_the_data_is_truncated() { + assert!(matches!(bool_array(&mut BitReader::new(&[0]), 9), Err(PrcError::Truncated(_)))); + assert_eq!(bool_array(&mut BitReader::new(&[0x80]), 2).unwrap(), [true, false]); + } +} diff --git a/crates/prc/src/entity.rs b/crates/prc/src/entity.rs new file mode 100644 index 0000000000..e6f488c8f8 --- /dev/null +++ b/crates/prc/src/entity.rs @@ -0,0 +1,121 @@ +//! What every section reader shares: the cursor, the schema, the authoring version, and the +//! base fields every PRC entity starts with [WD 7.2.3, 7.4.3, 8.6]. + +use crate::PrcError; +use crate::bits::BitReader; +use crate::schema::Schema; + +const ATTRIBUTE: u32 = 201; + +/// A [`PrcError::Malformed`] naming `what`. +pub(crate) fn malformed(what: String) -> PrcError { + PrcError::Malformed(what) +} + +/// A section reader. +pub(crate) struct Ctx<'a, 's> { + pub(crate) r: BitReader<'a>, + pub(crate) schema: &'s Schema, + /// The file structure's authoring version; gates version-added fields. + pub(crate) version: u32, + /// The current name: what a `same_name` entity reuses; every entity's own name replaces + /// it [WD 7.2.3.4]. Each section starts with none. + pub(crate) name: Option, +} + +impl<'a, 's> Ctx<'a, 's> { + pub(crate) fn new(r: BitReader<'a>, schema: &'s Schema, version: u32) -> Self { + Ctx { r, schema, version, name: None } + } + + /// Reads an entity type code; any value but `want` is malformed. + pub(crate) fn expect_type(&mut self, want: u32) -> Result<(), PrcError> { + let t = self.r.unsigned_integer()?; + if t != want { + return Err(malformed(format!("entity type {t} where {want} belongs"))); + } + Ok(()) + } + + /// A count that must fit in the remaining data at `min_bits` per item, which bounds any + /// allocation sized by it. + pub(crate) fn count(&mut self, min_bits: usize, what: &'static str) -> Result { + let n = self.r.unsigned_integer()? as usize; + if n.saturating_mul(min_bits) > self.r.remaining() { + return Err(PrcError::Truncated(what)); + } + Ok(n) + } + + /// A counted array of `UnsignedInteger`s. + pub(crate) fn uints(&mut self, what: &'static str) -> Result, PrcError> { + let n = self.count(1, what)?; + (0..n).map(|_| self.r.unsigned_integer()).collect() + } + + /// A counted array of `Double`s. + pub(crate) fn doubles(&mut self, what: &'static str) -> Result, PrcError> { + let n = self.count(2, what)?; + (0..n).map(|_| self.r.double()).collect() + } + + /// `AttributeEntry` [WD 7.4.3]: a predefined title code or a string. + fn attribute_entry(&mut self) -> Result<(), PrcError> { + if self.r.bit()? { + self.r.unsigned_integer()?; + } else { + self.r.string()?; + } + Ok(()) + } + + /// One attribute value [WD 7.4.3], read past. + fn attribute_value(&mut self) -> Result<(), PrcError> { + match self.r.unsigned_integer()? { + 0 => {} + 1 | 3 => { + self.r.integer()?; + } + 2 => { + self.r.double()?; + } + 4 => { + self.r.string()?; + } + // prc-rs: a 64-bit integer is its high part, then its low part. + 5 => { + self.r.integer()?; + self.r.unsigned_integer()?; + } + k => return Err(malformed(format!("attribute value kind {k}"))), + } + Ok(()) + } + + /// `ContentPRCBase` [WD 7.2.3]: attributes, then the name; returns the entity's name, + /// which is also the current name afterwards. + pub(crate) fn content_prc_base(&mut self) -> Result, PrcError> { + let n = self.count(1, "attributes")?; + for _ in 0..n { + self.expect_type(ATTRIBUTE)?; + self.attribute_entry()?; + let pairs = self.count(2, "attribute pairs")?; + for _ in 0..pairs { + self.attribute_entry()?; + self.attribute_value()?; + } + self.schema.skip_added_fields(ATTRIBUTE, &mut self.r)?; + } + // `same_name` TRUE reuses the current name [WD 7.2.3.4]. + if !self.r.bit()? { + self.name = self.r.string()?; + } + Ok(self.name.clone()) + } + + /// `UserData` [WD 8.6]: a bit count, then that many opaque bits. + pub(crate) fn user_data(&mut self) -> Result<(), PrcError> { + let n = self.r.unsigned_integer()? as usize; + self.r.skip_bits(n) + } +} diff --git a/crates/prc/src/lib.rs b/crates/prc/src/lib.rs index 1a62f9f272..fd49302234 100644 --- a/crates/prc/src/lib.rs +++ b/crates/prc/src/lib.rs @@ -13,10 +13,13 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod acof; +mod arrays; mod bits; mod container; +mod entity; mod error; mod schema; +mod tess; #[cfg(test)] mod testw; @@ -24,3 +27,4 @@ pub use bits::BitReader; pub use container::{FileStructure, MAX_FILE_STRUCTURES, MAX_INFLATED_BYTES, PRC_READER_VERSION, PrcFile, PrcHeader, SectionKind, UniqueId}; pub use error::PrcError; pub use schema::Schema; +pub use tess::{Tessellation, TriangleMesh, TriangleStyle}; diff --git a/crates/prc/src/tess.rs b/crates/prc/src/tess.rs new file mode 100644 index 0000000000..42704de652 --- /dev/null +++ b/crates/prc/src/tess.rs @@ -0,0 +1,419 @@ +//! The tessellation section: `FileStructureTessellation` (305) and its entities, `TESS_3D` +//! (172) with its `TESS_Face`s (174), `TESS_3D_Wire` (175), `TESS_Markup` (176) and +//! `TESS_3D_Compressed` (173) [WD 7.3.7, 7.8]. +//! +//! `TESS_3D_Compressed` is read field by field to its end [WD 7.8.9.7] so the entities after +//! it decode; its triangles are not rebuilt. +//! +//! The schema runs after the fields of each concrete type read here (201, 172, 173, 174, 175, +//! 176, 305), not for the abstract levels (`PRCBase`, `TESS`). + +mod triangulate; + +use crate::arrays; +use crate::bits::BitReader; +use crate::entity::{Ctx, malformed}; +use crate::{FileStructure, PrcError, SectionKind}; +use triangulate::{Corners, Face}; + +const FILE_STRUCTURE_TESSELLATION: u32 = 305; +const TESS_3D: u32 = 172; +const TESS_3D_COMPRESSED: u32 = 173; +const TESS_FACE: u32 = 174; +const TESS_3D_WIRE: u32 = 175; +const TESS_MARKUP: u32 = 176; + +/// prc-rs: `origin` exists in a compressed tessellation from this version (pdf-issues #705). +const ORIGIN_FROM: u32 = 7031; +/// prc-rs: `has_loops` exists from this file-structure authoring version. +const HAS_LOOPS_FROM: u32 = 7039; +/// prc-rs: `must_recalculate_normals` exists from this version. +const RECALCULATE_FROM: u32 = 7047; + +/// A wire header's count bits; the top four are flags [WD 7.8.7.5]. +const WIRE_COUNT_MASK: u32 = 0x0FFF_FFFF; +const WIRE_IS_CLOSING: u32 = 0x1000_0000; +const WIRE_IS_CONTINUOUS: u32 = 0x2000_0000; + +/// Normal angles wider than this are not a `ShortArray` [WD 7.8.9.7]. +const MAX_ANGLE_BITS: u32 = 16; + +/// One entry of the tessellation array; a representation item's `index_tessellation` selects +/// one by position. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Tessellation { + /// A `TESS_3D` triangle mesh. + Mesh(TriangleMesh), + /// A `TESS_3D_Wire`: polylines. + Wire(Vec>), + /// A `TESS_Markup` (PMI drawing), read past but not decoded. + Markup, + /// A `TESS_3D_Compressed` mesh, read past but not rebuilt. + #[non_exhaustive] + Compressed { + /// The entity's triangle count. + triangles: usize, + }, +} + +/// A `TESS_3D` decoded to indexed triangles, in the entity's own frame (the representation +/// item's placement is not applied). +#[derive(Debug, Clone, Default, PartialEq)] +#[non_exhaustive] +pub struct TriangleMesh { + /// The coordinate array as points. + pub positions: Vec<[f64; 3]>, + /// Counter-clockwise triangles seen from the outside, as indices into + /// [`Self::positions`] [WD 7.8.5.2]. + pub triangles: Vec<[u32; 3]>, + /// `triangles` split by `TESS_Face`: face `i` is `triangles[faces[i].clone()]`. + pub faces: Vec>, + /// The producer asks the reader to compute normals (none are stored). + pub normals_recalculated: bool, + /// The stored vertex normals [WD 7.8.5.1], as written (not renormalised); empty when the + /// mesh stores none or any of its normal indices is unusable. + pub normals: Vec<[f64; 3]>, + /// Per triangle, the index into [`Self::normals`] of each corner's normal, in the corner + /// order of [`Self::triangles`]; empty exactly when [`Self::normals`] is. + pub triangle_normals: Vec<[u32; 3]>, + /// Per triangle, the style its face's line attributes give it [WD 7.8.6]; empty when no + /// face carries any. + pub triangle_styles: Vec, + /// The stored texture coordinates as (u, v) pairs [WD 7.8.5.1]; empty when the mesh + /// stores none. + pub uvs: Vec<[f64; 2]>, + /// Per texture-coordinate set, per triangle, the index into [`Self::uvs`] of each + /// corner's coordinate, in the corner order of [`Self::triangles`]; each set is as long + /// as [`Self::triangles`], `None` for a triangle whose face stores no coordinate for the + /// set. Empty when [`Self::uvs`] is. + pub triangle_uvs: Vec>>, +} + +/// The graphics a face's `line_attributes` give one triangle [WD 7.8.6]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct TriangleStyle { + /// An index into the globals' style array plus one; 0 keeps the owner's graphics. + pub style: u32, + /// The face's behaviour bits [WD 5.4]. + pub behaviour: u16, +} + +impl FileStructure { + /// The tessellation section's entities, in the order a representation item's + /// `index_tessellation` counts them [WD 7.3.7]. + /// + /// # Errors + /// As [`crate::Schema::read`] for the globals' schema; [`PrcError::Truncated`] or + /// [`PrcError::Malformed`] for a damaged section; [`PrcError::Unsupported`] for + /// optimised vertex colours. + pub fn tessellations(&self) -> Result, PrcError> { + let schema = self.schema()?; + let r = BitReader::new(self.section(SectionKind::Tessellation)); + Ctx::new(r, &schema, self.authoring_version).file_structure_tessellation() + } +} + +impl Ctx<'_, '_> { + /// `FileStructureTessellation` (305) [WD 7.3.7]. + pub(crate) fn file_structure_tessellation(&mut self) -> Result, PrcError> { + self.expect_type(FILE_STRUCTURE_TESSELLATION)?; + self.content_prc_base()?; + let n = self.count(1, "tessellations")?; + let mut out = Vec::with_capacity(n.min(1024)); + for _ in 0..n { + out.push(match self.r.unsigned_integer()? { + TESS_3D => Tessellation::Mesh(self.tess_3d()?), + TESS_3D_WIRE => Tessellation::Wire(self.wire()?), + TESS_MARKUP => { + self.markup()?; + Tessellation::Markup + } + TESS_3D_COMPRESSED => Tessellation::Compressed { triangles: self.tess_3d_compressed()? }, + t => return Err(malformed(format!("entity type {t} in the tessellation array"))), + }); + } + self.schema.skip_added_fields(FILE_STRUCTURE_TESSELLATION, &mut self.r)?; + self.user_data()?; + Ok(out) + } + + /// `ContentBaseTessData` [WD 7.8.4]: `is_calculated`, then coordinates. + fn base_tess_data(&mut self) -> Result, PrcError> { + self.r.bit()?; + self.doubles("tessellation coordinates") + } + + /// `VertexColors` [WD 7.8.7.2] for `count` points, read past. + fn vertex_colors(&mut self, count: usize, in_wire: bool) -> Result<(), PrcError> { + let rgba = self.r.bit()?; + // prc-rs: `is_segment_color` exists only inside a wire. + let per_segment = in_wire && self.r.bit()?; + if self.r.bit()? { + return Err(PrcError::Unsupported("optimised vertex colours")); + } + let count = if per_segment { count / 2 } else { count }; + let bytes = if rgba { 4 } else { 3 }; + // Every colour but the first is at least its reuse bit. + if count.saturating_mul(1 + 8 * bytes) > self.r.remaining().saturating_add(8 * bytes) { + return Err(PrcError::Truncated("vertex colours")); + } + for i in 0..count { + if i == 0 || !self.r.bit()? { + for _ in 0..bytes { + self.r.character()?; + } + } + } + Ok(()) + } + + /// `TESS_3D` (172) after its type code [WD 7.8.5]. + fn tess_3d(&mut self) -> Result { + let coords = self.base_tess_data()?; + let _has_faces = self.r.bit()?; + if self.version >= HAS_LOOPS_FROM { + let _has_loops = self.r.bit()?; + } + let recalc = self.version >= RECALCULATE_FROM && self.r.bit()?; + if recalc { + let _flags = self.r.character()?; + let _crease_angle = self.r.double()?; + } + let normal_coords = self.doubles("normal coordinates")?; + let _wire_indices = self.uints("wire indices")?; + let indices = self.uints("triangulated indices")?; + let n_faces = self.count(1, "faces")?; + let mut faces = Vec::with_capacity(n_faces.min(1024)); + for _ in 0..n_faces { + self.expect_type(TESS_FACE)?; + faces.push(self.face()?); + } + let texture = self.doubles("texture coordinates")?; + self.schema.skip_added_fields(TESS_3D, &mut self.r)?; + + let mut mesh = TriangleMesh { positions: points(&coords), normals_recalculated: recalc, ..TriangleMesh::default() }; + let styled = faces.iter().any(|f| !f.styles.is_empty()); + let mut corners = Corners::default(); + for f in &faces { + let start = mesh.triangles.len(); + let entities = f.triangulate(&indices, !recalc, mesh.positions.len(), &mut mesh.triangles, &mut corners)?; + mesh.faces.push(start..mesh.triangles.len()); + if styled { + f.styles_into(&entities, &mut mesh.triangle_styles); + } + } + (mesh.normals, mesh.triangle_normals) = stored_normals(&normal_coords, &corners.normals, mesh.triangles.len()); + (mesh.uvs, mesh.triangle_uvs) = stored_uvs(&texture, corners.textures); + Ok(mesh) + } + + /// `TESS_Face` (174) after its type code [WD 7.8.6]. + fn face(&mut self) -> Result { + let styles = self.uints("face line attributes")?; + let _start_of_wire = self.r.unsigned_integer()?; + let _sizes_wire = self.uints("face wire sizes")?; + let flags = self.r.unsigned_integer()?; + let start = self.r.unsigned_integer()? as usize; + let data = self.uints("triangulated data")?; + let textures = self.r.unsigned_integer()? as usize; + let mut face = Face { flags, start, data, textures, styles, behaviour: 0 }; + if self.r.bit()? { + let n = face.point_count()?; + self.vertex_colors(n, false)?; + } + if !face.styles.is_empty() { + face.behaviour = u16::try_from(self.r.unsigned_integer()?).unwrap_or(0); + } + self.schema.skip_added_fields(TESS_FACE, &mut self.r)?; + Ok(face) + } + + /// `TESS_3D_Wire` (175) after its type code [WD 7.8.7]. + fn wire(&mut self) -> Result>, PrcError> { + let pts = points(&self.base_tess_data()?); + let words = self.uints("wire indices")?; + let mut wires: Vec> = Vec::new(); + let mut colours = 0usize; + if words.is_empty() { + colours = pts.len(); + if !pts.is_empty() { + wires.push(pts.clone()); + } + } + let mut it = words.iter().copied(); + while let Some(header) = it.next() { + let n = (header & WIRE_COUNT_MASK) as usize; + let mut wire = Vec::with_capacity(n.min(words.len())); + for _ in 0..n { + let idx = it.next().ok_or_else(|| malformed("wire runs past its indices".into()))?; + wire.push(point_at(&pts, idx)?); + } + colours = colours.saturating_add(n); + if header & WIRE_IS_CLOSING != 0 { + colours = colours.saturating_add(1); + if let Some(&first) = wire.first() { + wire.push(first); + } + } + match wires.last_mut() { + Some(prev) if header & WIRE_IS_CONTINUOUS != 0 => prev.extend(wire), + _ => wires.push(wire), + } + } + if self.r.bit()? { + self.vertex_colors(colours, true)?; + } + self.schema.skip_added_fields(TESS_3D_WIRE, &mut self.r)?; + Ok(wires) + } + + /// `TESS_Markup` (176) after its type code [WD 7.8.8], read past. + fn markup(&mut self) -> Result<(), PrcError> { + self.base_tess_data()?; + self.uints("markup codes")?; + let n = self.count(1, "markup strings")?; + for _ in 0..n { + self.r.string()?; + } + self.r.string()?; + self.r.character()?; + self.schema.skip_added_fields(TESS_MARKUP, &mut self.r) + } + + /// `TESS_3D_Compressed` (173) after its type code [WD 7.8.9.7], read past; returns its + /// triangle count. prc-rs: the field order and version gates follow its schema. + /// + /// Sizes the WD leaves unstated: the face count is the largest face index + 1, + /// `normal_is_reversed` is one bit per triangle, and the point-reference array's + /// compressed flag is implicit (more than three references). + fn tess_3d_compressed(&mut self) -> Result { + let r = &mut self.r; + r.bit()?; // is_calculated + r.bit()?; // has_faces + r.double()?; // tolerance + if self.version >= ORIGIN_FROM { + for _ in 0..3 { + r.float_as_bytes()?; + } + } + arrays::compressed_integer_array(r)?; // point_array + let edge_status = arrays::character_array(r, 2, None, false)?; + let face_of = arrays::compressed_indice_array(r, None)?; + let t = face_of.len(); + if edge_status.len() != t && edge_status.len() != t.saturating_mul(3) { + return Err(malformed(format!("{} edge statuses for {t} triangles", edge_status.len()))); + } + let faces = face_of.iter().max().map_or(0, |&m| (m as usize).saturating_add(1)); + let n = r.unsigned_integer()? as usize; + let references = arrays::bool_array(r, n)?.iter().filter(|&&b| b).count(); + let refs = arrays::compressed_indice_array(r, Some(references > 3))?; + if refs.len() != references { + return Err(malformed(format!("{} point references for {references} flagged points", refs.len()))); + } + self.compressed_normals(t, faces)?; + let r = &mut self.r; + if r.bit()? { + arrays::bool_array(r, faces)?; // is_point_color_on_face + arrays::character_array(r, 8, None, false)?; // point_color_array + } + if r.bit()? { + arrays::bool_array(r, faces)?; // is_multiple_line_attribute_on_face + } + arrays::short_array(r, 16)?; // line_attributes + if !r.bit()? { + self.compressed_texture_parameter()?; + if !self.r.bit()? { + arrays::bool_array(&mut self.r, faces)?; // face_has_texture + } + } + if self.r.bit()? { + arrays::character_array(&mut self.r, 8, None, false)?; // behaviours + } + self.schema.skip_added_fields(TESS_3D_COMPRESSED, &mut self.r)?; + Ok(t) + } + + /// A compressed tessellation's normals, from `must_recalculate_normals` on, read past. + fn compressed_normals(&mut self, triangles: usize, faces: usize) -> Result<(), PrcError> { + let r = &mut self.r; + if r.bit()? { + arrays::bool_array(r, triangles)?; // normal_is_reversed + r.double()?; // crease_angle + r.character()?; // normal_recalculation_flags + return Ok(()); + } + let angle_bits = u32::from(r.character()?); + if angle_bits > MAX_ANGLE_BITS { + return Err(malformed(format!("{angle_bits}-bit normal angles"))); + } + let n = r.unsigned_integer()? as usize; + arrays::bool_array(r, n)?; // is_binary_normal + arrays::short_array(r, angle_bits)?; // normal angles + arrays::bool_array(r, faces)?; // is_face_planar + Ok(()) + } + + /// `CompressedTextureParameter` [WD 7.8.9.8-7.8.9.9; pdf-issues #729, #749], read past. + fn compressed_texture_parameter(&mut self) -> Result<(), PrcError> { + let words = self.count(32, "texture data")?; + // count() bounded words * 32 by the remaining bits. + self.r.skip_bits(words.saturating_mul(32))?; + self.r.unsigned_integer()?; // last_integer_used_bit_number + let n = self.count(5, "texture references")?; + for _ in 0..n { + self.r.nbits_then_unsigned()?; + } + self.r.double()?; // tolerance + let n = self.count(32, "texture parameters")?; + for _ in 0..n { + self.r.float_as_bytes()?; + } + Ok(()) + } +} + +/// The normal array and each triangle's corner normals, or both empty when any slot fails to +/// name a normal: a mesh shades from its stored normals wholly or not at all. +fn stored_normals(coords: &[f64], slots: &[[u32; 3]], triangles: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) { + let normals = points(coords); + let named = |k: u32| k.is_multiple_of(3) && (k as usize / 3) < normals.len(); + if normals.is_empty() || slots.len() != triangles || !slots.iter().flatten().all(|&k| named(k)) { + return (Vec::new(), Vec::new()); + } + let corners = slots.iter().map(|t| t.map(|k| k / 3)).collect(); + (normals, corners) +} + +/// Per texture-coordinate set, each triangle's corner pair indices. +type UvSets = Vec>>; + +/// The texture coordinates as (u, v) pairs, and per set the corner slots turned into pair +/// indices (a slot is a `Double` offset, so even); a triangle with a slot naming no pair is +/// `None`. +fn stored_uvs(coords: &[f64], sets: UvSets) -> (Vec<[f64; 2]>, UvSets) { + let uvs: Vec<[f64; 2]> = coords.chunks_exact(2).map(|c| [c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0)]).collect(); + if uvs.is_empty() { + return (Vec::new(), Vec::new()); + } + let pair = |k: u32| (k.is_multiple_of(2) && (k as usize / 2) < uvs.len()).then_some(k / 2); + let sets = + sets.into_iter().map(|set| set.into_iter().map(|tri| tri.and_then(|[a, b, c]| Some([pair(a)?, pair(b)?, pair(c)?]))).collect()).collect(); + (uvs, sets) +} + +fn points(coords: &[f64]) -> Vec<[f64; 3]> { + coords.chunks_exact(3).map(|c| [0, 1, 2].map(|i| c.get(i).copied().unwrap_or(0.0))).collect() +} + +/// The point a `Double`-offset index names [WD 7.8.5.2: multiples of 3]. +fn point_at(pts: &[[f64; 3]], idx: u32) -> Result<[f64; 3], PrcError> { + if !idx.is_multiple_of(3) { + return Err(malformed(format!("point index {idx} is not a multiple of 3"))); + } + pts.get(idx as usize / 3).copied().ok_or_else(|| malformed(format!("point index {idx} past the coordinates"))) +} + +#[cfg(test)] +mod tests; diff --git a/crates/prc/src/tess/tests.rs b/crates/prc/src/tess/tests.rs new file mode 100644 index 0000000000..ee202e6a2a --- /dev/null +++ b/crates/prc/src/tess/tests.rs @@ -0,0 +1,581 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::triangulate::MAX_TEXTURE_SETS; +use super::*; +use crate::Schema; +use crate::testw::W; + +const SQUARE: [f64; 12] = [0., 0., 0., 1., 0., 0., 1., 1., 0., 0., 1., 0.]; +const ATTRIBUTE: u32 = 201; +const NORMAL_SINGLE: u32 = 0x4000_0000; + +/// A 305 section holding `body` (already-written entities, `n` of them). +fn section(n: u32, body: &W) -> W { + let mut w = W::default(); + w.uint(305).uint(0).bit(true).uint(n).append(body).uint(0); + w +} + +fn uints(w: &mut W, v: &[u32]) { + w.uint(u32::try_from(v.len()).unwrap()); + for &x in v { + w.uint(x); + } +} + +fn square(w: &mut W, entity: u32) { + w.uint(entity).bit(false).uint(12); + for c in SQUARE { + w.double(c); + } +} + +#[derive(Default)] +struct Mesh<'a> { + recalc: bool, + indices: &'a [u32], + faces: &'a [(u32, u32, &'a [u32])], + textures: u32, + uvs: &'a [f64], +} + +/// A TESS_3D over [`SQUARE`] at authoring version `v`. +fn tess_3d(w: &mut W, v: u32, m: &Mesh<'_>) { + square(w, 172); + w.bit(true); + if v >= 7039 { + w.bit(false); + } + if v >= 7047 { + w.bit(m.recalc); + if m.recalc { + w.put(0, 8).double(0.5); + } + } + if m.recalc { + w.uint(0); + } else { + // Two normals: slot 0 is +z, slot 3 is -z. + w.uint(6); + for c in [0., 0., 1., 0., 0., -1.] { + w.double(c); + } + } + w.uint(0); + uints(w, m.indices); + w.uint(u32::try_from(m.faces.len()).unwrap()); + for &(flags, start, data) in m.faces { + w.uint(174).uint(0).uint(0).uint(0).uint(flags).uint(start); + uints(w, data); + w.uint(m.textures).bit(false); + } + w.uint(u32::try_from(m.uvs.len()).unwrap()); + for &c in m.uvs { + w.double(c); + } +} + +fn decode(w: &W, schema: &Schema, version: u32) -> Result, PrcError> { + let bytes = w.bytes(); + Ctx::new(BitReader::new(&bytes), schema, version).file_structure_tessellation() +} + +fn mesh(t: &Tessellation) -> &TriangleMesh { + match t { + Tessellation::Mesh(m) => m, + other => panic!("not a mesh: {other:?}"), + } +} + +fn one(indices: &[u32], faces: &[(u32, u32, &[u32])], textures: u32) -> TriangleMesh { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices, faces, textures, ..Mesh::default() }); + mesh(&decode(§ion(1, &b), &Schema::default(), 8137).unwrap()[0]).clone() +} + +#[test] +fn triangle_and_fan_with_normals() { + // One triangle (n,p x3), then a fan of 4 vertices (n,p each). + let m = one(&[3, 0, 0, 3, 3, 6, 0, 0, 3, 3, 0, 6, 3, 9], &[(0x2 | 0x4, 0, &[1, 1, 4])], 0); + assert_eq!(m.positions.len(), 4); + assert_eq!(m.positions[2], [1., 1., 0.]); + assert_eq!(m.triangles, [[0, 1, 2], [0, 1, 2], [0, 2, 3]]); + assert_eq!(m.faces, vec![0..3]); + assert!(!m.normals_recalculated); + assert_eq!(m.normals, [[0., 0., 1.], [0., 0., -1.]]); + // Each corner keeps the normal stored beside its point. + assert_eq!(m.triangle_normals, [[1, 0, 1], [0, 1, 0], [0, 0, 1]]); +} + +#[test] +fn strip_normals_follow_the_alternating_winding() { + let m = one(&[3, 0, 0, 3, 3, 6, 0, 9], &[(0x8, 0, &[1, 4])], 0); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]); + assert_eq!(m.triangle_normals, [[1, 0, 1], [1, 0, 0]]); +} + +#[test] +fn a_normal_index_past_the_array_drops_every_normal_but_no_triangle() { + let m = one(&[0, 0, 6, 3, 0, 6], &[(0x2, 0, &[1])], 0); + assert_eq!(m.triangles, [[0, 1, 2]]); + assert!(m.normals.is_empty()); + assert!(m.triangle_normals.is_empty()); +} + +#[test] +fn recalculated_normals_drop_the_normal_slots_and_strips_alternate() { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { recalc: true, indices: &[0, 3, 6, 9], faces: &[(0x8, 0, &[1, 4])], ..Mesh::default() }); + let t = decode(§ion(1, &b), &Schema::default(), 8137).unwrap(); + let m = mesh(&t[0]); + assert!(m.normals_recalculated); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]); + assert!(m.normals.is_empty() && m.triangle_normals.is_empty()); +} + +#[test] +fn version_gates_the_loop_and_recalculation_flags() { + for v in [7038, 7039, 7046, 7047, 8137] { + let mut b = W::default(); + tess_3d(&mut b, v, &Mesh { indices: &[0, 0, 0, 3, 0, 6], faces: &[(0x2, 0, &[1])], ..Mesh::default() }); + // A trailing wire: a misread flag desyncs it. + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), v).unwrap(); + assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2]], "version {v}"); + assert!(matches!(t[1], Tessellation::Wire(_)), "version {v}"); + } +} + +#[test] +fn one_normal_blocks_and_textures() { + // 0x20: n,p,p,p. 0x40 with NORMAL_Single: n then p per vertex. + // 0x2000 with one texture set: n,t,p,t,p,t,p. + let idx = [ + 0, 0, 3, 6, // 0x20 + 3, 0, 6, 9, // 0x40 single, 3 vertices + 0, 0, 9, 2, 0, 4, 3, // 0x2000 + ]; + let m = one(&idx, &[(0x20 | 0x40 | 0x2000, 0, &[1, 1, 3 | NORMAL_SINGLE, 1])], 1); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3], [3, 0, 1]]); + // A one-normal entity gives every corner its single normal. + assert_eq!(m.triangle_normals, [[0, 0, 0], [1, 1, 1], [0, 0, 0]]); +} + +#[test] +fn one_normal_fan_without_single_keeps_a_normal_per_vertex() { + let m = one(&[0, 0, 0, 3, 0, 6, 0, 9], &[(0x40, 0, &[1, 4])], 0); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]); +} + +#[test] +fn textured_fan_and_strip_carry_texture_slots_per_vertex() { + // 0x400 then 0x800, two texture sets: n,t,t,p per vertex. + let mut idx = Vec::new(); + for p in [0, 3, 6, 0, 3, 6, 9] { + idx.extend([0, 0, 2, p]); + } + let m = one(&idx, &[(0x400 | 0x800, 0, &[1, 3, 1, 4])], 2); + assert_eq!(m.triangles, [[0, 1, 2], [0, 1, 2], [2, 1, 3]]); +} + +/// The unit square as two textured triangles (one set; per vertex a normal, a texture slot, +/// a point), its corners at the picture's corners. +fn textured_square() -> W { + let mut body = W::default(); + let indices = [0, 0, 0, 0, 2, 3, 0, 4, 6, 0, 0, 0, 0, 4, 6, 0, 6, 9]; + tess_3d( + &mut body, + 8137, + &Mesh { indices: &indices, faces: &[(0x200, 0, &[2])], textures: 1, uvs: &[0., 0., 1., 0., 1., 1., 0., 1.], ..Mesh::default() }, + ); + section(1, &body) +} + +#[test] +fn a_textured_mesh_keeps_each_corners_coordinates() { + let t = decode(&textured_square(), &Schema::default(), 8137).unwrap(); + let m = mesh(&t[0]); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]); + assert_eq!(m.uvs, [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]); + assert_eq!(m.triangle_uvs, [vec![Some([0, 1, 2]), Some([0, 2, 3])]]); + // An odd slot names no pair: that triangle has no coordinates. + let mut b = W::default(); + tess_3d( + &mut b, + 8137, + &Mesh { indices: &[0, 1, 0, 0, 2, 3, 0, 4, 6], faces: &[(0x200, 0, &[1])], textures: 1, uvs: &[0.0; 6], ..Mesh::default() }, + ); + let t = decode(§ion(1, &b), &Schema::default(), 8137).unwrap(); + let m = mesh(&t[0]); + assert_eq!(m.triangles, [[0, 1, 2]]); + assert_eq!(m.triangle_uvs, [vec![None]]); +} + +#[test] +fn too_many_texture_sets_are_refused_before_anything_is_allocated() { + // An empty fan on a face claiming 2^32 - 1 texture sets: no slot bounds the count. + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: u32::MAX, ..Mesh::default() }); + assert!(matches!(decode(§ion(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_)))); + let sets = u32::try_from(MAX_TEXTURE_SETS).unwrap(); + let mut ok = W::default(); + tess_3d(&mut ok, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: sets, ..Mesh::default() }); + assert!(decode(§ion(1, &ok), &Schema::default(), 8137).is_ok()); +} + +#[test] +fn two_faces_partition_the_triangles() { + let m = one(&[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], &[(0x2, 0, &[1]), (0x2, 6, &[1])], 0); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]); + assert_eq!(m.faces, [0..1, 1..2]); +} + +/// A wire over [`SQUARE`]: an open two-point wire continued by a closing two-point wire, +/// then optionally per-segment RGB colours. +fn wire(w: &mut W, colours: bool) { + square(w, 175); + uints(w, &[2, 0, 3, WIRE_IS_CONTINUOUS | WIRE_IS_CLOSING | 2, 6, 9]); + w.bit(colours); + if colours { + // 5 points (4 + the implicit closing one) -> 2 segment colours. + w.bit(false).bit(true).bit(false).put(0xff0000, 24); + w.bit(true); + } +} + +#[test] +fn wires_close_and_continue_and_colours_are_counted() { + for colours in [false, true] { + let mut b = W::default(); + wire(&mut b, colours); + // A second entity: a wrong colour count would desync it. + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + for e in &t { + let Tessellation::Wire(w) = e else { panic!("not a wire") }; + assert_eq!(w, &[vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [1., 1., 0.]]]); + } + } +} + +#[test] +fn a_closing_wire_colours_its_implicit_point() { + // Per-point RGB: 4 indexed points + 1 implicit = 5 colours. + let mut b = W::default(); + square(&mut b, 175); + uints(&mut b, &[2, 0, 3, WIRE_IS_CONTINUOUS | WIRE_IS_CLOSING | 2, 6, 9]); + b.bit(true).bit(false).bit(false).bit(false).put(1, 24); + for _ in 1..5 { + b.bit(true); + } + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + assert!(matches!(t[1], Tessellation::Wire(_))); + assert_eq!(t[0], t[1]); +} + +#[test] +fn a_wire_index_must_name_a_point() { + let mut b = W::default(); + square(&mut b, 175); + uints(&mut b, &[2, 0, 12]); + b.bit(false); + assert!(matches!(decode(§ion(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_)))); +} + +#[test] +fn a_wire_without_indices_is_the_coordinate_polyline() { + let mut b = W::default(); + square(&mut b, 175); + b.uint(0).bit(true); + // 4 implicit points -> 4 RGB colours, all "same" after the first. + b.bit(false).bit(false).bit(false).put(1, 24); + for _ in 1..4 { + b.bit(true); + } + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + let Tessellation::Wire(w) = &t[0] else { panic!("not a wire") }; + assert_eq!(w.len(), 1); + assert_eq!(w[0].len(), 4); + assert!(matches!(t[1], Tessellation::Wire(_))); +} + +/// A square as one triangle plus one 4-vertex fan (two entities, three triangles), the face +/// carrying `attrs` and, when it has any, behaviour 0x30; then `colours` per-point colours +/// and a trailing wire. +fn styled_face(attrs: &[u32], colours: usize) -> Vec { + let mut b = W::default(); + square(&mut b, 172); + b.bit(true).bit(false).bit(false).uint(0).uint(0); + uints(&mut b, &[0, 0, 0, 3, 0, 6, 0, 0, 0, 3, 0, 6, 0, 9]); + b.uint(1).uint(174); + uints(&mut b, attrs); + b.uint(0).uint(0).uint(0x2 | 0x4).uint(0); + uints(&mut b, &[1, 1, 4]); + b.uint(0).bit(colours > 0); + if colours > 0 { + b.bit(false).bit(false).put(0x10_2030, 24); + for _ in 1..colours { + b.bit(true); + } + } + if !attrs.is_empty() { + b.uint(0x30); + } + b.uint(0); + wire(&mut b, false); + decode(§ion(2, &b), &Schema::default(), 8137).unwrap() +} + +#[test] +fn face_vertex_colours_count_points_not_data() { + // A triangle plus a fan of 4 vertices = 7 points -> 7 colours. + let t = styled_face(&[], 7); + assert_eq!(mesh(&t[0]).triangles.len(), 3); + assert!(matches!(t[1], Tessellation::Wire(_))); +} + +fn styles(attrs: &[u32]) -> Vec<(u32, u16)> { + let t = styled_face(attrs, 0); + assert!(matches!(t[1], Tessellation::Wire(_))); + mesh(&t[0]).triangle_styles.iter().map(|g| (g.style, g.behaviour)).collect() +} + +#[test] +fn face_line_attributes_style_its_triangles() { + assert!(styles(&[]).is_empty()); + assert_eq!(styles(&[5]), [(5, 0x30); 3]); + // One per entity: the triangle, then both triangles of the fan. + assert_eq!(styles(&[4, 7]), [(4, 0x30), (7, 0x30), (7, 0x30)]); + // An entity styled 0 inherits, as do entities past the list. + assert_eq!(styles(&[4, 0, 9]), [(4, 0x30), (0, 0x30), (0, 0x30)]); +} + +#[test] +fn markup_is_read_past() { + let mut b = W::default(); + b.uint(176).bit(false).uint(3); + for c in [1., 2., 3.] { + b.double(c); + } + uints(&mut b, &[6, 0]); + b.uint(1).string(Some("R1")).string(Some("label")).put(7, 8); + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + assert_eq!(t[0], Tessellation::Markup); + assert!(matches!(t[1], Tessellation::Wire(_))); +} + +#[test] +fn schema_fields_are_skipped_after_each_entity() { + // A newer producer added one UnsignedInteger to TESS_3D and one to TESS_Face. + let mut s = W::default(); + s.uint(2).uint(172).uint(1).uint(3).uint(174).uint(1).uint(3); + let sb = s.bytes(); + let schema = Schema::read(&mut BitReader::new(&sb)).unwrap(); + + let mut b = W::default(); + square(&mut b, 172); + b.bit(true).bit(false).bit(false).uint(0).uint(0); + uints(&mut b, &[0, 0, 0, 3, 0, 6]); + b.uint(1).uint(174).uint(0).uint(0).uint(0).uint(0x2).uint(0); + uints(&mut b, &[1]); + b.uint(0).bit(false).uint(99); // the face's added field + b.uint(0).uint(77); // texture count, then TESS_3D's added field + wire(&mut b, false); + let t = decode(§ion(2, &b), &schema, 8137).unwrap(); + assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2]]); + assert!(matches!(t[1], Tessellation::Wire(_))); + // Without the schema the same bytes do not decode to the same thing. + assert_ne!(decode(§ion(2, &b), &Schema::default(), 8137).ok(), Some(t)); +} + +#[test] +fn attributes_names_and_user_data_are_read_past() { + let mut w = W::default(); + w.uint(305).uint(1).uint(ATTRIBUTE); + w.bit(true).uint(2); // predefined title + w.uint(2); + w.bit(false).string(Some("k")).uint(4).string(Some("v")); + w.bit(true).uint(1).uint(5).int(-1).uint(9); + w.uint(42); // the attribute's schema-added field + w.bit(false).string(Some("tess")).uint(1); + wire(&mut w, false); + w.uint(5).put(0b10110, 5); + let mut sw = W::default(); + sw.uint(1).uint(ATTRIBUTE).uint(1).uint(3); + let sb = sw.bytes(); + let schema = Schema::read(&mut BitReader::new(&sb)).unwrap(); + let bytes = w.bytes(); + let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, 8137); + let t = ctx.file_structure_tessellation().unwrap(); + assert!(matches!(t[..], [Tessellation::Wire(_)])); + assert_eq!(ctx.name.as_deref(), Some("tess")); + assert_eq!(ctx.r.position(), w.len()); +} + +/// A one-triangle TESS_3D_Compressed. `full` takes every optional branch: stored normals, +/// colours, line attributes, texture, behaviours. Otherwise `three_t` picks the 3T edge form. +fn compressed(w: &mut W, v: u32, full: bool, three_t: bool) { + w.uint(173).bit(false).bit(true).double(0.001); + if v >= 7031 { + w.put(0, 32).put(0, 32).put(0, 32); + } + // point_array: nine 2-bit values. + w.bit(false).uint(9); + for _ in 0..9 { + w.put(2, 8); + } + for _ in 0..9 { + w.bit(false).bit(true); + } + // edge_status_array: the 3T form (Huffman) or the T form. + if full { + w.bit(true).huffman(2, 2, &[(0, 0b10, 2)], &[0, 0, 0]); + } else if three_t { + w.bit(false).uint(3).put(0, 8).put(0, 8).put(0, 8); + } else { + w.bit(false).uint(1).put(0, 8); + } + // triangle_face_array [0]. `full`: four references, so the flagless reference array is + // Huffman; else none. + w.bit(false).uint(1).put(1, 8).bit(false); + if full { + w.uint(4).put(0b1111, 4); + w.huffman(6, 2, &[(1, 0b10, 2), (0, 0b11, 2)], &[1, 0, 0, 0]); + w.put(0, 4); + } else { + w.uint(3).put(0, 3).uint(0); + } + if full { + w.bit(false).put(10, 8).uint(3).put(0b101, 3); + w.bit(true).huffman(10, 2, &[(5, 0b10, 2)], &[5, 5]); + w.bit(true); // is_face_planar + w.bit(true).bit(true).bit(false).uint(5); + for c in [1, 255, 0, 0, 128] { + w.put(c, 8); + } + w.bit(true).bit(false); + } else { + w.bit(true).bit(false).double(0.5).put(0, 8); + w.bit(false).bit(false); + } + w.bit(false).uint(1).put(1, 8).put(0, 8); // line_attribute_array + if full { + w.bit(false).uint(1).put(0, 32).uint(32); + w.uint(1).put(1, 5).bit(true).double(0.5).uint(2); + w.put(0, 32).put(0, 32).bit(false).bit(true); + w.bit(true).bit(false).uint(1).put(1, 8); + } else { + w.bit(true).bit(false); + } +} + +#[test] +fn a_compressed_mesh_is_read_to_its_end() { + for (full, three_t) in [(false, false), (false, true), (true, true)] { + for v in [7030, 7031, 8137] { + let mut b = W::default(); + compressed(&mut b, v, full, three_t); + let indices = [0, 0, 0, 3, 0, 6, 0, 0, 0, 3, 0, 6, 0, 9]; + tess_3d(&mut b, v, &Mesh { indices: &indices, faces: &[(0x2 | 0x4, 0, &[1, 1, 4])], ..Mesh::default() }); + let w = section(2, &b); + let bytes = w.bytes(); + let schema = Schema::default(); + let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, v); + let t = ctx.file_structure_tessellation().unwrap(); + assert!(matches!(t[..], [Tessellation::Compressed { triangles: 1, .. }, Tessellation::Mesh(_)]), "full={full} v={v}: {t:?}"); + assert_eq!(ctx.r.position(), w.len(), "full={full} v={v}"); + } + } +} + +#[test] +fn refusals_and_damage_are_errors() { + // Edge statuses neither T nor 3T long. + let mut c = W::default(); + c.uint(173).bit(false).bit(true).double(0.001); + c.put(0, 32).put(0, 32).put(0, 32); + c.bit(false).uint(0).bit(false).uint(2).put(0, 16); + c.bit(false).uint(1).put(1, 8).bit(false); + assert!(matches!(decode(§ion(1, &c), &Schema::default(), 8137), Err(PrcError::Malformed(_)))); + // Optimised vertex colours. + let mut o = W::default(); + square(&mut o, 175); + o.uint(0).bit(true).bit(false).bit(false).bit(true); + assert!(matches!(decode(§ion(1, &o), &Schema::default(), 8137), Err(PrcError::Unsupported(_)))); + // A point index that is not a multiple of 3, and one past the end. + for bad in [4, 12] { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, bad, 0, 6], faces: &[(0x2, 0, &[1])], ..Mesh::default() }); + assert!(matches!(decode(§ion(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_)))); + } + // A fan whose count claims a billion points. + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0], faces: &[(0x4, 0, &[1, 0x3FFF_FFFF])], ..Mesh::default() }); + assert!(decode(§ion(1, &b), &Schema::default(), 8137).is_err()); + // A tessellation count far past the data. + let mut w = W::default(); + w.uint(305).uint(0).bit(true).uint(u32::MAX); + assert!(matches!(decode(&w, &Schema::default(), 8137), Err(PrcError::Truncated(_)))); + // A wrong entity type where the section's own belongs. + let mut w = W::default(); + w.uint(306); + assert!(matches!(decode(&w, &Schema::default(), 8137), Err(PrcError::Malformed(_)))); +} + +#[test] +fn every_truncation_of_a_section_is_an_error_not_a_panic() { + let mut b = W::default(); + compressed(&mut b, 8137, true, true); + wire(&mut b, true); + let bytes = section(2, &b).bytes(); + for end in 0..bytes.len() { + let cut = bytes.get(..end).unwrap(); + // The zero padding of the last byte may still decode; anything shorter must not. + let _ = Ctx::new(BitReader::new(cut), &Schema::default(), 8137).file_structure_tessellation(); + } + let cut = bytes.get(..bytes.len() / 2).unwrap(); + assert!(Ctx::new(BitReader::new(cut), &Schema::default(), 8137).file_structure_tessellation().is_err()); +} + +/// A one-file-structure PRC stream whose tessellation section is `tess`, with an empty +/// globals section schema. +fn prc_file(tess: &[u8]) -> Vec { + use std::io::Write as _; + const HEADER_LEN: usize = 107; + let zlib = |data: &[u8]| { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() + }; + let u32s = |out: &mut Vec, vs: &[u32]| vs.iter().for_each(|v| out.extend_from_slice(&v.to_le_bytes())); + let mut globals = W::default(); + globals.uint(0); + let sections: [&[u8]; 5] = [&globals.bytes(), b"", tess, b"", b""]; + let mut body = b"PRC".to_vec(); + u32s(&mut body, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 0]); + let mut offs = vec![HEADER_LEN]; + for s in sections { + offs.push(HEADER_LEN + body.len()); + body.extend(zlib(s)); + } + let mf_start = HEADER_LEN + body.len(); + body.extend(zlib(b"")); + let mf_end = HEADER_LEN + body.len(); + let mut out = b"PRC".to_vec(); + u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]); + u32s(&mut out, &[5, 6, 7, 8, 0, 6]); + u32s(&mut out, &offs.iter().map(|&o| u32::try_from(o).unwrap()).collect::>()); + u32s(&mut out, &[u32::try_from(mf_start).unwrap(), u32::try_from(mf_end).unwrap(), 0]); + out.extend(body); + out +} + +#[test] +fn a_prc_file_yields_its_tessellations() { + let f = crate::PrcFile::parse(&prc_file(&textured_square().bytes())).unwrap(); + let t = f.file_structures[0].tessellations().unwrap(); + assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2], [0, 2, 3]]); +} diff --git a/crates/prc/src/tess/triangulate.rs b/crates/prc/src/tess/triangulate.rs new file mode 100644 index 0000000000..56917d3dc8 --- /dev/null +++ b/crates/prc/src/tess/triangulate.rs @@ -0,0 +1,258 @@ +//! A `TESS_Face`'s triangulated data turned into triangles [WD 7.8.5.5, 7.8.6]. + +use super::TriangleStyle; +use crate::PrcError; +use crate::entity::malformed; + +/// A fan/strip count's point-count bits; bit 30 is `NORMAL_Single` [WD 7.8.6.2]. +const COUNT_MASK: u32 = 0x3FFF_FFFF; +const NORMAL_SINGLE: u32 = 0x4000_0000; + +/// Texture-coordinate sets a face may carry. Each set costs a slot per point and a corner +/// triple per triangle of the whole mesh, so the count is capped. +pub(super) const MAX_TEXTURE_SETS: usize = 8; + +pub(super) struct Face { + pub(super) flags: u32, + pub(super) start: usize, + pub(super) data: Vec, + pub(super) textures: usize, + /// `line_attributes`, each a style index + 1: none = the owner's graphics, one = the + /// whole face's, more = one per triangulation entity [WD 7.8.6]. + pub(super) styles: Vec, + /// The behaviour bits read when `styles` is not empty. + pub(super) behaviour: u16, +} + +/// A `used_entities_flag` block's shape [WD 7.8.5.5]. +#[derive(Clone, Copy)] +enum Shape { + Triangles, + Fan, + Strip, +} + +/// One `used_entities_flag` block. +#[derive(Clone, Copy)] +struct Block { + bit: u32, + shape: Shape, + /// One normal per entity rather than per point. + one_normal: bool, + textured: bool, +} + +const fn block(bit: u32, shape: Shape, one_normal: bool, textured: bool) -> Block { + Block { bit, shape, one_normal, textured } +} + +/// The blocks in stream order, low bit first [WD 7.8.5.5]. +const BLOCKS: [Block; 12] = [ + block(0x0002, Shape::Triangles, false, false), + block(0x0004, Shape::Fan, false, false), + block(0x0008, Shape::Strip, false, false), + block(0x0020, Shape::Triangles, true, false), + block(0x0040, Shape::Fan, true, false), + block(0x0080, Shape::Strip, true, false), + block(0x0200, Shape::Triangles, false, true), + block(0x0400, Shape::Fan, false, true), + block(0x0800, Shape::Strip, false, true), + block(0x2000, Shape::Triangles, true, true), + block(0x4000, Shape::Fan, true, true), + block(0x8000, Shape::Strip, true, true), +]; + +/// One triangulation entity's index slots: per point its position, normal slot (when the +/// array stores normals) and texture slots. +#[derive(Default)] +struct Slots { + verts: Vec, + normals: Vec, + /// Per texture set, each point's slot. + textures: Vec>, +} + +/// What a mesh's triangles carry besides positions, built in step with them: corner normal +/// slots and, per texture set, corner texture slots (`None` for a triangle whose face stores +/// fewer sets). +#[derive(Default)] +pub(super) struct Corners { + pub(super) normals: Vec<[u32; 3]>, + pub(super) textures: Vec>>, +} + +impl Face { + /// Entity sizes per set block, from `TriangulatedData` [WD 7.8.6]. A triangle block is + /// one size word (its triangle count); fans and strips list a vertex count each. + fn entities(&self) -> Result)>, PrcError> { + let mut d = self.data.iter().copied(); + let mut next = || d.next().ok_or_else(|| malformed("triangulated data ends early".into())); + let mut out = Vec::new(); + for b in BLOCKS { + if self.flags & b.bit == 0 { + continue; + } + let sizes = match b.shape { + Shape::Triangles => vec![next()?], + Shape::Fan | Shape::Strip => { + let n = next()? as usize; + if n > self.data.len() { + return Err(malformed("triangulated data ends early".into())); + } + (0..n).map(|_| next()).collect::>()? + } + }; + out.push((b, sizes)); + } + Ok(out) + } + + /// Points the face's index slots name: the vertex-colour count (pdf-issues #820). + pub(super) fn point_count(&self) -> Result { + let mut n = 0usize; + for (b, sizes) in self.entities()? { + for s in sizes { + let s = (s & COUNT_MASK) as usize; + n = n.saturating_add(if matches!(b.shape, Shape::Triangles) { s.saturating_mul(3) } else { s }); + } + } + Ok(n) + } + + /// Emit the face's triangles into `out`; `normals` = the index array stores normal slots, + /// in which case each triangle's corner normal slots go to `corners`, as do its corner + /// texture slots. Returns the triangles each triangulation entity emitted, in order: one + /// entity per triangle of a triangle block, per fan, per strip [WD 7.8.6]. + pub(super) fn triangulate( + &self, + indices: &[u32], + normals: bool, + n_points: usize, + out: &mut Vec<[u32; 3]>, + corners: &mut Corners, + ) -> Result, PrcError> { + if self.textures > MAX_TEXTURE_SETS { + return Err(malformed(format!("{} texture-coordinate sets on a face", self.textures))); + } + let mut entities = Vec::new(); + let mut slots = indices.get(self.start..).unwrap_or(&[]).iter().copied(); + for (b, sizes) in self.entities()? { + let t = if b.textured { self.textures } else { 0 }; + for word in sizes { + let count = (word & COUNT_MASK) as usize; + // Points in this entity, and whether each carries its own normal slot. + let (points, per_point_normal) = match b.shape { + Shape::Triangles => (count.saturating_mul(3), !b.one_normal), + Shape::Fan | Shape::Strip => (count, !b.one_normal || word & NORMAL_SINGLE == 0), + }; + if points.saturating_mul(t.saturating_add(1)) > indices.len() { + return Err(malformed("face runs past the triangulated index array".into())); + } + let s = read_slots(&mut slots, b.shape, points, t, normals.then_some(per_point_normal), n_points)?; + let made = emit(b.shape, &s, out, corners); + match b.shape { + Shape::Triangles => entities.extend(std::iter::repeat_n(1, made)), + Shape::Fan | Shape::Strip => entities.push(made), + } + } + } + Ok(entities) + } + + /// Append one [`TriangleStyle`] per triangle of the face, whose entities emitted + /// `entities[i]` triangles each. Entities past the face's styles inherit (style 0). + pub(super) fn styles_into(&self, entities: &[usize], out: &mut Vec) { + let whole = match self.styles.as_slice() { + [] => Some(0), + [one] => Some(*one), + _ => None, + }; + for (i, &n) in entities.iter().enumerate() { + let style = whole.unwrap_or_else(|| self.styles.get(i).copied().unwrap_or(0)); + out.extend(std::iter::repeat_n(TriangleStyle { style, behaviour: self.behaviour }, n)); + } + } +} + +/// One entity's `points` index slots. `normals` is `None` when the array stores no normal +/// slots, else whether every point has its own (rather than one per entity). +fn read_slots( + slots: &mut impl Iterator, + shape: Shape, + points: usize, + texture_sets: usize, + normals: Option, + n_points: usize, +) -> Result { + let mut take = || slots.next().ok_or_else(|| malformed("face runs past the triangulated index array".into())); + // The caller bounded points * (texture_sets + 1) by the index array's length. + let mut s = Slots { textures: vec![Vec::with_capacity(points); texture_sets], ..Slots::default() }; + let mut current = 0; + for k in 0..points { + let new_entity = match shape { + Shape::Triangles => k % 3 == 0, + Shape::Fan | Shape::Strip => k == 0, + }; + if let Some(per_point) = normals { + if per_point || new_entity { + current = take()?; + } + s.normals.push(current); + } + for set in &mut s.textures { + set.push(take()?); + } + let p = take()?; + if !p.is_multiple_of(3) || p as usize / 3 >= n_points { + return Err(malformed(format!("point index {p} is not a point"))); + } + s.verts.push(p / 3); + } + Ok(s) +} + +/// Append the triangles of one entity's `slots` to `out` and `corners`, keeping every +/// texture set as long as `out`. Returns how many it made. +fn emit(shape: Shape, slots: &Slots, out: &mut Vec<[u32; 3]>, corners: &mut Corners) -> usize { + let before = out.len(); + assemble(shape, &slots.verts, out); + assemble(shape, &slots.normals, &mut corners.normals); + let made = out.len().saturating_sub(before); + while corners.textures.len() < slots.textures.len() { + corners.textures.push(vec![None; before]); + } + for (k, set) in corners.textures.iter_mut().enumerate() { + match slots.textures.get(k) { + Some(own) => { + let mut tris = Vec::with_capacity(made); + assemble(shape, own, &mut tris); + set.extend(tris.into_iter().map(Some)); + } + None => set.extend(std::iter::repeat_n(None, made)), + } + } + made +} + +/// Append the triangles `shape` makes of `verts`. +fn assemble(shape: Shape, verts: &[u32], out: &mut Vec<[u32; 3]>) { + match shape { + Shape::Triangles => out.extend(verts.chunks_exact(3).filter_map(|c| match c { + [a, b, c] => Some([*a, *b, *c]), + _ => None, + })), + Shape::Fan => { + if let Some((&c, rim)) = verts.split_first() { + out.extend(rim.windows(2).filter_map(|w| match w { + [a, b] => Some([c, *a, *b]), + _ => None, + })); + } + } + Shape::Strip => out.extend(verts.windows(3).enumerate().filter_map(|(k, w)| match w { + [a, b, c] if k % 2 == 0 => Some([*a, *b, *c]), + [a, b, c] => Some([*b, *a, *c]), + _ => None, + })), + } +} diff --git a/crates/prc/src/testw.rs b/crates/prc/src/testw.rs index 4706a20ef8..37e90606b6 100644 --- a/crates/prc/src/testw.rs +++ b/crates/prc/src/testw.rs @@ -94,8 +94,73 @@ impl W { self } + /// A Huffman container [WD 12.2]: `leaves` are `(symbol, stored code, length)`, each value + /// is emitted as its leaf's code. + pub(crate) fn huffman(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], values: &[i32]) -> &mut Self { + let mask = (1u32 << nbits) - 1; + let codes: Vec<(u32, u32)> = values + .iter() + .map(|&v| { + let l = leaves.iter().find(|l| l.0 & mask == v.cast_unsigned() & mask).expect("a leaf per value"); + (l.1, l.2) + }) + .collect(); + self.huffman_raw(nbits, len_width, leaves, &codes) + } + + /// As [`Self::huffman`] with each element's `(code, length)` given. + pub(crate) fn huffman_raw(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], codes: &[(u32, u32)]) -> &mut Self { + let mut blob = Self::huffman_head(nbits, len_width, leaves); + lsb(&mut blob, u32::try_from(codes.len()).expect("a short test array"), 32); + for &(c, l) in codes { + for i in (0..l).rev() { + blob.push((c >> i) & 1 == 1); + } + } + self.huffman_words(blob) + } + + /// A Huffman container whose element count is `count` with no elements written. + pub(crate) fn huffman_count(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], count: u32) -> &mut Self { + let mut blob = Self::huffman_head(nbits, len_width, leaves); + lsb(&mut blob, count, 32); + self.huffman_words(blob) + } + + fn huffman_head(nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)]) -> Vec { + let mut blob = Vec::new(); + lsb(&mut blob, u32::try_from(leaves.len()).expect("a short test table"), nbits + 1); + lsb(&mut blob, len_width, 8); + for &(s, c, l) in leaves { + lsb(&mut blob, s, nbits); + lsb(&mut blob, l, len_width); + lsb(&mut blob, c, l); + } + blob + } + + /// The payload as whole 32-bit words of LSB-first bytes, then the last word's used bits. + fn huffman_words(&mut self, mut blob: Vec) -> &mut Self { + let bits = blob.len(); + let words = bits.div_ceil(32); + blob.resize(words * 32, false); + self.uint(u32::try_from(words).expect("a short test payload")); + for byte in blob.chunks(8) { + let b = byte.iter().enumerate().fold(0u64, |a, (i, &x)| a | (u64::from(x) << i)); + self.put(b, 8); + } + self.uint(u32::try_from(bits - (words - 1) * 32).expect("at most 32")) + } + /// The bits packed MSB-first, the last byte zero-padded. pub(crate) fn bytes(&self) -> Vec { self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect() } } + +/// The low `n` bits of `v`, least significant first. +fn lsb(blob: &mut Vec, v: u32, n: u32) { + for i in 0..n { + blob.push((v >> i) & 1 == 1); + } +} From c571b1346936bbe75876818cbc2f7253f92b8377 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 10:56:03 -0400 Subject: [PATCH 019/124] M12: PRC reader: product tree and styles (#1297) Walk the PRC product tree into placed, coloured tessellations. - PrcFile::placements / placements_with(StyleAlpha, EntityOverrides): every drawable tessellation reached from the model file's roots, placed by its occurrence path's locations and then the representation item's local coordinate systems (a 4x4 Matrix) [WD 7.3.10.1, 7.6.3.2]. - Colours: styles and materials from the file structure globals, the father/son inheritance bits [WD 7.2.4.2], style transparency combined with material alpha by StyleAlpha, per-face styles and topology references via Placement::triangle_colours. A prototype's part is coloured by its own structure's styles. A colour index counts the colour array's doubles (pdf-issues #816). - Occurrences' entity references: colour, hide or relocate the entity they name, in the occurrence's subtree, everywhere, or not at all. - PrcFile::model_tree: the assembly tree as a model-tree panel lists it, with name sources, visibility and each node's placement range. - TriangleMesh::transformed applies a Matrix: positions, cofactor-mapped normals, winding flipped under a mirror. Never panics: occurrence and prototype cycles are Malformed; depth 64, 2^20 visits, MAX_PLACEMENTS and a face-override ceiling bound the walk; counts are checked against the remaining data before allocating. Markups, views, scene lights and clipping planes are Unsupported; textures are read past. Field orders the working draft leaves open follow prc-rs (MIT); each is marked `// prc-rs:`, as are the step-1 container, Double and schema choices that did. Adapted from pdfcer (MIT, same author), crates/pdfcer-3d/src/tree and vec3.rs at 0c9742b5936232af89cd707138ef149dc1f4a660. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc/README.md | 13 +- crates/prc/src/bits.rs | 2 + crates/prc/src/container.rs | 3 +- crates/prc/src/entity.rs | 7 +- crates/prc/src/geometry.rs | 179 ++++++++++ crates/prc/src/lib.rs | 8 +- crates/prc/src/schema.rs | 2 + crates/prc/src/tess.rs | 2 +- crates/prc/src/tess/tests.rs | 7 + crates/prc/src/tree/mod.rs | 293 ++++++++++++++++ crates/prc/src/tree/node.rs | 68 ++++ crates/prc/src/tree/overrides.rs | 128 +++++++ crates/prc/src/tree/read.rs | 451 ++++++++++++++++++++++++ crates/prc/src/tree/style.rs | 348 +++++++++++++++++++ crates/prc/src/tree/tests.rs | 576 +++++++++++++++++++++++++++++++ crates/prc/src/tree/walk.rs | 262 ++++++++++++++ 16 files changed, 2344 insertions(+), 5 deletions(-) create mode 100644 crates/prc/src/geometry.rs create mode 100644 crates/prc/src/tree/mod.rs create mode 100644 crates/prc/src/tree/node.rs create mode 100644 crates/prc/src/tree/overrides.rs create mode 100644 crates/prc/src/tree/read.rs create mode 100644 crates/prc/src/tree/style.rs create mode 100644 crates/prc/src/tree/tests.rs create mode 100644 crates/prc/src/tree/walk.rs diff --git a/crates/prc/README.md b/crates/prc/README.md index cb3865d3fd..614231cf64 100644 --- a/crates/prc/README.md +++ b/crates/prc/README.md @@ -14,6 +14,10 @@ PrcFile::parse_with_limit(data, max_inflated) -> Result FileStructure::section(SectionKind) -> &[u8] // inflated globals / tree / tessellation / geometry / extra geometry FileStructure::schema() -> Result // the globals section's leading schema FileStructure::tessellations() -> Result, PrcError> // meshes, wires, markups, compressed meshes +PrcFile::placements() / placements_with(StyleAlpha, EntityOverrides) -> Result, PrcError> +PrcFile::model_tree() -> Result, PrcError> // the assembly tree as a model-tree panel lists it +Placement::triangle_colours(&TriangleMesh) -> Option>> +TriangleMesh::transformed(&Matrix) -> TriangleMesh Schema::read(&mut BitReader) / Schema::skip_added_fields(entity_type, &mut BitReader) BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_vbn, nbits_then_unsigned, string, float_as_bytes, double ``` @@ -26,6 +30,13 @@ BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_ `Wire` is polylines; `Markup` is read past. - `Tessellation::Compressed` is read to its end so later entities decode; its triangles are not rebuilt yet. - A face may carry at most 8 texture-coordinate sets; more is `Malformed`. Optimised vertex colours are `Unsupported`. -- Not yet in the crate: the product tree and rebuilding compressed meshes. +- The product tree: each drawable tessellation placed by its occurrence path's locations and the item's coordinate + systems (a 4x4 `Matrix`), coloured by the style inheritance rules, with occurrences' entity references applied + (`EntityOverrides`: subtree, everywhere or ignored) and style transparency combined with material alpha by `StyleAlpha`. + A prototype's part is coloured by its own file structure's styles. Each `ModelNode` lists its name, where the name + came from, its visibility and the range of placements it drew. +- Occurrence and prototype cycles are `Malformed`; the walk is capped at depth 64, 2^20 visits and `MAX_PLACEMENTS`. +- Trees carrying markups (PMI), views, scene lights or clipping planes are `Unsupported`; textures are read past. +- Not yet in the crate: rebuilding compressed meshes, textures. - The `Double` code table's rows come from [prc-rs](https://github.com/ralovich/prc-rs) (MIT); its notice is in `src/acof/table.rs`. A few reading choices also follow prc-rs; each is marked `// prc-rs:` in the source. diff --git a/crates/prc/src/bits.rs b/crates/prc/src/bits.rs index b4a2ff352b..a1efe7a64f 100644 --- a/crates/prc/src/bits.rs +++ b/crates/prc/src/bits.rs @@ -201,6 +201,8 @@ impl<'a> BitReader<'a> { /// [`PrcError::UnknownDoubleCode`], [`PrcError::Truncated`], or [`PrcError::Malformed`] /// for a back-reference outside the value. pub fn double(&mut self) -> Result { + // prc-rs: the decoding steps (code lookup, sign, exponent, byte-wise mantissa with + // back-references) follow prc-rs's. let mut code = 0u32; let mut entry = None; for n in 1..=acof::MAX_CODE_BITS { diff --git a/crates/prc/src/container.rs b/crates/prc/src/container.rs index 8a44921334..15c70af8fc 100644 --- a/crates/prc/src/container.rs +++ b/crates/prc/src/container.rs @@ -181,7 +181,7 @@ impl<'a> Bytes<'a> { /// A stored `u32` offset or count as a `usize` (saturating on a 16-bit target, where no data /// that large could exist). -fn offset(v: u32) -> usize { +pub(crate) fn offset(v: u32) -> usize { usize::try_from(v).unwrap_or(usize::MAX) } @@ -316,6 +316,7 @@ impl PrcFile { } let mut budget = Budget { left: limit, limit }; + // prc-rs: the last section runs to the model file's start. let last = mf_start.min(data.len()); let file_structures = descriptions.iter().map(|(id, offs)| file_structure(data, *id, &Extent { offs, last }, &mut budget)).collect::, _>>()?; diff --git a/crates/prc/src/entity.rs b/crates/prc/src/entity.rs index e6f488c8f8..1ad53dbd72 100644 --- a/crates/prc/src/entity.rs +++ b/crates/prc/src/entity.rs @@ -4,6 +4,7 @@ use crate::PrcError; use crate::bits::BitReader; use crate::schema::Schema; +use crate::tree::Graphics; const ATTRIBUTE: u32 = 201; @@ -21,11 +22,15 @@ pub(crate) struct Ctx<'a, 's> { /// The current name: what a `same_name` entity reuses; every entity's own name replaces /// it [WD 7.2.3.4]. Each section starts with none. pub(crate) name: Option, + /// The PRC unique id of the last `ContentPRCRefBase` read [WD 7.2.3.2]. + pub(crate) uid: u32, + /// The current graphics: what a `same_graphics` entity reuses [WD 7.2.4]. + pub(crate) graphics: Graphics, } impl<'a, 's> Ctx<'a, 's> { pub(crate) fn new(r: BitReader<'a>, schema: &'s Schema, version: u32) -> Self { - Ctx { r, schema, version, name: None } + Ctx { r, schema, version, name: None, uid: 0, graphics: Graphics::default() } } /// Reads an entity type code; any value but `want` is malformed. diff --git a/crates/prc/src/geometry.rs b/crates/prc/src/geometry.rs new file mode 100644 index 0000000000..3328bf10f1 --- /dev/null +++ b/crates/prc/src/geometry.rs @@ -0,0 +1,179 @@ +//! Model-space vectors and matrices, and their application to meshes. + +use crate::TriangleMesh; + +/// A point or direction in model space. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub(crate) struct Vec3(pub(crate) [f64; 3]); + +impl Vec3 { + /// The right-handed cross product `self × b`. + pub(crate) fn cross(self, b: Vec3) -> Vec3 { + let ([ax, ay, az], [bx, by, bz]) = (self.0, b.0); + Vec3([ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx]) + } +} + +/// A 4×4 matrix, `m.0[row][col]`, acting on column vectors. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Matrix(pub [[f64; 4]; 4]); + +impl Default for Matrix { + fn default() -> Self { + Matrix::IDENTITY + } +} + +impl Matrix { + /// The identity matrix. + pub const IDENTITY: Matrix = Matrix([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]); + + /// The entry at `row`, `col`; 0 outside the matrix. + #[must_use] + pub fn at(&self, row: usize, col: usize) -> f64 { + self.0.get(row).and_then(|r| r.get(col)).copied().unwrap_or(0.0) + } + + /// `self × b`: `b` applied first. + #[must_use] + pub fn multiply(&self, b: &Matrix) -> Matrix { + let mut m = [[0.0; 4]; 4]; + for (i, row) in m.iter_mut().enumerate() { + for (j, cell) in row.iter_mut().enumerate() { + *cell = (0..4).map(|k| self.at(i, k) * b.at(k, j)).sum(); + } + } + Matrix(m) + } + + /// `self × (p, 1)`, divided by the homogeneous coordinate when it is neither 0 nor 1. + #[must_use] + pub fn transform_point(&self, p: [f64; 3]) -> [f64; 3] { + let [x, y, z] = p; + let row = |i: usize| self.at(i, 0) * x + self.at(i, 1) * y + self.at(i, 2) * z + self.at(i, 3); + let w = row(3); + let s = if w == 0.0 || w == 1.0 { 1.0 } else { 1.0 / w }; + [row(0) * s, row(1) * s, row(2) * s] + } + + /// The determinant of the upper-left 3×3 block. + fn det3(&self) -> f64 { + let a = |i, j| self.at(i, j); + a(0, 0) * (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) - a(0, 1) * (a(1, 0) * a(2, 2) - a(1, 2) * a(2, 0)) + + a(0, 2) * (a(1, 0) * a(2, 1) - a(1, 1) * a(2, 0)) + } + + /// Cofactor `(i, j)` of the upper-left 3×3 block (`i`, `j` < 3). + fn cofactor(&self, i: usize, j: usize) -> f64 { + let (r0, r1) = ((i + 1) % 3, (i + 2) % 3); + let (c0, c1) = ((j + 1) % 3, (j + 2) % 3); + self.at(r0, c0) * self.at(r1, c1) - self.at(r0, c1) * self.at(r1, c0) + } +} + +impl TriangleMesh { + /// This mesh with every position mapped through `m` and every stored normal through its + /// inverse transpose (renormalised); a mirroring `m` (negative determinant) reverses each + /// triangle so the outside stays counter-clockwise. + /// + /// ``` + /// use pdfcraft_prc::{Matrix, TriangleMesh}; + /// let mut mirror = Matrix::IDENTITY; + /// mirror.0[0][0] = -1.0; + /// let mut mesh = TriangleMesh::default(); + /// mesh.positions = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + /// mesh.triangles = vec![[0, 1, 2]]; + /// let placed = mesh.transformed(&mirror); + /// assert_eq!(placed.positions, [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + /// assert_eq!(placed.triangles, [[0, 2, 1]]); + /// ``` + #[must_use] + pub fn transformed(&self, m: &Matrix) -> Self { + let mut out = self.clone(); + for p in &mut out.positions { + *p = m.transform_point(*p); + } + // The cofactor matrix is det times the inverse transpose, so its sign is corrected + // and the result renormalised. + let det = m.det3(); + let sign = if det < 0.0 { -1.0 } else { 1.0 }; + for n in &mut out.normals { + let [x, y, z] = *n; + let v = [0, 1, 2].map(|i| sign * (m.cofactor(i, 0) * x + m.cofactor(i, 1) * y + m.cofactor(i, 2) * z)); + let len = v.iter().map(|c| c * c).sum::().sqrt(); + *n = if len > 0.0 && len.is_finite() { v.map(|c| c / len) } else { v }; + } + if det < 0.0 { + for t in &mut out.triangles { + t.swap(1, 2); + } + for t in &mut out.triangle_normals { + t.swap(1, 2); + } + for t in out.triangle_uvs.iter_mut().flatten().flatten() { + t.swap(1, 2); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cross_follows_the_right_hand_rule() { + assert_eq!(Vec3([1.0, 0.0, 0.0]).cross(Vec3([0.0, 1.0, 0.0])), Vec3([0.0, 0.0, 1.0])); + assert_eq!(Vec3([0.0, 1.0, 0.0]).cross(Vec3([1.0, 0.0, 0.0])), Vec3([0.0, 0.0, -1.0])); + } + + #[test] + fn multiply_applies_the_right_operand_first() { + let mut t = Matrix::IDENTITY; + t.0[0][3] = 5.0; + let mut s = Matrix::IDENTITY; + s.0[0][0] = 2.0; + assert_eq!(t.multiply(&s).transform_point([1.0, 0.0, 0.0]), [7.0, 0.0, 0.0]); + assert_eq!(s.multiply(&t).transform_point([1.0, 0.0, 0.0]), [12.0, 0.0, 0.0]); + } + + #[test] + fn a_projective_point_is_divided_by_w() { + let mut m = Matrix::IDENTITY; + m.0[3][3] = 2.0; + assert_eq!(m.transform_point([2.0, 4.0, 6.0]), [1.0, 2.0, 3.0]); + m.0[3][3] = 0.0; + assert_eq!(m.transform_point([2.0, 4.0, 6.0]), [2.0, 4.0, 6.0]); + } + + #[test] + fn normals_take_the_inverse_transpose_and_mirror_with_their_triangle() { + // A shear in x by y turns the plane x = 0 into x = y; its x normal tilts to stay perpendicular. + let mut shear = Matrix::IDENTITY; + shear.0[0][1] = 1.0; + let mesh = TriangleMesh { + positions: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + triangles: vec![[0, 1, 2]], + normals: vec![[1.0, 0.0, 0.0]], + triangle_normals: vec![[0, 0, 0]], + ..TriangleMesh::default() + }; + let n = shear.transformed_normal(&mesh); + let s = std::f64::consts::FRAC_1_SQRT_2; + assert!((n[0] - s).abs() < 1e-12 && (n[1] + s).abs() < 1e-12 && n[2] == 0.0, "{n:?}"); + + let mut mirror = Matrix::IDENTITY; + mirror.0[2][2] = -1.0; + let placed = mesh.transformed(&mirror); + assert_eq!(placed.triangles, [[0, 2, 1]]); + assert_eq!(placed.triangle_normals, [[0, 0, 0]]); + assert_eq!(placed.normals, [[1.0, 0.0, 0.0]]); + } + + impl Matrix { + fn transformed_normal(&self, mesh: &TriangleMesh) -> [f64; 3] { + mesh.transformed(self).normals.first().copied().unwrap_or_default() + } + } +} diff --git a/crates/prc/src/lib.rs b/crates/prc/src/lib.rs index fd49302234..8e1a749944 100644 --- a/crates/prc/src/lib.rs +++ b/crates/prc/src/lib.rs @@ -3,7 +3,9 @@ //! //! Layer L0, standalone: this crate depends on no other PdfCraft crate. It reads the //! uncompressed container, inflates each section, and decodes the bit-level primitives and -//! the schema a newer producer uses to describe fields this reader does not know. +//! the schema a newer producer uses to describe fields this reader does not know; it decodes +//! tessellations and walks the product tree into placed, coloured meshes +//! ([`PrcFile::placements`], [`PrcFile::model_tree`]). //! //! Clause numbers in `[WD x.y]` refer to the PRC working draft, ISO/TC 171/SC 2 N570. //! @@ -18,13 +20,17 @@ mod bits; mod container; mod entity; mod error; +mod geometry; mod schema; mod tess; #[cfg(test)] mod testw; +mod tree; pub use bits::BitReader; pub use container::{FileStructure, MAX_FILE_STRUCTURES, MAX_INFLATED_BYTES, PRC_READER_VERSION, PrcFile, PrcHeader, SectionKind, UniqueId}; pub use error::PrcError; +pub use geometry::Matrix; pub use schema::Schema; pub use tess::{Tessellation, TriangleMesh, TriangleStyle}; +pub use tree::{EntityOverrides, MAX_PLACEMENTS, ModelNode, NameSource, Placement, Rgba, StyleAlpha}; diff --git a/crates/prc/src/schema.rs b/crates/prc/src/schema.rs index 686cc8e056..ba58474f3d 100644 --- a/crates/prc/src/schema.rs +++ b/crates/prc/src/schema.rs @@ -213,6 +213,8 @@ impl Exec<'_, '_, '_> { self.repeat(prog, pos, count, run, depth)?; } 16 => { + // prc-rs: reads SimpleFor's count as an Integer; this reader deliberately reads an + // UnsignedInteger (module doc). let count = if run { i64::from(self.r.unsigned_integer()?) } else { 0 }; self.repeat(prog, pos, count, run, depth)?; } diff --git a/crates/prc/src/tess.rs b/crates/prc/src/tess.rs index 42704de652..f41ea0a260 100644 --- a/crates/prc/src/tess.rs +++ b/crates/prc/src/tess.rs @@ -416,4 +416,4 @@ fn point_at(pts: &[[f64; 3]], idx: u32) -> Result<[f64; 3], PrcError> { } #[cfg(test)] -mod tests; +pub(crate) mod tests; diff --git a/crates/prc/src/tess/tests.rs b/crates/prc/src/tess/tests.rs index ee202e6a2a..ad7f497e82 100644 --- a/crates/prc/src/tess/tests.rs +++ b/crates/prc/src/tess/tests.rs @@ -223,6 +223,13 @@ fn too_many_texture_sets_are_refused_before_anything_is_allocated() { assert!(decode(§ion(1, &ok), &Schema::default(), 8137).is_ok()); } +/// A tessellation section holding one TESS_3D: [`SQUARE`] as two faces of one triangle each. +pub(crate) fn two_face_square() -> W { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], faces: &[(0x2, 0, &[1]), (0x2, 6, &[1])], ..Mesh::default() }); + section(1, &b) +} + #[test] fn two_faces_partition_the_triangles() { let m = one(&[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], &[(0x2, 0, &[1]), (0x2, 6, &[1])], 0); diff --git a/crates/prc/src/tree/mod.rs b/crates/prc/src/tree/mod.rs new file mode 100644 index 0000000000..6967ba40ea --- /dev/null +++ b/crates/prc/src/tree/mod.rs @@ -0,0 +1,293 @@ +//! The product tree: the tree section (`FileStructureTree`, 304), the globals' styles and +//! coordinate systems (303) and the model file (301), walked into placed tessellations +//! [WD 7.3.3-7.3.11, 7.5, 7.6]. +//! +//! Markups, views, scene lights and clipping planes are not read past; a tree carrying any +//! of them is [`PrcError::Unsupported`]. + +// prc-rs: where the working draft leaves an entity's field order open, the order here is +// prc-rs's format description's; each such entity's reader carries its own marker. + +use std::sync::Arc; + +use crate::bits::BitReader; +use crate::container::offset; +use crate::entity::{Ctx, malformed}; +use crate::geometry::{Matrix, Vec3}; +use crate::{PrcError, PrcFile, Schema, SectionKind, UniqueId}; + +mod node; +mod overrides; +mod read; +mod style; +mod walk; + +pub use node::{ModelNode, NameSource}; +pub use overrides::EntityOverrides; +use overrides::{Override, Target}; +pub(crate) use style::Graphics; +use style::{Globals, Paint, resolve_style}; +pub use style::{Rgba, StyleAlpha}; +use walk::{At, Walk}; + +const BASE_WITH_GRAPHICS: u32 = 2; +const MODEL_FILE: u32 = 301; +const FILE_STRUCTURE: u32 = 302; +const FILE_STRUCTURE_GLOBALS: u32 = 303; +const FILE_STRUCTURE_TREE: u32 = 304; +const PRODUCT_OCCURRENCE: u32 = 310; +const PART_DEFINITION: u32 = 311; +const FILTER: u32 = 320; +const CARTESIAN_TRANSFORMATION: u32 = 202; +const ENTITY_REFERENCE: u32 = 203; +const REFERENCE_ON_PRC_BASE: u32 = 205; +const REFERENCE_ON_TOPOLOGY: u32 = 206; +const GENERAL_TRANSFORMATION: u32 = 207; +const RI_BREP_MODEL: u32 = 232; +const RI_CURVE: u32 = 233; +const RI_DIRECTION: u32 = 234; +const RI_PLANE: u32 = 235; +const RI_POINT_SET: u32 = 236; +const RI_POLY_BREP_MODEL: u32 = 237; +const RI_POLY_WIRE: u32 = 238; +const RI_SET: u32 = 239; +const RI_COORDINATE_SYSTEM: u32 = 240; +const STYLE: u32 = 701; +const MATERIAL: u32 = 702; +const PICTURE: u32 = 703; +const TEXTURE_APPLICATION: u32 = 711; +const TEXTURE_DEFINITION: u32 = 712; +const TEXTURE_TRANSFORMATION: u32 = 713; +const LINE_PATTERN: u32 = 721; +const SCENE_DISPLAY_PARAMETERS: u32 = 741; +const CAMERA: u32 = 742; +/// prc-rs: a scene's `is_absolute` exists from this authoring version. +const ABSOLUTE_SCENE_FROM: u32 = 8137; +/// `TextureDefinition` mapping type "operator" and function "blend", 0-based +/// (pdf-issues #485). +const TEXTURE_MAPPING_OPERATOR: i32 = 3; +const TEXTURE_FUNCTION_BLEND: i32 = 3; +/// Texture application mode: alpha test [WD 7.5.7]. +const TEXTURE_ALPHA_TEST: u8 = 0x02; + +/// Ceiling on `RI_Set` nesting, the occurrence walk's depth and a prototype chain's length. +const MAX_DEPTH: usize = 64; +/// Ceiling on occurrences visited in one walk: prototypes share subtrees, so a small file can +/// describe an exponential walk. +const MAX_VISITS: usize = 1 << 20; +/// Ceiling on placements one walk yields. +pub const MAX_PLACEMENTS: usize = 1 << 20; + +/// `GraphicsContent` behaviour bits [WD 7.2.4.2]. +const SHOW: u16 = 0x0001; +const SON_HERIT_COLOR: u16 = 0x0008; +const FATHER_HERIT_COLOR: u16 = 0x0010; +const REMOVED: u16 = 0x2000; +/// `product_behavior` SUPPRESSED [WD 7.3.10]. +const SUPPRESSED: u8 = 0x01; + +/// Per file structure, entry `b` is the paint of biased style `b`. +type Palettes = Arc<[Arc<[Option]>]>; + +/// A tessellation drawn at a place in the model: world = [`Self::matrix`] × the +/// tessellation's own coordinates. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct Placement { + /// Index into [`PrcFile::file_structures`]. + pub file_structure: usize, + /// Index into that file structure's [`tessellations`](crate::FileStructure::tessellations). + pub tessellation: usize, + /// The occurrence path's locations, then the representation item's coordinate systems. + pub matrix: Matrix, + /// The colour of the style the tree resolves for this item; `None` when no style + /// reaches it or the style names no colour this reader resolves (a textured material + /// without a base). Faces with their own style are coloured by + /// [`Self::triangle_colours`]. + pub colour: Option, + /// The graphics from the root to this item, outermost first. + chain: Vec, + palettes: Palettes, + /// An occurrence's entity reference replacing the item's style. + item_override: Option, + /// Per face index, sorted, an entity reference replacing that face's style. + face_overrides: Vec<(u32, Graphics)>, +} + +/// The paint of the style `won` names, in its file structure's globals. +fn paint_of(palettes: &Palettes, won: Graphics) -> Option { + palettes.get(won.fs)?.get(offset(won.style)).copied().flatten() +} + +impl Placement { + /// Per triangle of `mesh` (this placement's tessellation), its colour as + /// [`Self::colour`] describes, with each face's own style taking part in the inheritance + /// [WD 7.2.4, 7.8.6]; `None` when no face of `mesh` carries a style or an override, so + /// every triangle is [`Self::colour`]. + #[must_use] + pub fn triangle_colours(&self, mesh: &crate::TriangleMesh) -> Option>> { + if mesh.triangle_styles.is_empty() && self.face_overrides.is_empty() { + return None; + } + let faces = overrides::triangle_faces(mesh); + let by_face = |k: usize| { + let f = faces.get(k)?; + let i = self.face_overrides.binary_search_by_key(f, |(i, _)| *i).ok()?; + self.face_overrides.get(i).map(|(_, g)| *g) + }; + let colour = |won| paint_of(&self.palettes, won).map(|p| p.rgba); + if mesh.triangle_styles.is_empty() { + let whole = self.item_override.unwrap_or_else(|| resolve_style(&self.chain)); + return Some((0..mesh.triangles.len()).map(|k| colour(by_face(k).unwrap_or(whole))).collect()); + } + let mut chain = self.chain.clone(); + chain.push(Graphics::default()); + let looks = mesh.triangle_styles.iter().enumerate().map(|(k, s)| { + if let Some(won) = by_face(k).or(self.item_override) { + return colour(won); + } + if let Some(last) = chain.last_mut() { + *last = Graphics { style: s.style, bits: s.behaviour, fs: self.file_structure }; + } + colour(resolve_style(&chain)) + }); + Some(looks.collect()) + } +} + +/// A representation item that can be drawn, flattened out of any `RI_Set`. +#[derive(Debug, Clone, PartialEq)] +struct Item { + /// `index_local_coordinate_system + 1` of each enclosing set and the item, outermost + /// first; 0 entries dropped. + local: Vec, + /// `index_tessellation + 1`; never 0 here. + tessellation: u32, + /// The part's graphics, each enclosing set's, then the item's own. + graphics: Vec, + /// The PRC unique id of each enclosing set, then the item's own. + uids: Vec, + /// A B-rep model's stored `(context, body)` ids [WD 7.6.2]. + brep: Option<(u32, u32)>, +} + +/// A `ProductOccurrence` (310) as far as placing and naming. +#[derive(Debug, Clone, PartialEq, Default)] +struct Product { + part: u32, + prototype: u32, + prototype_fs: Option, + external: u32, + external_fs: Option, + sons: Vec, + /// Its own graphics hide it (Show clear or Removed set). + hidden: bool, + suppressed: bool, + graphics: Graphics, + location: Option, + name: Option, + /// Its entity references that name a target. + overrides: Vec, +} + +/// One file structure's tree section. +#[derive(Debug, Clone, PartialEq, Default)] +struct Tree { + parts: Vec>, + /// Each part definition's name, parallel to `parts`. + part_names: Vec>, + products: Vec, +} + +/// The model file's roots: file structure id and biased occurrence index. +#[derive(Debug, Clone, PartialEq)] +struct ModelFile { + roots: Vec<(UniqueId, u32)>, +} + +impl PrcFile { + /// Where each drawable tessellation sits in the model: the product-occurrence tree walked + /// from the model file's roots, each occurrence's location composed onto its father's, + /// and each representation item's local coordinate systems applied last [WD 7.3.10.1, + /// 7.6.3.2], coloured with [`StyleAlpha::ZeroUnset`] and entity references applied by + /// [`EntityOverrides::Subtree`]. Suppressed occurrences and hidden entities are left + /// out; a tessellation several occurrences draw appears once per occurrence. Empty when + /// the file stores no tree ([`Self::stores_tree`]). + /// + /// # Errors + /// [`PrcError::Unsupported`] for a tree carrying markups (PMI), views, scene lights or + /// clipping planes, or, when an item is placed by a reference coordinate system, globals + /// carrying fonts or fill patterns; [`PrcError::Malformed`] for an index naming no entity, + /// a cycle of occurrences or prototypes, or a tree past [`MAX_PLACEMENTS`] or the depth + /// ceiling; read errors as [`crate::FileStructure::tessellations`]. + pub fn placements(&self) -> Result, PrcError> { + self.placements_with(StyleAlpha::default(), EntityOverrides::default()) + } + + /// [`Self::placements`], combining style transparency with material alpha by `rule` and + /// applying occurrences' entity references by `scope`. + /// + /// # Errors + /// As [`Self::placements`]. + pub fn placements_with(&self, rule: StyleAlpha, scope: EntityOverrides) -> Result, PrcError> { + Ok(self.walk(rule, scope)?.0) + } + + /// The assembly tree as a model-tree panel lists it: every product occurrence the model + /// file's roots reach, depth first, with its display name and stored visibility. Hidden + /// and suppressed subtrees are listed (with `drawn` false) though [`Self::placements`] + /// leaves them out; each node's `placements` range indexes that list, so attributes each + /// placed mesh to its part. Empty when the file stores no tree. + /// + /// # Errors + /// As [`Self::placements`]. + pub fn model_tree(&self) -> Result, PrcError> { + Ok(self.walk(StyleAlpha::default(), EntityOverrides::default())?.1) + } + + /// Whether the file stores a tree: its model file holds an entity. ISO 14739-1 requires + /// one, but a tessellation-only file still draws (unplaced), so its absence reads as an + /// empty tree, not an error. + #[must_use] + pub fn stores_tree(&self) -> bool { + self.model_file.iter().any(|&b| b != 0) + } + + fn walk(&self, rule: StyleAlpha, scope: EntityOverrides) -> Result<(Vec, Vec), PrcError> { + if !self.stores_tree() { + return Ok((Vec::new(), Vec::new())); + } + let mut trees = Vec::with_capacity(self.file_structures.len()); + for fs in &self.file_structures { + let schema = fs.schema()?; + let tree = Ctx::new(BitReader::new(fs.section(SectionKind::Tree)), &schema, fs.authoring_version).file_structure_tree()?; + // The globals carry the coordinate systems and the colours; colours alone are not + // worth refusing the placements over. + let globals = (|| { + let mut r = BitReader::new(fs.section(SectionKind::Globals)); + Schema::read(&mut r)?; + Ctx::new(r, &schema, fs.authoring_version).globals() + })(); + let globals = match globals { + Ok(g) => g, + Err(e) if tree.parts.iter().flatten().any(|i| !i.local.is_empty()) => return Err(e), + Err(_) => Globals::default(), + }; + trees.push((fs.id, tree, globals)); + } + let mut r = BitReader::new(&self.model_file); + let schema = Schema::read(&mut r)?; + let model = Ctx::new(r, &schema, self.header.authoring_version).model_file()?; + let mut walk = Walk::new(trees.iter().map(|(id, t, g)| (*id, t, g)).collect(), rule, scope); + for (id, root) in model.roots { + let fs = trees.iter().position(|t| t.0 == id).ok_or_else(|| malformed("a root names an unknown file structure".into()))?; + if let Some(index) = root.checked_sub(1) { + walk.occurrence(fs, offset(index), &Matrix::IDENTITY, &[], At::ROOT)?; + } + } + Ok((walk.out, walk.nodes)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/prc/src/tree/node.rs b/crates/prc/src/tree/node.rs new file mode 100644 index 0000000000..19a8e04e5c --- /dev/null +++ b/crates/prc/src/tree/node.rs @@ -0,0 +1,68 @@ +//! The assembly tree as a list: what a model-tree panel shows. + +use std::ops::Range; + +/// Where a [`ModelNode`]'s name came from. PRC does not say which name a +/// viewer should show; this reader takes the first present of these, in order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum NameSource { + /// The occurrence's own name. + Occurrence, + /// The name of an occurrence in its prototype chain. + Prototype, + /// The name of the part definition it draws. + Part, + /// No name anywhere: [`ModelNode::name`] is `None`. + Unnamed, +} + +/// One product occurrence of a PRC model's assembly tree, as +/// [`PrcFile::model_tree`](crate::PrcFile::model_tree) lists it: depth +/// first, a parent before its children, children in file order. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ModelNode { + /// The name to show, by [`Self::name_from`]; `None` when unnamed. + pub name: Option, + /// Where [`Self::name`] came from. + pub name_from: NameSource, + /// Index of the parent node in the list; `None` for a root. + pub parent: Option, + /// 0 for a root. + pub depth: usize, + /// Index into [`crate::PrcFile::file_structures`] of the structure + /// holding this occurrence. + pub file_structure: usize, + /// The occurrence's 0-based index in that structure's tree. + pub occurrence: usize, + /// The occurrence's own graphics hide it (Show clear, or Removed) + /// [WD 7.2.4.2]: the visibility the file stores, which a viewer opens on. + pub hidden: bool, + /// The occurrence is suppressed (`product_behavior`) [WD 7.3.10]. + pub suppressed: bool, + /// Whether anything of this node is drawn: false when it or an + /// ancestor is hidden or suppressed. + pub drawn: bool, + /// Whether the occurrence (or its prototype) carries a part definition, + /// i.e. geometry of its own rather than only children. + pub has_part: bool, + /// The placements this node and its descendants draw: indices into + /// [`PrcFile::placements`](crate::PrcFile::placements) (or + /// [`placements_with`](crate::PrcFile::placements_with), which yields the same order): + /// the meshes attributed to this occurrence's part and its descendants' parts. Empty when + /// nothing under it is drawn. + pub placements: Range, +} + +impl ModelNode { + /// The label the node is listed under: its name, or `occurrence F:I` + /// (file structure, occurrence) when it has none. + #[must_use] + pub fn label(&self) -> String { + match &self.name { + Some(name) => name.clone(), + None => format!("occurrence {}:{}", self.file_structure, self.occurrence), + } + } +} diff --git a/crates/prc/src/tree/overrides.rs b/crates/prc/src/tree/overrides.rs new file mode 100644 index 0000000000..a85a83ba09 --- /dev/null +++ b/crates/prc/src/tree/overrides.rs @@ -0,0 +1,128 @@ +//! Product-occurrence entity references: the colour, visibility and placement an occurrence +//! imposes on an entity it reaches [WD 7.3.10.1, 7.4.4]. + +use super::{Graphics, Item, RI_BREP_MODEL, RI_COORDINATE_SYSTEM, UniqueId}; + +/// `PRC_TYPE_TOPO_Face` [WD 6.2]. +const TOPO_FACE: u32 = 149; + +/// Which entities an occurrence's entity references reach. ISO 14739-1 says a reference +/// overrides the referenced entity's properties, and not where. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[non_exhaustive] +pub enum EntityOverrides { + /// Only where the occurrence's own subtree places the target, so one instance of a + /// shared part can be coloured alone. + #[default] + Subtree, + /// Every placement of the target anywhere in the model. + Everywhere, + /// Read past them: every entity keeps its own and inherited graphics. + Ignore, +} + +/// One `MISC_EntityReference` held by a product occurrence. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Override { + /// The reference's graphics; style 0 leaves the target's colour alone. + pub(super) graphics: Graphics, + /// The graphics hide the target (Show clear or Removed set). + pub(super) hidden: bool, + /// `index_local_coordinate_system + 1` replacing the target's; 0 none. + pub(super) local: u32, + pub(super) target: Target, +} + +/// What an entity reference names. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum Target { + /// `ReferenceOnPRCBase`: the entity of type `kind` whose PRC unique id is `uid` in + /// structure `fs` (`None`: the occurrence's own). + Entity { kind: u32, fs: Option, uid: u32 }, + /// `ReferenceOnTopology` into the B-rep stored as `(context, body)`: its topological + /// items of type `kind` at `indices` (0-based). With `fs` `None` it matches that B-rep in + /// any structure the subtree reaches: producers write "same structure" for an assembly's + /// reference into a part's own structure. + Topology { kind: u32, fs: Option, brep: (u32, u32), indices: Vec }, +} + +impl Target { + /// The structure the reference names, if it names one. + pub(super) fn fs(&self) -> Option { + match self { + Target::Entity { fs, .. } | Target::Topology { fs, .. } => *fs, + } + } +} + +/// An entity reference in force: the structure of the occurrence holding it, the structure +/// it names (`None`: any), and the reference. +pub(super) type Active<'t> = (usize, Option, &'t Override); + +/// The overrides that reach one representation item. +#[derive(Debug, Default)] +pub(super) struct Reached { + /// Graphics replacing the item's resolved style, in its owner's structure. + pub(super) item: Option, + pub(super) hidden: bool, + /// The owner's structure and its local coordinate system. + pub(super) local: Option<(usize, u32)>, + /// Per face index, sorted, graphics replacing that face's style. + pub(super) faces: Vec<(u32, Graphics)>, +} + +impl Reached { + /// Applies `active` innermost first, so an outer occurrence's override wins over an + /// inner one's, as an assembly's appearance beats its sub-assembly's. + pub(super) fn collect(active: &[Active<'_>], fs: usize, item: &Item) -> Self { + let mut r = Reached::default(); + // A map, not a list searched per index: one reference may name millions of faces. + let mut faces = std::collections::BTreeMap::new(); + for &(owner, named, ov) in active.iter().rev() { + let in_fs = named.is_none_or(|n| n == fs); + let g = Graphics { fs: owner, ..ov.graphics }; + match &ov.target { + Target::Entity { kind, uid, .. } => { + if !in_fs || !is_item(*kind) || !item.uids.contains(uid) { + continue; + } + if ov.graphics.style != 0 { + r.item = Some(g); + } + r.hidden = ov.hidden; + if ov.local != 0 { + r.local = Some((owner, ov.local)); + } + } + Target::Topology { kind, brep, indices, .. } => { + if !in_fs || *kind != TOPO_FACE || item.brep != Some(*brep) || ov.graphics.style == 0 { + continue; + } + for &f in indices { + faces.insert(f, g); + } + } + } + } + r.faces = faces.into_iter().collect(); + r + } +} + +/// Representation-item types a `ReferenceOnPRCBase` can colour. +fn is_item(kind: u32) -> bool { + (RI_BREP_MODEL..=RI_COORDINATE_SYSTEM).contains(&kind) +} + +/// Per triangle of `mesh`, the index of the `TESS_Face` it was read from (`u32::MAX` for +/// none). +pub(super) fn triangle_faces(mesh: &crate::TriangleMesh) -> Vec { + let mut out = vec![u32::MAX; mesh.triangles.len()]; + for (i, range) in mesh.faces.iter().enumerate() { + let face = u32::try_from(i).unwrap_or(u32::MAX); + for f in out.get_mut(range.clone()).into_iter().flatten() { + *f = face; + } + } + out +} diff --git a/crates/prc/src/tree/read.rs b/crates/prc/src/tree/read.rs new file mode 100644 index 0000000000..6c8047b989 --- /dev/null +++ b/crates/prc/src/tree/read.rs @@ -0,0 +1,451 @@ +//! The tree section's and the model file's entities: part definitions, representation items, +//! product occurrences and their transformations and entity references [WD 7.3, 7.4, 7.6]. + +use super::{ + ABSOLUTE_SCENE_FROM, BASE_WITH_GRAPHICS, CAMERA, CARTESIAN_TRANSFORMATION, ENTITY_REFERENCE, FILE_STRUCTURE, FILE_STRUCTURE_TREE, FILTER, + GENERAL_TRANSFORMATION, Graphics, Item, MAX_DEPTH, MODEL_FILE, Matrix, ModelFile, Override, PART_DEFINITION, PRODUCT_OCCURRENCE, Product, + REFERENCE_ON_PRC_BASE, REFERENCE_ON_TOPOLOGY, REMOVED, RI_BREP_MODEL, RI_COORDINATE_SYSTEM, RI_CURVE, RI_DIRECTION, RI_PLANE, RI_POINT_SET, + RI_POLY_BREP_MODEL, RI_POLY_WIRE, RI_SET, SCENE_DISPLAY_PARAMETERS, SHOW, SUPPRESSED, Target, Tree, UniqueId, Vec3, +}; +use crate::PrcError; +use crate::entity::{Ctx, malformed}; + +impl Ctx<'_, '_> { + pub(super) fn vector3(&mut self) -> Result { + Ok(Vec3([self.r.double()?, self.r.double()?, self.r.double()?])) + } + + pub(super) fn unique_id(&mut self) -> Result { + let mut id = [0; 4]; + for v in &mut id { + *v = self.r.unsigned_integer()?; + } + Ok(UniqueId(id)) + } + + /// `ContentPRCRefBase` [WD 7.2.3.2; pdf-issues #697]: the base, then the CAD ids and the + /// PRC unique id, which becomes [`Ctx::uid`]. + pub(super) fn content_prc_ref_base(&mut self) -> Result<(), PrcError> { + self.content_prc_base()?; + self.r.unsigned_integer()?; // CAD identifier + self.r.unsigned_integer()?; // CAD persistent identifier + self.uid = self.r.unsigned_integer()?; + Ok(()) + } + + /// `PRCBaseWithGraphics` [WD 7.2.4; pdf-issues #405]: whether the entity's own graphics + /// hide it (Show clear or Removed set), and its graphics. Reused graphics + /// (`same_graphics`) are the current graphics and never hide. + pub(super) fn base_with_graphics(&mut self) -> Result<(bool, Graphics), PrcError> { + self.content_prc_ref_base()?; + let hidden = if self.r.bit()? { + false + } else { + self.r.unsigned_integer()?; // layer + 1 + let style = self.r.unsigned_integer()?; + let lo = u16::from(self.r.character()?); + let hi = u16::from(self.r.character()?); + let bits = lo | hi << 8; + self.graphics = Graphics { style, bits, fs: 0 }; + bits & SHOW == 0 || bits & REMOVED != 0 + }; + // prc-rs: schema additions to the base itself follow the graphics. + self.schema.skip_added_fields(BASE_WITH_GRAPHICS, &mut self.r)?; + Ok((hidden, self.graphics)) + } + + /// `CartesianTransformation` (202) or `GeneralTransformation` (207), type code included + /// [WD 7.4.8, 7.4.9, 7.4.11; pdf-issues #430]. + pub(super) fn transformation(&mut self) -> Result { + let t = self.r.unsigned_integer()?; + let m = match t { + GENERAL_TRANSFORMATION => { + // Column-major [WD 7.4.9]. + let mut m = Matrix::IDENTITY; + for col in 0..4 { + for row in &mut m.0 { + if let Some(c) = row.get_mut(col) { + *c = self.r.double()?; + } + } + } + m + } + CARTESIAN_TRANSFORMATION => self.transformation_3d()?, + t => return Err(malformed(format!("entity type {t} as a transformation"))), + }; + self.schema.skip_added_fields(t, &mut self.r)?; + Ok(m) + } + + /// The embedded `Transformation3D` [WD 7.4.11.1]: a behaviour byte, then each field its + /// bits name. + fn transformation_3d(&mut self) -> Result { + let b = self.r.character()?; + let mut m = Matrix::IDENTITY; + let set_col = |m: &mut Matrix, col: usize, v: Vec3| { + for (row, x) in m.0.iter_mut().zip(v.0) { + if let Some(c) = row.get_mut(col) { + *c = x; + } + } + }; + if b & 0x01 != 0 { + let t = self.vector3()?; + set_col(&mut m, 3, t); + } + if b & 0x20 != 0 { + for col in 0..3 { + let v = self.vector3()?; + set_col(&mut m, col, v); + } + } else if b & 0x02 != 0 { + let x = self.vector3()?; + let y = self.vector3()?; + // Mirrored (0x04): the third axis is y × x. + let z = if b & 0x04 != 0 { y.cross(x) } else { x.cross(y) }; + set_col(&mut m, 0, x); + set_col(&mut m, 1, y); + set_col(&mut m, 2, z); + } + let mut scale = [1.0; 3]; + if b & 0x10 != 0 { + scale = self.vector3()?.0; + } + // prc-rs: with both scale bits set, both are read and multiply. + if b & 0x08 != 0 { + let s = self.r.double()?; + scale = scale.map(|v| v * s); + } + for row in m.0.iter_mut().take(3) { + for (c, s) in row.iter_mut().zip(scale) { + *c *= s; + } + } + if b & 0x40 != 0 { + let h = [self.r.double()?, self.r.double()?, self.r.double()?, self.r.double()?]; + if let Some(r) = m.0.get_mut(3) { + *r = h; + } + } + Ok(m) + } + + /// A polymorphic representation item [WD 7.6]; drawable ones are pushed onto `out` with + /// `local` (the enclosing sets' coordinate systems) prefixed. + // prc-rs: the item's own fields follow its local coordinate system and tessellation. + fn representation_item(&mut self, at: &Item, out: &mut Vec, depth: usize) -> Result<(), PrcError> { + if depth > MAX_DEPTH { + return Err(malformed("representation sets nest too deep".into())); + } + let t = self.r.unsigned_integer()?; + let (hidden, own) = self.base_with_graphics()?; + let mut item = at.clone(); + item.uids.push(self.uid); + item.graphics.push(own); + let cs = self.r.unsigned_integer()?; + let tess = self.r.unsigned_integer()?; + if cs != 0 { + item.local.push(cs); + } + let (drawable, brep) = if t == RI_SET { + let n = self.count(1, "representation set")?; + let mut members = Vec::new(); + for _ in 0..n { + self.representation_item(&item, &mut members, depth + 1)?; + } + if !hidden { + out.extend(members); + } + (false, None) + } else { + self.leaf_item_fields(t)? + }; + self.schema.skip_added_fields(t, &mut self.r)?; + self.user_data()?; + if drawable && !hidden && tess != 0 { + out.push(Item { tessellation: tess, brep, ..item }); + } + Ok(()) + } + + /// The type-specific fields of a representation item other than `RI_Set` [WD 7.6]; + /// whether the item can be drawn, and a B-rep model's `(context, body)`. + fn leaf_item_fields(&mut self, t: u32) -> Result<(bool, Option<(u32, u32)>), PrcError> { + match t { + RI_BREP_MODEL => { + let brep = self.exact_tolerance()?; + self.r.bit()?; // is_closed + return Ok((true, brep)); + } + RI_CURVE | RI_PLANE => { + self.exact_tolerance()?; + } + RI_DIRECTION => { + if self.r.bit()? { + self.vector3()?; // origin + } + self.vector3()?; + return Ok((false, None)); + } + RI_POINT_SET => { + let n = self.count(6, "point set")?; + for _ in 0..n { + self.vector3()?; + } + } + RI_POLY_BREP_MODEL => { + self.r.bit()?; // is_closed + } + RI_POLY_WIRE => {} + RI_COORDINATE_SYSTEM => { + self.transformation()?; + return Ok((false, None)); + } + t => return Err(malformed(format!("entity type {t} as a representation item"))), + } + Ok((true, None)) + } + + /// A B-rep, curve or plane item's optional exact-geometry `(context, body)` ids + /// [WD 7.6.2]. + fn exact_tolerance(&mut self) -> Result, PrcError> { + if self.r.bit()? { + return Ok(Some((self.r.unsigned_integer()?, self.r.unsigned_integer()?))); + } + Ok(None) + } + + /// `SceneDisplayParameters` (741) [WD 7.5.19]: read past. Lights and clipping planes are + /// [`PrcError::Unsupported`]. + // prc-rs: the scene's field order, and `is_absolute` from version 8137. + fn scene_display_parameters(&mut self) -> Result<(), PrcError> { + self.expect_type(SCENE_DISPLAY_PARAMETERS)?; + self.content_prc_ref_base()?; + self.r.bit()?; // is_active + if self.r.unsigned_integer()? != 0 { + return Err(PrcError::Unsupported("scene lights")); + } + if self.r.bit()? { + self.camera()?; + } + if self.r.bit()? { + self.vector3()?; // rotation centre + } + if self.r.unsigned_integer()? != 0 { + return Err(PrcError::Unsupported("scene clipping planes")); + } + self.r.unsigned_integer()?; // background style + 1 + self.r.unsigned_integer()?; // default style + 1 + let n = self.count(2, "scene default styles")?; + for _ in 0..n { + self.r.unsigned_integer()?; // entity type + self.r.unsigned_integer()?; // style + 1 + } + if self.version >= ABSOLUTE_SCENE_FROM { + self.r.bit()?; // is_absolute + } + self.schema.skip_added_fields(SCENE_DISPLAY_PARAMETERS, &mut self.r) + } + + /// `Camera` (742) [WD 7.5.20]: read past. + // prc-rs: the camera's field order. + fn camera(&mut self) -> Result<(), PrcError> { + self.expect_type(CAMERA)?; + self.content_prc_ref_base()?; + self.r.bit()?; // is_orthographic + for _ in 0..3 { + self.vector3()?; // position, look, up + } + for _ in 0..6 { + self.r.double()?; // x, y, ratio, near, far, zoom + } + self.schema.skip_added_fields(CAMERA, &mut self.r) + } + + /// `MarkupData` [WD 7.3.10.4]: only the empty form reads past. + fn markup_data(&mut self) -> Result<(), PrcError> { + for _ in 0..4 { + if self.r.unsigned_integer()? != 0 { + return Err(PrcError::Unsupported("tree markups (PMI)")); + } + } + Ok(()) + } + + fn views(&mut self) -> Result<(), PrcError> { + if self.r.unsigned_integer()? != 0 { + return Err(PrcError::Unsupported("tree views")); + } + Ok(()) + } + + /// `MISC_EntityReference` (203) [WD 7.4.4, 7.4.10]; `None` when it names no target. + // prc-rs: the local coordinate system precedes the optional reference data. + fn entity_reference(&mut self) -> Result, PrcError> { + self.expect_type(ENTITY_REFERENCE)?; + let (hidden, graphics) = self.base_with_graphics()?; + let local = self.r.unsigned_integer()?; + let mut target = None; + if self.r.bit()? { + match self.r.unsigned_integer()? { + REFERENCE_ON_PRC_BASE => { + let kind = self.r.unsigned_integer()?; + let fs = self.file_identifier()?; + let uid = self.r.unsigned_integer()?; + target = Some(Target::Entity { kind, fs, uid }); + } + REFERENCE_ON_TOPOLOGY => target = self.reference_on_topology()?, + t => return Err(malformed(format!("entity type {t} as reference data"))), + } + } + self.schema.skip_added_fields(ENTITY_REFERENCE, &mut self.r)?; + self.user_data()?; + Ok(target.map(|target| Override { graphics, hidden, local, target })) + } + + /// `ReferenceOnTopology` (206) after its type code; `None` without a body. + fn reference_on_topology(&mut self) -> Result, PrcError> { + let kind = self.r.unsigned_integer()?; + if !self.r.bit()? { + return Ok(None); + } + let fs = self.file_identifier()?; + let brep = (self.r.unsigned_integer()?, self.r.unsigned_integer()?); + let indices = self.uints("topology reference indices")?; + Ok(Some(Target::Topology { kind, fs, brep, indices })) + } + + /// `ASM_Filter` (320) [WD 7.3.12]: read past. + // prc-rs: the filter's field order. + fn filter(&mut self) -> Result<(), PrcError> { + self.expect_type(FILTER)?; + self.content_prc_ref_base()?; + self.r.bit()?; // is_active + self.r.bit()?; // layers inclusive + self.uints("filter layers")?; + self.r.bit()?; // entities inclusive + let n = self.count(1, "filter entities")?; + for _ in 0..n { + self.entity_reference()?; + } + self.schema.skip_added_fields(FILTER, &mut self.r)?; + self.user_data() + } + + /// `PartDefinition` (311): its drawable items and its name. + fn part_definition(&mut self) -> Result<(Vec, Option), PrcError> { + self.expect_type(PART_DEFINITION)?; + let (hidden, own) = self.base_with_graphics()?; + let name = self.name.clone(); + self.vector3()?; // bounding box + self.vector3()?; + let n = self.count(1, "representation items")?; + let mut items = Vec::new(); + let at = Item { local: Vec::new(), tessellation: 0, graphics: vec![own], uids: Vec::new(), brep: None }; + for _ in 0..n { + self.representation_item(&at, &mut items, 0)?; + } + self.markup_data()?; + self.views()?; + self.schema.skip_added_fields(PART_DEFINITION, &mut self.r)?; + self.user_data()?; + if hidden { + items.clear(); + } + Ok((items, name)) + } + + /// `FileIdentifier` [WD 7.3.10.2.2]: `None` for the same structure. + fn file_identifier(&mut self) -> Result, PrcError> { + if self.r.bit()? { Ok(None) } else { self.unique_id().map(Some) } + } + + /// `ProductOccurrence` (310) [WD 7.3.10]. + fn product_occurrence(&mut self) -> Result { + self.expect_type(PRODUCT_OCCURRENCE)?; + let (hidden, graphics) = self.base_with_graphics()?; + let mut p = Product { hidden, graphics, name: self.name.clone(), part: self.r.unsigned_integer()?, ..Product::default() }; + p.prototype = self.r.unsigned_integer()?; + if p.prototype != 0 { + p.prototype_fs = self.file_identifier()?; + } + p.external = self.r.unsigned_integer()?; + if p.external != 0 { + p.external_fs = self.file_identifier()?; + } + p.sons = self.uints("son occurrences")?; + p.suppressed = self.r.character()? & SUPPRESSED != 0; + // ProductInformation [WD 7.3.10.3]: unit flag, unit, load status, information. + self.r.bit()?; + self.r.double()?; + self.r.character()?; + self.r.integer()?; + if self.r.bit()? { + p.location = Some(self.transformation()?); + } + let n = self.count(1, "entity references")?; + for _ in 0..n { + p.overrides.extend(self.entity_reference()?); + } + self.markup_data()?; + self.views()?; + if self.r.bit()? { + self.filter()?; + } + let n = self.count(1, "display filters")?; + for _ in 0..n { + self.filter()?; + } + let n = self.count(1, "scene display parameters")?; + for _ in 0..n { + self.scene_display_parameters()?; + } + self.schema.skip_added_fields(PRODUCT_OCCURRENCE, &mut self.r)?; + self.user_data()?; + Ok(p) + } + + /// `FileStructureTree` (304) [WD 7.3.6], with its `FileStructure` (302) inside. + pub(super) fn file_structure_tree(&mut self) -> Result { + self.expect_type(FILE_STRUCTURE_TREE)?; + self.content_prc_base()?; + let n = self.count(1, "part definitions")?; + let mut tree = Tree::default(); + for _ in 0..n { + let (items, name) = self.part_definition()?; + tree.parts.push(items); + tree.part_names.push(name); + } + let n = self.count(1, "product occurrences")?; + for _ in 0..n { + tree.products.push(self.product_occurrence()?); + } + self.expect_type(FILE_STRUCTURE)?; + self.content_prc_base()?; + self.r.unsigned_integer()?; // next_available_index + self.r.unsigned_integer()?; // product_occurrence_index + 1 (the structure's root) + self.schema.skip_added_fields(FILE_STRUCTURE, &mut self.r)?; + self.schema.skip_added_fields(FILE_STRUCTURE_TREE, &mut self.r)?; + self.user_data()?; + Ok(tree) + } + + /// `ModelFile` (301) after its schema [WD 7.3.3; pdf-issues #724, #740]: `units_in_mm` is + /// always present. + pub(super) fn model_file(&mut self) -> Result { + self.expect_type(MODEL_FILE)?; + self.content_prc_base()?; + self.r.bit()?; // units_from_cad_file + self.r.double()?; // units_in_mm + let n = self.count(6, "root occurrences")?; + let mut roots = Vec::with_capacity(n); + for _ in 0..n { + let id = self.unique_id()?; + let index = self.r.unsigned_integer()?; + self.r.bit()?; // is_active + roots.push((id, index)); + } + Ok(ModelFile { roots }) + } +} diff --git a/crates/prc/src/tree/style.rs b/crates/prc/src/tree/style.rs new file mode 100644 index 0000000000..d313fb2f64 --- /dev/null +++ b/crates/prc/src/tree/style.rs @@ -0,0 +1,348 @@ +//! Graphics, styles, materials and the globals that hold them [WD 7.2.4, 7.3.5, 7.5]. + +use super::{ + FATHER_HERIT_COLOR, FILE_STRUCTURE_GLOBALS, LINE_PATTERN, MATERIAL, Matrix, PICTURE, RI_COORDINATE_SYSTEM, SON_HERIT_COLOR, STYLE, + TEXTURE_ALPHA_TEST, TEXTURE_APPLICATION, TEXTURE_DEFINITION, TEXTURE_FUNCTION_BLEND, TEXTURE_MAPPING_OPERATOR, TEXTURE_TRANSFORMATION, Vec3, +}; +use crate::PrcError; +use crate::container::offset; +use crate::entity::{Ctx, malformed}; + +/// One entity's `GraphicsContent` [WD 7.2.4]: `style` is `line_style_index + 1` (0 none), +/// `bits` the behaviour bits. `fs` is the file structure whose globals `style` indexes: the +/// one whose tree holds the entity. The walk sets it; a reader leaves it 0. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct Graphics { + pub(crate) style: u32, + pub(crate) bits: u16, + pub(crate) fs: usize, +} + +/// A straight (not premultiplied) colour, each channel 0-1; `a` is the opacity. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +#[non_exhaustive] +pub struct Rgba { + /// Red. + pub r: f64, + /// Green. + pub g: f64, + /// Blue. + pub b: f64, + /// Opacity: 0 transparent, 1 opaque. + pub a: f64, +} + +/// How a style's `transparency` combines with its material's diffuse alpha. ISO 14739-1 +/// defines both and not their combination, and gives a diffuse alpha of 0.0 no "unset" +/// meaning. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[non_exhaustive] +pub enum StyleAlpha { + /// As [`Self::StyleWins`], except that a material diffuse alpha of exactly 0.0 under a + /// style with no transparency reads as unset, so opaque. Producers write 0.0 on the + /// materials of models meant to be seen; read literally, every part is invisible. + #[default] + ZeroUnset, + /// The style's transparency, when it has one, is the opacity; the material alpha, read + /// literally (0.0 invisible), applies only to a style without one. + StyleWins, + /// The opacity is material alpha × style transparency. + Multiply, +} + +/// A style's resolved colour, and whether its opacity comes from reading a zero material +/// alpha as unset. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Paint { + pub(crate) rgba: Rgba, + pub(crate) alpha_unset: bool, +} + +/// A `Style` (701) as far as colour: `index` is `colour_or_material + 1`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Style { + pub(crate) is_material: bool, + pub(crate) index: u32, + pub(crate) transparency: Option, +} + +/// A `materials` entry as far as colour. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum Material { + /// `Material` (702): `diffuse + 1` (a colour index) and the diffuse alpha. + Plain { diffuse: u32, alpha: f64 }, + /// `TextureApplication` (711) [WD 7.5.6]: `material_generic_index + 1`, the material + /// under the texture. + Textured { base: u32 }, +} + +/// What `FileStructureGlobals` contributes to placing and colouring. +#[derive(Debug, Clone, PartialEq, Default)] +pub(crate) struct Globals { + /// Entry `i` places an item with biased local coordinate system `i + 1`. + pub(crate) systems: Vec, + /// RGB, `a` 1. + pub(crate) colours: Vec, + pub(crate) materials: Vec, + pub(crate) styles: Vec\n\n\n", - esc(title) + "\n\n\n\n{}\n{}\n\n\n", + esc(title), + if simple { + "" + } else { + "" + } ); for it in items(pages) { match it { @@ -59,13 +71,13 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi } // The paragraph has its first run's colour; a run in another colour says so. let colour = rgb8(r.block.color); - if colour != rgb8(first.color) { + if !simple && colour != rgb8(first.color) { let [red, green, blue] = colour; run = format!("{run}"); } t.push_str(&run); } - let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; match lvl { 0 => s.push_str(&format!("{t}

\n")), l => s.push_str(&format!("{t}\n")), @@ -84,7 +96,7 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi body = format!("{body}"); } let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; s.push_str(&format!("{body}")); } s.push_str("\n"); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index db53ee59c8..2b82919bbf 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -27,9 +27,9 @@ pub(crate) use as_docx::hex; #[cfg(test)] use as_docx::{MAX_MEDIA, docx_table}; pub(crate) use as_html::esc; -pub use as_html::html; #[cfg(test)] use as_html::{allowed, base64}; +pub use as_html::{html, html_simple}; pub use as_markdown::markdown; #[cfg(test)] use as_markdown::{emphasis, paragraph}; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 27cd8264e0..f630d98d5d 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -85,6 +85,24 @@ fn html_has_headings_paragraphs_and_images_in_order() { assert_eq!(base64(b"Ma"), "TWE="); } +#[test] +fn simple_html_has_no_css_or_layout_elements() { + let mut p = page(); + let mut unicode = block("naïve 日本語.", 72.0, 300.0, 300.0, 311.0); + unicode.bold = true; + unicode.italic = true; + p.blocks.push(unicode); + let source = html_simple(&[p, table_page()], "Simple export"); + for banned in ["", "", "", " Date: Tue, 3 Nov 2026 20:14:17 +0500 Subject: [PATCH 078/124] Open: measure cached objects once and by what they hold, read plain reals without a copy, and keep no /PieceInfo in inspection (#5873) (#5958) --- crates/cos/src/document.rs | 4 +- crates/cos/src/document/objects.rs | 49 ++++---------- crates/cos/src/document/state.rs | 105 ++++++++++++++++++++++++----- crates/cos/src/document/tests.rs | 59 ++++++++++++++-- crates/cos/src/parser.rs | 15 ++++- crates/render/src/inspect/tests.rs | 42 ++++++++++++ crates/render/src/structure.rs | 13 +++- 7 files changed, 224 insertions(+), 63 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 082d1910d1..2aaf33ec63 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -27,7 +27,7 @@ pub(crate) use state::ObjectReader; use state::{ObjStm, Slot}; pub use state::{ReadState, SourceIdentity}; #[cfg(test)] -use state::{SECTION_READS, objstm_member, owned_bytes}; +use state::{SECTION_READS, held_bytes, objstm_member}; use xref::XrefIndex; pub use xref::{Revision, XrefEntry}; @@ -47,7 +47,7 @@ pub struct Document { /// `fill_xref_gaps`. located: Arc>, repair_log: Arc>, - cache: Arc>>>, + cache: Arc>, objstms: Arc>>>, /// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan /// share them, and each would otherwise decode them again (see `Document::jbig2_globals`). diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 2ac18cff03..4efb185f1a 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,5 +1,5 @@ use super::reader::find; -use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; +use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, held_bytes, objstm_member}; use super::xref::{Revision, XrefEntry}; use super::{ Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared, @@ -399,8 +399,9 @@ impl Document { let obj = Arc::new(obj); // The editing cache is bounded like the scan reader's: re-parsing a file-backed view // is cheap, and returned objects outlive eviction. - self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone()); - self.evict_editor_cache_if_over_budget(num, &obj)?; + let owned = held_bytes(&obj, &self.data, 0); + self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone(), owned); + self.evict_editor_cache_if_over_budget(num)?; Ok(obj) } @@ -409,10 +410,10 @@ impl Document { /// document cannot pin its whole object graph. Only owned (decrypted/copied) bytes /// count; file-backed views re-parse for free. /// - /// Worst case per document: input bytes + up to `EDITOR_CACHE_OBJECTS` × - /// `EDITOR_LARGE_OBJECT` (1024 × 256 KB = 256 MB) of small objects between full - /// accounting passes + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits. - /// The count cap always holds; byte accounting between passes is approximate. + /// Worst case per document: input bytes + `EDITOR_CACHE_BYTES` (32 MB) of owned object + /// data + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits. Each object is measured + /// once, as it is cached ([`ObjectCache`](super::state::ObjectCache)), so both caps hold + /// after every insertion. /// Clones share these caches, so one clone's eviction drops another's hot set; /// that only costs re-parsing, never correctness. Eviction keeps just the newcomer: /// file-backed views re-parse cheaply and editor working sets stay far below 1024. @@ -425,37 +426,13 @@ impl Document { const EDITOR_OBJSTM_BYTES: usize = 32 * 1024 * 1024; - /// Newcomers above this force a full accounting pass even below the count cap: - /// `EDITOR_CACHE_BYTES / 128`, so 128 large newcomers always trigger a pass before the - /// byte budget can double. - const EDITOR_LARGE_OBJECT: usize = 256 * 1024; - - fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Object) -> Result<(), CosError> { - // Fast path: the full fold is O(cache) per insert, so skip it for a small newcomer - // below the count cap. `len` is copied out first; nothing here holds the lock. - let len = self.cache.lock().map_err(|_| CosError::Poisoned)?.len(); - if len <= Self::EDITOR_CACHE_OBJECTS && owned_bytes(fresh, &self.data, 0) <= Self::EDITOR_LARGE_OBJECT { - return Ok(()); - } - // Walk a snapshot off-lock; re-lock only to evict. - let live: Vec<(u32, Arc)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect(); - let data = &self.data; - let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(owned_bytes(o, data, 0))); - if live.len() <= Self::EDITOR_CACHE_OBJECTS && owned <= Self::EDITOR_CACHE_BYTES { - return Ok(()); - } - // Re-check under the final lock: another thread may have inserted or evicted since - // the snapshot. Keep its newcomers, and skip the eviction if back within budget. - let snapshot: std::collections::HashSet = live.into_iter().map(|(n, _)| n).collect(); + fn evict_editor_cache_if_over_budget(&self, fresh_num: u32) -> Result<(), CosError> { let mut cache = self.cache.lock().map_err(|_| CosError::Poisoned)?; - if cache.len() <= Self::EDITOR_CACHE_OBJECTS { - let data = &self.data; - let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))); - if owned <= Self::EDITOR_CACHE_BYTES { - return Ok(()); - } + if cache.len() <= Self::EDITOR_CACHE_OBJECTS && cache.owned() <= Self::EDITOR_CACHE_BYTES { + return Ok(()); } - cache.retain(|n, _| *n == fresh_num || !snapshot.contains(n)); + // Eviction keeps just the newcomer: file-backed views re-parse cheaply. + cache.retain(|n| *n == fresh_num); Ok(()) } diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index b4323f982e..96dde01c0f 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -163,11 +163,10 @@ impl ObjectReader { // input bytes, which the document keeps anyway, so they don't count; decrypted // streams and strings do. Checked after failed loads too: resolving a /Length may // have cached large objects. - if let Ok(mut cache) = self.document.cache.lock() { - let data = &self.document.data; - if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))) > Self::STREAM_BYTES { - cache.clear(); - } + if let Ok(mut cache) = self.document.cache.lock() + && (cache.len() >= Self::OBJECTS || cache.owned() > Self::STREAM_BYTES) + { + cache.clear(); } object } @@ -179,26 +178,96 @@ thread_local! { pub(super) static SECTION_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object +/// Parsed objects by number, each with what it holds ([`held_bytes`]) measured once, when it goes +/// in, and their sum kept beside them. +/// +/// Both caches are bounded by those bytes. Summing them on every read walked every cached object +/// whole each time: a file whose page dictionaries carry kilobytes of private data (InDesign's +/// `/PieceInfo`) took seconds to open, almost all of it re-measuring objects already measured +/// (#5873). +#[derive(Default)] +pub(crate) struct ObjectCache { + entries: HashMap, usize)>, + owned: usize, +} + +impl ObjectCache { + pub(crate) fn get(&self, num: &u32) -> Option<&Arc> { + self.entries.get(num).map(|(object, _)| object) + } + + #[cfg(test)] + pub(crate) fn contains_key(&self, num: &u32) -> bool { + self.entries.contains_key(num) + } + + /// Keep `object` as `num`, which holds `owned` bytes (what [`held_bytes`] says of it). + pub(crate) fn insert(&mut self, num: u32, object: Arc, owned: usize) { + if let Some((_, before)) = self.entries.insert(num, (object, owned)) { + self.owned = self.owned.saturating_sub(before); + } + self.owned = self.owned.saturating_add(owned); + } + + pub(crate) fn len(&self) -> usize { + self.entries.len() + } + + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// The bytes the cached objects hold between them. + pub(crate) fn owned(&self) -> usize { + self.owned + } + + pub(crate) fn clear(&mut self) { + self.entries.clear(); + self.owned = 0; + } + + pub(crate) fn retain(&mut self, mut keep: impl FnMut(&u32) -> bool) { + self.entries.retain(|num, _| keep(num)); + self.owned = self.entries.values().fold(0usize, |n, (_, owned)| n.saturating_add(*owned)); + } + + #[cfg(test)] + pub(crate) fn values(&self) -> impl Iterator> { + self.entries.values().map(|(object, _)| object) + } +} + +/// Nesting [`held_bytes`] walks: past the parser's own nesting limit, so every parsed object /// is measured in full. const OWNED_BYTES_DEPTH: usize = 256; -/// Bytes `object` holds of its own: string and stream data that is not a view into `data` -/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured -/// and counts as too large to keep. -pub(super) fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { +/// What keeping `object` parsed costs: string and stream data that is not a view into `data` +/// (the input file), and the parsed tree around it -- an [`Object`] per value and a key per +/// dictionary entry. An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured and +/// counts as too large to keep. +/// +/// The tree counts. Measured by its strings and streams alone, a page dictionary carrying +/// InDesign's `/PieceInfo` -- thousands of small numeric arrays, parsed into tens of thousands of +/// values -- weighed nothing, so the caches kept a thousand of them: a 35 MB file held 1.5 GB +/// (#5873). +pub(super) fn held_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { if depth > OWNED_BYTES_DEPTH { return usize::MAX; } + let node = std::mem::size_of::(); + // A key, its slot beside the value, and room in the index a large dictionary keeps. + let entry = |key: &[u8]| key.len().saturating_add(std::mem::size_of::<(crate::Name, Object)>()).saturating_add(16); + let dict = + |d: &crate::Dict, start: usize| d.iter().fold(start, |n, (k, v)| n.saturating_add(entry(k)).saturating_add(held_bytes(v, data, depth + 1))); match object { - Object::String(s) => s.bytes.len(), - Object::Stream(s) => { - let own = if s.raw.shares(data) { 0 } else { s.raw.len() }; - s.dict.iter().fold(own, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))) - } - Object::Array(a) => a.iter().fold(0usize, |n, v| n.saturating_add(owned_bytes(v, data, depth + 1))), - Object::Dict(d) => d.iter().fold(0usize, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))), - _ => 0, + Object::String(s) => node.saturating_add(s.bytes.len()), + Object::Name(n) => node.saturating_add(n.len()), + Object::Stream(s) => dict(&s.dict, node.saturating_add(if s.raw.shares(data) { 0 } else { s.raw.len() })), + Object::Array(a) => a.iter().fold(node, |n, v| n.saturating_add(held_bytes(v, data, depth + 1))), + Object::Dict(d) => dict(d, node), + _ => node, } } diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index cca4b87326..4c7b3eb652 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1109,7 +1109,8 @@ fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() { // Data of its own (decrypted streams, strings) counts: past the budget the cache is // cleared after the next read, including a failed one. let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1])); - reader.document.cache.lock().unwrap().insert(3, Arc::new(owned)); + let size = held_bytes(&owned, &reader.document.data, 0); + reader.document.cache.lock().unwrap().insert(3, Arc::new(owned), size); assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null); assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted"); } @@ -1192,13 +1193,14 @@ fn editor_cache_counts_deeply_nested_strings() { source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); let doc = Document::open(bytes).unwrap(); - assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20); + let held = held_bytes(&nested, &doc.data, 0); + assert!((1 << 20..(1 << 20) + 4096).contains(&held), "the string and a little tree: {held}"); let root = doc.get(doc.root().unwrap()); for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() { let Object::Ref(reference) = reference else { panic!("indirect object") }; drop(doc.get(*reference)); let cache = doc.cache.lock().unwrap(); - let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0))); + let owned = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0))); let strings = cache.values().filter(|o| o.as_array().is_some()).count(); assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted"); } @@ -1240,8 +1242,8 @@ fn editor_cache_stays_bounded() { #[test] fn editor_cache_byte_bound_fires() { - // 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget. Each - // newcomer exceeds EDITOR_LARGE_OBJECT, so every insert takes a full accounting pass. + // 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget, which is + // checked after every insertion. let mut source = Document::new_empty(); let objects: Vec<_> = (0..40) .map(|i| { @@ -1701,3 +1703,50 @@ 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"); } + +/// The cache keeps the sum of what its objects hold as they come and go, so neither reader has +/// to measure the cache again on every read (#5873). +#[test] +fn the_object_cache_keeps_its_total_as_objects_come_and_go() { + let data = Arc::new(Vec::new()); + let one = Arc::new(Object::String(crate::PdfString::literal(vec![b'x'; 100]))); + let two = Arc::new(Object::Array(vec![Object::Int(1); 10])); + let (a, b) = (held_bytes(&one, &data, 0), held_bytes(&two, &data, 0)); + let mut cache = super::state::ObjectCache::default(); + cache.insert(1, one.clone(), a); + cache.insert(2, two, b); + assert_eq!(cache.owned(), a + b); + cache.insert(2, one, a); + assert_eq!(cache.owned(), a + a, "a replaced object no longer counts"); + cache.retain(|n| *n == 1); + assert_eq!((cache.len(), cache.owned()), (1, a)); + cache.clear(); + assert_eq!((cache.len(), cache.owned()), (0, 0)); +} + +/// A dictionary of numbers owns no string or stream bytes, but parsed it is tens of thousands +/// of values: InDesign's per-page `/PieceInfo`. Counting only strings and streams, the editing +/// cache kept a thousand such pages and a 35 MB book held 1.5 GB (#5873). What the parsed tree +/// holds counts now, so the byte budget bounds them. +#[test] +fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() { + let entries: String = (0..2000).map(|i| format!("/{i}[{i} 141 3 116.22 -375.993 1.0]")).collect(); + let dict = format!("<< /PieceInfo << /InDesign << /Map << {entries} >> >> >> >>"); + let count = 300; + let refs: String = (0..count).map(|i| format!("{} 0 R ", i + 2)).collect(); + let mut bodies = vec![format!("<< /Type /Catalog /Extension [{refs}] >>")]; + bodies.extend((0..count).map(|_| dict.clone())); + let bodies: Vec<&str> = bodies.iter().map(String::as_str).collect(); + let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap(); + let one = doc.get(crate::ObjRef::new(2, 0)); + let held = held_bytes(&one, &doc.data, 0); + assert!(held > 2000 * std::mem::size_of::(), "the parsed tree counts: {held} bytes"); + for i in 0..count { + drop(doc.get(crate::ObjRef::new(i as u32 + 2, 0))); + } + let cache = doc.cache.lock().unwrap(); + assert!(cache.owned() <= Document::EDITOR_CACHE_BYTES, "{} bytes held", cache.owned()); + assert!(cache.len() < count, "the byte budget evicted some, below the count cap: {} kept", cache.len()); + let sum = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0))); + assert_eq!(cache.owned(), sum, "the kept total is the cache's own"); +} diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 1e9b45ad61..709659f537 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -341,7 +341,11 @@ impl<'a> Lexer<'a> { let s = std::str::from_utf8(tok).map_err(|_| self.err("bad number"))?; if s.contains('.') { // Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix. - let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1); + // Only a doubled sign needs a cleaned copy: a page of InDesign `/PieceInfo` is + // thousands of plain reals, and building a copy and a substring search for each + // was a large part of reading one (#5873). + let t = s.trim_start_matches('+'); + let cleaned = if t.as_bytes().windows(2).any(|w| w == b"--") { std::borrow::Cow::Owned(t.replacen("--", "-", 1)) } else { t.into() }; let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::(p).ok()); return Ok(Object::Real(v.unwrap_or(0.0))); } @@ -707,4 +711,13 @@ mod tests { let mut lx = Lexer::new(two.as_bytes(), 0); assert!(lx.object().is_ok() && lx.object().is_ok()); } + + /// A plain real is read without a cleaned copy of its token; the forms that need one read + /// as they did (#5873). + #[test] + fn reals_read_the_same_with_and_without_a_doubled_sign() { + for (token, want) in [("1.5", 1.5), ("-375.993", -375.993), ("+2.25", 2.25), ("--5.5", -5.5), ("1.2.3", 1.2), ("-.5", -0.5), ("0.0", 0.0)] { + assert_eq!(Lexer::new(token.as_bytes(), 0).object().unwrap(), Object::Real(want), "{token}"); + } + } } diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index a5bfec474d..7221e97d5a 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1870,3 +1870,45 @@ trailer << /Root 1 0 R >> assert!((p.width - 3874.96).abs() < 0.1, "width expected ~3874.96, got {}", p.width); assert!((p.height - 5493.54).abs() < 0.1, "height expected ~5493.54, got {}", p.height); } + +/// Inspection keeps every object it reads, converted, for as long as it runs, and nothing it +/// reports comes from `/PieceInfo`. InDesign writes kilobytes of that private data into every +/// page, and a 1,412-page book held over a gigabyte of it (#5873). It is left out of what +/// inspection keeps, and what inspection reports is the same as for the page without it. +#[test] +fn inspection_keeps_no_private_page_data() { + use lopdf::dictionary; + let make = |private: bool| -> Vec { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let mut page = dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], + "Rotate" => 90, + }; + if private { + let map: Dictionary = (0..50).map(|i| (i.to_string(), Object::Array(vec![Object::Integer(i), Object::Real(116.22)]))).collect(); + page.set("PieceInfo", dictionary! { "InDesign" => dictionary! { "PageItemUIDToLocationDataMap" => map } }); + } + let page_id = doc.add_object(page); + doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => vec![page_id.into()], "Count" => 1 }.into()); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + doc.trailer.set("Root", catalog); + let mut out = Vec::new(); + doc.save_to(&mut out).unwrap(); + out + }; + let (with, without) = (make(true), make(false)); + let lazy = LazyStructure::new(Arc::new(with.clone()), None).unwrap(); + let page = lazy.get_object((2, 0)).unwrap().as_dict().unwrap(); + assert!(!page.has(b"PieceInfo"), "the private data is not kept"); + assert!(page.has(b"MediaBox") && page.has(b"Rotate") && page.has(b"Parent"), "the rest of the page is"); + + let report = |bytes: Vec| { + let mut info = inspect(Arc::new(bytes), None).unwrap(); + info.file_size = 0; + format!("{info:?}") + }; + assert_eq!(report(with), report(without), "what inspection reports does not change"); +} diff --git a/crates/render/src/structure.rs b/crates/render/src/structure.rs index bf9c16e23f..effbc87d92 100644 --- a/crates/render/src/structure.rs +++ b/crates/render/src/structure.rs @@ -187,9 +187,20 @@ fn convert_dict(dict: &pdfcraft_cos::Dict, depth: usize) -> Result { if depth > 100 { return Err(Error::RecursionLimit); } - dict.iter().map(|(key, value)| Ok((key.clone(), convert(value, depth + 1)?))).collect::>>().map(|items| items.into_iter().collect()) + dict.iter() + .filter(|(key, _)| key.as_slice() != PRIVATE_DATA) + .map(|(key, value)| Ok((key.clone(), convert(value, depth + 1)?))) + .collect::>>() + .map(|items| items.into_iter().collect()) } +/// `/PieceInfo`: an application's private data about a page, form or document (ISO 32000-1 +/// §14.5), left out of what inspection keeps. Nothing here reads it, and every object read is +/// kept converted for the inspection's lifetime: InDesign writes kilobytes of it into every page, +/// and a 1,412-page book held over a gigabyte of it (#5873). The file and the editing document are +/// untouched; this is the read-only view inspection walks. +const PRIVATE_DATA: &[u8] = b"PieceInfo"; + fn convert(object: &CosObject, depth: usize) -> Result { if depth > 100 { return Err(Error::RecursionLimit); From 3709926f39039b57e71644227aa8b41b46bde940 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:14:23 -0600 Subject: [PATCH 079/124] Continue Bates numbering across ordered PDFs (#5953) * feat: add multi-file Bates numbering * fix(automation): doc_bates_multi rejects a malformed start_number and >> in prefix or suffix, accepts a range ending at u64::MAX, and counts only staged outputs Addresses CodeRabbit's quick-win notes. Parity is partial until a GUI dialog exists. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/automation/src/bates.rs | 214 +++++++++++++++++++++++ crates/automation/src/dispatch.rs | 1 + crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 8 + crates/automation/tests/it/automation.rs | 68 +++++++ parity/acrobat-features.toml | 8 +- 6 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 crates/automation/src/bates.rs diff --git a/crates/automation/src/bates.rs b/crates/automation/src/bates.rs new file mode 100644 index 0000000000..3cd1d7849e --- /dev/null +++ b/crates/automation/src/bates.rs @@ -0,0 +1,214 @@ +use std::io::Write; + +use pdfcraft_engine::{Edit, HeaderFooter}; +use serde_json::{Value, json}; + +use super::error::{Result, ToolError, failed}; +use super::json::Args; +use super::paths::read_input; +use super::{Arc, Automation, Path, PathBuf}; + +struct FilePlan { + input: String, + source: PathBuf, + password: Option, + output: PathBuf, + pages: usize, + first: u64, + last: u64, +} + +impl Automation { + pub(crate) fn doc_bates_multi(&mut self, args: &Args) -> Result { + let files = args.0.get("files").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("files must be an array".into()))?; + if files.is_empty() || files.len() > 1000 { + return Err(ToolError::InvalidArgs("files must contain 1 to 1000 entries".into())); + } + let start = match args.0.get("start_number") { + None | Some(Value::Null) => 1, + Some(value) => value + .as_u64() + .filter(|n| *n >= 1) + .ok_or_else(|| ToolError::InvalidArgs("start_number must be a whole number of at least 1".into()))?, + }; + let digits = args.opt_int("digits")?.unwrap_or(6); + let digits = + u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?; + let prefix = args.opt_str("prefix")?.unwrap_or(""); + let suffix = args.opt_str("suffix")?.unwrap_or(""); + // `#` separates the token's fields and `>>` ends it: either would cut the token short. + if prefix.len() > 128 || suffix.len() > 128 || [prefix, suffix].iter().any(|s| s.contains('#') || s.contains(">>")) { + return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 bytes and cannot contain # or >>".into())); + } + let dry_run = args.opt_bool("dry_run")?.unwrap_or(false); + + // Resolve and reserve the complete path list before opening or writing anything. + let mut sources = Vec::with_capacity(files.len()); + let mut targets = Vec::with_capacity(files.len()); + let mut passwords = Vec::with_capacity(files.len()); + for entry in files { + let input = entry + .get("input") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty input path".into()))?; + let output = entry + .get("output") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty output path".into()))?; + let source = self.resolve(input, false)?; + let target = self.resolve(output, true)?; + if target.exists() { + return Err(failed(format!("output already exists; refusing to replace it: {output}"))); + } + let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {output}")))?; + if !parent.is_dir() { + return Err(failed(format!("output directory does not exist: {}", parent.display()))); + } + sources.push((input.to_string(), source)); + targets.push((output.to_string(), target)); + passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned)); + } + let input_paths: std::collections::HashSet<_> = sources.iter().map(|(_, path)| path.clone()).collect(); + let mut output_paths = std::collections::HashSet::new(); + for (name, path) in &targets { + if input_paths.contains(path) { + return Err(failed(format!("output {name} names an input file; choose a separate output path"))); + } + if !output_paths.insert(path.clone()) { + return Err(failed(format!("two files use the same output path: {name}"))); + } + } + + let mut plans = Vec::with_capacity(files.len()); + let mut next = start; + let mut input_bytes = 0u64; + for (index, ((input_name, input_path), (_, output_path))) in sources.iter().zip(&targets).enumerate() { + let size = std::fs::metadata(input_path).map_err(failed)?.len(); + input_bytes = input_bytes + .checked_add(size) + .filter(|total| *total <= 512 * 1024 * 1024) + .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; + let bytes = read_input(input_path)?; + input_bytes = input_bytes + .saturating_sub(size) + .checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)) + .filter(|total| *total <= 512 * 1024 * 1024) + .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; + let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let password = passwords.get(index).and_then(Option::as_deref); + let id = self.session.open(display_name, Some(input_path.to_string_lossy().into_owned()), Arc::new(bytes), password).map_err(failed)?; + let pages = + self.session.get(id).map(|doc| doc.info.pages.len()).ok_or_else(|| failed("opened document disappeared during Bates preflight"))?; + self.session.close(id); + if pages == 0 { + return Err(failed(format!("{input_name} has no pages to number"))); + } + let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?; + let last = next.checked_add(count.saturating_sub(1)).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?; + plans.push(FilePlan { + input: input_name.clone(), + source: input_path.clone(), + password: passwords.get(index).cloned().flatten(), + output: output_path.clone(), + pages, + first: next, + last, + }); + if index + 1 < sources.len() { + next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?; + } + } + + let intervals: Vec = plans + .iter() + .zip(&targets) + .map(|(p, (name, _))| json!({"input":p.input,"output":name,"pages":p.pages,"first":p.first,"last":p.last})) + .collect(); + if dry_run { + return Ok(json!({"dry_run":true,"files":intervals})); + } + + let mut staged = Vec::with_capacity(plans.len()); + let mut results = Vec::with_capacity(plans.len()); + let mut output_bytes = 0u64; + for plan in &plans { + let source_bytes = match read_input(&plan.source) { + Ok(bytes) => bytes, + Err(error) => { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + continue; + } + }; + let name = plan.source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let id = match self.session.open(name, Some(plan.source.to_string_lossy().into_owned()), Arc::new(source_bytes), plan.password.as_deref()) + { + Ok(id) => id, + Err(error) => { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + continue; + } + }; + if self.session.get(id).is_none_or(|doc| doc.info.pages.len() != plan.pages) { + self.session.close(id); + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"input changed after preflight; page count differs"})); + continue; + } + let template = format!("<>", plan.first); + let mut settings = HeaderFooter::default(); + settings.text[4] = template; + let pages: Vec = (0..plan.pages).collect(); + let applied = self.session.apply(id, Edit::AddHeaderFooter { pages, settings, replace: false }); + let result = applied.and_then(|()| self.session.save_full_bytes(id)); + match result { + Ok(bytes) => { + let total = output_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)); + if total > 512 * 1024 * 1024 { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"})); + } else { + output_bytes = total; + staged.push((plan.output.clone(), bytes)); + } + } + Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})), + } + self.session.close(id); + } + for (path, bytes) in staged { + match publish_new(&path, &bytes) { + Ok(()) => { + let plan = plans.iter().find(|p| p.output == path); + if let Some(plan) = plan { + results.push(json!({"input":plan.input,"output":path,"status":"ok","first":plan.first,"last":plan.last,"pages":plan.pages})); + } + } + Err(error) => { + let plan = plans.iter().find(|p| p.output == path); + if let Some(plan) = plan { + results.push(json!({"input":plan.input,"output":path,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + } + } + } + } + results.sort_by_key(|result| plans.iter().position(|plan| result["output"] == json!(plan.output)).unwrap_or(usize::MAX)); + let completed = results.iter().filter(|r| r["status"] == "ok").count(); + let failed_count = results.len().saturating_sub(completed); + Ok(json!({"dry_run":false,"completed":completed,"failed":failed_count,"files":results})) + } +} + +/// Publish a PDF atomically while refusing a target created by another process after preflight. +fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(failed)?; + temp.write_all(bytes).map_err(failed)?; + temp.as_file().sync_all().map_err(failed)?; + temp.persist_noclobber(path).map(|_| ()).map_err(|error| { + if error.error.kind() == std::io::ErrorKind::AlreadyExists { + failed(format!("output appeared after preflight; refusing to replace it: {}", path.display())) + } else { + failed(error.error) + } + }) +} diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 49d4e6fd4d..f3c51d22e0 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -53,6 +53,7 @@ impl Automation { tools::check_args(def, args)?; let a = Args(args); let out = match name { + "doc_bates_multi" => self.doc_bates_multi(&a)?, "doc_open" => self.doc_open(&a)?, "doc_batch" => self.doc_batch(&a)?, "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f1910867c9..5bcb032fce 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -16,6 +16,7 @@ mod a11y; mod batch; +mod bates; mod comments; mod content; mod conventions; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 24f81588cf..4672e49886 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -121,6 +121,14 @@ pub fn tools() -> Vec { let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." }); let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." }); vec![ + t("doc_bates_multi", "Add Bates numbering across files", "Apply one consecutive Bates sequence to an ordered list of PDFs. Each file entry has input and output paths and an optional open or permissions password. The operation first validates every input/output and computes inclusive numeric intervals; dry_run returns that allocation without writing. Existing output files are never replaced. On application, files that cannot be edited or saved are reported individually and later files continue.") + .destructive() + .with(schema(json!({ + "files": {"type":"array","minItems":1,"maxItems":1000,"description":"PDFs in Bates sequence order.","items":{"type":"object","properties":{"input":{"type":"string","minLength":1,"description":"Input PDF path."},"output":{"type":"string","minLength":1,"description":"New output PDF path; must not exist."},"password":{"type":"string","description":"Open or permissions password, if needed."}},"required":["input","output"],"additionalProperties":false}}, + "start_number": {"type":"integer","minimum":1,"description":"First number in the sequence (default 1)."}, "digits": {"type":"integer","minimum":1,"maximum":12,"description":"Zero-padded number width (default 6)."}, + "prefix": {"type":"string","maxLength":128,"description":"Text before the number: at most 128 UTF-8 bytes, without # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Text after the number: at most 128 UTF-8 bytes, without # or >>."}, + "dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false)."} + }), &["files"])), t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") .with(schema(json!({ "paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000}, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 875531b81a..828d2e89ac 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2832,6 +2832,74 @@ fn headers_watermarks_and_backgrounds_through_tools() { assert!(matches!(refused, Err(ToolError::InvalidArgs(e)) if e.contains("Courier"))); } +#[test] +fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failures() { + let dir = workdir("bates-multi"); + let files = json!([ + {"input":"b.pdf","output":"b-numbered.pdf"}, + {"input":"a.pdf","output":"a-numbered.pdf"} + ]); + let mut a = auto(&dir); + let allocation = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":50,"dry_run":true})); + assert_eq!(allocation["files"][0]["first"], 50); + assert_eq!(allocation["files"][0]["last"], 51); + assert_eq!(allocation["files"][1]["first"], 52); + assert_eq!(allocation["files"][1]["last"], 54); + assert!(!dir.join("a-numbered.pdf").exists()); + + let applied = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":17,"prefix":"CASE-"})); + assert_eq!(applied["completed"], 2); + let mut out = auto(&dir); + let first = ok(&mut out, "doc_open", json!({"path":"a-numbered.pdf"}))["doc"].as_u64().unwrap(); + let second = ok(&mut out, "doc_open", json!({"path":"b-numbered.pdf"}))["doc"].as_u64().unwrap(); + assert!(page_text(&mut out, second)[0].contains("CASE-000017")); + assert!(page_text(&mut out, first)[0].contains("CASE-000019")); + + let overflow = err(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"overflow.pdf"}],"start_number":u64::MAX})); + assert!(overflow.to_string().contains("overflows")); + assert!(!dir.join("overflow.pdf").exists()); + // A range may end at u64::MAX itself. + let at_max = ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"max.pdf"}],"start_number":u64::MAX - 1,"dry_run":true})); + assert_eq!(at_max["files"][0]["last"], u64::MAX); + for bad in + [json!({"start_number":-3}), json!({"start_number":2.5}), json!({"start_number":"7"}), json!({"prefix":"CASE>>"}), json!({"suffix":"A>>B"})] + { + let mut args = json!({"files":[{"input":"b.pdf","output":"bad.pdf"}],"dry_run":true}); + args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(a.call("doc_bates_multi", &args).is_err(), "{bad}"); + } + + use pdfcraft_engine::{Changes, Edit, Protection, Session}; + let mut session = Session::new(); + let id = session.open("locked.pdf", None, std::sync::Arc::new(fixture(1)), None).unwrap(); + session + .apply( + id, + Edit::Protect(Protection { + open_password: Some("open".into()), + permissions_password: Some("owner".into()), + changes: Changes::None, + ..Default::default() + }), + ) + .unwrap(); + std::fs::write(dir.join("locked.pdf"), session.save_bytes(id).unwrap().as_ref()).unwrap(); + let result = ok( + &mut a, + "doc_bates_multi", + json!({"files":[ + {"input":"b.pdf","output":"partial-numbered.pdf"}, + {"input":"locked.pdf","output":"locked-numbered.pdf","password":"open"} + ]}), + ); + assert_eq!(result["completed"], 1); + assert_eq!(result["failed"], 1); + assert_eq!(result["files"][1]["first"], 3); + assert!(result["files"][1]["error"].as_str().unwrap().contains("owner password"), "{result}"); + assert!(dir.join("partial-numbered.pdf").exists()); + assert!(!dir.join("locked-numbered.pdf").exists()); +} + #[test] fn a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools() { let dir = workdir("hf-shrink"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 793220d4f6..3871b1496b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2607,11 +2607,11 @@ title = "Bates numbering across multiple files" area = "C" tier = "P1" milestone = "M7" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_bates_multi"] +evidence = ["crates/automation/tests/it/automation.rs::bates_numbers_continue_across_files_and_report_preflight_and_permission_failures"] +notes = "The headless batch tool allocates inclusive intervals in input order and reports per-file failures; it refuses existing output paths. Missing: a GUI batch dialog (Acrobat sets this up from Header & Footer ▸ Add Bates numbering with a file list), so it is reachable only through `doc_bates_multi`." [[feature]] id = "edit.bates-remove" From 0d3f57e120a3bd0e103f870b5e32d59e43c3ba94 Mon Sep 17 00:00:00 2001 From: AdrianTM Date: Tue, 3 Nov 2026 10:24:45 -0500 Subject: [PATCH 080/124] M5: keep Fill & Sign editing on Enter and preserve line breaks (#5850) (#5954) * M5: keep Fill & Sign editing on Enter and preserve line breaks (#5850) * M5: preserve typewriter whitespace through readers and edits (#5850) * M5: retain direct-subtype comment listing for Fill & Sign fix (#5850) --- ROADMAP.md | 10 ++ crates/annot/src/info.rs | 8 +- crates/annot/src/lookup.rs | 8 +- crates/annot/src/tests.rs | 24 +++++ crates/automation/README.md | 2 + crates/automation/src/comments.rs | 6 +- crates/automation/tests/it/automation.rs | 29 ++++++ crates/engine/src/tests.rs | 17 ++++ crates/render/src/inspect/core.rs | 18 +++- crates/ui-egui/src/tools/comments/composer.rs | 5 +- crates/ui-egui/src/tools/fill_sign.rs | 16 +++- crates/ui-egui/tests/it/comments.rs | 84 ++++++++++++++++- crates/ui-egui/tests/it/fill_sign.rs | 92 +++++++++++++++++-- docs/roadmap.md | 8 +- parity/acrobat-features.toml | 4 +- 15 files changed, 302 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6da118da92..5bfe1fc817 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,9 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) + +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** 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) + > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged) > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged) @@ -219,6 +223,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **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. + - **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop. - **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer. @@ -749,6 +757,8 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 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-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index b56e0de20d..1a54dbc077 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -47,8 +47,12 @@ pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option Object::Name(n) => String::from_utf8_lossy(n).into_owned(), _ => return None, }; - let s = s.trim_matches('\0').trim().to_string(); - (!s.is_empty()).then_some(s) + let s = s.trim_matches('\0'); + // Typewriter outer whitespace belongs to the text (keep in step with render's inspector). + let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected)); + let keep_breaks = key == b"Contents" && name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter"); + let s = if keep_breaks { s } else { s.trim() }; + (!s.trim().is_empty()).then(|| s.to_owned()) } /// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index c7a0fd9831..9af83b0542 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -137,11 +137,11 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { return true; } - let subtype = d.name(b"Subtype").unwrap_or_default(); - if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") { + let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected)); + if name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter") { return true; } - if subtype == b"Stamp" { + if name_is(b"Subtype", b"Stamp") { if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) { return true; } @@ -151,7 +151,7 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { return true; } } - subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature") + name_is(b"Subtype", b"Ink") && text_value(doc, d, b"Subj").as_deref() == Some("Signature") } pub(crate) fn annotation_flags(doc: &Document, d: &Dict) -> i64 { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index cf800fa98a..32d28fc6ec 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2250,3 +2250,27 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() { assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}"); } } + +#[test] +fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() { + let mut doc = fixture(); + let intent = Object::Ref(doc.add(Object::name("FreeTextTypeWriter"))); + let page = page_refs(&doc).unwrap()[0]; + let mut entries = annots(&doc, page); + let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") }; + annotation.set(b"Subtype".to_vec(), Object::name("FreeText")); + annotation.set(b"IT".to_vec(), intent); + let contents = " \nAda\n\nLovelace\n\n "; + annotation.set(b"Contents".to_vec(), PdfString::text(contents)); + set_annots(&mut doc, page, entries).unwrap(); + let check = |doc: &Document| { + let list = summaries(doc); + let summary = list.iter().find(|a| a.page == 0 && a.index == 0).expect("direct Subtype is listed"); + assert_eq!(summary.contents.as_deref(), Some(contents), "indirect typewriter intent preserves outer whitespace"); + assert_eq!(summary.subtype, "FreeText"); + assert_eq!(summary.intent.as_deref(), Some("FreeTextTypeWriter")); + assert!(summary.fill_sign, "indirect intent still identifies a Fill & Sign mark"); + }; + check(&doc); + check(&reopen(&doc)); +} diff --git a/crates/automation/README.md b/crates/automation/README.md index 92fd2775c9..8ffa2d0b3e 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -5,6 +5,8 @@ Agent control for PdfCraft: a headless tool table over the engine, and an opt-in - **Layer:** L7 (architecture §13), but headless. It depends on `engine`, `render` and `organize`, never on a UI toolkit. - **Status:** over a hundred tools; `pdfcraft-cli tools` lists the current set with their schemas (the count isn't repeated here, so it can't go stale). They cover inspection, rendering, text, page edits and organizing, page labels, bookmarks, comments and their review, form filling and authoring (fields, properties, scripts, data exchange, detection), redaction and sanitizing, password protection and signatures, calibrated 2D measurements and CSV export, adding text, images, links and stamps, optimizing, OCR, printing, export, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). The MCP server also serves the open documents as resources, page images included. The running app's UI is driven separately, through the `ui.*` control channel in `pdfcraft-ui-egui`. +`fill_sign_add` text accepts line breaks, including blank lines. Its typewriter box grows to hold all wrapped rows, and saving/reopening preserves the contents and appearance. + ## API ```rust diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 5dbd5b8542..ae46be87d0 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -539,8 +539,10 @@ impl Automation { return self.apply(a, edit); } let text_at = |t: &str| { - let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0); - let h = size * 1.2 + 6.0; + let longest = t.lines().map(|line| pdfcraft_engine::annot_text::text_width(line, size)).fold(0.0, f64::max); + let w = (longest + 8.0).clamp(20.0, 600.0); + let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1); + let h = rows as f64 * size * 1.2 + 6.0; let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); Shape::Typewriter { rect, font_size: size } }; diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 828d2e89ac..13f9af4371 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10613,3 +10613,32 @@ fn mixed_script_tools_normalize_but_undo_restores_the_exact_mixture() { assert_eq!(page_text(&mut a, reopened)[0], plain); } } + +#[test] +fn multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance() { + let dir = workdir("fill-sign-multiline"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + let text = "\nAda\n\nLovelace\n"; + ok(&mut a, "fill_sign_add", json!({"doc":doc,"page":1,"type":"text","at":[20,20],"text":text})); + let id = pdfcraft_engine::DocId(doc); + let annot = &a.session().get(id).unwrap().info.annotations[0]; + assert_eq!(annot.contents.as_deref(), Some(text), "typewriter contents keep outer line breaks"); + assert!(annot.rect[3] - annot.rect[1] >= 66.0, "five text lines need their full height, including blank lines: {:?}", annot.rect); + let pixels = a.render_pixels(doc, 1, 1.0).unwrap(); + // The fixture's page text is at y=150; only the typewriter draws at y=65..80. + assert!( + (65..80).any(|y| (20..80).any(|x| { + let i = (y * pixels.0 as usize + x) * 4; + pixels.2[i..i + 3].iter().all(|v| *v < 100) + })), + "the lower nonempty line appears inside its box" + ); + let rendered = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"multiline.pdf"})); + let mut re = auto(&dir); + let reopened = ok(&mut re, "doc_open", json!({"path":"multiline.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(re.session().get(pdfcraft_engine::DocId(reopened)).unwrap().info.annotations[0].contents.as_deref(), Some(text)); + 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(); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d60ed04d66..a77e471a7d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10251,3 +10251,20 @@ fn image_transform_engine_refuses_singular_matrices_without_mutation() { assert_eq!(s.get(id).unwrap().bytes, before); } } + +#[test] +fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { + let (mut session, id) = rotated_blank(0); + let text = "\nAda\n\nLovelace\n"; + let shape = Shape::Typewriter { rect: text_box_at(0, [20.0, 250.0], 120.0, 66.0).unwrap(), font_size: 10.0 }; + let style = Style::default_for(&shape); + session.apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: text.into(), author: "Test".into() })).unwrap(); + assert_eq!(session.get(id).unwrap().info.annotations[0].contents.as_deref(), Some(text), "engine keeps outer typewriter line breaks"); + let before = render_page(&session, id); + assert!(before.rgba.as_chunks::<4>().0.iter().any(|p| p[..3] == [0, 0, 0]), "the appearance contains ink"); + let bytes = session.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let again = reopened.open("multiline.pdf", None, bytes, None).unwrap(); + 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"); +} diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index c190dc565b..924bfcc51d 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -288,11 +288,23 @@ impl<'a> Inspector<'a> { } pub(super) fn text(&self, d: &Dictionary, key: &[u8]) -> Option { - let s = trimmed_text(self.resolve(d.get(key).ok()?))?; - (!s.is_empty()).then_some(s) + // Typewriter outer whitespace belongs to the text (keep in step with annot's summaries). + let s = if key == b"Contents" + && self.name(d, b"Subtype").as_deref() == Some("FreeText") + && self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter") + { + match self.resolve(d.get(key).ok()?) { + Object::String(bytes, _) => text_string(bytes).trim_matches('\0').to_owned(), + Object::Name(bytes) => String::from_utf8_lossy(bytes).trim_matches('\0').to_owned(), + _ => return None, + } + } else { + trimmed_text(self.resolve(d.get(key).ok()?))? + }; + (!s.trim().is_empty()).then_some(s) } - /// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims + /// Whitespace-trimmed metadata, spent from `budget` as [`spend`] does, so text that trims /// short or to nothing still spends its raw length. pub(super) fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option { let o = self.resolve(d.get(key).ok()?); diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index fa2d7f9b1a..8c3dee280c 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -111,7 +111,10 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, return None; } let c = view.comments.composer.take()?; - let text = c.text.trim_end().to_string(); + let typewriter_edit = matches!(c.kind, ComposerKind::Edit(index) if info.annotations.iter().any(|a| + a.page == page && a.index == index && a.subtype == "FreeText" && a.intent.as_deref() == Some("FreeTextTypeWriter") + )); + let text = if typewriter_edit { c.text } else { c.text.trim_end().to_string() }; match c.kind { ComposerKind::Edit(index) => Some(Edit::SetAnnotationContents { page, index, text }), ComposerKind::Note => { diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..597e139c9e 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -490,7 +490,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &m FillAction::Edit(Box::new(typed_with_color( tb.page, tb.at, - tb.text.trim(), + &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, @@ -555,14 +555,20 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let Some(t) = view.fill_text.as_mut() else { return }; // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); - let width = ((t.text.chars().count().max(8) as f32) * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0); + let font = egui::FontId::proportional((t.size as f32 * zoom).max(8.0)); + let natural = ui.fonts_mut(|f| f.layout_no_wrap(t.text.clone(), font.clone(), Color32::BLACK).size().x); + let margin = egui::Margin::symmetric(4, 2); + // Include the field padding and a pixel for layout rounding in the measured width. + let width = (natural + margin.sum().x + 1.0).max(8.0 * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0); // The area's max rect is last frame's size, which caps the edit's width; widen it so the // box grows as text is typed. ui.set_min_width(width); let r = ui .add( - egui::TextEdit::singleline(&mut t.text) - .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) + egui::TextEdit::multiline(&mut t.text) + .margin(margin) + .desired_rows(1) + .font(font) .desired_width(width) .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) .text_color(crate::comments::color32(t.color)) @@ -587,7 +593,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, if commit { let tb = view.fill_text.take()?; if !tb.text.trim().is_empty() { - return Some(typed_with_color(tb.page, tb.at, tb.text.trim(), tb.size, crate::comments::page_rotation(info, tb.page), author, tb.color)); + return Some(typed_with_color(tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, tb.color)); } } None diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 939de58dff..bd70d802ef 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2925,7 +2925,7 @@ fn moved_fill_and_sign_text_is_drawn_where_it_goes_before_release() { click_pt(&mut h, (40.0, 150.0)); h.event(egui::Event::Text("WWWWWW".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + click(&mut h, pos2(350.0, 850.0)); // Outside the field: Enter now inserts a line break. h.run_steps(3); let a = comments(&h).into_iter().find(|a| a.contents.as_deref() == Some("WWWWWW")).expect("the typed text"); assert!(a.fill_sign && a.subtype == "FreeText", "{a:?}"); @@ -3379,3 +3379,85 @@ fn hotbar_floating_picker_does_not_overlap_rendered_bar() { settings_rect ); } + +fn edit_composer_preserves_only_typewriter_whitespace(kind: u8) { + let shots = std::env::var_os("PDFCRAFT_COMPOSER_SHOTS").is_some(); + let _gpu = shots.then(crate::gpu); + use pdfcraft_engine::{Edit, NewAnnotation, NoteIcon, Shape, Style}; + let mut h = harness(move |app| { + app.set_option("layout", "single").unwrap(); + app.set_option("panel", "comments").unwrap(); + app.quick_tool = QuickTool::Select; + // Both pages use index 0, with opposite intents. The lookup must use page AND index. + for page in 0..2 { + let typewriter = if page == 0 { kind != 0 } else { kind == 0 }; + let shape = if typewriter { + Shape::Typewriter { rect: [40.0, 40.0, 180.0, 90.0], font_size: 10.0 } + } else if kind == 2 { + Shape::Note { at: [80.0, 80.0], icon: NoteIcon::Comment } + } else { + Shape::TextBox { rect: [40.0, 40.0, 180.0, 90.0], font_size: 10.0 } + }; + let style = Style::default_for(&shape); + assert!(app.apply_edit(Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: "Original".into(), author: "Tester".into() }))); + } + }); + h.state_mut().views[0].go_to_page(1); + h.run_steps(4); + support::settle(&mut h); + let point = { + let s = h.state(); + let v = &s.views[0]; + let doc = s.session.get(v.id).unwrap(); + let a = doc.info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap(); + let page = v.page_screen_rect(1).expect("the second page is visible"); + let info = &doc.info.pages[1]; + let p = info.user_to_view((a.rect[0] + a.rect[2]) / 2.0, (a.rect[1] + a.rect[3]) / 2.0); + page.min + egui::vec2(p[0], p[1]) * (page.width() / info.width) + }; + support::input::double_click(&mut h, point); + h.run_steps(3); + let c = h.state().views[0].comments.composer.as_ref().expect("double-click opens the edit composer"); + assert_eq!(c.page, 1); + assert_eq!(c.kind, pdfcraft_ui_egui::comments::ComposerKind::Edit(0)); + h.input_mut().events.push(egui::Event::Text(" edited\n\n".into())); + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.composer.as_ref().unwrap().text, "Original edited\n\n"); + if shots { + h.render().unwrap().save(format!("/tmp/5954-composer-{kind}.png")).unwrap(); + } + h.get_all_by_label("Post").last().unwrap().click(); + h.run_steps(3); + assert!(h.state().views[0].comments.composer.is_none()); + let id = h.state().views[0].id; + let bytes = h.state().session.save_bytes(id).unwrap(); + // Check the raw saved string: ordinary-comment reader trimming must not hide a bad post. + let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + let pages = pdfcraft_model::pages(&cos); + let annots = cos.resolve(pages[1].dict.get(b"Annots").unwrap()); + let annot = cos.resolve(&annots.as_array().unwrap()[0]); + let contents = cos.resolve(annot.as_dict().unwrap().get(b"Contents").unwrap()); + let expected = if kind == 0 { "Original edited\n\n" } else { "Original edited" }; + assert_eq!(contents.as_string().unwrap().to_text(), expected, "only a typewriter edit preserves trailing blank lines"); + let mut reopened = pdfcraft_engine::Session::new(); + let again = reopened.open("edited.pdf", None, bytes, None).unwrap(); + let saved = reopened.get(again).unwrap().info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap(); + assert_eq!(saved.contents.as_deref(), Some(expected)); +} + +#[test] +fn typewriter_edit_composer_keeps_trailing_blank_lines() { + edit_composer_preserves_only_typewriter_whitespace(0); +} + +#[test] +fn other_edit_composers_keep_trimming_trailing_whitespace() { + for kind in [1, 2] { + edit_composer_preserves_only_typewriter_whitespace(kind); + } +} diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index ce21bd9f74..858e082137 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -130,7 +130,7 @@ fn text_marks_and_date() { click(&mut h, 50.0, 250.0); h.event(egui::Event::Text("Ada Lovelace".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(4); let v = items(&h); assert_eq!(v.len(), 4, "{v:?}"); @@ -139,9 +139,9 @@ fn text_marks_and_date() { assert!(v.iter().any(|(t, _)| t == "Stamp")); } -/// The open type box, found as the focused text input. +/// The open type box, found by its accessible name for either input role. fn type_box_rect(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { - h.query_all_by_role(egui::accesskit::Role::TextInput).find(|n| n.is_focused()).expect("an open type box").rect() + h.get_by_label("Text to place").rect() } #[test] @@ -149,13 +149,13 @@ fn clicking_elsewhere_with_add_text_keeps_the_typed_text() { let mut h = harness(); h.state_mut().execute("sign.fill.text"); click(&mut h, 50.0, 250.0); - h.event(egui::Event::Text("Ada Lovelace".into())); + h.event(egui::Event::Text("\nAda\nLovelace\n".into())); h.run_steps(1); // Still on Add text: the next click starts a new box, and must keep the first one. click(&mut h, 50.0, 150.0); h.run_steps(2); let v = items(&h); - assert_eq!(v, vec![("FreeText".to_string(), Some("Ada Lovelace".to_string()))]); + assert_eq!(v, vec![("FreeText".to_string(), Some("\nAda\nLovelace\n".to_string()))]); assert!(h.state().views[0].fill_text.as_ref().is_some_and(|t| t.text.is_empty()), "a new, empty box is open"); } @@ -170,7 +170,7 @@ fn fill_text_size_controls_new_text_and_survives_settings_restore() { assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.size), Some(24.0)); h.event(egui::Event::Text("Larger text".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(3); let annotation = h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Larger text")).unwrap(); @@ -192,7 +192,7 @@ fn fill_text_size_resizes_the_selected_text() { click(&mut h, 50.0, 250.0); h.event(egui::Event::Text("Resize me".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(3); let height = |h: &Harness<'static, PdfCraftApp>| { let s = h.state(); @@ -297,7 +297,7 @@ fn fill_text_colour_survives_preview_commit_settings_and_save() { assert_eq!(h.state().views[0].fill_text.as_ref().unwrap().color, color, "the editor uses the selected colour"); h.event(egui::Event::Text("Blue form text".into())); h.run_steps(2); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(3); let id = h.state().views[0].id; let expected = color.map(|v| v as f32); @@ -1812,3 +1812,79 @@ fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() { h.run_steps(3); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box"); } + +fn commit_typed_text(h: &mut Harness<'static, PdfCraftApp>) { + let p = pos2(350.0, 850.0); // Blank canvas outside the page and the type box. + h.hover_at(p); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); +} + +fn fill_input(h: &mut Harness<'static, PdfCraftApp>, events: impl IntoIterator) { + h.input_mut().events.extend(events); + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); + h.run_steps(2); +} + +fn fill_key(h: &mut Harness<'static, PdfCraftApp>, key: egui::Key) { + fill_input( + h, + [true, false].map(|pressed| egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: egui::Modifiers::NONE }), + ); +} + +#[test] +fn fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels() { + let shots = std::env::var_os("PDFCRAFT_FILL_SIGN_SHOTS").is_some(); + let _gpu = shots.then(gpu); + let mut h = harness(); + assert!(h.state_mut().execute("sign.fill.text")); + click(&mut h, 50.0, 300.0); + let initial_height = type_box_rect(&h).height(); + fill_key(&mut h, egui::Key::Enter); + assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.text.as_str()), Some("\n"), "Enter inserts a newline and keeps the editor open"); + fill_input(&mut h, [egui::Event::Text("Ada".into())]); + fill_key(&mut h, egui::Key::Enter); + fill_key(&mut h, egui::Key::Enter); + fill_input(&mut h, [egui::Event::Text("Lovelace".into())]); + fill_key(&mut h, egui::Key::Enter); + let text = "\nAda\n\nLovelace\n"; + assert_eq!(h.state().views[0].fill_text.as_ref().unwrap().text, text); + assert!(items(&h).is_empty(), "Enter never places an annotation"); + let galley = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == text => Some(&t.galley), + _ => None, + }) + .unwrap(); + assert_eq!(galley.rows.len(), 5, "short typed lines fit without accidental soft wraps"); + assert!(type_box_rect(&h).height() > initial_height * 3.0, "the editor grows with its lines"); + if shots { + h.render().unwrap().save("/tmp/5850-multiline.png").unwrap(); + } + commit_typed_text(&mut h); + assert!(h.state().views[0].fill_text.is_none()); + assert_eq!(items(&h), [("FreeText".to_owned(), Some(text.to_owned()))]); + let id = h.state().views[0].id; + let saved = h.state().session.save_bytes(id).unwrap(); + let mut session = pdfcraft_engine::Session::new(); + let reopened = session.open("again.pdf", None, saved.clone(), None).unwrap(); + let annot = &session.get(reopened).unwrap().info.annotations[0]; + assert_eq!(annot.contents.as_deref(), Some(text)); + assert!(annot.rect[3] - annot.rect[1] >= 5.0 * 10.0 * 1.2, "the saved box keeps all five lines"); + click(&mut h, 50.0, 180.0); + fill_input(&mut h, [egui::Event::Text("Cancel me".into())]); + fill_key(&mut h, egui::Key::Enter); + fill_key(&mut h, egui::Key::Escape); + assert!(h.state().views[0].fill_text.is_none()); + assert_eq!(h.state().session.save_bytes(id).unwrap(), saved, "Escape cancels without writing"); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 1caf3f3e22..f376f7cb00 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,9 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) + +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** 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-03, M7 / #5835 review:** active image crops retain secondary clicks over overlapping images; crop controls share measured layout with hit detection. Rotation, handle and page-edge regressions added; focused tests, scoped Clippy, formatting, layering and asset checks passed locally. Estimates unchanged. > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged) @@ -219,7 +223,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: replies thread by their `/IRT` reference rather than the parent's `/NM`, so files that repeat names per page (PyMuPDF) thread correctly, and the Comments header counts comments without their replies, as Acrobat does, with the reply count under it; the Comments panel groups replies and counts all page roots once per frame (#2669, item 5); each reply in the Comments panel has its own "…" (Edit, Copy text, Delete): Delete takes that reply and the replies to it, the comment and its other replies stay, and the card stays selected; stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; Enter adds line breaks in a growing typewriter editor, outside clicks commit, Escape cancels, and saved/headless appearances keep multiline text (#5850); indirect typewriter intent resolves before whitespace decisions and Fill & Sign classification, with comment listing/property lookup retaining direct Subtype, and composer edits retain typewriter text as written while other comments retain trimming; agent tools.
Done since: replies thread by their `/IRT` reference rather than the parent's `/NM`, so files that repeat names per page (PyMuPDF) thread correctly, and the Comments header counts comments without their replies, as Acrobat does, with the reply count under it; the Comments panel groups replies and counts all page roots once per frame (#2669, item 5); each reply in the Comments panel has its own "…" (Edit, Copy text, Delete): Delete takes that reply and the replies to it, the comment and its other replies stay, and the card stays selected; stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | @@ -300,6 +304,8 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | +| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | | 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer | | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3871b1496b..8b7ac909a0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5399,8 +5399,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated"] -notes = "typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" +evidence = ["crates/annot/src/tests.rs::indirect_typewriter_names_keep_outer_whitespace_in_summaries", "crates/ui-egui/tests/it/comments.rs::typewriter_edit_composer_keeps_trailing_blank_lines", "crates/ui-egui/tests/it/comments.rs::other_edit_composers_keep_trimming_trailing_whitespace", "crates/ui-egui/tests/it/comments.rs::moved_fill_and_sign_text_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::fill_sign_multiline_text_survives_engine_save_reopen_and_render", "crates/ui-egui/tests/it/fill_sign.rs::fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels", "crates/automation/tests/it/automation.rs::multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated"] +notes = "Indirect IT is resolved when recognizing typewriter contents and Fill & Sign marks; comment listing and properties retain direct Subtype as before; composer edits preserve the typewriter text as written, while other comments retain their existing trimming. Enter inserts a newline; the editor grows with its lines, outside clicks commit and Escape cancels. Nonempty text preserves leading/internal/trailing line breaks, including after save/reopen; fill_sign_add sizes all wrapped rows for the saved appearance (#5850). typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" [[feature]] id = "form.fill-sign-marks" From ecdd1e658cbb9ab5ceaa924a5598f091a6135bec Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 10:30:12 -0500 Subject: [PATCH 081/124] fix(audit): read content one operator at a time and charge hidden text and findings (#5875) The faux-redaction audit held every operator of a page's content and of each form XObject in a vector before walking it, so a stream of millions of short operators (compressed to a few kilobytes) was materialised whole within the page's decode budget. Each Do and gs also copied the whole XObject or ExtGState table to pick one entry, and each annotation listed in /Annots was deep-copied before its subtype was read. The walk now reads operators one at a time through pdfcraft_content::ops, borrows resource tables and annotation dictionaries through the resolved Arc, charges a hidden line's text bytes (padding included) against the comparison bound once per cover, and charges each finding's record against the output bound, so a shared annotation list on many pages or withheld text cannot grow the finding list for free. --- crates/audit/src/cover.rs | 37 +++++++++++----------- crates/audit/src/lib.rs | 57 ++++++++++++++++++++++------------ crates/audit/src/tests.rs | 65 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 117 insertions(+), 42 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 041ba9ddd8..bb5adf7ff8 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -15,7 +15,7 @@ //! the joined stream)`; rectangles inside a form XObject carry the `Do` operator's position. //! Positions compare lexicographically in paint order. -use pdfcraft_content::{Matrix, Op, Pieces, parse}; +use pdfcraft_content::{Matrix, Op, Pieces, ops}; use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::AuditError; @@ -123,12 +123,13 @@ impl<'a> Walker<'a> { Ok(()) } - /// Walk one operator slice with the given graphics state. `at_do` is the `Do` position to + /// Walk one stream's operators, read one at a time (a stream of millions of short operators + /// is never held whole), with the given graphics state. `at_do` is the `Do` position to /// stamp on rectangles found inside a form XObject, or `None` at page level (where each /// fill operator's own position is used). fn walk_ops( &mut self, - ops: &[Op], + ops: impl Iterator, gs: &mut Gs, stack: &mut Vec, resources: &Dict, @@ -136,8 +137,9 @@ impl<'a> Walker<'a> { at_do: Option<(usize, usize)>, ) -> Result<(), AuditError> { let mut path = Path::Empty; - for (i, op) in ops.iter().enumerate() { + for (i, op) in ops.enumerate() { self.bump()?; + let op = &op; // Paint position of this operator: its own `(piece, joined index)` at page level, // the enclosing `Do`'s position inside a form XObject. let pos = match at_do { @@ -257,8 +259,9 @@ impl<'a> Walker<'a> { /// changes inside never leak out; every rectangle found is stamped with the `Do` /// position `pos`. fn walk_xobject(&mut self, name: &[u8], gs: &Gs, resources: &Dict, depth: usize, pos: (usize, usize)) -> Result<(), AuditError> { - let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let Some(entry) = xobjects.get(name) else { return Ok(()) }; + // Borrowed, not copied: a `Do` must not cost the size of the whole XObject table. + let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)); + let Some(entry) = xobjects.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)) else { return Ok(()) }; let resolved = self.doc.resolve(entry); let Object::Stream(stream) = &*resolved else { return Ok(()) }; let dict = &stream.dict; @@ -274,18 +277,18 @@ impl<'a> Walker<'a> { (v.len() == 6 && v.iter().all(|x| x.is_finite())).then(|| Matrix([v[0], v[1], v[2], v[3], v[4], v[5]])) }) .unwrap_or(Matrix::IDENTITY); - let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let (sub_resources, empty) = (dict.get(b"Resources").map(|o| self.doc.resolve(o)), Dict::new()); + let sub_resources = sub_resources.as_deref().and_then(Object::as_dict).unwrap_or(&empty); if !self.budget.enter_form() { return Err(AuditError::TooManyForms); } // A form stream that cannot be decoded fails the audit: it might hide a cover. let data = self.budget.decode_strict(stream)?; - let ops = parse(&data).ops; // The XObject matrix maps form space into the user space active at `Do`. let mut sub_gs = gs.clone(); sub_gs.ctm = matrix.then(&gs.ctm); let mut sub_stack = Vec::new(); - self.walk_ops(&ops, &mut sub_gs, &mut sub_stack, &sub_resources, depth + 1, Some(pos)) + self.walk_ops(ops(&data), &mut sub_gs, &mut sub_stack, sub_resources, depth + 1, Some(pos)) } } @@ -298,13 +301,10 @@ fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> { /// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque. fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { - let alpha = resources - .get(b"ExtGState") - .map(|o| doc.resolve(o)) - .and_then(|o| o.as_dict().cloned()) - .and_then(|d| d.get(name).map(|o| doc.resolve(o))) - .and_then(|o| o.as_dict().cloned()) - .and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64())); + // Borrowed, not copied: a `gs` must not cost the size of the whole ExtGState table. + let states = resources.get(b"ExtGState").map(|o| doc.resolve(o)); + let state = states.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)).map(|o| doc.resolve(o)); + let alpha = state.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64())); alpha.filter(|a| a.is_finite()).map(|a| a.clamp(0.0, 1.0)).unwrap_or(1.0) } @@ -330,10 +330,11 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi } } let pieces = Pieces::join(&streams); - let parsed = pieces.parse(); let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget }; let mut gs = Gs::default(); let mut stack = Vec::new(); - w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?; + // Read one operator at a time: the joined stream can hold millions of them within the + // decode budget, and the walk only ever needs the current one. + w.walk_ops(ops(pieces.data()), &mut gs, &mut stack, &resources, 0, None)?; Ok(w.out) } diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index f2bcf63358..97429d4c40 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -99,6 +99,9 @@ const COVER_FRACTION: f64 = 0.4; const WORD_FRACTION: f64 = 0.5; /// WCAG contrast ratio below which the text is unreadable against the cover. const COVER_CONTRAST: f64 = 3.0; +/// Bytes of a hidden line's text read (copied or trimmed) that cost one comparison: a byte scan +/// is far cheaper than a box comparison, but a long padded line under many covers adds up. +const TEXT_BYTES_PER_COMPARISON: usize = 16; /// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any /// content that cannot be decoded, or that needs more work or report than the audit's bounds, is @@ -157,15 +160,24 @@ impl Budget { cover: &[f64; 4], hides: impl Fn(&TextLine) -> bool, ) -> Result, AuditError> { - self.comparisons = self.comparisons.saturating_add(self.page_cost); - if self.comparisons > self.limits.comparisons { - return Err(AuditError::TooManyComparisons); - } + self.spend(self.page_cost)?; if !copying { - return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new)); + let mut hidden = false; + for line in lines.iter().filter(|l| hides(l)) { + // Trimming walks the line's text (padding and all): it costs its bytes. + self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?; + if !line.text.trim().is_empty() { + hidden = true; + break; + } + } + return Ok(hidden.then(String::new)); } let mut covered = String::new(); for line in lines.iter().filter(|l| hides(l)) { + // Copying and trimming the line walks its text once per cover: a long padded + // line under many covers would otherwise be read far more than its runs suggest. + self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?; if !covered.is_empty() { covered.push(' '); } @@ -174,11 +186,21 @@ impl Budget { Ok((!covered.trim().is_empty()).then_some(covered)) } + /// `n` units of work done against the comparison bound. + fn spend(&mut self, n: usize) -> Result<(), AuditError> { + self.comparisons = self.comparisons.saturating_add(n); + if self.comparisons > self.limits.comparisons { + return Err(AuditError::TooManyComparisons); + } + Ok(()) + } + /// The text a finding reports for `covered`: none when copying is withheld. Charges what it - /// reports against the output bound. + /// reports, and the finding's own record, against the output bound: a finding with no text + /// (copying withheld, or a shared annotation list on many pages) still takes memory. fn report_text(&mut self, covered: String, copying: bool) -> Result { let reported = if copying { covered } else { String::new() }; - self.covered_text = self.covered_text.saturating_add(reported.len()); + self.covered_text = self.covered_text.saturating_add(reported.len()).saturating_add(std::mem::size_of::()); if self.covered_text > self.limits.covered_text { return Err(AuditError::TooMuchOutput); } @@ -321,17 +343,14 @@ enum AnnotationCover { fn annots(doc: &Document, page: &Dict) -> Result, AuditError> { let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) }; let resolved = doc.resolve(a); - let items: Vec = match &*resolved { - pdfcraft_cos::Object::Array(items) => items.clone(), - // A missing /Annots means no marks; a malformed one fails the audit. - _ => return Err(AuditError::BadAnnots), - }; + // A missing /Annots means no marks; a malformed one fails the audit. + let Some(items) = resolved.as_array() else { return Err(AuditError::BadAnnots) }; let mut out = Vec::new(); - for a in &items { - let dict = match &*doc.resolve(a) { - pdfcraft_cos::Object::Dict(d) => d.clone(), - _ => continue, - }; + for a in items { + // Borrowed from the resolved object: one large dictionary listed many times is not + // copied once per listing. + let resolved = doc.resolve(a); + let Some(dict) = resolved.as_dict() else { continue }; let v: Vec = match dict.get(b"Rect") { None => continue, Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(), @@ -340,7 +359,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro continue; } let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]; - match subtype(doc, &dict)?.as_deref() { + match subtype(doc, dict)?.as_deref() { Some(b"Redact") => out.push(AnnotationCover::Redact(rect)), Some(b"Square") => { let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); @@ -349,7 +368,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro } let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0); if alpha >= 1.0 - && let Some(rgb) = annotation_color(doc, &dict, b"IC") + && let Some(rgb) = annotation_color(doc, dict, b"IC") { out.push(AnnotationCover::Square { rect, rgb }); } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index e5fb9ab683..074eb39f66 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -720,9 +720,11 @@ fn runs_without_words_count_against_the_comparison_bound() { let doc = open(one_page(&content, "")); let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::>(), [(1, 51)]); - let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX }; + // Plus the hidden line's text, read once to copy it. + let text = lines[0].text.len() / crate::TEXT_BYTES_PER_COMPARISON; + let limits = crate::Limits { comparisons: 50 + text, covered_text: usize::MAX }; assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); - let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX }; + let limits = crate::Limits { comparisons: 51 + text, covered_text: usize::MAX }; assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1); } @@ -746,14 +748,18 @@ fn copy_restricted_documents_withhold_covered_text() { let findings = audit_redactions(&user).expect("audit must run"); assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}"); assert_eq!(findings[0].covered_text, ""); - // The output bound counts the text reported, so withheld text costs nothing. - let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 }; + // The output bound counts the text reported and the finding's record, so withheld text + // costs only the record. + let record = std::mem::size_of::(); + let nothing = crate::Limits { comparisons: usize::MAX, covered_text: record }; assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1); + let none = crate::Limits { covered_text: record - 1, ..nothing }; + assert!(matches!(crate::audit(&user, none), Err(crate::AuditError::TooMuchOutput))); let owner = Document::open_with_password(bytes, Some("owner")).expect("open"); let findings = audit_redactions(&owner).expect("audit must run"); assert_eq!(findings.len(), 1, "{findings:?}"); assert_eq!(findings[0].covered_text, "SECRET DATA 123"); - let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() }; + let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() + record }; assert!(crate::audit(&owner, exact).is_ok()); let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact }; assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput))); @@ -820,3 +826,52 @@ fn form_entries_past_the_page_allowance_fail_the_audit() { let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms))); } + +/// Copying a covered line's text walks all of it, padding included, so a long padded line under +/// many covers costs its bytes once per cover, not one run per cover (CodeRabbit). +#[test] +fn covered_text_bytes_count_against_the_comparison_bound() { + // One run: 20 KiB of spaces around a word, which trims to the word alone. + let mut content = b"BT /F1 24 Tf 100 700 Td (".to_vec(); + content.extend_from_slice(&[b' '; 10_000]); + content.extend_from_slice(b"SECRET"); + content.extend_from_slice(&[b' '; 10_000]); + content.extend_from_slice(b") Tj ET\n0 g\n"); + for _ in 0..10 { + content.extend_from_slice(b"-1 688 1000000 32 re f\n"); + } + let doc = open(one_page(&content, "")); + let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); + assert_eq!(lines.len(), 1); + let bytes = lines[0].text.len(); + assert!(bytes > 20_000, "{bytes} bytes of text"); + // Each cover: one comparison for the line, plus its bytes when it is hidden. + let exact = crate::Limits { comparisons: 10 * (1 + bytes / crate::TEXT_BYTES_PER_COMPARISON), covered_text: usize::MAX }; + let findings = crate::audit(&doc, exact).expect("within the budget"); + assert_eq!(findings.len(), 10, "{findings:?}"); + assert!(findings.iter().all(|f| f.covered_text == "SECRET"), "{findings:?}"); + let short = crate::Limits { comparisons: exact.comparisons - 1, ..exact }; + assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooManyComparisons))); +} + +/// One annotation list shared by many pages makes a finding on each: every finding's record +/// counts against the output bound, text or no text (CodeRabbit). +#[test] +fn every_finding_record_counts_against_the_output_bound() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n"; + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R 7 0 R 8 0 R] /Count 3 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + b.add_stream(content); + b.add(FONT); + b.add("[<< /Type /Annot /Subtype /Redact /Rect [95 685 200 725] >>]"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + let doc = open(b.build()); + let one = "SECRET".len() + std::mem::size_of::(); + let exact = crate::Limits { comparisons: usize::MAX, covered_text: 3 * one }; + assert_eq!(crate::audit(&doc, exact).expect("within the budget").len(), 3); + let short = crate::Limits { covered_text: 3 * one - 1, ..exact }; + assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooMuchOutput))); +} From 01d3479f2680d1a931569a3605f540a6aac3b0dd Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 3 Nov 2026 19:06:56 +0100 Subject: [PATCH 082/124] 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 083/124] 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 084/124] 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 76260bb6a5032fd9ab3cfd364add751c3f80b828 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:33:41 -0500 Subject: [PATCH 085/124] fix(xfa): scripts come only from the XDP's own packets and quote the date pattern (#5987) The template (and the saved form state) was found anywhere under the XDP root, so a template-shaped element inside imported datasets could supply the form and its event scripts. A date field's pattern went into AFDate_FormatEx/KeystrokeEx string literals unescaped. --- crates/xfa/src/parse.rs | 6 ++++-- crates/xfa/src/pdf.rs | 9 ++++++--- crates/xfa/src/pdf/common.rs | 32 ++++++++++++++++++++++++++++++++ crates/xfa/src/tests.rs | 10 ++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 9ca6a7f3cd..76409dc697 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -746,10 +746,12 @@ pub fn parse(xml: &str) -> Result<(Template, Vec), XfaError> { && n.tag_name().name() == "template" && (n.tag_name().namespace().is_some_and(|ns| ns.contains("xfa-template")) || children(*n, "subform").next().is_some()) }; + // The template is a packet of the XDP itself, a child of its root: one nested deeper is + // data (an imported dataset, say), which must not supply the form's scripts. let template = if is_template(&root) { root } else { - root.descendants().find(is_template).ok_or_else(|| XfaError::Malformed("no