From cc9d95e1fbd6f9bff0ef93e4a194e9ac1ebf3cbd Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 00:29:23 -0400 Subject: [PATCH 01/27] 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 02/27] 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 03/27] 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 04/27] 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 05/27] 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 06/27] 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 07/27] 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 08/27] 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 e93e8d50ba7082c4c6e60c4dd41791280bbb2506 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 13:15:43 -0500 Subject: [PATCH 09/27] fix(edit): keep a soft mask anchored when the edit is lifted over the scan (#3973) A soft mask is placed by the CTM in force when its `gs` runs (ISO 32000-2 11.6.5.2). The paragraph lifted over the scan re-selects the graphics state after its own `cm`, so a mask selected before a translation landed translated with it and could hide the new text. A re-emitted state that carries a dictionary /SMask now records its anchor CTM and is selected there: `cm` to the anchor, `gs`, `cm` back. The test edits the same paragraph with and without a mask and requires the same ink; it fails with the re-anchoring disabled (the mask lands 100 pt right and the text vanishes). Both rewrite paths. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/text/interp.rs | 54 ++++++++++++++++++++------ crates/edit/src/text/rewrite.rs | 4 +- crates/edit/src/text_regions/styled.rs | 3 +- crates/engine/tests/it/ocr_edit.rs | 53 +++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 13 deletions(-) diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index a9eb8a0c5f..61f82509b1 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -18,7 +18,7 @@ pub(super) struct TextState { /// graphics state the text was painted with that [`TextState::ops`] doesn't restate. pub(super) stroke: Vec, pub(super) line_width: Option, - ext_gstate: Vec>, + ext_gstate: Vec, /// The dash pattern and line cap in force (see [`Ts`]): compared, not restated. pub(super) dash: Dash, pub(super) line_cap: i64, @@ -74,13 +74,17 @@ impl TextState { /// /// A `gs` has no inverse, so a selection `current` has and this state doesn't stays in force: /// run these inside a `q`/`Q` that starts from `current`, one per state drawn. - pub(crate) fn paint_ops(&self, current: &TextState) -> Vec { + /// + /// `ctm` is the CTM where they run. A soft mask is placed by the CTM its `gs` ran at + /// (ISO 32000-2 §11.6.5.2), so one selected at another CTM is selected there and the CTM + /// set back. + pub(crate) fn paint_ops(&self, current: &TextState, ctm: [f64; 6]) -> Vec { let n = pdfcraft_content::num; let mut v = Vec::new(); // The selections both share stay in force; the ones after them are selected again in order. let shared = self.ext_gstate.iter().zip(¤t.ext_gstate).take_while(|(a, b)| a == b).count(); - for name in self.ext_gstate.iter().skip(shared) { - v.push(Op::new("gs", vec![Object::Name(name.clone())])); + for sel in self.ext_gstate.iter().skip(shared) { + sel.push_ops(&mut v, ctm); } // A reselected ExtGState may set `/LW`, so the width (already the effective one) follows it. let reselected = !v.is_empty(); @@ -242,7 +246,7 @@ pub(super) struct Ts { /// The line width a `w` or an ExtGState's `/LW` set (`None`: the initial 1.0). pub(super) line_width: Option, /// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]). - pub(super) ext_gstate: Vec>, + pub(super) ext_gstate: Vec, /// The dash pattern a `d` or an ExtGState's `/D` set (initially solid: `[] 0`). pub(super) dash: Dash, /// The line cap a `J` or an ExtGState's `/LC` set (initially 0, butt). @@ -275,12 +279,38 @@ const MAX_GS: usize = 32; /// Record `gs /name` in `list`. A `gs` sets every parameter its dictionary names to an absolute /// value, so a later selection of the same dictionary overrides the earlier one completely: the /// earlier entry goes, which keeps replaying the list equivalent to replaying the stream. -fn select_gs(list: &mut Vec>, name: &[u8]) { - list.retain(|n| n != name); +fn select_gs(list: &mut Vec, name: &[u8], anchor: Option<[f64; 6]>) { + list.retain(|s| s.name != name); if list.len() >= MAX_GS { list.remove(0); } - list.push(name.to_vec()); + list.push(GsSelection { name: name.to_vec(), anchor }); +} + +/// One `gs` selection: the ExtGState's resource name, and the CTM it ran at when the dictionary +/// has a soft mask, which that CTM places (ISO 32000-2 §11.6.5.2). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GsSelection { + name: Vec, + anchor: Option<[f64; 6]>, +} + +impl GsSelection { + /// Select this ExtGState from where the CTM is `ctm`: at its anchor, then back to `ctm`. Both + /// moves need an invertible matrix; without one it is selected where it runs. + fn push_ops(&self, v: &mut Vec, ctm: [f64; 6]) { + let gs = Op::new("gs", vec![Object::Name(self.name.clone())]); + let moves = self.anchor.filter(|a| *a != ctm).and_then(|a| { + let (anchor, here) = (Matrix(a), Matrix(ctm)); + Some((anchor.then(&here.invert()?), here.then(&anchor.invert()?))) + }); + let Some((to, back)) = moves else { + v.push(gs); + return; + }; + let cm = |m: Matrix| Op::new("cm", m.0.iter().map(|x| pdfcraft_content::num(*x)).collect()); + v.extend([cm(to), gs, cm(back)]); + } } /// The text-showing operators of one stream with their text, font, box and baseline. @@ -898,10 +928,12 @@ pub(super) fn interpret( b"Ts" => ts.rise = op.num(0).unwrap_or(0.0), b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1), b"gs" => { - if let Some(name) = op.name(0) { - select_gs(&mut ts.ext_gstate, name); - } let params = op.name(0).and_then(|name| states.get(name)).map(|p| doc.resolve(p)); + if let Some(name) = op.name(0) { + let masked = + params.as_deref().and_then(Object::as_dict).and_then(|p| p.get(b"SMask")).is_some_and(|m| doc.resolve(m).as_dict().is_some()); + select_gs(&mut ts.ext_gstate, name, masked.then_some(ts.ctm.0)); + } if let Some(params) = params.as_deref().and_then(Object::as_dict) { ts.gs.apply(doc, params); // `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5). diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 6bf3d89784..d27d23e0b5 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -702,11 +702,13 @@ fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, let mut ops = vec![Op::new("q", vec![])]; ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); // Moved: a translation inside that state. + let mut ctm = Matrix(plan.emit.ctm); if let Some((ax, ay)) = plan.movement { ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); + ctm = Matrix([1.0, 0.0, 0.0, 1.0, ax, ay]).then(&ctm); } // Painted as the paragraph was, including what its text object set before the first show. - ops.extend(plan.state.paint_ops(plan.after)); + ops.extend(plan.state.paint_ops(plan.after, ctm.0)); ops.push(Op::new("BT", vec![])); let mut state = plan.state.clone(); state.word_spacing = 0.0; diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 5ddb1064fb..7a9204a8c0 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1001,6 +1001,7 @@ pub(crate) fn rewrite_styled_block( .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; let (tx, ty) = back.apply(dx, dy); block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)])); + let run_ctm = Matrix([1.0, 0.0, 0.0, 1.0, tx, ty]).then(&Matrix(o.ctm)).0; // A new row can land one leading above the next paragraph and look like the same // paragraph. When every row is in this rewrite, a paragraph marker keeps them in one // scope so the neighbour stays out (#2517). A row left in the source stream cannot share @@ -1098,7 +1099,7 @@ pub(crate) fn rewrite_styled_block( // object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. block_ops.push(Op::new("q", vec![])); - block_ops.extend(state.paint_ops(drawn_from)); + block_ops.extend(state.paint_ops(drawn_from, run_ctm)); if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; let mut properties = Dict::new(); diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index a51ce8ddcb..53ca309326 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -216,6 +216,11 @@ fn ocr_under_scan(text: &str) -> std::sync::Arc> { /// A page whose `text` content is followed by the opaque 200 × 100 scan image. fn under_scan(text: &str) -> std::sync::Arc> { + under_scan_with(text, |_| pdfcraft_cos::Dict::new()) +} + +/// As [`under_scan`], with the page's ExtGState resources made by `ext_gstates`. +fn under_scan_with(text: &str, ext_gstates: impl FnOnce(&mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict) -> std::sync::Arc> { use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; let (w, h) = (200u32, 100u32); let mut rgb = Vec::new(); @@ -248,6 +253,7 @@ fn under_scan(text: &str) -> std::sync::Arc> { let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + res.set(b"ExtGState".to_vec(), Object::Dict(ext_gstates(&mut d))); let mut p = Dict::new(); p.set(b"Type".to_vec(), Object::name("Page")); p.set(b"Parent".to_vec(), Object::Ref(pages)); @@ -343,3 +349,50 @@ fn an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit() { assert_edit_shows(&s, id, &format!("{edit:?} split")); } } + +/// `/SM`: a luminosity soft mask that is white over 30..110 × 30..70 in the space it is selected +/// in, black elsewhere. +fn soft_mask_state(d: &mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict { + use pdfcraft_cos::{Dict, Object, Stream}; + let mut group = Dict::new(); + group.set(b"S".to_vec(), Object::name("Transparency")); + group.set(b"CS".to_vec(), Object::name("DeviceRGB")); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec())); + form.set(b"Group".to_vec(), Object::Dict(group)); + let mut mask = Dict::new(); + mask.set(b"Type".to_vec(), Object::name("Mask")); + mask.set(b"S".to_vec(), Object::name("Luminosity")); + mask.set(b"G".to_vec(), Object::Ref(d.add(Object::Stream(Stream::flate(form, b"1 g 30 30 80 40 re f"))))); + let mut state = Dict::new(); + state.set(b"Type".to_vec(), Object::name("ExtGState")); + state.set(b"SMask".to_vec(), Object::Dict(mask)); + let mut states = Dict::new(); + states.set(b"SM".to_vec(), Object::Dict(state)); + states +} + +/// The soft mask is selected before a translation, so it is placed by the CTM before it. The +/// paragraph lifted over the scan selects it again after its own `cm`: there it must be placed +/// as it was, not 100 pt to the right where it would hide the new text, so the edit renders +/// exactly as the same edit on the page without the mask. Both rewrite paths. +#[test] +fn a_soft_mask_selected_before_a_cm_stays_put_when_the_edit_is_lifted() { + for edit in ["Jane", "Jane\nDoe"] { + let mut plain = Session::new(); + let page = under_scan("q 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q"); + let plain_id = plain.open("plain.pdf", None, page, None).unwrap(); + edit_block(&mut plain, plain_id, 0, edit); + let mut s = Session::new(); + let page = under_scan_with("q /SM gs 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q", soft_mask_state); + let id = s.open("masked.pdf", None, page, None).unwrap(); + assert_eq!(texts(&s, id), ["John"]); + edit_block(&mut s, id, 0, edit); + assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}"); + assert_edit_shows(&s, id, &format!("{edit:?} masked")); + let ink = |s: &Session, id| word_pixels(s, id).iter().filter(|p| near(**p, INK)).count(); + assert_eq!(ink(&s, id), ink(&plain, plain_id), "{edit:?}: the mask does not hide the new text"); + } +} From 56578a6006ebce754bac22b13389ea1f8319620c Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 13:42:16 -0500 Subject: [PATCH 10/27] M3: test what moves an OCR edit on top: clipped, two images, form (#3973) Review asked for text under and above a scan (already pinned), an image clipped away entirely, two images, and a form holding the image. The clipped case fails when the hit test uses the image's box instead of its visible box; the negative cases fail when any later XObject counts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/tests.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index deb4d67a7c..2b78b07113 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6214,16 +6214,17 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { } /// Invisible text moves on top only when something painted after it covers it: an XObject drawn -/// over its box, or any inline image or shading. +/// over its box (an image, or a form holding one), or any inline image or shading. An image +/// clipped away entirely, or drawn elsewhere, does not move it. #[test] fn revealed_text_moves_on_top_only_when_painted_over() { - let on_top = |content: &str| { - let doc = image_page_with(content); + let on_top_of = |doc: Document| { let block = text::text_blocks(&doc, 0).unwrap().remove(0); let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; let at = ops.iter().position(|o| o.is("BT")).unwrap(); revealed_under_paint(&doc, 0, block.invisible, block.rect, &ops, at) }; + let on_top = |content: &str| on_top_of(image_page_with(content)); let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; assert!(on_top(&format!("BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET {scan}")), "the scan covers it"); assert!(!on_top(&format!("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET {scan}")), "an image elsewhere"); @@ -6232,6 +6233,29 @@ fn revealed_text_moves_on_top_only_when_painted_over() { assert!(!on_top("BT /F1 12 Tf 3 Tr 110 150 Td (Path) Tj ET 0 0 600 800 re f"), "a filled path does not move it"); assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Shade) Tj ET /Sh sh"), "a shading after it"); assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Inline) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 ID x EI"), "an inline image after it"); + let under = "BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET"; + let elsewhere = "q 50 0 0 50 400 600 cm /Im0 Do Q"; + assert!(!on_top(&format!("{under} q 0 0 10 10 re W n {scan} Q")), "the scan is clipped away entirely"); + assert!(on_top(&format!("{under} {elsewhere} {scan}")), "two images, the second over it"); + assert!(on_top(&format!("{under} {scan} {elsewhere}")), "two images, the first over it"); + assert!(!on_top(&format!("{under} {elsewhere} {elsewhere}")), "two images, neither over it"); + let in_form = |content: &str| { + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> >> >>"; + let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"); + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im0 6 0 R >> >>", "/Im0 Do"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + page.into(), + stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + image, + form, + ]; + on_top_of(build(&objs.iter().map(String::as_str).collect::>())) + }; + assert!(in_form(&format!("{under} q 200 0 0 100 100 100 cm /Fm0 Do Q")), "a form holding the scan, over it"); + assert!(!in_form(&format!("{under} q 50 0 0 50 400 600 cm /Fm0 Do Q")), "a form holding an image elsewhere"); } /// Revealed OCR text is checked where the rewrite lands, not only where it was: an image drawn From e90c92124c1695dc87780f763f70a2556e63b81e Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 30 Oct 2026 07:31:31 -0600 Subject: [PATCH 11/27] M5.7: the quick bar's Fill & Sign button shows the tool it picks The button showed a generic pen until clicked, then swapped to Add text's icon. It now works like the Comment, Highlight and Draw groups: it shows the last Fill & Sign tool used (Signature at first) and clicking it picks that tool. The flyout marks the active saved signature or initials card, and the cards get the pointing-hand cursor like the other rows. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 9 ++++-- crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/hotbar.rs | 16 +++++++++-- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/tools/fill_sign.rs | 13 ++++++--- crates/ui-egui/tests/it/comments.rs | 2 +- crates/ui-egui/tests/it/fill_sign.rs | 40 +++++++++++++++++++++++---- 9 files changed, 69 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6ac8ea8783..bb1748f705 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -108,6 +108,7 @@ impl PdfCraftApp { ui_scale_applied: None, flatten_fill_sign_on_save: false, fill_text_size: fill_sign::TEXT_SIZE, + fill_tool: fill_sign::FillTool::Signature, fill_text_color: [0.0; 3], dialog: None, updates: updates::Updates::default(), diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 0309042bf1..fee5378b6e 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -333,9 +333,12 @@ impl PdfCraftApp { field if field.starts_with("field-") => QuickTool::Field( prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?, ), - fill if fill.starts_with("fill-") => QuickTool::Fill( - fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?, - ), + fill if fill.starts_with("fill-") => { + let f = + fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?; + self.fill_tool = f; + QuickTool::Fill(f) + } other => { let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?; self.comment_prefs.group_tool[t.group()] = t; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index f072b7febe..4fdec5af8a 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -697,6 +697,7 @@ impl PdfCraftApp { } fill if crate::fill_sign::FillTool::from_command(fill).is_some() => { let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return Some(false) }; + self.fill_tool = tool; self.quick_tool = crate::QuickTool::Fill(tool); let initials = tool == crate::fill_sign::FillTool::Initials; if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 048d519222..8105a22342 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1349,6 +1349,7 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) app.signature = d.saved(); crate::fill_sign::FillTool::Signature }; + app.fill_tool = tool; app.quick_tool = crate::QuickTool::Fill(tool); app.toast = None; } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index c66b7e30b0..bbd98be2ef 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -239,8 +239,20 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_ QuickTool::Fill(f) => Some(f), _ => None, }; - let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text); - let resp = icons::button(ui, if current_fill.is_some() { shown.icon() } else { "pen-line" }, cell, current_fill.is_some(), tl!("Fill & Sign")); + let shown = app.fill_tool; + let tip = match shown { + crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials => { + let initials = shown == crate::fill_sign::FillTool::Initials; + let saved = if initials { app.initials.is_some() } else { app.signature.is_some() }; + crate::i18n::fmt( + tl!(if saved { "Place saved {what}" } else { "Add {what}" }), + &[("what", tl!(if initials { "initials" } else { "signature" }))], + ) + } + _ => tl!(shown.label()).to_string(), + }; + let tip = format!("{}: {tip}", tl!("Fill & Sign")); + let resp = icons::button(ui, shown.icon(), cell, current_fill.is_some(), &tip); let r = resp.rect; let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if current_fill.is_some() { t.accent } else { t.text_faint }, Stroke::NONE)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 643d5e90ff..ae89f96bcb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -331,6 +331,7 @@ pub struct PdfCraftApp { pub flatten_fill_sign_on_save: bool, /// Point size for new Fill & Sign text and dates, kept across launches. pub fill_text_size: f64, + pub fill_tool: fill_sign::FillTool, /// Colour for new Fill & Sign text and dates, kept across launches. pub fill_text_color: [f64; 3], pub dialog: Option, diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..9beaa9b834 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -373,20 +373,22 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, } continue; }; - ui.horizontal(|ui| { + let on = app.quick_tool == crate::QuickTool::Fill(if i == 0 { FillTool::Signature } else { FillTool::Initials }); + let wash = ui.painter().add(egui::Shape::Noop); + let row = ui.horizontal(|ui| { let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click()); let use_label = label("Use {what}"); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, use_label.clone())); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, use_label.clone())); let painter = ui.painter_at(rect); painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE); painter.rect_stroke( rect, CornerRadius::same(4), - Stroke::new(1.0, if response.hovered() { t.accent } else { t.border }), + Stroke::new(1.0, if on || response.hovered() { t.accent } else { t.border }), egui::StrokeKind::Inside, ); paint_saved(ui, rect.shrink(8.0), saved, &mut app.saved_signature_previews[i], &format!("saved-{what}")); - if response.on_hover_text(label("Place saved {what}")).clicked() { + if crate::theme::hand(response).on_hover_text(label("Place saved {what}")).clicked() { command = Some(use_id); } if crate::icons::button(ui, "pencil", 26.0, false, &label("Change {what}")).clicked() { @@ -396,6 +398,9 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, command = Some(remove_id); } }); + if on { + ui.painter().set(wash, egui::Shape::rect_filled(row.response.rect.expand(3.0), CornerRadius::same(6), t.accent_soft)); + } ui.add_space(4.0); } command diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 939de58dff..51b9eabce1 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3280,7 +3280,7 @@ fn hotbar_docked_left_does_not_override_quick_tools() { }); h.run_steps(5); // Controls in the docked bar share one layout, so none may overlap (#5890). - let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign", "Hotbar settings"]; + let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign: Add signature", "Hotbar settings"]; let rects: Vec<_> = labels.iter().map(|&label| h.get_by_label(label).rect()).collect(); for (i, a) in rects.iter().enumerate() { for (j, b) in rects.iter().enumerate().skip(i + 1) { diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index ce21bd9f74..503080e71c 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -2,7 +2,7 @@ use egui::{Pos2, pos2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_ui_egui::fill_sign::{FillTool, SavedSig}; use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool}; @@ -423,9 +423,9 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() { let original = format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations); // The quick toolbar exposes replacement without changing the normal placement action. - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); h.get_by_label("Change signature").click(); h.run_steps(2); @@ -445,9 +445,9 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() { click(&mut h, 40.0, 200.0); assert_eq!(items(&h).len(), 2); - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); h.get_by_label("Change initials").click(); h.run_steps(2); @@ -1812,3 +1812,33 @@ 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"); } + +#[test] +fn the_quick_bar_fill_button_shows_the_tool_it_picks() { + let mut h = harness(); + h.get_by_label("Fill & Sign: Add signature").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature)); + assert_eq!(h.state().dialog, Some(Dialog::Signature)); + + h.state_mut().dialog = None; + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(2); + h.get_by_label("Fill & Sign: Place saved signature").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature)); + assert_eq!(h.state().dialog, None); + h.get_by_label("Fill & Sign: Place saved signature").click(); + h.run_steps(2); + assert_eq!(h.get_by_label("Use signature").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the active tool's card is marked"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + + h.state_mut().execute("sign.fill.check"); + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(2); + h.get_by_label("Fill & Sign: Checkmark").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check), "the button keeps the last Fill & Sign tool"); +} From edee301ebf8f719a5574949e17919c1bb8cddd16 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 30 Oct 2026 07:55:28 -0600 Subject: [PATCH 12/27] M5.7: the Fill & Sign button follows the active tool and names it Tools restored without a command (leaving full screen, the temporary tool, the control channel) left the remembered Fill & Sign tool stale, so the button could show Checkmark while a click placed a signature. The bar now takes the remembered tool from the active one, which replaces the dialog's and control channel's own updates. While a Fill & Sign tool is active the button opens the flyout, so its label names the tool instead of describing placing or adding a signature. --- crates/ui-egui/src/app/control.rs | 9 +++------ crates/ui-egui/src/dialogs.rs | 1 - crates/ui-egui/src/hotbar.rs | 5 ++++- crates/ui-egui/tests/it/fill_sign.rs | 14 +++++++++++--- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index fee5378b6e..0309042bf1 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -333,12 +333,9 @@ impl PdfCraftApp { field if field.starts_with("field-") => QuickTool::Field( prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?, ), - fill if fill.starts_with("fill-") => { - let f = - fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?; - self.fill_tool = f; - QuickTool::Fill(f) - } + fill if fill.starts_with("fill-") => QuickTool::Fill( + fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?, + ), other => { let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?; self.comment_prefs.group_tool[t.group()] = t; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8105a22342..048d519222 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1349,7 +1349,6 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) app.signature = d.saved(); crate::fill_sign::FillTool::Signature }; - app.fill_tool = tool; app.quick_tool = crate::QuickTool::Fill(tool); app.toast = None; } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index bbd98be2ef..88c45bbd1e 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -239,9 +239,12 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_ QuickTool::Fill(f) => Some(f), _ => None, }; + if let Some(f) = current_fill { + app.fill_tool = f; + } let shown = app.fill_tool; let tip = match shown { - crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials => { + crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials if current_fill.is_none() => { let initials = shown == crate::fill_sign::FillTool::Initials; let saved = if initials { app.initials.is_some() } else { app.signature.is_some() }; crate::i18n::fmt( diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 503080e71c..bd8bd30939 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -425,7 +425,7 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() { // The quick toolbar exposes replacement without changing the normal placement action. h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); - h.get_by_label("Fill & Sign: Place saved signature").click(); + h.get_by_label("Fill & Sign: Sign").click(); h.run_steps(2); h.get_by_label("Change signature").click(); h.run_steps(2); @@ -447,7 +447,7 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() { h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); - h.get_by_label("Fill & Sign: Place saved signature").click(); + h.get_by_label("Fill & Sign: Sign").click(); h.run_steps(2); h.get_by_label("Change initials").click(); h.run_steps(2); @@ -1829,7 +1829,7 @@ fn the_quick_bar_fill_button_shows_the_tool_it_picks() { h.run_steps(2); assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature)); assert_eq!(h.state().dialog, None); - h.get_by_label("Fill & Sign: Place saved signature").click(); + h.get_by_label("Fill & Sign: Sign").click(); h.run_steps(2); assert_eq!(h.get_by_label("Use signature").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the active tool's card is marked"); h.key_press(egui::Key::Escape); @@ -1841,4 +1841,12 @@ fn the_quick_bar_fill_button_shows_the_tool_it_picks() { h.get_by_label("Fill & Sign: Checkmark").click(); h.run_steps(2); assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check), "the button keeps the last Fill & Sign tool"); + + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Initials); + h.run_steps(2); + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(2); + h.get_by_label("Fill & Sign: Add initials").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials), "a tool restored without a command still updates the button"); } From 2e699f489a093a4edb8e57cd7f6bd8c10cf455f6 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 2 Nov 2026 05:33:39 -0700 Subject: [PATCH 13/27] M5.7: a test that the Fill & Sign button keeps Date across documents Picks Date from the flyout, deselects it, and checks that the button still shows Date and that a click picks it, in a newly opened document and again after every document has been closed. --- crates/ui-egui/tests/it/fill_sign.rs | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index bd8bd30939..77e4420ba2 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1850,3 +1850,36 @@ fn the_quick_bar_fill_button_shows_the_tool_it_picks() { h.run_steps(2); assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials), "a tool restored without a command still updates the button"); } + +#[test] +fn the_quick_bar_fill_button_keeps_date_after_deselecting_and_across_documents() { + let mut h = harness(); + h.get_by_label("Fill & Sign: Add signature").click_secondary(); + h.run_steps(2); + h.get_by_label("Date").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date)); + h.get_by_label("Select (V)").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select); + + h.state_mut().open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap(); + h.state_mut().active = Some(1); + h.run_steps(2); + h.get_by_label("Fill & Sign: Date").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date), "a click picks the shown tool, not the default"); + assert_eq!(h.state().dialog, None, "the default tool's dialog stays closed"); + h.get_by_label("Select (V)").click(); + h.run_steps(2); + + h.state_mut().close_tab(1); + h.state_mut().close_tab(0); + h.run_steps(2); + h.state_mut().open_bytes("third.pdf", None, FIXTURE.to_vec()).unwrap(); + h.state_mut().active = Some(0); + h.run_steps(2); + h.get_by_label("Fill & Sign: Date").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date), "the tool outlives closing every document"); +} From 6028bdab7c13b0d11c5495f1eb91b1a10f898f41 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 2 Nov 2026 06:47:43 -0700 Subject: [PATCH 14/27] M5.7: the Fill & Sign button remembers tools where they are selected The quick bar copied the active tool into fill_tool on every draw, so a tool restored on leaving full screen replaced the last selection, and a tool selected while the bar was hidden was forgotten. The signature dialog and the control channel now record the tool when they select it, and the bar only reads: the active tool if there is one, otherwise the remembered one. --- crates/ui-egui/src/app/control.rs | 9 ++++-- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/hotbar.rs | 5 +-- crates/ui-egui/tests/it/fill_sign.rs | 46 ++++++++++++++++++++++++++-- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 0309042bf1..443b970148 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -333,9 +333,12 @@ impl PdfCraftApp { field if field.starts_with("field-") => QuickTool::Field( prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?, ), - fill if fill.starts_with("fill-") => QuickTool::Fill( - fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?, - ), + fill if fill.starts_with("fill-") => { + let tool = + fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?; + self.fill_tool = tool; + QuickTool::Fill(tool) + } other => { let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?; self.comment_prefs.group_tool[t.group()] = t; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 048d519222..8105a22342 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1349,6 +1349,7 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) app.signature = d.saved(); crate::fill_sign::FillTool::Signature }; + app.fill_tool = tool; app.quick_tool = crate::QuickTool::Fill(tool); app.toast = None; } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 88c45bbd1e..2b1ec38a96 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -239,10 +239,7 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_ QuickTool::Fill(f) => Some(f), _ => None, }; - if let Some(f) = current_fill { - app.fill_tool = f; - } - let shown = app.fill_tool; + let shown = current_fill.unwrap_or(app.fill_tool); let tip = match shown { crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials if current_fill.is_none() => { let initials = shown == crate::fill_sign::FillTool::Initials; diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 77e4420ba2..241726cc9e 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1841,14 +1841,54 @@ fn the_quick_bar_fill_button_shows_the_tool_it_picks() { h.get_by_label("Fill & Sign: Checkmark").click(); h.run_steps(2); assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check), "the button keeps the last Fill & Sign tool"); +} - h.state_mut().quick_tool = QuickTool::Fill(FillTool::Initials); +#[test] +fn a_restored_fill_tool_shows_without_replacing_the_last_selection() { + let mut h = harness(); + h.state_mut().execute("sign.fill.date"); h.run_steps(2); + h.state_mut().apply_full_screen(true); + h.run_steps(2); + h.state_mut().execute("sign.fill.check"); + h.run_steps(2); + h.state_mut().apply_full_screen(false); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date)); + h.get_by_label("Fill & Sign: Date"); + h.state_mut().quick_tool = QuickTool::Select; h.run_steps(2); - h.get_by_label("Fill & Sign: Add initials").click(); + h.get_by_label("Fill & Sign: Checkmark").click(); h.run_steps(2); - assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials), "a tool restored without a command still updates the button"); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check)); +} + +#[test] +fn a_fill_tool_selected_while_the_quick_bar_is_hidden_is_remembered() { + let mut h = harness(); + let mode = h.state().mode; + h.state_mut().set_option("mode", "read").unwrap(); + h.run_steps(2); + h.state_mut().set_option("quick", "fill-date").unwrap(); + h.run_steps(2); + h.state_mut().set_option("quick", "select").unwrap(); + h.state_mut().mode = mode; + h.run_steps(2); + h.get_by_label("Fill & Sign: Date"); + + h.state_mut().initials = Some(SavedSig::Typed("AL".into())); + h.state_mut().set_option("mode", "read").unwrap(); + h.run_steps(2); + h.state_mut().execute("sign.fill.initials.change"); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials)); + h.state_mut().quick_tool = QuickTool::Select; + h.state_mut().mode = mode; + h.run_steps(2); + h.get_by_label("Fill & Sign: Place saved initials"); } #[test] From ea6d0c6bfbba5ca8985c6880791c8c46cbabd8ba Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 20:46:33 -0500 Subject: [PATCH 15/27] M12: pdfcraft-prc-render, a headless renderer for PRC models New L3 crate over pdfcraft-prc: assemble places every product-tree tessellation into world space with its colours (pure `place` does the transform), Camera fits a view to the model, and render fills depth-buffered triangles with headlight or unlit shading into an RGBA image. Opaque triangles draw first, translucent ones blend over them; perspective views clip at a near plane. Never panics: MAX_RENDER_PIXELS and MAX_ASSEMBLED_TRIANGLES cap the work, every pixel and vertex index is checked, and damage returns RenderError. Single-threaded. Tests: 24 unit tests (two drive the product-tree placement path) plus an end-to-end one that builds a synthetic PRC stream in-test, renders it, and checks the background, the nearer triangle and the farther one by pixel; every truncation of that stream parses and assembles without panicking. Reverting the depth test or the placement transform fails the matching tests. Adapted from pdfcer (MIT, same author) at 912437c3, trimmed to the camera, raster and headlight parts and rewritten to the pdfcraft-prc types. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- Cargo.lock | 9 + Cargo.toml | 2 + crates/prc-render/Cargo.toml | 18 ++ crates/prc-render/README.md | 28 ++ crates/prc-render/src/assemble.rs | 126 ++++++++ crates/prc-render/src/assemble/tests.rs | 88 ++++++ crates/prc-render/src/lib.rs | 17 ++ crates/prc-render/src/raster.rs | 226 +++++++++++++++ crates/prc-render/src/render.rs | 365 ++++++++++++++++++++++++ crates/prc-render/src/render/tests.rs | 269 +++++++++++++++++ crates/prc-render/src/vec3.rs | 53 ++++ crates/prc-render/tests/model.rs | 145 ++++++++++ xtask/src/layers.rs | 1 + 13 files changed, 1347 insertions(+) create mode 100644 crates/prc-render/Cargo.toml create mode 100644 crates/prc-render/README.md create mode 100644 crates/prc-render/src/assemble.rs create mode 100644 crates/prc-render/src/assemble/tests.rs create mode 100644 crates/prc-render/src/lib.rs create mode 100644 crates/prc-render/src/raster.rs create mode 100644 crates/prc-render/src/render.rs create mode 100644 crates/prc-render/src/render/tests.rs create mode 100644 crates/prc-render/src/vec3.rs create mode 100644 crates/prc-render/tests/model.rs diff --git a/Cargo.lock b/Cargo.lock index 6e92f0fcf0..bc76adfde5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4560,6 +4560,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-prc-render" +version = "0.18.0" +dependencies = [ + "flate2", + "pdfcraft-prc", + "thiserror 2.0.21", +] + [[package]] name = "pdfcraft-preflight" version = "0.18.0" diff --git a/Cargo.toml b/Cargo.toml index 4e1dbffaea..1515269906 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,8 @@ pdfcraft-create = { path = "crates/create", version = "0.18.0" } pdfcraft-forms = { path = "crates/forms", version = "0.18.0" } pdfcraft-xfa = { path = "crates/xfa", version = "0.18.0" } pdfcraft-render = { path = "crates/render", version = "0.18.0" } +pdfcraft-prc = { path = "crates/prc", version = "0.18.0" } +pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.0" } pdfcraft-engine = { path = "crates/engine", version = "0.18.0" } pdfcraft-platform = { path = "crates/platform", version = "0.18.0" } pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.0" } diff --git a/crates/prc-render/Cargo.toml b/crates/prc-render/Cargo.toml new file mode 100644 index 0000000000..11ccd3346f --- /dev/null +++ b/crates/prc-render/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pdfcraft-prc-render" +description = "Headless CPU renderer for PRC 3D models: camera, depth-buffered triangle fill, headlight shading" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +pdfcraft-prc.workspace = true +thiserror.workspace = true + +[dev-dependencies] +flate2.workspace = true diff --git a/crates/prc-render/README.md b/crates/prc-render/README.md new file mode 100644 index 0000000000..43730040f2 --- /dev/null +++ b/crates/prc-render/README.md @@ -0,0 +1,28 @@ +# pdfcraft-prc-render + +A headless CPU renderer for PRC 3D models read by `pdfcraft-prc`: placed meshes in world space, a fitted camera, a +depth-buffered triangle fill and headlight shading, into a straight-RGBA image. + +- **Layer:** L3. Depends on `pdfcraft-prc` only. +- **Licence:** MIT OR Apache-2.0. +- `#![forbid(unsafe_code)]`. Never panics on input: image size, triangle count and every index are checked, and + damage returns `RenderError`. + +## API + +```rust +assemble(&PrcFile) -> Result, RenderError> // every placement's mesh moved into the world and coloured +place(&TriangleMesh, &Matrix, colour, triangle_colours) -> WorldMesh +Camera::fit(&Bounds, direction, up, perspective, aspect) / Camera::fit_meshes(&[WorldMesh], ...) +render(&[WorldMesh], &Camera, &RenderOptions) -> Result +``` + +- With no product tree, each tessellated mesh is drawn where it lies, in the default colour. +- A triangle's own colour wins over its mesh's, which wins over `RenderOptions::colour`. +- Opaque triangles are drawn first, then translucent ones blended over them; alpha 0 is not drawn. +- `Lighting::Headlight` (default) shades by the angle to the view direction; `Lighting::Unlit` draws flat colour. +- Perspective triangles are clipped at a near plane; orthographic views are not clipped. +- `MAX_RENDER_PIXELS` (2^24, about 16 MP) bounds the image: at most 64 MB of RGBA plus 128 MB of depth. + `MAX_ASSEMBLED_TRIANGLES` (2^24) bounds one `assemble` call. Neither bounds time: a model of many large + triangles at full size is a long CPU render, so callers on a UI thread should render small or off-thread. +- Not yet: shadows, ambient occlusion, specular light, scene lights, textures, wires and markups. diff --git a/crates/prc-render/src/assemble.rs b/crates/prc-render/src/assemble.rs new file mode 100644 index 0000000000..3f8fa54caf --- /dev/null +++ b/crates/prc-render/src/assemble.rs @@ -0,0 +1,126 @@ +//! A parsed PRC file's tessellations placed in the world, ready for [`crate::render`]. + +use crate::RenderError; +use crate::raster::channel; +use pdfcraft_prc::{Matrix, PrcFile, Rgba, Tessellation, TriangleMesh}; + +/// The most triangles [`assemble`] places, over every placement: a tessellation many +/// occurrences draw is copied once per occurrence. +pub const MAX_ASSEMBLED_TRIANGLES: usize = 1 << 24; + +/// A triangle mesh in world coordinates with the colours it draws in. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct WorldMesh { + /// The triangles, already placed. + pub mesh: TriangleMesh, + /// The mesh's straight-RGBA colour; `None` takes [`crate::RenderOptions::colour`], opaque. + pub colour: Option<[u8; 4]>, + /// Per triangle, its own colour when its face is styled; `None` (the whole list or an entry) + /// takes [`Self::colour`]. + pub triangle_colours: Option>>, +} + +impl WorldMesh { + /// `mesh`, uncoloured. + #[must_use] + pub fn new(mesh: TriangleMesh) -> Self { + WorldMesh { mesh, colour: None, triangle_colours: None } + } + + /// The colour triangle `t` draws in, before the render options' default. + pub(crate) fn colour_of(&self, t: usize) -> Option<[u8; 4]> { + self.triangle_colours.as_ref().and_then(|c| c.get(t).copied().flatten()).or(self.colour) + } +} + +/// `mesh` moved into the world by `matrix`, drawn in `colour` and, per triangle, `triangle_colours`. +#[must_use] +pub fn place(mesh: &TriangleMesh, matrix: &Matrix, colour: Option<[u8; 4]>, triangle_colours: Option>>) -> WorldMesh { + WorldMesh { mesh: mesh.transformed(matrix), colour, triangle_colours } +} + +/// Every triangle mesh `prc` draws, in world coordinates and coloured as its product tree says +/// ([`PrcFile::placements`]). A file with no tree draws each file structure's meshes where they +/// are, uncoloured. Wires, markups and compressed tessellations are not drawn. +/// +/// # Errors +/// [`RenderError::Model`] when a tessellation section or the tree cannot be read; +/// [`RenderError::TooManyTriangles`] past [`MAX_ASSEMBLED_TRIANGLES`]. +pub fn assemble(prc: &PrcFile) -> Result, RenderError> { + let tessellations = prc.file_structures.iter().map(|fs| fs.tessellations()).collect::, _>>()?; + let mut budget = Budget(0); + let mut out = Vec::new(); + if !prc.stores_tree() { + for mesh in tessellations.iter().flatten().filter_map(as_mesh) { + budget.spend(mesh)?; + out.push(WorldMesh::new(mesh.clone())); + } + return Ok(out); + } + let items = prc.placements()?.into_iter().map(|p| Item { + file_structure: p.file_structure, + tessellation: p.tessellation, + matrix: p.matrix, + colour: p.colour, + triangle_colours: Box::new(move |mesh: &TriangleMesh| p.triangle_colours(mesh)), + }); + place_items(&tessellations, items, &mut budget, &mut out)?; + Ok(out) +} + +/// One placement as [`place_items`] reads it: a tessellation by file structure and index, where +/// it goes and how it is coloured. +pub(crate) struct Item<'a> { + pub(crate) file_structure: usize, + pub(crate) tessellation: usize, + pub(crate) matrix: Matrix, + pub(crate) colour: Option, + pub(crate) triangle_colours: Box>, +} + +/// Per triangle of a mesh, the colour its face's style resolves to. +type FaceColours<'a> = dyn Fn(&TriangleMesh) -> Option>> + 'a; + +/// Each item's mesh moved into the world and coloured, appended to `out`. Items naming no +/// triangle mesh are skipped. +pub(crate) fn place_items<'a>( + tessellations: &[Vec], + items: impl IntoIterator>, + budget: &mut Budget, + out: &mut Vec, +) -> Result<(), RenderError> { + for p in items { + let Some(mesh) = tessellations.get(p.file_structure).and_then(|t| t.get(p.tessellation)).and_then(as_mesh) else { + continue; + }; + budget.spend(mesh)?; + let triangle_colours = (p.triangle_colours)(mesh).map(|c| c.into_iter().map(|c| c.map(bytes)).collect()); + out.push(place(mesh, &p.matrix, p.colour.map(bytes), triangle_colours)); + } + Ok(()) +} + +/// Triangles placed so far. +pub(crate) struct Budget(pub(crate) usize); + +impl Budget { + fn spend(&mut self, mesh: &TriangleMesh) -> Result<(), RenderError> { + self.0 = self.0.checked_add(mesh.triangles.len()).filter(|&n| n <= MAX_ASSEMBLED_TRIANGLES).ok_or(RenderError::TooManyTriangles)?; + Ok(()) + } +} + +fn as_mesh(t: &Tessellation) -> Option<&TriangleMesh> { + match t { + Tessellation::Mesh(m) => Some(m), + _ => None, + } +} + +/// A 0–1 colour as straight-RGBA bytes, each channel clamped. +fn bytes(c: Rgba) -> [u8; 4] { + [c.r, c.g, c.b, c.a].map(|v| channel(v.clamp(0.0, 1.0) * 255.0)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/prc-render/src/assemble/tests.rs b/crates/prc-render/src/assemble/tests.rs new file mode 100644 index 0000000000..87c133f4da --- /dev/null +++ b/crates/prc-render/src/assemble/tests.rs @@ -0,0 +1,88 @@ +#![allow(clippy::unwrap_used)] + +use super::*; + +fn triangle() -> TriangleMesh { + let mut m = TriangleMesh::default(); + m.positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + m.triangles = vec![[0, 1, 2]]; + m +} + +#[test] +fn a_placement_moves_its_mesh_into_the_world() { + let mut shift = Matrix::IDENTITY; + shift.0[0][3] = 5.0; + shift.0[2][3] = -2.0; + let placed = place(&triangle(), &shift, Some([1, 2, 3, 255]), None); + assert_eq!(placed.mesh.positions, [[5.0, 0.0, -2.0], [6.0, 0.0, -2.0], [5.0, 1.0, -2.0]]); + assert_eq!(placed.colour, Some([1, 2, 3, 255])); + // A mirror keeps the triangle facing the same way round. + let mut mirror = Matrix::IDENTITY; + mirror.0[0][0] = -1.0; + assert_eq!(place(&triangle(), &mirror, None, None).mesh.triangles, [[0, 2, 1]]); +} + +#[test] +fn a_triangle_colour_wins_over_the_mesh_colour() { + let mut m = place(&triangle(), &Matrix::IDENTITY, Some([9, 9, 9, 255]), Some(vec![Some([1, 1, 1, 255]), None])); + m.mesh.triangles.push([0, 2, 1]); + m.mesh.triangles.push([1, 2, 0]); + assert_eq!(m.colour_of(0), Some([1, 1, 1, 255])); + assert_eq!(m.colour_of(1), Some([9, 9, 9, 255]), "an unstyled face takes the mesh's colour"); + assert_eq!(m.colour_of(2), Some([9, 9, 9, 255]), "past the list too"); + assert_eq!(WorldMesh::new(triangle()).colour_of(0), None); +} + +#[test] +fn colours_are_clamped_into_bytes() { + let [r, g, b, a] = [0.0, 1.0, 0.5, 2.0]; + let mut c = Rgba::default(); + (c.r, c.g, c.b, c.a) = (r, g, b, a); + assert_eq!(bytes(c), [0, 255, 128, 255]); + c.r = f64::NAN; + c.g = -3.0; + assert_eq!(bytes(c)[..2], [0, 0]); +} + +#[test] +fn the_triangle_budget_is_shared_and_refuses_past_the_ceiling() { + let mut b = Budget(MAX_ASSEMBLED_TRIANGLES - 1); + assert!(b.spend(&triangle()).is_ok()); + assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles)); + let mut b = Budget(usize::MAX); + assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles)); +} + +fn item<'a>(fs: usize, tess: usize, x: f64, colour: Option) -> Item<'a> { + let mut matrix = Matrix::IDENTITY; + matrix.0[0][3] = x; + Item { file_structure: fs, tessellation: tess, matrix, colour, triangle_colours: Box::new(|_| None) } +} + +#[test] +fn each_placement_draws_its_tessellation_where_and_in_the_colour_the_tree_says() { + let tessellations = vec![vec![Tessellation::Mesh(triangle())]]; + let mut red = Rgba::default(); + red.r = 1.0; + red.a = 1.0; + // Two occurrences of one tessellation at different places, one coloured; one naming nothing. + let items = [item(0, 0, 3.0, Some(red)), item(0, 0, -4.0, None), item(1, 0, 0.0, None), item(0, 7, 0.0, None)]; + let (mut budget, mut out) = (Budget(0), Vec::new()); + place_items(&tessellations, items, &mut budget, &mut out).unwrap(); + assert_eq!(out.len(), 2, "items naming no mesh are skipped"); + assert_eq!(out[0].mesh.positions, [[3.0, 0.0, 0.0], [4.0, 0.0, 0.0], [3.0, 1.0, 0.0]]); + assert_eq!(out[0].colour, Some([255, 0, 0, 255])); + assert_eq!(out[1].mesh.positions, [[-4.0, 0.0, 0.0], [-3.0, 0.0, 0.0], [-4.0, 1.0, 0.0]]); + assert_eq!(out[1].colour, None); + assert_eq!(budget.0, 2, "each occurrence spends its triangles"); +} + +#[test] +fn a_placement_past_the_triangle_budget_is_an_error() { + let tessellations = vec![vec![Tessellation::Mesh(triangle())]]; + let (mut budget, mut out) = (Budget(MAX_ASSEMBLED_TRIANGLES), Vec::new()); + let r = place_items(&tessellations, [item(0, 0, 0.0, None)], &mut budget, &mut out); + assert!(matches!(r, Err(RenderError::TooManyTriangles))); + assert!(out.is_empty()); +} diff --git a/crates/prc-render/src/lib.rs b/crates/prc-render/src/lib.rs new file mode 100644 index 0000000000..da0ed41230 --- /dev/null +++ b/crates/prc-render/src/lib.rs @@ -0,0 +1,17 @@ +//! Headless CPU rendering of PRC 3D models (ISO 14739-1) to an RGBA image. +//! +//! Layer L3: depends only on `pdfcraft-prc`. [`assemble`] turns a parsed [`pdfcraft_prc::PrcFile`] +//! into world-space meshes; [`render`] draws them from a [`Camera`] with one depth-tested sample per +//! pixel centre and a light at the camera. Single-threaded and GUI-free, so it runs unchanged on wasm32. +//! The image size is capped by [`MAX_RENDER_PIXELS`] and the assembled model by +//! [`MAX_ASSEMBLED_TRIANGLES`]; bad input 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 assemble; +mod raster; +mod render; +mod vec3; + +pub use assemble::{MAX_ASSEMBLED_TRIANGLES, WorldMesh, assemble, place}; +pub use render::{Bounds, Camera, Image, Lighting, MAX_RENDER_PIXELS, Projection, RenderError, RenderOptions, render}; diff --git a/crates/prc-render/src/raster.rs b/crates/prc-render/src/raster.rs new file mode 100644 index 0000000000..dddb3b394d --- /dev/null +++ b/crates/prc-render/src/raster.rs @@ -0,0 +1,226 @@ +//! Projection, near-plane clipping and the depth-tested triangle fill behind [`crate::render`]. + +use crate::render::{Camera, Projection, RenderError}; +use crate::vec3::{length, sub}; + +/// One view-space corner: x, y, z (z along the view direction), then its red, green and blue shade. +pub(crate) type ViewCorner = [f64; 6]; + +/// One screen-space corner: pixel x, pixel y and depth key, and its shade. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Corner { + pub(crate) at: [f64; 3], + pub(crate) shade: [f64; 3], +} + +pub(crate) enum View { + /// `focal` = 1 / tan(fov_y / 2); `near` = the clip distance. + Perspective { + focal: f64, + aspect: f64, + near: f64, + }, + Orthographic { + half_height: f64, + aspect: f64, + }, +} + +impl View { + /// The projection for `camera` at width/height `aspect`; refuses a field of view outside + /// (0, 180) degrees or a non-positive orthographic height. + pub(crate) fn new(camera: &Camera, aspect: f64) -> Result { + match camera.projection { + Projection::Perspective { fov_y } => { + if !(fov_y > 0.0 && fov_y < 180.0) { + return Err(RenderError::Camera("the field of view is not between 0 and 180 degrees")); + } + let distance = length(sub(camera.target, camera.eye)); + Ok(View::Perspective { focal: 1.0 / (fov_y.to_radians() / 2.0).tan(), aspect, near: (distance * 1e-4).max(f64::MIN_POSITIVE) }) + } + Projection::Orthographic { height } => { + if !(height.is_finite() && height > 0.0) { + return Err(RenderError::Camera("the orthographic height is not positive")); + } + Ok(View::Orthographic { half_height: height / 2.0, aspect }) + } + } + } + + /// The part of `tri` in front of the near plane (Sutherland–Hodgman against one plane): + /// 0, 3 or 4 corners, the shade interpolated along each cut edge. + pub(crate) fn clip(&self, tri: [ViewCorner; 3]) -> Vec { + let View::Perspective { near, .. } = *self else { + return tri.to_vec(); + }; + let mut out = Vec::with_capacity(4); + for (k, &p) in tri.iter().enumerate() { + let q = tri.get((k + 1) % 3).copied().unwrap_or(p); + let z = |v: ViewCorner| v.get(2).copied().unwrap_or(0.0); + let (p_in, q_in) = (z(p) >= near, z(q) >= near); + if p_in { + out.push(p); + } + if p_in != q_in { + let t = (near - z(p)) / (z(q) - z(p)); + let mut cut = p; + for (c, d) in cut.iter_mut().zip(q) { + *c += t * (d - *c); + } + out.push(cut); + } + } + out + } + + /// The screen corner of view corner `v`: pixel x, pixel y and a depth key that is linear + /// in screen space and larger for nearer points. + pub(crate) fn project(&self, [x, y, z, r, g, b]: ViewCorner, width: u32, height: u32) -> Corner { + let (nx, ny, key) = match *self { + View::Perspective { focal, aspect, .. } => (x * focal / (z * aspect), y * focal / z, 1.0 / z), + View::Orthographic { half_height, aspect } => (x / (half_height * aspect), y / half_height, -z), + }; + Corner { at: [(nx + 1.0) / 2.0 * f64::from(width), (1.0 - ny) / 2.0 * f64::from(height), key], shade: [r, g, b] } + } +} + +/// The image being drawn: `rgba` holds `width * height` straight-RGBA pixels and `depth` their +/// depth keys, row by row from the top. +pub(crate) struct Target<'b> { + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) rgba: &'b mut [u8], + pub(crate) depth: &'b mut [f64], +} + +impl Target<'_> { + /// Fill the screen triangle of `corners` with straight-RGBA `colour`, depth-tested at pixel + /// centres, the shade interpolated across it. An opaque pixel replaces the pixel and its + /// depth; a translucent one is blended over it, leaving the depth. + pub(crate) fn fill(&mut self, corners: [Corner; 3], colour: [u8; 4]) { + let [a, b, c] = corners; + let area = edge(a.at, b.at, c.at); + if !area.is_finite() || area.abs() < 1e-12 { + return; + } + let Some((x0, x1, y0, y1)) = self.bounds(&corners) else { + return; + }; + for y in y0..=y1 { + for x in x0..=x1 { + let p = [f64::from(x) + 0.5, f64::from(y) + 0.5, 0.0]; + let w = [edge(b.at, c.at, p), edge(c.at, a.at, p), edge(a.at, b.at, p)].map(|e| e / area); + if w.iter().any(|&k| k < 0.0) { + continue; + } + let mix = |f: &dyn Fn(&Corner) -> f64| w.iter().zip(&corners).map(|(k, c)| k * f(c)).sum::(); + let key = mix(&|c| at(c.at, 2)); + let Some(i) = pixel_index(self.width, x, y) else { + continue; + }; + if self.depth.get(i).is_none_or(|&d| key <= d) { + continue; + } + let shade = if a.shade == b.shade && b.shade == c.shade { a.shade } else { [0, 1, 2].map(|j| mix(&|c| at(c.shade, j))) }; + self.blend(i, key, shaded(colour, shade)); + } + } + } + + /// The pixel rows and columns `corners` may cover, clipped to the image; `None` when off it. + fn bounds(&self, corners: &[Corner; 3]) -> Option<(u32, u32, u32, u32)> { + let lo = |i: usize| corners.iter().map(|c| at(c.at, i)).fold(f64::INFINITY, f64::min).floor().max(0.0); + let hi = |i: usize, limit: u32| corners.iter().map(|c| at(c.at, i)).fold(f64::NEG_INFINITY, f64::max).ceil().min(f64::from(limit) - 1.0); + let (x0, x1) = (lo(0), hi(0, self.width)); + let (y0, y1) = (lo(1), hi(1, self.height)); + // Each bound is finite and within 0..limit here, so the casts are exact. + (x0 <= x1 && y0 <= y1).then_some((x0 as u32, x1 as u32, y0 as u32, y1 as u32)) + } + + /// Write straight-RGBA `rgba` at pixel `i`: replacing it and its depth when opaque, blended + /// over it otherwise. + fn blend(&mut self, i: usize, key: f64, rgba: [u8; 4]) { + let opaque = rgba[3] == 255; + if opaque && let Some(d) = self.depth.get_mut(i) { + *d = key; + } + let Some(px) = i.checked_mul(4).and_then(|s| self.rgba.get_mut(s..s.checked_add(4)?)) else { + return; + }; + if opaque { + px.copy_from_slice(&rgba); + } else if let [pr, pg, pb, pa] = px { + let [sr, sg, sb, sa] = rgba; + let src = f64::from(sa) / 255.0; + let dst = f64::from(*pa) / 255.0 * (1.0 - src); + let out = src + dst; + if out <= 0.0 { + return; + } + for (p, s) in [pr, pg, pb].into_iter().zip([sr, sg, sb]) { + *p = channel((f64::from(s) * src + f64::from(*p) * dst) / out); + } + *pa = channel(out * 255.0); + } + } +} + +/// Pixel `x, y`'s index in an image `width` wide; `None` on overflow. +pub(crate) fn pixel_index(width: u32, x: u32, y: u32) -> Option { + usize::try_from(y).ok()?.checked_mul(usize::try_from(width).ok()?)?.checked_add(usize::try_from(x).ok()?) +} + +/// `v` rounded and clamped to a colour channel; NaN is 0. +pub(crate) fn channel(v: f64) -> u8 { + // Clamped to 0..=255 first, so the cast is exact (NaN casts to 0). + v.round().clamp(0.0, 255.0) as u8 +} + +/// `colour`'s RGB scaled channel by channel by `shade`, saturating at 255; alpha kept. +fn shaded([r, g, b, a]: [u8; 4], [sr, sg, sb]: [f64; 3]) -> [u8; 4] { + let [r, g, b] = [(r, sr), (g, sg), (b, sb)].map(|(v, s)| channel(f64::from(v) * s)); + [r, g, b, a] +} + +/// Twice the signed area of (a, b, p) in the xy plane. +pub(crate) fn edge(a: [f64; 3], b: [f64; 3], p: [f64; 3]) -> f64 { + // Two triangles sharing an edge meet it in opposite orders; evaluating it in one canonical + // order makes their values exact negatives, so a pixel centre on the edge cannot round out + // of both and leave a crack. + if (at(a, 0), at(a, 1)) > (at(b, 0), at(b, 1)) { + return -edge(b, a, p); + } + (at(b, 0) - at(a, 0)) * (at(p, 1) - at(a, 1)) - (at(b, 1) - at(a, 1)) * (at(p, 0) - at(a, 0)) +} + +/// Component `i` of `v`, 0.0 out of range. +pub(crate) fn at(v: [f64; 3], i: usize) -> f64 { + v.get(i).copied().unwrap_or(0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_edge_seen_from_either_triangle_is_exactly_negated() { + // So no pixel centre on a shared edge is rejected by both. + let vals = [0.1, 0.7, 1.3, 2.9, 3.3, 5.5]; + for (a, b, p) in [([0.1, 0.1], [0.7, 0.7], [0.1, 2.9]), ([5.5, 0.7], [1.3, 3.3], [2.9, 0.1])] + .into_iter() + .chain(vals.iter().map(|&v| ([v, 0.1], [0.7, v], [v, v]))) + { + let [a, b, p] = [a, b, p].map(|[x, y]| [x, y, 0.0]); + assert_eq!(edge(a, b, p), -edge(b, a, p), "{a:?} {b:?} {p:?}"); + } + } + + #[test] + fn pixel_indices_and_channels_never_overflow() { + assert_eq!(pixel_index(10, 3, 2), Some(23)); + assert_eq!(channel(f64::NAN), 0); + assert_eq!(channel(1e300), 255); + assert_eq!(channel(-4.0), 0); + assert_eq!(channel(127.5), 128); + } +} diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs new file mode 100644 index 0000000000..146266434b --- /dev/null +++ b/crates/prc-render/src/render.rs @@ -0,0 +1,365 @@ +//! The camera, the render options and [`render`]: world meshes to an RGBA image. +//! +//! One sample per pixel centre, no antialiasing. Faces are lit on both sides because a +//! producer's winding is not trusted to face outward. + +use crate::WorldMesh; +use crate::raster::{Corner, Target, View, ViewCorner, at}; +use crate::vec3::{cross, dot, length, normalize, scale, sub}; +use pdfcraft_prc::TriangleMesh; + +/// The largest image [`render`] draws, in pixels (width × height). +pub const MAX_RENDER_PIXELS: u64 = 16 * 1024 * 1024; + +/// [`Camera::fit`]'s framed extent over the model's: 5% clear on each side. +const FIT_MARGIN: f64 = 1.1; + +/// How the camera projects the scene onto the image. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Projection { + /// A pinhole camera with this vertical field of view, in degrees (exclusive range 0–180). + Perspective { + /// Vertical field of view, degrees. + fov_y: f64, + }, + /// A parallel projection showing this much of the scene vertically, in model units. + Orthographic { + /// Visible height, model units. + height: f64, + }, +} + +/// Where the camera is and where it looks, in world coordinates. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Camera { + /// The camera position. + pub eye: [f64; 3], + /// The point at the image centre. + pub target: [f64; 3], + /// The direction that appears upward; must not be parallel to the view direction. + pub up: [f64; 3], + /// The projection. + pub projection: Projection, +} + +/// An axis-aligned box around every point a set of meshes draws. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Bounds { + /// The smallest x, y and z. + pub min: [f64; 3], + /// The largest x, y and z. + pub max: [f64; 3], +} + +impl Bounds { + /// The box around every finite vertex the meshes' triangles use, or `None` when there is none. + #[must_use] + pub fn of(meshes: &[WorldMesh]) -> Option { + let mut out: Option = None; + for p in vertices(meshes) { + let b = out.get_or_insert(Bounds { min: p, max: p }); + for ((lo, hi), c) in b.min.iter_mut().zip(b.max.iter_mut()).zip(p) { + *lo = lo.min(c); + *hi = hi.max(c); + } + } + out + } + + /// The box's centre. + #[must_use] + pub fn centre(&self) -> [f64; 3] { + [0, 1, 2].map(|i| (at(self.min, i) + at(self.max, i)) / 2.0) + } + + /// Half the box's diagonal: the radius of a sphere holding it. + #[must_use] + pub fn radius(&self) -> f64 { + length(sub(self.max, self.min)) / 2.0 + } +} + +/// Every finite vertex the meshes' triangles use. +fn vertices(meshes: &[WorldMesh]) -> impl Iterator + Clone + '_ { + meshes.iter().flat_map(|m| { + let mesh = &m.mesh; + mesh.triangles + .iter() + .flatten() + .filter_map(|&i| mesh.positions.get(usize::try_from(i).ok()?).copied()) + .filter(|p| p.iter().all(|c| c.is_finite())) + }) +} + +impl Camera { + /// A camera looking along `direction` that fits `bounds` in an image of the given aspect + /// ratio (width / height), with a 30° perspective or an orthographic projection: the box's + /// eight corners framed from this direction, about 5% clear on each side of the tighter axis. + /// + /// # Errors + /// [`RenderError::Camera`] when `direction` is zero or parallel to `up`, or `aspect` is not positive. + pub fn fit(bounds: &Bounds, direction: [f64; 3], up: [f64; 3], perspective: bool, aspect: f64) -> Result { + let corners = (0..8).map(|k| [0, 1, 2].map(|i| if k >> i & 1 == 0 { at(bounds.min, i) } else { at(bounds.max, i) })); + Camera::frame(bounds, corners, direction, up, (perspective, aspect)) + } + + /// As [`Camera::fit`], framing every vertex the meshes' triangles use rather than their box, + /// so an oblique view is not padded by empty box corners. + /// + /// # Errors + /// As [`Camera::fit`], and when the meshes have no finite vertex. + pub fn fit_meshes(meshes: &[WorldMesh], direction: [f64; 3], up: [f64; 3], perspective: bool, aspect: f64) -> Result { + let bounds = Bounds::of(meshes).ok_or(RenderError::Camera("the model has no vertex"))?; + Camera::frame(&bounds, vertices(meshes), direction, up, (perspective, aspect)) + } + + /// Frames `points` (which `bounds` holds) from `direction`, aimed at the middle of their projection. + fn frame( + bounds: &Bounds, + points: impl Iterator + Clone, + direction: [f64; 3], + up: [f64; 3], + (perspective, aspect): (bool, f64), + ) -> Result { + if !(aspect.is_finite() && aspect > 0.0) { + return Err(RenderError::Camera("the aspect ratio is not positive")); + } + let dir = normalize(direction).ok_or(RenderError::Camera("the view direction is zero"))?; + let centre = bounds.centre(); + let radius = match bounds.radius() { + r if r.is_finite() && r > 0.0 => r, + _ => 1.0, + }; + let [right, upward, forward] = + Camera { eye: sub(centre, dir), target: centre, up, projection: Projection::Orthographic { height: 1.0 } }.basis()?; + let view = |p: [f64; 3]| { + let r = sub(p, centre); + [dot(r, right), dot(r, upward), dot(r, forward)] + }; + let ((x0, x1), (y0, y1)) = points + .clone() + .map(view) + .fold(((f64::INFINITY, f64::NEG_INFINITY), (f64::INFINITY, f64::NEG_INFINITY)), |((a0, a1), (b0, b1)), [x, y, _]| { + ((a0.min(x), a1.max(x)), (b0.min(y), b1.max(y))) + }); + if !(x0 <= x1 && y0 <= y1) { + return Err(RenderError::Camera("the model has no vertex")); + } + let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let target = [0, 1, 2].map(|i| at(centre, i) + mx * at(right, i) + my * at(upward, i)); + let floor = radius * 1e-3; + let (projection, distance) = if perspective { + let fov_y: f64 = 30.0; + let ty = (fov_y.to_radians() / 2.0).tan() / FIT_MARGIN; + let tx = ty * aspect; + // A point at lateral (x, y) from the target and depth f past it is in view when + // |x| <= (d + f)·tx and |y| <= (d + f)·ty. + let d = points.map(view).fold(floor, |d, [x, y, f]| { + let need = ((x - mx).abs() / tx).max((y - my).abs() / ty); + d.max(need - f).max(floor - f) + }); + (Projection::Perspective { fov_y }, d) + } else { + let height = ((y1 - y0).max((x1 - x0) / aspect) * FIT_MARGIN).max(floor); + (Projection::Orthographic { height }, 2.0 * radius) + }; + let camera = Camera { eye: sub(target, scale(dir, distance)), target, up, projection }; + camera.basis()?; + Ok(camera) + } + + /// Right, up and forward unit vectors. + pub(crate) fn basis(&self) -> Result<[[f64; 3]; 3], RenderError> { + let forward = normalize(sub(self.target, self.eye)).ok_or(RenderError::Camera("the eye and the target coincide"))?; + let right = normalize(cross(forward, self.up)).ok_or(RenderError::Camera("the up direction is zero or parallel to the view direction"))?; + Ok([right, cross(right, forward), forward]) + } +} + +/// How surfaces are lit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum Lighting { + /// One light at the camera: a surface's colour times `0.3 + 0.7·|n·v|`, where `v` points from + /// the surface to the eye (orthographic: against the view direction). + #[default] + Headlight, + /// No shading: every surface in its own colour. + Unlit, +} + +/// What [`render`] draws. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RenderOptions { + /// Image width, pixels. + pub width: u32, + /// Image height, pixels. + pub height: u32, + /// Background colour, straight RGBA. + pub background: [u8; 4], + /// The colour of a surface with none of its own. + pub colour: [u8; 3], + /// How surfaces are lit. + pub lighting: Lighting, +} + +impl Default for RenderOptions { + /// 1024 × 768, opaque white background, light grey surface, the headlight. + fn default() -> Self { + RenderOptions { width: 1024, height: 768, background: [255, 255, 255, 255], colour: [190, 192, 200], lighting: Lighting::Headlight } + } +} + +/// A rendered image: `rgba` holds `width * height` straight-alpha pixels, row by row from the top. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Image { + /// Width, pixels. + pub width: u32, + /// Height, pixels. + pub height: u32, + /// The pixels. + pub rgba: Vec, +} + +/// Why [`render`], [`Camera::fit`] or [`crate::assemble`] refused. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum RenderError { + /// The image is empty or larger than [`MAX_RENDER_PIXELS`]. + #[error("a {width}x{height} image is empty or larger than the render ceiling")] + Size { + /// Requested width. + width: u32, + /// Requested height. + height: u32, + }, + /// The camera cannot form a view. + #[error("invalid camera: {0}")] + Camera(&'static str), + /// The model could not be read. + #[error("unreadable model: {0}")] + Model(#[from] pdfcraft_prc::PrcError), + /// The placed model has more than [`crate::MAX_ASSEMBLED_TRIANGLES`] triangles. + #[error("the placed model has more triangles than the assembly ceiling")] + TooManyTriangles, +} + +/// Draw `meshes` as seen by `camera`. +/// +/// Each triangle is filled in its own colour ([`WorldMesh::triangle_colours`]), else its mesh's +/// ([`WorldMesh::colour`]), else [`RenderOptions::colour`], and lit per [`RenderOptions::lighting`]; +/// nearer surfaces hide farther ones. Opaque triangles are drawn first; translucent ones are then +/// blended over them, hidden by nearer opaque surfaces but not by each other, in mesh order; fully +/// transparent ones are not drawn. Triangles with a non-finite or out-of-range vertex are skipped. +/// A perspective camera clips what lies behind it. +/// +/// # Errors +/// [`RenderError::Size`] for an empty or over-ceiling image; [`RenderError::Camera`] for a camera +/// that cannot form a view (eye on the target, `up` parallel to the view, a field of view outside +/// 0–180°, a non-positive orthographic height). +pub fn render(meshes: &[WorldMesh], camera: &Camera, options: &RenderOptions) -> Result { + let (width, height) = (options.width, options.height); + let size = RenderError::Size { width, height }; + let pixels = u64::from(width) * u64::from(height); + if pixels == 0 || pixels > MAX_RENDER_PIXELS { + return Err(size); + } + let pixels = usize::try_from(pixels).map_err(|_| size)?; + let view = View::new(camera, f64::from(width) / f64::from(height))?; + let scene = Scene { eye: camera.eye, basis: camera.basis()?, view, lighting: options.lighting }; + let mut rgba = options.background.repeat(pixels); + let mut depth = vec![f64::NEG_INFINITY; pixels]; + let mut target = Target { width, height, rgba: &mut rgba, depth: &mut depth }; + let [r, g, b] = options.colour; + for opaque in [true, false] { + for m in meshes { + for t in 0..m.mesh.triangles.len() { + let colour = m.colour_of(t).unwrap_or([r, g, b, 255]); + if (colour[3] == 255) == opaque && colour[3] != 0 { + scene.draw(&mut target, &m.mesh, t, colour); + } + } + } + } + Ok(Image { width, height, rgba }) +} + +/// The camera a frame is drawn from, with its basis and projection. +struct Scene { + eye: [f64; 3], + /// Right, up and forward. + basis: [[f64; 3]; 3], + view: View, + lighting: Lighting, +} + +impl Scene { + /// The shade at world point `p` with normal `n`. + fn shade(&self, p: [f64; 3], n: [f64; 3]) -> [f64; 3] { + if self.lighting == Lighting::Unlit { + return [1.0; 3]; + } + let towards = match self.view { + View::Perspective { .. } => normalize(sub(self.eye, p)), + View::Orthographic { .. } => Some(scale(at3(&self.basis, 2), -1.0)), + }; + [0.3 + 0.7 * towards.map_or(1.0, |v| dot(n, v).abs().min(1.0)); 3] + } + + /// Draw triangle `t` of `mesh` into `target` in straight-RGBA `colour`. + fn draw(&self, target: &mut Target, mesh: &TriangleMesh, t: usize, colour: [u8; 4]) { + let Some(world) = mesh.triangles.get(t).and_then(|tri| corners(mesh, tri)) else { + return; + }; + let Some(face) = normalize(cross(sub(at3(&world, 1), at3(&world, 0)), sub(at3(&world, 2), at3(&world, 0)))) else { + return; + }; + let stored = stored_normals(mesh, t); + let normal = |k: usize| stored.and_then(|n| n.get(k).copied().flatten()).unwrap_or(face); + // Without stored normals the triangle is flat: one shade, lit along the view ray to its first corner. + let flat = stored.is_none().then(|| self.shade(at3(&world, 0), normal(0))); + let [right, up, forward] = self.basis; + let view_corners = [0, 1, 2].map(|k| -> ViewCorner { + let p = at3(&world, k); + let [r, g, b] = flat.unwrap_or_else(|| self.shade(p, normal(k))); + let d = sub(p, self.eye); + [dot(d, right), dot(d, up), dot(d, forward), r, g, b] + }); + let screen: Vec = self.view.clip(view_corners).into_iter().map(|v| self.view.project(v, target.width, target.height)).collect(); + if let Some((&a, rest)) = screen.split_first() { + for pair in rest.windows(2) { + if let [b, c] = pair { + target.fill([a, *b, *c], colour); + } + } + } + } +} + +/// Triangle `t`'s stored corner normals, unit length; a corner whose normal is zero or not finite +/// is `None`, and so is the whole when the mesh stores none. +fn stored_normals(mesh: &TriangleMesh, t: usize) -> Option<[Option<[f64; 3]>; 3]> { + let slots = mesh.triangle_normals.get(t)?; + Some(slots.map(|i| mesh.normals.get(usize::try_from(i).ok()?).copied().and_then(normalize))) +} + +/// The three corners of a triangle, or `None` when one is missing or not finite. +fn corners(mesh: &TriangleMesh, tri: &[u32; 3]) -> Option<[[f64; 3]; 3]> { + let mut out = [[0.0; 3]; 3]; + for (slot, &i) in out.iter_mut().zip(tri) { + let p = *mesh.positions.get(usize::try_from(i).ok()?)?; + if !p.iter().all(|c| c.is_finite()) { + return None; + } + *slot = p; + } + Some(out) +} + +/// Corner `i` of a triangle, the origin when out of range. +fn at3(v: &[[f64; 3]; 3], i: usize) -> [f64; 3] { + v.get(i).copied().unwrap_or([0.0; 3]) +} + +#[cfg(test)] +mod tests; diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs new file mode 100644 index 0000000000..008f177491 --- /dev/null +++ b/crates/prc-render/src/render/tests.rs @@ -0,0 +1,269 @@ +#![allow(clippy::unwrap_used, clippy::panic, clippy::unreachable)] + +use super::*; + +fn mesh(positions: Vec<[f64; 3]>, triangles: Vec<[u32; 3]>) -> TriangleMesh { + let mut m = TriangleMesh::default(); + m.positions = positions; + m.triangles = triangles; + m +} + +fn quad_mesh(z: f64, half: f64, tilt: f64) -> TriangleMesh { + mesh(vec![[-half, -half, z - tilt], [half, -half, z + tilt], [half, half, z + tilt], [-half, half, z - tilt]], vec![[0, 1, 2], [0, 2, 3]]) +} + +fn quad(z: f64, half: f64, tilt: f64) -> WorldMesh { + WorldMesh::new(quad_mesh(z, half, tilt)) +} + +fn painted(m: WorldMesh, colour: Option<[u8; 4]>) -> WorldMesh { + WorldMesh { colour, ..m } +} + +fn ortho(height: f64) -> Camera { + Camera { eye: [0.0, 0.0, 10.0], target: [0.0, 0.0, 0.0], up: [0.0, 1.0, 0.0], projection: Projection::Orthographic { height } } +} + +fn small() -> RenderOptions { + RenderOptions { width: 40, height: 40, ..RenderOptions::default() } +} + +fn pixel(image: &Image, x: u32, y: u32) -> [u8; 4] { + let i = (y * image.width + x) as usize * 4; + image.rgba[i..i + 4].try_into().unwrap() +} + +#[test] +fn a_facing_square_fills_the_middle_and_leaves_the_corners() { + let image = render(&[quad(0.0, 1.0, 0.0)], &ortho(4.0), &small()).unwrap(); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]); + assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255]); + // Half the view height is covered: pixels 10..30. + assert_eq!(pixel(&image, 10, 20)[0], 190); + assert_eq!(pixel(&image, 9, 20)[0], 255); +} + +#[test] +fn stored_normals_shade_smoothly_across_a_flat_square() { + let mut m = quad_mesh(0.0, 1.0, 0.0); + let s = 3f64.sqrt() / 2.0; + // The left corners lean 60° away from the viewer; the right face it. + m.normals = vec![[s, 0.0, 0.5], [0.0, 0.0, 2.0]]; + m.triangle_normals = vec![[0, 1, 1], [0, 1, 0]]; + let image = render(&[WorldMesh::new(m.clone())], &ortho(4.0), &small()).unwrap(); + let red = |x| pixel(&image, x, 20)[0]; + assert!(red(11) < red(20) && red(20) < red(29), "{} {} {}", red(11), red(20), red(29)); + // Shade 0.3 + 0.7 cos θ at the edges: 0.65 left, 1.0 right. + assert!((i32::from(red(10)) - 128).abs() <= 4, "{}", red(10)); + assert!(red(29) >= 186); + m.normals.clear(); + m.triangle_normals.clear(); + let flat = render(&[WorldMesh::new(m)], &ortho(4.0), &small()).unwrap(); + assert_eq!(pixel(&flat, 11, 20), pixel(&flat, 29, 20)); +} + +#[test] +fn the_nearer_surface_hides_the_farther_whatever_the_order() { + let far = quad(-1.0, 2.0, 1.0); + let near = quad(1.0, 0.5, 0.0); + for meshes in [[far.clone(), near.clone()], [near, far]] { + let image = render(&meshes, &ortho(8.0), &small()).unwrap(); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]); + assert!(pixel(&image, 20, 12)[0] < 190, "the tilted far square is darker"); + } +} + +const RED: Option<[u8; 4]> = Some([255, 0, 0, 255]); +const GLASS: Option<[u8; 4]> = Some([0, 0, 255, 102]); + +/// Glass (40% blue) in front of red blends whichever is listed first, glass behind red is hidden, +/// a clear mesh draws nothing, and a mesh with no colour takes the options' colour. +#[test] +fn meshes_draw_in_their_own_colours_and_glass_blends() { + let unlit = RenderOptions { lighting: Lighting::Unlit, ..small() }; + let at_middle = |meshes: &[(f64, Option<[u8; 4]>)]| { + let meshes: Vec = meshes.iter().map(|&(z, c)| painted(quad(z, 1.0, 0.0), c)).collect(); + pixel(&render(&meshes, &ortho(4.0), &unlit).unwrap(), 20, 20) + }; + assert_eq!(at_middle(&[(1.0, GLASS), (-1.0, RED)]), [153, 0, 102, 255]); + assert_eq!(at_middle(&[(-1.0, RED), (1.0, GLASS)]), [153, 0, 102, 255]); + assert_eq!(at_middle(&[(-1.0, GLASS), (1.0, RED)]), [255, 0, 0, 255]); + assert_eq!(at_middle(&[(1.0, Some([0, 0, 255, 0])), (-1.0, RED)]), [255, 0, 0, 255]); + assert_eq!(at_middle(&[(1.0, None)]), [190, 192, 200, 255]); + assert_eq!(at_middle(&[(2.0, GLASS), (1.0, GLASS), (-1.0, RED)]), [92, 0, 163, 255], "glass does not hide glass"); + let clear = RenderOptions { background: [0, 0, 0, 0], ..unlit }; + let image = render(&[painted(quad(1.0, 1.0, 0.0), GLASS)], &ortho(4.0), &clear).unwrap(); + assert_eq!(pixel(&image, 20, 20), [0, 0, 255, 102], "glass over nothing"); +} + +#[test] +fn a_triangle_colour_overrides_its_mesh_colour() { + let mut m = painted(quad(0.0, 1.0, 0.0), RED); + // The lower-right triangle is green; the upper-left keeps the mesh's red. + m.triangle_colours = Some(vec![Some([0, 255, 0, 255]), None]); + let unlit = RenderOptions { lighting: Lighting::Unlit, ..small() }; + let image = render(&[m], &ortho(4.0), &unlit).unwrap(); + assert_eq!(pixel(&image, 25, 25), [0, 255, 0, 255]); + assert_eq!(pixel(&image, 14, 14), [255, 0, 0, 255]); +} + +#[test] +fn each_edge_bounds_a_triangle() { + let tri = WorldMesh::new(mesh(vec![[-1.0, -1.0, 0.0], [1.0, -1.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2]])); + let image = render(&[tri], &ortho(4.0), &small()).unwrap(); + assert_ne!(pixel(&image, 20, 25)[0], 255); + // Just past each edge: below the base, left and right of the apex. + for (x, y) in [(20, 31), (13, 15), (27, 15)] { + assert_eq!(pixel(&image, x, y)[0], 255, "({x},{y})"); + } +} + +#[test] +fn a_face_turned_away_is_still_drawn() { + let mut back = quad_mesh(0.0, 1.0, 0.0); + for t in &mut back.triangles { + t.swap(1, 2); + } + let image = render(&[WorldMesh::new(back)], &ortho(4.0), &small()).unwrap(); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]); +} + +#[test] +fn perspective_clips_what_is_behind_the_camera() { + let camera = Camera { projection: Projection::Perspective { fov_y: 60.0 }, ..ortho(1.0) }; + let image = render(&[quad(20.0, 1.0, 0.0)], &camera, &small()).unwrap(); + assert!(image.rgba.chunks(4).all(|p| p == [255, 255, 255, 255])); + // A floor running from behind the camera to beyond the target. + let floor = WorldMesh::new(mesh(vec![[-5.0, -1.0, 20.0], [5.0, -1.0, 20.0], [0.0, -1.0, -20.0]], vec![[0, 1, 2]])); + let image = render(&[floor], &camera, &small()).unwrap(); + assert_ne!(pixel(&image, 20, 38), [255, 255, 255, 255]); + assert_eq!(pixel(&image, 20, 2), [255, 255, 255, 255]); +} + +#[test] +fn a_fitted_camera_frames_the_model() { + // Depth matters in perspective: the nearer square looks larger. + let meshes = [quad(3.0, 3.0, 0.0), quad(-3.0, 3.0, 0.0)]; + let bounds = Bounds::of(&meshes).unwrap(); + for perspective in [true, false] { + let camera = Camera::fit(&bounds, [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], perspective, 2.0).unwrap(); + let image = render(&meshes, &camera, &RenderOptions { width: 80, height: 40, ..RenderOptions::default() }).unwrap(); + assert_ne!(pixel(&image, 40, 20)[0], 255); + // Nothing drawn touches the image border. + for x in 0..80 { + assert_eq!(pixel(&image, x, 0)[0], 255); + assert_eq!(pixel(&image, x, 39)[0], 255); + } + } +} + +#[test] +fn a_mesh_fit_ignores_empty_box_corners() { + // A thin strip along the box's diagonal: seen across the diagonal, the box is as tall as it + // is wide, the strip is not. + let meshes = [WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [10.0, 10.0, 0.0], [10.0, 10.01, 0.0]], vec![[0, 1, 2]]))]; + let (dir, up) = ([0.0, 0.0, -1.0], [1.0, -1.0, 0.0]); + let height = |c: Camera| match c.projection { + Projection::Orthographic { height } => height, + Projection::Perspective { .. } => unreachable!(), + }; + let boxed = height(Camera::fit(&Bounds::of(&meshes).unwrap(), dir, up, false, 2.0).unwrap()); + let tight = height(Camera::fit_meshes(&meshes, dir, up, false, 2.0).unwrap()); + assert!((boxed / tight - 2.0).abs() < 0.01, "{boxed} vs {tight}"); + let near = Camera::fit_meshes(&meshes, dir, up, true, 2.0).unwrap(); + let far = Camera::fit(&Bounds::of(&meshes).unwrap(), dir, up, true, 2.0).unwrap(); + assert!(length(sub(near.eye, near.target)) < 0.6 * length(sub(far.eye, far.target))); + + // Lopsided in its box: the limiting vertex sits exactly on the margin, measured from the + // re-centred target. Narrow (width limits) and wide (height limits). + let tri = WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [1.0, 2.0, 1.0]], vec![[0, 1, 2]])); + for aspect in [0.5, 3.0] { + let c = Camera::fit_meshes(std::slice::from_ref(&tri), [0.0, 0.3, -1.0], [1.0, 1.0, 0.0], true, aspect).unwrap(); + let [right, upward, forward] = c.basis().unwrap(); + let ty = 15f64.to_radians().tan() / FIT_MARGIN; + let fill = tri + .mesh + .positions + .iter() + .map(|&p| { + let r = sub(p, c.eye); + (dot(r, right).abs() / (ty * aspect)).max(dot(r, upward).abs() / ty) / dot(r, forward) + }) + .fold(0.0, f64::max); + assert!((fill - 1.0).abs() < 1e-6, "aspect {aspect}: {fill}"); + } +} + +#[test] +fn a_fitted_camera_fills_the_tighter_axis() { + let drawn = |image: &Image, horizontal: bool, line: u32| { + let n = if horizontal { image.width } else { image.height }; + (0..n) + .filter(|&i| { + let (x, y) = if horizontal { (i, line) } else { (line, i) }; + pixel(image, x, y)[0] != 255 + }) + .count() + }; + // Height-limited: a 6 x 6 square in an 80 x 40 image. + let square = [quad(0.0, 3.0, 0.0)]; + // Width-limited and off-origin: a 10 x 1 strip in a 40 x 40 image. + let strip = [WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 1.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2], [0, 2, 3]]))]; + for perspective in [true, false] { + let fit = + |meshes: &[WorldMesh], aspect| Camera::fit(&Bounds::of(meshes).unwrap(), [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], perspective, aspect).unwrap(); + let wide = RenderOptions { width: 80, height: 40, ..RenderOptions::default() }; + let rows = drawn(&render(&square, &fit(&square, 2.0), &wide).unwrap(), false, 40); + assert!((34..=37).contains(&rows), "{perspective}: {rows} of 40 rows"); + let image = render(&strip, &fit(&strip, 1.0), &small()).unwrap(); + let cols = drawn(&image, true, 20); + assert!((34..=37).contains(&cols), "{perspective}: {cols} of 40 columns"); + // Centred: the margins differ by at most a pixel. + let left = (0..40).take_while(|&x| pixel(&image, x, 20)[0] == 255).count(); + assert!(left.abs_diff(40 - cols - left) <= 1, "{perspective}: left {left}"); + } +} + +#[test] +fn bad_sizes_and_cameras_are_refused() { + let m = [quad(0.0, 1.0, 0.0)]; + let size = |width, height| RenderOptions { width, height, ..RenderOptions::default() }; + assert!(matches!(render(&m, &ortho(1.0), &size(0, 10)), Err(RenderError::Size { .. }))); + assert!(matches!(render(&m, &ortho(1.0), &size(10_000, 10_000)), Err(RenderError::Size { .. }))); + let bad = [ + Camera { eye: [0.0; 3], target: [0.0; 3], ..ortho(1.0) }, + Camera { up: [0.0, 0.0, 1.0], ..ortho(1.0) }, + Camera { projection: Projection::Perspective { fov_y: 180.0 }, ..ortho(1.0) }, + Camera { projection: Projection::Perspective { fov_y: f64::NAN }, ..ortho(1.0) }, + ortho(0.0), + ortho(f64::INFINITY), + ]; + for camera in bad { + assert!(matches!(render(&m, &camera, &small()), Err(RenderError::Camera(_))), "{camera:?}"); + } + let b = Bounds::of(&m).unwrap(); + assert!(Camera::fit(&b, [0.0; 3], [0.0, 1.0, 0.0], true, 1.0).is_err()); + assert!(Camera::fit(&b, [0.0, 0.0, 1.0], [0.0, 1.0, 0.0], true, 0.0).is_err()); + assert!(Camera::fit_meshes(&[], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0], true, 1.0).is_err()); +} + +#[test] +fn non_finite_and_missing_vertices_are_skipped() { + let m = WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2], [0, 2, 9], [0, 2, u32::MAX]])); + let image = render(std::slice::from_ref(&m), &ortho(4.0), &small()).unwrap(); + assert!(image.rgba.chunks(4).all(|p| p == [255, 255, 255, 255])); + assert_eq!(Bounds::of(&[m]).unwrap().max, [0.0, 1.0, 0.0]); +} + +#[test] +fn unlit_draws_the_plain_colour_and_the_headlight_shades_by_angle() { + let lit = |lighting, tilt| { + let image = render(&[quad(0.0, 1.0, tilt)], &ortho(4.0), &RenderOptions { lighting, ..small() }).unwrap(); + pixel(&image, 20, 20) + }; + assert_eq!(lit(Lighting::Headlight, 0.0), [190, 192, 200, 255]); + // Tilted 45°: 0.3 + 0.7 cos 45° ≈ 0.795 of the colour. + assert_eq!(lit(Lighting::Headlight, 1.0), [151, 153, 159, 255]); + assert_eq!(lit(Lighting::Unlit, 1.0), [190, 192, 200, 255]); +} diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs new file mode 100644 index 0000000000..90af0f3ba2 --- /dev/null +++ b/crates/prc-render/src/vec3.rs @@ -0,0 +1,53 @@ +//! Three-component vector arithmetic for the camera and the rasterizer. + +/// A point or direction, `[x, y, z]`. +pub(crate) type Vec3 = [f64; 3]; + +/// `a - b`. +pub(crate) fn sub([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 { + [ax - bx, ay - by, az - bz] +} + +/// `a` times `s`. +pub(crate) fn scale(a: Vec3, s: f64) -> Vec3 { + a.map(|c| c * s) +} + +/// The dot product `a · b`. +pub(crate) fn dot([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> f64 { + ax * bx + ay * by + az * bz +} + +/// The right-handed cross product `a × b`. +pub(crate) fn cross([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 { + [ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx] +} + +/// The Euclidean length of `a`. +pub(crate) fn length(a: Vec3) -> f64 { + dot(a, a).sqrt() +} + +/// `a` scaled to unit length; `None` when its length is zero or not finite. +pub(crate) fn normalize(a: Vec3) -> Option { + let l = length(a); + (l.is_finite() && l > 0.0).then(|| scale(a, 1.0 / l)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cross_follows_the_right_hand_rule() { + assert_eq!(cross([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), [0.0, 0.0, 1.0]); + assert_eq!(cross([0.0, 1.0, 0.0], [1.0, 0.0, 0.0]), [0.0, 0.0, -1.0]); + } + + #[test] + fn normalize_rejects_degenerate_vectors() { + assert_eq!(normalize([0.0, 4.0, 0.0]), Some([0.0, 1.0, 0.0])); + assert_eq!(normalize([0.0; 3]), None); + assert_eq!(normalize([f64::NAN, 0.0, 0.0]), None); + } +} diff --git a/crates/prc-render/tests/model.rs b/crates/prc-render/tests/model.rs new file mode 100644 index 0000000000..80e84252c6 --- /dev/null +++ b/crates/prc-render/tests/model.rs @@ -0,0 +1,145 @@ +//! End to end: a synthetic PRC stream parsed, assembled and rendered to pixels. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use pdfcraft_prc::PrcFile; +use pdfcraft_prc_render::{Camera, Image, RenderOptions, assemble, render}; +use std::io::Write as _; + +/// An MSB-first bit sink: the inverse of the PRC bit reader, for the few values these tests write. +#[derive(Default)] +struct W { + bits: Vec, +} + +impl W { + fn put(&mut self, v: u64, n: u32) -> &mut Self { + for i in (0..n).rev() { + self.bits.push((v >> i) & 1 == 1); + } + self + } + + fn bit(&mut self, b: bool) -> &mut Self { + self.bits.push(b); + self + } + + /// An `UnsignedInteger`: bytes low first, each after a 1 bit, then a 0 bit. + 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) + } + + fn uints(&mut self, v: &[u32]) -> &mut Self { + self.uint(u32::try_from(v.len()).unwrap()); + for &x in v { + self.uint(x); + } + self + } + + /// A `Double` from the few the code table stores whole: 0, ±0.5, ±1, ±2. + fn double(&mut self, v: f64) -> &mut Self { + let negative = v < 0.0; + match v.abs() { + 0.0 => self.put(0b01, 2), + 0.5 => self.put(0x777, 11).bit(negative), + 1.0 => self.put(0b0000, 4).bit(negative), + // The exponent row of [2, 4) with no mantissa bytes. + 2.0 => self.put(0x1b, 5).bit(negative).bit(false), + _ => panic!("{v} has no short code here"), + } + } + + 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() + } +} + +/// A tessellation section of one `TESS_3D` (authoring version 8137) with explicit normals and +/// triangles of `(normal, point)` coordinate indices. +fn tess_section(points: &[[f64; 3]], normals: &[[f64; 3]], corners: &[(u32, u32)]) -> Vec { + let mut w = W::default(); + w.uint(305).uint(0).bit(true).uint(1); + w.uint(172).bit(false).uint(u32::try_from(points.len() * 3).unwrap()); + points.iter().flatten().for_each(|&c| _ = w.double(c)); + // Faces present; no loop flag; normals not recalculated. + w.bit(true).bit(false).bit(false); + w.uint(u32::try_from(normals.len() * 3).unwrap()); + normals.iter().flatten().for_each(|&c| _ = w.double(c)); + w.uint(0).uints(&corners.iter().flat_map(|&(n, p)| [n, p]).collect::>()); + // One face of triangles with a normal per corner. + w.uint(1).uint(174).uint(0).uint(0).uint(0).uint(0x2).uint(0).uints(&[u32::try_from(corners.len() / 3).unwrap()]); + w.uint(0).bit(false); + w.uint(0).uint(0); + w.bytes() +} + +/// A one-file-structure PRC stream whose tessellation section is `tess`, with no product tree. +fn prc_file(tess: &[u8]) -> Vec { + 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 +} + +fn pixel(image: &Image, x: u32, y: u32) -> [u8; 4] { + let i = (y * image.width + x) as usize * 4; + image.rgba[i..i + 4].try_into().unwrap() +} + +#[test] +fn a_synthetic_prc_renders_its_nearer_triangle_over_the_farther() { + // Listed first, a small triangle at z = 1 facing the viewer; behind it at z = -1, a larger + // one whose stored normal lies across the view, so the headlight shades it darkest. + let points = [[-1., -1., 1.], [1., -1., 1.], [0., 1., 1.], [-2., -2., -1.], [2., -2., -1.], [0., 2., -1.]]; + let normals = [[0., 0., 1.], [1., 0., 0.]]; + let corners = [(0, 0), (0, 3), (0, 6), (3, 9), (3, 12), (3, 15)]; + let prc = PrcFile::parse(&prc_file(&tess_section(&points, &normals, &corners))).unwrap(); + let meshes = assemble(&prc).unwrap(); + assert_eq!(meshes.len(), 1); + assert_eq!(meshes[0].mesh.triangles.len(), 2); + let camera = Camera::fit_meshes(&meshes, [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], false, 1.0).unwrap(); + let image = render(&meshes, &camera, &RenderOptions { width: 40, height: 40, ..RenderOptions::default() }).unwrap(); + assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255], "background corner"); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255], "the nearer triangle, lit head on"); + // Below the near triangle only the far one: 0.3 of the surface colour. + assert_eq!(pixel(&image, 20, 34), [57, 58, 60, 255], "the farther triangle, lit edge on"); +} + +#[test] +fn damaged_streams_are_errors_not_panics() { + let points = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]]; + let bytes = prc_file(&tess_section(&points, &[[0., 0., 1.]], &[(0, 0), (0, 3), (0, 6)])); + for end in 0..bytes.len() { + if let Ok(prc) = PrcFile::parse(&bytes[..end]) { + let _ = assemble(&prc); + } + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 4f59c1dc94..923b7121ab 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -45,6 +45,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("ops", Class::Layer(2)), // L3 core services ("render", Class::Layer(3)), + ("prc-render", Class::Layer(3)), ("text", Class::Layer(3)), ("annot", Class::Layer(3)), ("forms", Class::Layer(3)), From ce393b276be374a8cb27fbc0f6ce574862c3b4a1 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 21:41:05 -0500 Subject: [PATCH 16/27] M12: prc-render review fixes (missing tessellations, vertex ceiling, seams, perspective shade) - assemble: a placement naming a tessellation its file structure does not have is now PrcError::Malformed instead of a silently missing part; wires, markups and compressed tessellations are still skipped. - assemble: positions, normals and texture coordinates are charged against a new MAX_ASSEMBLED_VERTICES (2^24) with RenderError::TooManyVertices, so a one-triangle mesh with many vertices placed many times cannot outgrow the triangle ceiling's memory bound. A refused mesh spends nothing. - raster: a field of view too small for a finite focal length is a Camera error, not a background-only image. - raster: a pixel centre exactly on an edge belongs to one triangle only (the side the triangle lies on, +x first, then +y), so translucent quads no longer blend their diagonal twice. - raster: under a perspective camera the shade is interpolated with the 1/z depth key, so it follows the surface rather than the screen. - vec3: normalize divides by the largest component first, so a cross product above ~1e154 (or a subnormal one) still normalizes. - render docs and README state the near plane: 1/10,000 of the eye-to-target distance. Each new test fails with its fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc-render/README.md | 4 +- crates/prc-render/src/assemble.rs | 38 ++++++++++---- crates/prc-render/src/assemble/tests.rs | 42 ++++++++++++--- crates/prc-render/src/lib.rs | 4 +- crates/prc-render/src/raster.rs | 68 ++++++++++++++++++++++--- crates/prc-render/src/render.rs | 13 +++-- crates/prc-render/src/render/tests.rs | 1 + crates/prc-render/src/vec3.rs | 15 +++++- 8 files changed, 154 insertions(+), 31 deletions(-) diff --git a/crates/prc-render/README.md b/crates/prc-render/README.md index 43730040f2..c3d4db867e 100644 --- a/crates/prc-render/README.md +++ b/crates/prc-render/README.md @@ -21,8 +21,8 @@ render(&[WorldMesh], &Camera, &RenderOptions) -> Result - A triangle's own colour wins over its mesh's, which wins over `RenderOptions::colour`. - Opaque triangles are drawn first, then translucent ones blended over them; alpha 0 is not drawn. - `Lighting::Headlight` (default) shades by the angle to the view direction; `Lighting::Unlit` draws flat colour. -- Perspective triangles are clipped at a near plane; orthographic views are not clipped. +- Perspective triangles are clipped at a near plane 1/10,000 of the eye-to-target distance from the eye; orthographic views are not clipped. - `MAX_RENDER_PIXELS` (2^24, about 16 MP) bounds the image: at most 64 MB of RGBA plus 128 MB of depth. - `MAX_ASSEMBLED_TRIANGLES` (2^24) bounds one `assemble` call. Neither bounds time: a model of many large + `MAX_ASSEMBLED_TRIANGLES` and `MAX_ASSEMBLED_VERTICES` (2^24 each) bound one `assemble` call. None bounds time: a model of many large triangles at full size is a long CPU render, so callers on a UI thread should render small or off-thread. - Not yet: shadows, ambient occlusion, specular light, scene lights, textures, wires and markups. diff --git a/crates/prc-render/src/assemble.rs b/crates/prc-render/src/assemble.rs index 3f8fa54caf..7fbb9bd914 100644 --- a/crates/prc-render/src/assemble.rs +++ b/crates/prc-render/src/assemble.rs @@ -8,6 +8,9 @@ use pdfcraft_prc::{Matrix, PrcFile, Rgba, Tessellation, TriangleMesh}; /// occurrences draw is copied once per occurrence. pub const MAX_ASSEMBLED_TRIANGLES: usize = 1 << 24; +/// The most positions, normals and texture coordinates [`assemble`] copies, over every placement. +pub const MAX_ASSEMBLED_VERTICES: usize = 1 << 24; + /// A triangle mesh in world coordinates with the colours it draws in. #[derive(Debug, Clone, Default, PartialEq)] pub struct WorldMesh { @@ -44,11 +47,12 @@ pub fn place(mesh: &TriangleMesh, matrix: &Matrix, colour: Option<[u8; 4]>, tria /// are, uncoloured. Wires, markups and compressed tessellations are not drawn. /// /// # Errors -/// [`RenderError::Model`] when a tessellation section or the tree cannot be read; -/// [`RenderError::TooManyTriangles`] past [`MAX_ASSEMBLED_TRIANGLES`]. +/// [`RenderError::Model`] when a tessellation section or the tree cannot be read, or the tree +/// places a tessellation its file structure does not have; [`RenderError::TooManyTriangles`] past +/// [`MAX_ASSEMBLED_TRIANGLES`] and [`RenderError::TooManyVertices`] past [`MAX_ASSEMBLED_VERTICES`]. pub fn assemble(prc: &PrcFile) -> Result, RenderError> { let tessellations = prc.file_structures.iter().map(|fs| fs.tessellations()).collect::, _>>()?; - let mut budget = Budget(0); + let mut budget = Budget::default(); let mut out = Vec::new(); if !prc.stores_tree() { for mesh in tessellations.iter().flatten().filter_map(as_mesh) { @@ -81,8 +85,9 @@ pub(crate) struct Item<'a> { /// Per triangle of a mesh, the colour its face's style resolves to. type FaceColours<'a> = dyn Fn(&TriangleMesh) -> Option>> + 'a; -/// Each item's mesh moved into the world and coloured, appended to `out`. Items naming no -/// triangle mesh are skipped. +/// Each item's mesh moved into the world and coloured, appended to `out`. Items naming a wire, +/// markup or compressed tessellation are skipped; an item naming no tessellation at all is +/// [`PrcError::Malformed`](pdfcraft_prc::PrcError::Malformed). pub(crate) fn place_items<'a>( tessellations: &[Vec], items: impl IntoIterator>, @@ -90,7 +95,11 @@ pub(crate) fn place_items<'a>( out: &mut Vec, ) -> Result<(), RenderError> { for p in items { - let Some(mesh) = tessellations.get(p.file_structure).and_then(|t| t.get(p.tessellation)).and_then(as_mesh) else { + let Some(tessellation) = tessellations.get(p.file_structure).and_then(|t| t.get(p.tessellation)) else { + let what = format!("tessellation {} of file structure {}, which has none by that index", p.tessellation, p.file_structure); + return Err(pdfcraft_prc::PrcError::Malformed(what).into()); + }; + let Some(mesh) = as_mesh(tessellation) else { continue; }; budget.spend(mesh)?; @@ -100,12 +109,23 @@ pub(crate) fn place_items<'a>( Ok(()) } -/// Triangles placed so far. -pub(crate) struct Budget(pub(crate) usize); +/// Triangles and vertex entries (positions, normals, texture coordinates) placed so far. +#[derive(Debug, Default)] +pub(crate) struct Budget { + pub(crate) triangles: usize, + pub(crate) vertices: usize, +} impl Budget { fn spend(&mut self, mesh: &TriangleMesh) -> Result<(), RenderError> { - self.0 = self.0.checked_add(mesh.triangles.len()).filter(|&n| n <= MAX_ASSEMBLED_TRIANGLES).ok_or(RenderError::TooManyTriangles)?; + let triangles = + self.triangles.checked_add(mesh.triangles.len()).filter(|&n| n <= MAX_ASSEMBLED_TRIANGLES).ok_or(RenderError::TooManyTriangles)?; + let vertices = [mesh.positions.len(), mesh.normals.len(), mesh.uvs.len()] + .into_iter() + .try_fold(self.vertices, usize::checked_add) + .filter(|&n| n <= MAX_ASSEMBLED_VERTICES) + .ok_or(RenderError::TooManyVertices)?; + (self.triangles, self.vertices) = (triangles, vertices); Ok(()) } } diff --git a/crates/prc-render/src/assemble/tests.rs b/crates/prc-render/src/assemble/tests.rs index 87c133f4da..6947e78033 100644 --- a/crates/prc-render/src/assemble/tests.rs +++ b/crates/prc-render/src/assemble/tests.rs @@ -47,13 +47,29 @@ fn colours_are_clamped_into_bytes() { #[test] fn the_triangle_budget_is_shared_and_refuses_past_the_ceiling() { - let mut b = Budget(MAX_ASSEMBLED_TRIANGLES - 1); + let mut b = Budget { triangles: MAX_ASSEMBLED_TRIANGLES - 1, vertices: 0 }; assert!(b.spend(&triangle()).is_ok()); assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles)); - let mut b = Budget(usize::MAX); + let mut b = Budget { triangles: usize::MAX, vertices: 0 }; assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles)); } +#[test] +fn the_vertex_budget_counts_positions_normals_and_uvs() { + let mut heavy = triangle(); + heavy.normals = vec![[0.0, 0.0, 1.0]; 2]; + heavy.uvs = vec![[0.0; 2]; 4]; + let mut b = Budget::default(); + b.spend(&heavy).unwrap(); + assert_eq!((b.triangles, b.vertices), (1, 9)); + // One triangle over many vertices: the triangle ceiling alone would let it through. + let mut b = Budget { triangles: 0, vertices: MAX_ASSEMBLED_VERTICES - 8 }; + assert_eq!(b.spend(&heavy), Err(RenderError::TooManyVertices)); + assert_eq!((b.triangles, b.vertices), (0, MAX_ASSEMBLED_VERTICES - 8), "a refused mesh spends nothing"); + let mut b = Budget { triangles: 0, vertices: usize::MAX }; + assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyVertices)); +} + fn item<'a>(fs: usize, tess: usize, x: f64, colour: Option) -> Item<'a> { let mut matrix = Matrix::IDENTITY; matrix.0[0][3] = x; @@ -66,22 +82,32 @@ fn each_placement_draws_its_tessellation_where_and_in_the_colour_the_tree_says() let mut red = Rgba::default(); red.r = 1.0; red.a = 1.0; - // Two occurrences of one tessellation at different places, one coloured; one naming nothing. - let items = [item(0, 0, 3.0, Some(red)), item(0, 0, -4.0, None), item(1, 0, 0.0, None), item(0, 7, 0.0, None)]; - let (mut budget, mut out) = (Budget(0), Vec::new()); + // Two occurrences of one tessellation at different places, one coloured. + let items = [item(0, 0, 3.0, Some(red)), item(0, 0, -4.0, None)]; + let (mut budget, mut out) = (Budget::default(), Vec::new()); place_items(&tessellations, items, &mut budget, &mut out).unwrap(); - assert_eq!(out.len(), 2, "items naming no mesh are skipped"); + assert_eq!(out.len(), 2); assert_eq!(out[0].mesh.positions, [[3.0, 0.0, 0.0], [4.0, 0.0, 0.0], [3.0, 1.0, 0.0]]); assert_eq!(out[0].colour, Some([255, 0, 0, 255])); assert_eq!(out[1].mesh.positions, [[-4.0, 0.0, 0.0], [-3.0, 0.0, 0.0], [-4.0, 1.0, 0.0]]); assert_eq!(out[1].colour, None); - assert_eq!(budget.0, 2, "each occurrence spends its triangles"); + assert_eq!(budget.triangles, 2, "each occurrence spends its triangles"); +} + +#[test] +fn a_placement_naming_a_missing_tessellation_is_malformed() { + let tessellations = vec![vec![Tessellation::Mesh(triangle())]]; + for (fs, tess) in [(1, 0), (0, 7)] { + let (mut budget, mut out) = (Budget::default(), Vec::new()); + let r = place_items(&tessellations, [item(fs, tess, 0.0, None)], &mut budget, &mut out); + assert!(matches!(r, Err(RenderError::Model(pdfcraft_prc::PrcError::Malformed(_)))), "{fs}/{tess}: {r:?}"); + } } #[test] fn a_placement_past_the_triangle_budget_is_an_error() { let tessellations = vec![vec![Tessellation::Mesh(triangle())]]; - let (mut budget, mut out) = (Budget(MAX_ASSEMBLED_TRIANGLES), Vec::new()); + let (mut budget, mut out) = (Budget { triangles: MAX_ASSEMBLED_TRIANGLES, vertices: 0 }, Vec::new()); let r = place_items(&tessellations, [item(0, 0, 0.0, None)], &mut budget, &mut out); assert!(matches!(r, Err(RenderError::TooManyTriangles))); assert!(out.is_empty()); diff --git a/crates/prc-render/src/lib.rs b/crates/prc-render/src/lib.rs index da0ed41230..4044f110bb 100644 --- a/crates/prc-render/src/lib.rs +++ b/crates/prc-render/src/lib.rs @@ -4,7 +4,7 @@ //! into world-space meshes; [`render`] draws them from a [`Camera`] with one depth-tested sample per //! pixel centre and a light at the camera. Single-threaded and GUI-free, so it runs unchanged on wasm32. //! The image size is capped by [`MAX_RENDER_PIXELS`] and the assembled model by -//! [`MAX_ASSEMBLED_TRIANGLES`]; bad input is an error, never a panic. +//! [`MAX_ASSEMBLED_TRIANGLES`] and [`MAX_ASSEMBLED_VERTICES`]; bad input is an error, never a panic. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -13,5 +13,5 @@ mod raster; mod render; mod vec3; -pub use assemble::{MAX_ASSEMBLED_TRIANGLES, WorldMesh, assemble, place}; +pub use assemble::{MAX_ASSEMBLED_TRIANGLES, MAX_ASSEMBLED_VERTICES, WorldMesh, assemble, place}; pub use render::{Bounds, Camera, Image, Lighting, MAX_RENDER_PIXELS, Projection, RenderError, RenderOptions, render}; diff --git a/crates/prc-render/src/raster.rs b/crates/prc-render/src/raster.rs index dddb3b394d..cced24db90 100644 --- a/crates/prc-render/src/raster.rs +++ b/crates/prc-render/src/raster.rs @@ -13,6 +13,9 @@ pub(crate) struct Corner { pub(crate) shade: [f64; 3], } +/// The perspective near plane, as a fraction of the eye-to-target distance. +pub(crate) const NEAR_FRACTION: f64 = 1e-4; + pub(crate) enum View { /// `focal` = 1 / tan(fov_y / 2); `near` = the clip distance. Perspective { @@ -35,8 +38,13 @@ impl View { if !(fov_y > 0.0 && fov_y < 180.0) { return Err(RenderError::Camera("the field of view is not between 0 and 180 degrees")); } + let focal = 1.0 / (fov_y.to_radians() / 2.0).tan(); + // A field of view too small to measure leaves no finite focal length. + if !focal.is_finite() { + return Err(RenderError::Camera("the field of view is too small")); + } let distance = length(sub(camera.target, camera.eye)); - Ok(View::Perspective { focal: 1.0 / (fov_y.to_radians() / 2.0).tan(), aspect, near: (distance * 1e-4).max(f64::MIN_POSITIVE) }) + Ok(View::Perspective { focal, aspect, near: (distance * NEAR_FRACTION).max(f64::MIN_POSITIVE) }) } Projection::Orthographic { height } => { if !(height.is_finite() && height > 0.0) { @@ -87,6 +95,8 @@ impl View { /// The image being drawn: `rgba` holds `width * height` straight-RGBA pixels and `depth` their /// depth keys, row by row from the top. pub(crate) struct Target<'b> { + /// Depth keys are 1/z (a perspective view), so the shade is interpolated perspective-correct. + pub(crate) perspective: bool, pub(crate) width: u32, pub(crate) height: u32, pub(crate) rgba: &'b mut [u8], @@ -95,11 +105,13 @@ pub(crate) struct Target<'b> { impl Target<'_> { /// Fill the screen triangle of `corners` with straight-RGBA `colour`, depth-tested at pixel - /// centres, the shade interpolated across it. An opaque pixel replaces the pixel and its - /// depth; a translucent one is blended over it, leaving the depth. + /// centres, the shade interpolated across it. A pixel centre on an edge two triangles share + /// belongs to one of them only. An opaque pixel replaces the pixel and its depth; a + /// translucent one is blended over it, leaving the depth. pub(crate) fn fill(&mut self, corners: [Corner; 3], colour: [u8; 4]) { let [a, b, c] = corners; let area = edge(a.at, b.at, c.at); + let edges = [(b.at, c.at), (c.at, a.at), (a.at, b.at)]; if !area.is_finite() || area.abs() < 1e-12 { return; } @@ -109,8 +121,8 @@ impl Target<'_> { for y in y0..=y1 { for x in x0..=x1 { let p = [f64::from(x) + 0.5, f64::from(y) + 0.5, 0.0]; - let w = [edge(b.at, c.at, p), edge(c.at, a.at, p), edge(a.at, b.at, p)].map(|e| e / area); - if w.iter().any(|&k| k < 0.0) { + let w = edges.map(|(from, to)| edge(from, to, p) / area); + if w.iter().zip(edges).any(|(&k, (from, to))| k < 0.0 || (k == 0.0 && !owns(from, to, area))) { continue; } let mix = |f: &dyn Fn(&Corner) -> f64| w.iter().zip(&corners).map(|(k, c)| k * f(c)).sum::(); @@ -121,7 +133,14 @@ impl Target<'_> { if self.depth.get(i).is_none_or(|&d| key <= d) { continue; } - let shade = if a.shade == b.shade && b.shade == c.shade { a.shade } else { [0, 1, 2].map(|j| mix(&|c| at(c.shade, j))) }; + let shade = if a.shade == b.shade && b.shade == c.shade { + a.shade + } else if self.perspective && key > 0.0 { + // Screen weights times 1/z, renormalised, are the surface's own weights. + [0, 1, 2].map(|j| mix(&|c| at(c.shade, j) * at(c.at, 2)) / key) + } else { + [0, 1, 2].map(|j| mix(&|c| at(c.shade, j))) + }; self.blend(i, key, shaded(colour, shade)); } } @@ -193,6 +212,15 @@ pub(crate) fn edge(a: [f64; 3], b: [f64; 3], p: [f64; 3]) -> f64 { (at(b, 0) - at(a, 0)) * (at(p, 1) - at(a, 1)) - (at(b, 1) - at(a, 1)) * (at(p, 0) - at(a, 0)) } +/// Whether a pixel centre exactly on the edge `from`-`to` of a triangle of signed doubled area +/// `area` is the triangle's: the side the triangle grows towards, +x first, then +y. Two +/// triangles sharing the edge lie on opposite sides of it, so exactly one owns it. +fn owns(from: [f64; 3], to: [f64; 3], area: f64) -> bool { + let s = area.signum(); + let (gx, gy) = (-(at(to, 1) - at(from, 1)) * s, (at(to, 0) - at(from, 0)) * s); + gx > 0.0 || (gx == 0.0 && gy > 0.0) +} + /// Component `i` of `v`, 0.0 out of range. pub(crate) fn at(v: [f64; 3], i: usize) -> f64 { v.get(i).copied().unwrap_or(0.0) @@ -223,4 +251,32 @@ mod tests { assert_eq!(channel(-4.0), 0); assert_eq!(channel(127.5), 128); } + + fn corner(x: f64, y: f64, key: f64, shade: f64) -> Corner { + Corner { at: [x, y, key], shade: [shade; 3] } + } + + #[test] + fn a_pixel_on_an_edge_two_triangles_share_is_blended_once() { + let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]); + let mut t = Target { perspective: false, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth }; + let [a, b, c, d] = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)].map(|(x, y)| corner(x, y, 1.0, 1.0)); + t.fill([a, b, c], [255, 0, 0, 128]); + t.fill([a, c, d], [255, 0, 0, 128]); + // The diagonal runs through four pixel centres; each must look like the rest. + for px in rgba.chunks(4) { + assert_eq!(px, [255, 0, 0, 128]); + } + } + + #[test] + fn a_perspective_shade_is_interpolated_on_the_surface_not_the_screen() { + let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]); + let mut t = Target { perspective: true, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth }; + // The x = 4 corner is three times as far away as the others, and the only lit one. + t.fill([corner(0.0, 0.0, 1.0, 0.0), corner(4.0, 0.0, 1.0 / 3.0, 1.0), corner(0.0, 4.0, 1.0, 0.0)], [240, 240, 240, 255]); + // At screen (1.5, 0.5) the weights are 0.5, 0.375, 0.125: on the surface the far corner + // counts 0.125 / 0.75 = 1/6, where on the screen it would count 0.375. + assert_eq!(rgba.get(4..8), Some(&[40, 40, 40, 255][..])); + } } diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs index 146266434b..d9072a3055 100644 --- a/crates/prc-render/src/render.rs +++ b/crates/prc-render/src/render.rs @@ -242,6 +242,10 @@ pub enum RenderError { /// The placed model has more than [`crate::MAX_ASSEMBLED_TRIANGLES`] triangles. #[error("the placed model has more triangles than the assembly ceiling")] TooManyTriangles, + /// The placed model has more than [`crate::MAX_ASSEMBLED_VERTICES`] positions, normals and + /// texture coordinates. + #[error("the placed model has more vertices than the assembly ceiling")] + TooManyVertices, } /// Draw `meshes` as seen by `camera`. @@ -251,12 +255,14 @@ pub enum RenderError { /// nearer surfaces hide farther ones. Opaque triangles are drawn first; translucent ones are then /// blended over them, hidden by nearer opaque surfaces but not by each other, in mesh order; fully /// transparent ones are not drawn. Triangles with a non-finite or out-of-range vertex are skipped. -/// A perspective camera clips what lies behind it. +/// A perspective camera clips at a near plane 1/10,000 of the way from the eye to the target, so +/// what lies behind the eye, or in front of it but nearer than that, is not drawn; an orthographic +/// camera does not clip. /// /// # Errors /// [`RenderError::Size`] for an empty or over-ceiling image; [`RenderError::Camera`] for a camera /// that cannot form a view (eye on the target, `up` parallel to the view, a field of view outside -/// 0–180°, a non-positive orthographic height). +/// 0–180° or too small for a finite focal length, a non-positive orthographic height). pub fn render(meshes: &[WorldMesh], camera: &Camera, options: &RenderOptions) -> Result { let (width, height) = (options.width, options.height); let size = RenderError::Size { width, height }; @@ -266,10 +272,11 @@ pub fn render(meshes: &[WorldMesh], camera: &Camera, options: &RenderOptions) -> } let pixels = usize::try_from(pixels).map_err(|_| size)?; let view = View::new(camera, f64::from(width) / f64::from(height))?; + let perspective = matches!(view, View::Perspective { .. }); let scene = Scene { eye: camera.eye, basis: camera.basis()?, view, lighting: options.lighting }; let mut rgba = options.background.repeat(pixels); let mut depth = vec![f64::NEG_INFINITY; pixels]; - let mut target = Target { width, height, rgba: &mut rgba, depth: &mut depth }; + let mut target = Target { perspective, width, height, rgba: &mut rgba, depth: &mut depth }; let [r, g, b] = options.colour; for opaque in [true, false] { for m in meshes { diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs index 008f177491..8d0764315d 100644 --- a/crates/prc-render/src/render/tests.rs +++ b/crates/prc-render/src/render/tests.rs @@ -236,6 +236,7 @@ fn bad_sizes_and_cameras_are_refused() { Camera { up: [0.0, 0.0, 1.0], ..ortho(1.0) }, Camera { projection: Projection::Perspective { fov_y: 180.0 }, ..ortho(1.0) }, Camera { projection: Projection::Perspective { fov_y: f64::NAN }, ..ortho(1.0) }, + Camera { projection: Projection::Perspective { fov_y: 1e-310 }, ..ortho(1.0) }, ortho(0.0), ortho(f64::INFINITY), ]; diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs index 90af0f3ba2..8ebf844087 100644 --- a/crates/prc-render/src/vec3.rs +++ b/crates/prc-render/src/vec3.rs @@ -28,8 +28,14 @@ pub(crate) fn length(a: Vec3) -> f64 { dot(a, a).sqrt() } -/// `a` scaled to unit length; `None` when its length is zero or not finite. +/// `a` scaled to unit length; `None` when its length is zero or not finite. `a` is first divided +/// by its largest component, so squaring cannot overflow or underflow. pub(crate) fn normalize(a: Vec3) -> Option { + let big = a.iter().fold(0.0_f64, |m, c| m.max(c.abs())); + if !(big.is_finite() && big > 0.0) { + return None; + } + let a = a.map(|c| c / big); let l = length(a); (l.is_finite() && l > 0.0).then(|| scale(a, 1.0 / l)) } @@ -49,5 +55,12 @@ mod tests { assert_eq!(normalize([0.0, 4.0, 0.0]), Some([0.0, 1.0, 0.0])); assert_eq!(normalize([0.0; 3]), None); assert_eq!(normalize([f64::NAN, 0.0, 0.0]), None); + assert_eq!(normalize([f64::INFINITY, 0.0, 0.0]), None); + } + + #[test] + fn normalize_keeps_huge_and_tiny_vectors() { + assert_eq!(normalize([1e200, 0.0, 0.0]), Some([1.0, 0.0, 0.0])); + assert_eq!(normalize([0.0, -3e-320, 0.0]), Some([0.0, -1.0, 0.0])); } } From ae1b28cf1dc9d8e133344ff43161111a4b685555 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 21:44:55 -0500 Subject: [PATCH 17/27] M12: model3d_render, a 3D annotation's PRC model drawn to a PNG pdfcraft-annot reads a page's 3D annotations and the model each shows: the /3DD stream, or the stream a /3DRef names (at most four deep, so a cycle ends), decoded within 256 MB and typed PRC or U3D by /Subtype. pdfcraft-engine exposes both on Document. The automation tool model3d_render picks a page's Nth 3D model, parses PRC with pdfcraft-prc, assembles and frames it with pdfcraft-prc-render from a chosen direction (default an isometric-style view, Z up), and returns the image as a PNG. U3D gives an error until a decoder lands. parity misc.3d-view moves to partial. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- Cargo.lock | 2 + crates/annot/src/lib.rs | 2 + crates/annot/src/model3d.rs | 86 ++++++++++++++ crates/annot/src/model3d/tests.rs | 84 ++++++++++++++ crates/automation/Cargo.toml | 2 + crates/automation/src/dispatch.rs | 1 + crates/automation/src/lib.rs | 1 + crates/automation/src/model3d_render.rs | 106 ++++++++++++++++++ crates/automation/src/tools.rs | 7 ++ crates/automation/tests/it/main.rs | 1 + crates/automation/tests/it/model3d_render.rs | 112 +++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/model3d.rs | 19 ++++ parity/acrobat-features.toml | 8 +- 14 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 crates/annot/src/model3d.rs create mode 100644 crates/annot/src/model3d/tests.rs create mode 100644 crates/automation/src/model3d_render.rs create mode 100644 crates/automation/tests/it/model3d_render.rs create mode 100644 crates/engine/src/model3d.rs diff --git a/Cargo.lock b/Cargo.lock index bc76adfde5..72be32bc2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4277,6 +4277,8 @@ dependencies = [ "pdfcraft-model", "pdfcraft-organize", "pdfcraft-platform", + "pdfcraft-prc", + "pdfcraft-prc-render", "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 7bd0894c66..0c83538ff8 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -22,6 +22,7 @@ mod create; mod draw; mod info; mod lookup; +mod model3d; mod modify; mod types; @@ -36,6 +37,7 @@ use lookup::{annots, page_ref}; pub use lookup::{ has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image, }; +pub use model3d::{MAX_MODEL_BYTES, Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, diff --git a/crates/annot/src/model3d.rs b/crates/annot/src/model3d.rs new file mode 100644 index 0000000000..66a755b862 --- /dev/null +++ b/crates/annot/src/model3d.rs @@ -0,0 +1,86 @@ +//! Reading the model a 3D annotation shows (ISO 32000-2 §13.6.3 and §13.6.4): its `/3DD` is a +//! 3D stream, or a 3D reference dictionary naming the stream another annotation shares. + +use super::lookup::{annots, page_ref}; +use super::{AnnotError, Dict, Document, Object}; + +/// The largest decoded 3D stream [`model_3d`] reads, in bytes. +pub const MAX_MODEL_BYTES: usize = 256 << 20; + +/// How many `/3DRef` dictionaries [`model_3d`] follows before giving up: the standard allows one, +/// so a longer chain is damage, and a cycle ends here too. +const MAX_REF_DEPTH: usize = 4; + +/// The format of a 3D stream, from its `/Subtype`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Model3dKind { + /// Product Representation Compact (ISO 14739-1). + Prc, + /// Universal 3D (ECMA-363). + U3d, +} + +/// A 3D annotation's model: its format and the decoded stream. +#[derive(Debug, Clone, PartialEq)] +pub struct Model3d { + pub kind: Model3dKind, + pub data: Vec, +} + +/// The `/Annots` positions of the 3D annotations on 0-based `page`, in order. +/// +/// # Errors +/// [`AnnotError::NoPageTree`] or [`AnnotError::NoSuchPage`]. +pub fn model_3d_annotations(doc: &Document, page: usize) -> Result, AnnotError> { + let list = annots(doc, page_ref(doc, page)?); + Ok(list.iter().enumerate().filter(|(_, a)| doc.dict(a).is_some_and(|d| d.name(b"Subtype") == Some(b"3D"))).map(|(i, _)| i).collect()) +} + +/// The model of the 3D annotation at `/Annots` position `index` on 0-based `page`, decoded. +/// +/// # Errors +/// [`AnnotError::NoSuchAnnotation`] when there is no annotation there; [`AnnotError::Invalid`] +/// when it is not a 3D annotation, has no readable 3D stream, names a format other than PRC or +/// U3D, or decodes past [`MAX_MODEL_BYTES`]. +pub fn model_3d(doc: &Document, page: usize, index: usize) -> Result { + let list = annots(doc, page_ref(doc, page)?); + let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let annot = doc.dict(entry).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let at = || format!("annotation {} on page {}", index.saturating_add(1), page.saturating_add(1)); + if annot.name(b"Subtype") != Some(b"3D") { + return Err(AnnotError::Invalid(format!("{} is not a 3D annotation", at()))); + } + let stream = stream_of(doc, &annot).ok_or_else(|| AnnotError::Invalid(format!("{} has no 3D stream", at())))?; + let Object::Stream(s) = stream.as_ref() else { + return Err(AnnotError::Invalid(format!("{} has no 3D stream", at()))); + }; + let kind = match s.dict.name(b"Subtype") { + Some(b"PRC") => Model3dKind::Prc, + Some(b"U3D") => Model3dKind::U3d, + other => { + let name = other.map_or_else(|| "none".to_owned(), |n| String::from_utf8_lossy(n).into_owned()); + return Err(AnnotError::Invalid(format!("{}'s 3D stream is of an unknown format ({name})", at()))); + } + }; + let data = doc + .with_direct_parms(s) + .decoded_within(MAX_MODEL_BYTES) + .map_err(|e| AnnotError::Invalid(format!("{}'s 3D stream could not be decoded: {e}", at())))?; + Ok(Model3d { kind, data }) +} + +/// The 3D stream an annotation's `/3DD` names, through at most [`MAX_REF_DEPTH`] `/3DRef`s. +fn stream_of(doc: &Document, annot: &Dict) -> Option> { + let mut obj = doc.resolve(annot.get(b"3DD")?); + for _ in 0..=MAX_REF_DEPTH { + match obj.as_ref() { + Object::Stream(_) => return Some(obj), + Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => obj = doc.resolve(d.get(b"3DD")?), + _ => return None, + } + } + None +} + +#[cfg(test)] +mod tests; diff --git a/crates/annot/src/model3d/tests.rs b/crates/annot/src/model3d/tests.rs new file mode 100644 index 0000000000..a58e24c30c --- /dev/null +++ b/crates/annot/src/model3d/tests.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use super::*; + +/// A one-page document whose page has `annots` as its `/Annots`, plus `extra` objects from 4 on. +fn doc(annots: &str, extra: &[Vec]) -> Document { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /Annots {annots} >>").into_bytes(), + ]; + objs.extend(extra.iter().cloned()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") +} + +fn stream(dict: &str, body: &[u8]) -> Vec { + let mut s = format!("<< {dict} /Length {} >>\nstream\n", body.len()).into_bytes(); + s.extend_from_slice(body); + s.extend_from_slice(b"\nendstream"); + s +} + +fn annot(dd: &str) -> Vec { + format!("<< /Type /Annot /Subtype /3D /Rect [0 0 100 100] /3DD {dd} >>").into_bytes() +} + +#[test] +fn a_plain_3d_stream_is_read_through_its_filters() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /PRC /Filter /ASCIIHexDecode", b"505243>")]); + assert_eq!(model_3d(&d, 0, 0), Ok(Model3d { kind: Model3dKind::Prc, data: b"PRC".to_vec() })); +} + +#[test] +fn a_3dref_leads_to_the_stream_it_names() { + let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); + assert_eq!(model_3d(&d, 0, 0).map(|m| m.data), Ok(b"PRC".to_vec())); +} + +#[test] +fn a_u3d_stream_is_read_and_says_so() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /U3D", b"U3D")]); + assert_eq!(model_3d(&d, 0, 0).map(|m| m.kind), Ok(Model3dKind::U3d)); +} + +#[test] +fn a_3d_annotation_without_a_stream_is_invalid() { + let d = doc("[<< /Type /Annot /Subtype /3D /Rect [0 0 1 1] >>]", &[]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); +} + +#[test] +fn an_unknown_format_is_invalid() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /STL", b"x")]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("unknown format (STL)"))); +} + +#[test] +fn a_cycle_of_3drefs_ends() { + let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3DD 6 0 R >>".to_vec(), b"<< /Type /3DRef /3DD 5 0 R >>".to_vec()]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); +} + +#[test] +fn another_annotation_is_not_a_3d_one() { + let d = doc("[<< /Type /Annot /Subtype /Square /Rect [0 0 1 1] >> 4 0 R]", &[annot("5 0 R"), stream("/Subtype /PRC", b"PRC")]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("not a 3D annotation"))); + assert_eq!(model_3d(&d, 0, 2), Err(AnnotError::NoSuchAnnotation { page: 0, index: 2 })); + assert_eq!(model_3d_annotations(&d, 0), Ok(vec![1])); + assert_eq!(model_3d_annotations(&d, 1), Err(AnnotError::NoSuchPage(1))); +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index a6e43de1f0..a825b1fe52 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -14,6 +14,8 @@ pdfcraft-cos = { workspace = true } pdfcraft-render = { workspace = true } pdfcraft-organize = { workspace = true } pdfcraft-platform = { workspace = true } +pdfcraft-prc = { workspace = true } +pdfcraft-prc-render = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index f3c51d22e0..8269963598 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -69,6 +69,7 @@ impl Automation { self.apply(&a, edit)? } "page_render" => return self.page_render(&a), + "model3d_render" => return self.model3d_render(&a), "comment_image_preview" => return self.comment_image_preview(&a), "text_extract" => self.text_extract(&a)?, "text_word_count" => self.text_word_count(&a)?, diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5bcb032fce..eb58b52e75 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -27,6 +27,7 @@ mod links; #[cfg(feature = "mcp")] pub mod mcp; mod measure; +mod model3d_render; mod printing; mod reading; mod redact; diff --git a/crates/automation/src/model3d_render.rs b/crates/automation/src/model3d_render.rs new file mode 100644 index 0000000000..7f789118a5 --- /dev/null +++ b/crates/automation/src/model3d_render.rs @@ -0,0 +1,106 @@ +//! `model3d_render`: a page's 3D model (ISO 32000-2 §13.6) drawn to a PNG, without a viewer. + +use pdfcraft_engine::model3d::Model3dKind; +use pdfcraft_prc::PrcFile; +use pdfcraft_prc_render::{Camera, RenderOptions, assemble, render}; + +use super::Automation; +use super::dispatch::Content; +use super::error::{Result, ToolError, failed}; +use super::json::Args; +use super::pages::encode_png; + +/// Default and largest side of the image, in pixels. +pub(crate) const DEFAULT_SIDE: u32 = 512; +pub(crate) const MAX_SIDE: u32 = 4096; + +/// The default view: from the front, right and above, as an isometric view looks. +const DEFAULT_DIRECTION: [f64; 3] = [-1.0, 1.0, -1.0]; + +impl Automation { + pub(crate) fn model3d_render(&mut self, a: &Args) -> Result> { + let page = self.page(a)?; + let width = side(a, "width")?; + let height = side(a, "height")?; + let direction = direction(a)?; + let perspective = a.opt_bool("perspective")?.unwrap_or(true); + let doc = self.doc(a)?; + let found = doc.model_3d_annotations(page).map_err(failed)?; + let index = match a.opt_int("model")? { + None => *found.first().ok_or_else(|| failed(format!("page {} has no 3D model", page.saturating_add(1))))?, + Some(n) => { + let pos = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| Args::wrong("model", "a positive integer"))?; + *found.get(pos).ok_or_else(|| { + ToolError::InvalidArgs(format!("page {} has {} 3D models, not {n}", page.saturating_add(1), found.len())) + })? + } + }; + let model = doc.model_3d(page, index).map_err(failed)?; + if model.kind == Model3dKind::U3d { + return Err(failed("this 3D model is in U3D, which can't be drawn yet; only PRC models can")); + } + let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?; + let meshes = assemble(&prc).map_err(failed)?; + let aspect = f64::from(width) / f64::from(height); + let camera = Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?; + let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?; + let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?; + Ok(vec![Content::Png { data, width: image.width, height: image.height }]) + } +} + +fn side(a: &Args, key: &str) -> Result { + match a.opt_int(key)? { + None => Ok(DEFAULT_SIDE), + Some(n) => u32::try_from(n).ok().filter(|n| (1..=MAX_SIDE).contains(n)).ok_or_else(|| Args::wrong(key, &format!("between 1 and {MAX_SIDE}"))), + } +} + +/// `direction`: three finite numbers, not all zero, from the eye towards the model. +fn direction(a: &Args) -> Result<[f64; 3]> { + let Some(v) = a.get("direction") else { return Ok(DEFAULT_DIRECTION) }; + let wrong = || Args::wrong("direction", "three numbers [x, y, z], not all zero"); + let items = v.as_array().filter(|x| x.len() == 3).ok_or_else(wrong)?; + let mut out = [0.0; 3]; + for (o, x) in out.iter_mut().zip(items) { + *o = x.as_f64().filter(|f| f.is_finite()).ok_or_else(wrong)?; + } + if out.iter().all(|c| *c == 0.0) { + return Err(wrong()); + } + Ok(out) +} + +/// Z is up, as CAD models are drawn, unless the view looks along it. +fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] { + if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] } +} + +/// Straight RGBA to the premultiplied RGBA `encode_png` takes. +fn premultiplied(mut rgba: Vec) -> Vec { + for px in rgba.chunks_exact_mut(4) { + if let [r, g, b, alpha] = px { + let a = u16::from(*alpha); + for c in [r, g, b] { + *c = u8::try_from((u16::from(*c) * a + 127) / 255).unwrap_or(u8::MAX); + } + } + } + rgba +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn premultiplying_scales_colour_by_alpha() { + assert_eq!(premultiplied(vec![200, 100, 0, 255, 200, 100, 0, 128, 9, 9, 9, 0]), vec![200, 100, 0, 255, 100, 50, 0, 128, 0, 0, 0, 0]); + } + + #[test] + fn a_view_along_z_keeps_y_up() { + assert_eq!(up_for([0.0, 0.0, -1.0]), [0.0, 1.0, 0.0]); + assert_eq!(up_for(DEFAULT_DIRECTION), [0.0, 0.0, 1.0]); + } +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4672e49886..0d8a3a6c97 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -181,6 +181,13 @@ pub fn tools() -> Vec { "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, "hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." } }), &["doc", "page"])), + t("model3d_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.") + .ro() + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 }, + "width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 }, + "perspective": { "type": "boolean" } + }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.") diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index d36dbe93a9..d4946b6d73 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -8,6 +8,7 @@ mod batch; mod button_caption; mod form_bulk; mod hostile_args; +mod model3d_render; mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; diff --git a/crates/automation/tests/it/model3d_render.rs b/crates/automation/tests/it/model3d_render.rs new file mode 100644 index 0000000000..47e757906a --- /dev/null +++ b/crates/automation/tests/it/model3d_render.rs @@ -0,0 +1,112 @@ +//! `model3d_render` draws a page's 3D model headlessly: a synthetic PDF holding a synthetic PRC. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// One triangle at z = 0 facing +z, with corners (-1,-1), (1,-1) and (0,1), as a PRC file of one +/// file structure with no product tree, in hexadecimal for the stream's `/ASCIIHexDecode`. +const TRIANGLE_PRC_HEX: &str = "505243c91f0000c91f00000100000002000000030000000400000000000000000000000000000000000000010000000500000006000000070000000800000000000000060000006b0000009a000000a3000000ab000000cd000000d5000000dd000000e500000000000000505243c91f0000c91f0000050000000600000007000000080000000000000000000000000000000000000000000000789c63000000010001789c030000000001789c9b71c087758da2422b43084fe3140e038713464cd759381a162800006ed70828789c030000000001789c030000000001789c030000000001"; + +/// A one-page PDF whose page holds a 3D annotation over a stream of `/Subtype /{subtype}`. +fn pdf(subtype: &str, hex: &str) -> Vec { + let stream = format!("<< /Type /3D /Subtype /{subtype} /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Square /Rect [0 0 10 10] >> 4 0 R] >>".into(), + "<< /Type /Annot /Subtype /3D /Rect [20 20 180 180] /3DD 5 0 R >>".into(), + stream, + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A fresh folder with `prc.pdf` and `u3d.pdf` in `in`, `outside.pdf` beside it, and an +/// automation rooted at `in`. +fn setup(test: &str) -> (PathBuf, Automation) { + let dir = std::env::temp_dir().join(format!("pdfcraft-model3d-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("in")).unwrap(); + std::fs::write(dir.join("in/prc.pdf"), pdf("PRC", TRIANGLE_PRC_HEX)).unwrap(); + std::fs::write(dir.join("in/u3d.pdf"), pdf("U3D", "553344")).unwrap(); + std::fs::write(dir.join("outside.pdf"), pdf("PRC", TRIANGLE_PRC_HEX)).unwrap(); + let a = Automation::new().with_root(dir.join("in")).unwrap(); + (dir, a) +} + +fn open(a: &mut Automation, path: &str) -> u64 { + match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) { + Content::Json(v) => v["doc"].as_u64().unwrap(), + other => panic!("expected JSON: {other:?}"), + } +} + +/// The RGB of pixel (x, y) of a PNG. +fn rgb(data: &[u8], x: usize, y: usize) -> [u8; 3] { + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + let channels = info.line_size / info.width as usize; + let at = y * info.line_size + x * channels; + [pixels[at], pixels[at + 1], pixels[at + 2]] +} + +#[test] +fn a_prc_model_is_drawn_over_the_background() { + let (_dir, mut a) = setup("prc"); + let doc = open(&mut a, "prc.pdf"); + let args = json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1], "perspective": false }); + let out = a.call("model3d_render", &args).unwrap(); + let Content::Png { data, width, height } = &out[0] else { panic!("expected a PNG") }; + assert_eq!((*width, *height), (64, 48)); + assert_eq!(rgb(data, 0, 0), [255, 255, 255], "the corner is background"); + assert_eq!(rgb(data, 32, 24), [190, 192, 200], "the centre is the triangle, lit head on"); + // In perspective the headlight meets the face at a slight angle, so only not-background holds. + let out = a.call("model3d_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1] })).unwrap(); + let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") }; + assert_ne!(rgb(data, 32, 24), [255, 255, 255], "the centre is drawn in perspective too"); + // The same model named explicitly, and the default isometric view, both draw. + assert!(a.call("model3d_render", &json!({ "doc": doc, "page": 1, "model": 1 })).is_ok()); +} + +#[test] +fn a_u3d_model_or_a_missing_one_is_a_clear_error() { + let (_dir, mut a) = setup("u3d"); + let doc = open(&mut a, "u3d.pdf"); + let err = a.call("model3d_render", &json!({ "doc": doc, "page": 1 })).unwrap_err().to_string(); + assert!(err.contains("U3D"), "{err}"); + let doc = open(&mut a, "prc.pdf"); + let err = a.call("model3d_render", &json!({ "doc": doc, "page": 1, "model": 2 })).unwrap_err().to_string(); + assert!(err.contains("has 1 3D models, not 2"), "{err}"); + for bad in [json!({ "direction": [0, 0, 0] }), json!({ "direction": [1, 2] }), json!({ "width": 0 }), json!({ "height": 5000 })] { + let mut args = json!({ "doc": doc, "page": 1 }); + args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(a.call("model3d_render", &args).is_err(), "{bad}"); + } +} + +#[test] +fn a_model_outside_the_root_is_never_opened_to_be_drawn() { + let (dir, mut a) = setup("root"); + let outside = dir.join("outside.pdf"); + for path in [Value::from("../outside.pdf"), Value::from(outside.to_string_lossy().into_owned())] { + assert!(a.call("doc_open", &json!({ "path": path })).is_err(), "{path} lies outside the root"); + } + // The tool takes no path of its own: only a document opened inside the root reaches it. + assert!(a.call("model3d_render", &json!({ "doc": 1, "page": 1 })).is_err()); + let doc = open(&mut a, "prc.pdf"); + assert!(a.call("model3d_render", &json!({ "doc": doc, "page": 1 })).is_ok()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe139164be..3359bad37c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -35,6 +35,7 @@ mod history; pub mod js; mod layers; pub mod links; +pub mod model3d; pub mod ocr; mod open; pub mod optimizer; diff --git a/crates/engine/src/model3d.rs b/crates/engine/src/model3d.rs new file mode 100644 index 0000000000..b95a332a22 --- /dev/null +++ b/crates/engine/src/model3d.rs @@ -0,0 +1,19 @@ +//! The 3D models on a page (ISO 32000-2 §13.6), read so a renderer can draw them. + +pub use pdfcraft_annot::{MAX_MODEL_BYTES, Model3d, Model3dKind}; + +use crate::Document; + +impl Document { + /// The `/Annots` positions of the 3D annotations on 0-based `page`, in order. + pub fn model_3d_annotations(&self, page: usize) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_annot::model_3d_annotations(&editor.cos, page).map_err(|e| e.to_string()) + } + + /// The decoded model of the 3D annotation at `/Annots` position `index` on 0-based `page`. + pub fn model_3d(&self, page: usize, index: usize) -> Result { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_annot::model_3d(&editor.cos, page, index).map_err(|e| e.to_string()) + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8b7ac909a0..21e208c003 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9896,11 +9896,11 @@ title = "View 3D (U3D/PRC) content" area = "N" tier = "P3" milestone = "M12" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["model3d_render"] +evidence = ["crates/automation/tests/it/model3d_render.rs::a_prc_model_is_drawn_over_the_background", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"] +notes = "headless only: model3d_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, with headlight shading. Missing: the 3D view in the app (no activation, no navigation), U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements" [[feature]] id = "misc.legacy-multimedia" From fb05b733f98fa3c8e9f14d6a5782766abdc9d387 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 22:53:15 -0500 Subject: [PATCH 18/27] M12: model_render, and a 3D reference's stream under /3D MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 3D reference dictionary holds the shared stream under /3D (ISO 32000-2 §13.6.3), not /3DD; it is followed once, and the caller refuses anything that is not a stream, so a chain or cycle of references ends. The tool is renamed model_render, since MCP tool names here are lowercase letters and underscores only, and to match model_add. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/annot/src/model3d.rs | 19 ++++++------------- crates/annot/src/model3d/tests.rs | 10 +++++++--- crates/automation/README.md | 9 +++++++++ crates/automation/src/dispatch.rs | 2 +- crates/automation/src/lib.rs | 2 +- .../{model3d_render.rs => model_render.rs} | 10 +++++----- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/main.rs | 2 +- .../it/{model3d_render.rs => model_render.rs} | 18 +++++++++--------- parity/acrobat-features.toml | 6 +++--- 10 files changed, 43 insertions(+), 37 deletions(-) rename crates/automation/src/{model3d_render.rs => model_render.rs} (91%) rename crates/automation/tests/it/{model3d_render.rs => model_render.rs} (86%) diff --git a/crates/annot/src/model3d.rs b/crates/annot/src/model3d.rs index 66a755b862..60d2df0f2a 100644 --- a/crates/annot/src/model3d.rs +++ b/crates/annot/src/model3d.rs @@ -7,10 +7,6 @@ use super::{AnnotError, Dict, Document, Object}; /// The largest decoded 3D stream [`model_3d`] reads, in bytes. pub const MAX_MODEL_BYTES: usize = 256 << 20; -/// How many `/3DRef` dictionaries [`model_3d`] follows before giving up: the standard allows one, -/// so a longer chain is damage, and a cycle ends here too. -const MAX_REF_DEPTH: usize = 4; - /// The format of a 3D stream, from its `/Subtype`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Model3dKind { @@ -69,17 +65,14 @@ pub fn model_3d(doc: &Document, page: usize, index: usize) -> Result Option> { - let mut obj = doc.resolve(annot.get(b"3DD")?); - for _ in 0..=MAX_REF_DEPTH { - match obj.as_ref() { - Object::Stream(_) => return Some(obj), - Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => obj = doc.resolve(d.get(b"3DD")?), - _ => return None, - } + let obj = doc.resolve(annot.get(b"3DD")?); + match obj.as_ref() { + Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => Some(doc.resolve(d.get(b"3D")?)), + _ => Some(obj), } - None } #[cfg(test)] diff --git a/crates/annot/src/model3d/tests.rs b/crates/annot/src/model3d/tests.rs index a58e24c30c..90addb1bb6 100644 --- a/crates/annot/src/model3d/tests.rs +++ b/crates/annot/src/model3d/tests.rs @@ -46,7 +46,7 @@ fn a_plain_3d_stream_is_read_through_its_filters() { #[test] fn a_3dref_leads_to_the_stream_it_names() { - let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); + let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3D 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); assert_eq!(model_3d(&d, 0, 0).map(|m| m.data), Ok(b"PRC".to_vec())); } @@ -69,8 +69,12 @@ fn an_unknown_format_is_invalid() { } #[test] -fn a_cycle_of_3drefs_ends() { - let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3DD 6 0 R >>".to_vec(), b"<< /Type /3DRef /3DD 5 0 R >>".to_vec()]); +fn a_3dref_is_followed_once_and_only_to_a_stream() { + // A reference naming another reference, or one that names itself, is damage: no stream. + let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3D 6 0 R >>".to_vec(), b"<< /Type /3DRef /3D 5 0 R >>".to_vec()]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); + // The stream is under `/3D`; a `/3DD` inside the reference is not the standard's key. + let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); } diff --git a/crates/automation/README.md b/crates/automation/README.md index 8ffa2d0b3e..d218fbcda6 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -250,6 +250,15 @@ choices and values over 4096 UTF-8 bytes return an error. Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`. +## Rendering a 3D model + +`model_render` draws a page's 3D model (a 3D annotation's PRC stream, directly or through a 3D +reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1); +`width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye +(default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true. +The model is framed to fill the image and shaded by a headlight on white. U3D models are refused +with an error, and the annotation's own views, lighting and render modes are not applied yet. + ## Viewer navigation history A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 8269963598..f694506adf 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -69,7 +69,7 @@ impl Automation { self.apply(&a, edit)? } "page_render" => return self.page_render(&a), - "model3d_render" => return self.model3d_render(&a), + "model_render" => return self.model_render(&a), "comment_image_preview" => return self.comment_image_preview(&a), "text_extract" => self.text_extract(&a)?, "text_word_count" => self.text_word_count(&a)?, diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index eb58b52e75..ddbfd45786 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -27,7 +27,7 @@ mod links; #[cfg(feature = "mcp")] pub mod mcp; mod measure; -mod model3d_render; +mod model_render; mod printing; mod reading; mod redact; diff --git a/crates/automation/src/model3d_render.rs b/crates/automation/src/model_render.rs similarity index 91% rename from crates/automation/src/model3d_render.rs rename to crates/automation/src/model_render.rs index 7f789118a5..145103ffe9 100644 --- a/crates/automation/src/model3d_render.rs +++ b/crates/automation/src/model_render.rs @@ -1,4 +1,4 @@ -//! `model3d_render`: a page's 3D model (ISO 32000-2 §13.6) drawn to a PNG, without a viewer. +//! `model_render`: a page's 3D model (ISO 32000-2 §13.6) drawn to a PNG, without a viewer. use pdfcraft_engine::model3d::Model3dKind; use pdfcraft_prc::PrcFile; @@ -18,7 +18,7 @@ pub(crate) const MAX_SIDE: u32 = 4096; const DEFAULT_DIRECTION: [f64; 3] = [-1.0, 1.0, -1.0]; impl Automation { - pub(crate) fn model3d_render(&mut self, a: &Args) -> Result> { + pub(crate) fn model_render(&mut self, a: &Args) -> Result> { let page = self.page(a)?; let width = side(a, "width")?; let height = side(a, "height")?; @@ -30,9 +30,9 @@ impl Automation { None => *found.first().ok_or_else(|| failed(format!("page {} has no 3D model", page.saturating_add(1))))?, Some(n) => { let pos = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| Args::wrong("model", "a positive integer"))?; - *found.get(pos).ok_or_else(|| { - ToolError::InvalidArgs(format!("page {} has {} 3D models, not {n}", page.saturating_add(1), found.len())) - })? + *found + .get(pos) + .ok_or_else(|| ToolError::InvalidArgs(format!("page {} has {} 3D models, not {n}", page.saturating_add(1), found.len())))? } }; let model = doc.model_3d(page, index).map_err(failed)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0d8a3a6c97..7944896b27 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -181,7 +181,7 @@ pub fn tools() -> Vec { "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, "hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." } }), &["doc", "page"])), - t("model3d_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.") + t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 }, "width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 }, diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index d4946b6d73..85524f13a2 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -8,7 +8,7 @@ mod batch; mod button_caption; mod form_bulk; mod hostile_args; -mod model3d_render; +mod model_render; mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; diff --git a/crates/automation/tests/it/model3d_render.rs b/crates/automation/tests/it/model_render.rs similarity index 86% rename from crates/automation/tests/it/model3d_render.rs rename to crates/automation/tests/it/model_render.rs index 47e757906a..615a5346a9 100644 --- a/crates/automation/tests/it/model3d_render.rs +++ b/crates/automation/tests/it/model_render.rs @@ -1,4 +1,4 @@ -//! `model3d_render` draws a page's 3D model headlessly: a synthetic PDF holding a synthetic PRC. +//! `model_render` draws a page's 3D model headlessly: a synthetic PDF holding a synthetic PRC. use std::path::PathBuf; @@ -69,32 +69,32 @@ fn a_prc_model_is_drawn_over_the_background() { let (_dir, mut a) = setup("prc"); let doc = open(&mut a, "prc.pdf"); let args = json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1], "perspective": false }); - let out = a.call("model3d_render", &args).unwrap(); + let out = a.call("model_render", &args).unwrap(); let Content::Png { data, width, height } = &out[0] else { panic!("expected a PNG") }; assert_eq!((*width, *height), (64, 48)); assert_eq!(rgb(data, 0, 0), [255, 255, 255], "the corner is background"); assert_eq!(rgb(data, 32, 24), [190, 192, 200], "the centre is the triangle, lit head on"); // In perspective the headlight meets the face at a slight angle, so only not-background holds. - let out = a.call("model3d_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1] })).unwrap(); + let out = a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1] })).unwrap(); let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") }; assert_ne!(rgb(data, 32, 24), [255, 255, 255], "the centre is drawn in perspective too"); // The same model named explicitly, and the default isometric view, both draw. - assert!(a.call("model3d_render", &json!({ "doc": doc, "page": 1, "model": 1 })).is_ok()); + assert!(a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 1 })).is_ok()); } #[test] fn a_u3d_model_or_a_missing_one_is_a_clear_error() { let (_dir, mut a) = setup("u3d"); let doc = open(&mut a, "u3d.pdf"); - let err = a.call("model3d_render", &json!({ "doc": doc, "page": 1 })).unwrap_err().to_string(); + let err = a.call("model_render", &json!({ "doc": doc, "page": 1 })).unwrap_err().to_string(); assert!(err.contains("U3D"), "{err}"); let doc = open(&mut a, "prc.pdf"); - let err = a.call("model3d_render", &json!({ "doc": doc, "page": 1, "model": 2 })).unwrap_err().to_string(); + let err = a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 2 })).unwrap_err().to_string(); assert!(err.contains("has 1 3D models, not 2"), "{err}"); for bad in [json!({ "direction": [0, 0, 0] }), json!({ "direction": [1, 2] }), json!({ "width": 0 }), json!({ "height": 5000 })] { let mut args = json!({ "doc": doc, "page": 1 }); args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); - assert!(a.call("model3d_render", &args).is_err(), "{bad}"); + assert!(a.call("model_render", &args).is_err(), "{bad}"); } } @@ -106,7 +106,7 @@ fn a_model_outside_the_root_is_never_opened_to_be_drawn() { assert!(a.call("doc_open", &json!({ "path": path })).is_err(), "{path} lies outside the root"); } // The tool takes no path of its own: only a document opened inside the root reaches it. - assert!(a.call("model3d_render", &json!({ "doc": 1, "page": 1 })).is_err()); + assert!(a.call("model_render", &json!({ "doc": 1, "page": 1 })).is_err()); let doc = open(&mut a, "prc.pdf"); - assert!(a.call("model3d_render", &json!({ "doc": doc, "page": 1 })).is_ok()); + assert!(a.call("model_render", &json!({ "doc": doc, "page": 1 })).is_ok()); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 21e208c003..26ca5e9df8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9898,9 +9898,9 @@ tier = "P3" milestone = "M12" status = "partial" commands = [] -tools = ["model3d_render"] -evidence = ["crates/automation/tests/it/model3d_render.rs::a_prc_model_is_drawn_over_the_background", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"] -notes = "headless only: model3d_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, with headlight shading. Missing: the 3D view in the app (no activation, no navigation), U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements" +tools = ["model_render"] +evidence = ["crates/automation/tests/it/model_render.rs::a_prc_model_is_drawn_over_the_background", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"] +notes = "headless only: model_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, with headlight shading. Missing: the 3D view in the app (no activation, no navigation), U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements" [[feature]] id = "misc.legacy-multimedia" From ca21d4fb7bb763e3dc49d6d9b8f05b5de7a8f8e7 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 23:06:54 -0500 Subject: [PATCH 19/27] M12: prc-render: scale a triangle's edges before their cross product normalize already scales by the largest component, but the face normal was the normalized cross product of two edges, and edges near 1e160 overflow that product to infinity first, so the triangle was dropped. unit_cross divides both edges by their largest component before the cross product. a_huge_square_is_drawn fails with the old line (white instead of [190,192,200]). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc-render/src/render.rs | 4 ++-- crates/prc-render/src/render/tests.rs | 8 ++++++++ crates/prc-render/src/vec3.rs | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs index d9072a3055..3670878e17 100644 --- a/crates/prc-render/src/render.rs +++ b/crates/prc-render/src/render.rs @@ -5,7 +5,7 @@ use crate::WorldMesh; use crate::raster::{Corner, Target, View, ViewCorner, at}; -use crate::vec3::{cross, dot, length, normalize, scale, sub}; +use crate::vec3::{cross, dot, length, normalize, scale, sub, unit_cross}; use pdfcraft_prc::TriangleMesh; /// The largest image [`render`] draws, in pixels (width × height). @@ -318,7 +318,7 @@ impl Scene { let Some(world) = mesh.triangles.get(t).and_then(|tri| corners(mesh, tri)) else { return; }; - let Some(face) = normalize(cross(sub(at3(&world, 1), at3(&world, 0)), sub(at3(&world, 2), at3(&world, 0)))) else { + let Some(face) = unit_cross(sub(at3(&world, 1), at3(&world, 0)), sub(at3(&world, 2), at3(&world, 0))) else { return; }; let stored = stored_normals(mesh, t); diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs index 8d0764315d..ec8104a260 100644 --- a/crates/prc-render/src/render/tests.rs +++ b/crates/prc-render/src/render/tests.rs @@ -268,3 +268,11 @@ fn unlit_draws_the_plain_colour_and_the_headlight_shades_by_angle() { assert_eq!(lit(Lighting::Headlight, 1.0), [151, 153, 159, 255]); assert_eq!(lit(Lighting::Unlit, 1.0), [190, 192, 200, 255]); } + +#[test] +fn a_huge_square_is_drawn() { + // Its edges' cross product (about 1e320) overflows unless they are scaled first. + let image = render(&[quad(0.0, 1e160, 0.0)], &ortho(4e160), &small()).unwrap(); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]); + assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255]); +} diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs index 8ebf844087..7a673e0055 100644 --- a/crates/prc-render/src/vec3.rs +++ b/crates/prc-render/src/vec3.rs @@ -40,6 +40,17 @@ pub(crate) fn normalize(a: Vec3) -> Option { (l.is_finite() && l > 0.0).then(|| scale(a, 1.0 / l)) } +/// The unit normal of the plane spanned by `a` and `b` (`a × b`, normalized); `None` when they are +/// parallel, zero or not finite. Both are first divided by their largest component, so the cross +/// product of two huge edges cannot overflow before it is normalized. +pub(crate) fn unit_cross(a: Vec3, b: Vec3) -> Option { + let big = a.iter().chain(b.iter()).fold(0.0_f64, |m, c| m.max(c.abs())); + if !(big.is_finite() && big > 0.0) { + return None; + } + normalize(cross(a.map(|c| c / big), b.map(|c| c / big))) +} + #[cfg(test)] mod tests { use super::*; @@ -63,4 +74,12 @@ mod tests { assert_eq!(normalize([1e200, 0.0, 0.0]), Some([1.0, 0.0, 0.0])); assert_eq!(normalize([0.0, -3e-320, 0.0]), Some([0.0, -1.0, 0.0])); } + + #[test] + fn unit_cross_survives_edges_whose_product_overflows() { + assert_eq!(unit_cross([1e160, 0.0, 0.0], [0.0, 1e160, 0.0]), Some([0.0, 0.0, 1.0])); + assert_eq!(unit_cross([1e-170, 0.0, 0.0], [0.0, 1e-170, 0.0]), Some([0.0, 0.0, 1.0])); + assert_eq!(unit_cross([2.0, 0.0, 0.0], [4.0, 0.0, 0.0]), None); + assert_eq!(unit_cross([f64::INFINITY, 0.0, 0.0], [0.0, 1.0, 0.0]), None); + } } From d3821f35747a25c8822e307501f2c46e12127a6b Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 23:47:10 -0500 Subject: [PATCH 20/27] M12: model_render premultiplies through as_chunks_mut CI's clippy (1.99) flags chunks_exact_mut with a constant size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/src/model_render.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/model_render.rs b/crates/automation/src/model_render.rs index 145103ffe9..dbfabc6708 100644 --- a/crates/automation/src/model_render.rs +++ b/crates/automation/src/model_render.rs @@ -78,12 +78,10 @@ fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] { /// Straight RGBA to the premultiplied RGBA `encode_png` takes. fn premultiplied(mut rgba: Vec) -> Vec { - for px in rgba.chunks_exact_mut(4) { - if let [r, g, b, alpha] = px { - let a = u16::from(*alpha); - for c in [r, g, b] { - *c = u8::try_from((u16::from(*c) * a + 127) / 255).unwrap_or(u8::MAX); - } + for [r, g, b, alpha] in rgba.as_chunks_mut::<4>().0 { + let a = u16::from(*alpha); + for c in [r, g, b] { + *c = u8::try_from((u16::from(*c) * a + 127) / 255).unwrap_or(u8::MAX); } } rgba From c336c59cfb69342f2e12ca4cb2e489b0b3d88142 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 00:03:28 -0500 Subject: [PATCH 21/27] M12: prc-render: scale-safe lengths, a render work ceiling, fit fixes - vec3: length divides by the largest component before squaring, so a camera 1e200 away keeps a finite near plane; unit_cross normalizes each edge first, so edges of very different sizes keep their normal. - render: MAX_RENDER_WORK (2^31) caps the pixel centres one render tests, charged per triangle's clipped bounding box before scanning; past it, RenderError::TooMuchWork. - Bounds::centre halves each end before adding, finite for any finite box. Bounds::of and fit_meshes use only corners of triangles render draws. Camera::frame builds its basis from the direction, not from points near the model, so a small model far from the origin fits. Each new test fails with its fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc-render/README.md | 5 ++- crates/prc-render/src/lib.rs | 7 ++-- crates/prc-render/src/raster.rs | 37 ++++++++++++++----- crates/prc-render/src/render.rs | 53 +++++++++++++++------------ crates/prc-render/src/render/tests.rs | 34 ++++++++++++++++- crates/prc-render/src/vec3.rs | 44 ++++++++++++++++------ 6 files changed, 131 insertions(+), 49 deletions(-) diff --git a/crates/prc-render/README.md b/crates/prc-render/README.md index c3d4db867e..000d673179 100644 --- a/crates/prc-render/README.md +++ b/crates/prc-render/README.md @@ -23,6 +23,7 @@ render(&[WorldMesh], &Camera, &RenderOptions) -> Result - `Lighting::Headlight` (default) shades by the angle to the view direction; `Lighting::Unlit` draws flat colour. - Perspective triangles are clipped at a near plane 1/10,000 of the eye-to-target distance from the eye; orthographic views are not clipped. - `MAX_RENDER_PIXELS` (2^24, about 16 MP) bounds the image: at most 64 MB of RGBA plus 128 MB of depth. - `MAX_ASSEMBLED_TRIANGLES` and `MAX_ASSEMBLED_VERTICES` (2^24 each) bound one `assemble` call. None bounds time: a model of many large - triangles at full size is a long CPU render, so callers on a UI thread should render small or off-thread. + `MAX_ASSEMBLED_TRIANGLES` and `MAX_ASSEMBLED_VERTICES` (2^24 each) bound one `assemble` call. `MAX_RENDER_WORK` (2^31) bounds + the pixel centres one `render` tests, summed over each triangle's clipped bounding box; past it the render returns + `RenderError::TooMuchWork`. That is still seconds of CPU at the top, so callers on a UI thread should render small or off-thread. - Not yet: shadows, ambient occlusion, specular light, scene lights, textures, wires and markups. diff --git a/crates/prc-render/src/lib.rs b/crates/prc-render/src/lib.rs index 4044f110bb..9cfd49efbe 100644 --- a/crates/prc-render/src/lib.rs +++ b/crates/prc-render/src/lib.rs @@ -3,8 +3,9 @@ //! Layer L3: depends only on `pdfcraft-prc`. [`assemble`] turns a parsed [`pdfcraft_prc::PrcFile`] //! into world-space meshes; [`render`] draws them from a [`Camera`] with one depth-tested sample per //! pixel centre and a light at the camera. Single-threaded and GUI-free, so it runs unchanged on wasm32. -//! The image size is capped by [`MAX_RENDER_PIXELS`] and the assembled model by -//! [`MAX_ASSEMBLED_TRIANGLES`] and [`MAX_ASSEMBLED_VERTICES`]; bad input is an error, never a panic. +//! The image size is capped by [`MAX_RENDER_PIXELS`], the drawing work by [`MAX_RENDER_WORK`] and the +//! assembled model by [`MAX_ASSEMBLED_TRIANGLES`] and [`MAX_ASSEMBLED_VERTICES`]; bad input is an +//! error, never a panic. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -14,4 +15,4 @@ mod render; mod vec3; pub use assemble::{MAX_ASSEMBLED_TRIANGLES, MAX_ASSEMBLED_VERTICES, WorldMesh, assemble, place}; -pub use render::{Bounds, Camera, Image, Lighting, MAX_RENDER_PIXELS, Projection, RenderError, RenderOptions, render}; +pub use render::{Bounds, Camera, Image, Lighting, MAX_RENDER_PIXELS, MAX_RENDER_WORK, Projection, RenderError, RenderOptions, render}; diff --git a/crates/prc-render/src/raster.rs b/crates/prc-render/src/raster.rs index cced24db90..e30105352c 100644 --- a/crates/prc-render/src/raster.rs +++ b/crates/prc-render/src/raster.rs @@ -101,23 +101,29 @@ pub(crate) struct Target<'b> { pub(crate) height: u32, pub(crate) rgba: &'b mut [u8], pub(crate) depth: &'b mut [f64], + /// Pixel centres the fills may still test. + pub(crate) work: u64, } impl Target<'_> { /// Fill the screen triangle of `corners` with straight-RGBA `colour`, depth-tested at pixel /// centres, the shade interpolated across it. A pixel centre on an edge two triangles share /// belongs to one of them only. An opaque pixel replaces the pixel and its depth; a - /// translucent one is blended over it, leaving the depth. - pub(crate) fn fill(&mut self, corners: [Corner; 3], colour: [u8; 4]) { + /// translucent one is blended over it, leaving the depth. The clipped bounding box's pixel + /// centres are charged to [`Self::work`] first: [`RenderError::TooMuchWork`], with nothing + /// drawn, when too few are left. + pub(crate) fn fill(&mut self, corners: [Corner; 3], colour: [u8; 4]) -> Result<(), RenderError> { let [a, b, c] = corners; let area = edge(a.at, b.at, c.at); let edges = [(b.at, c.at), (c.at, a.at), (a.at, b.at)]; if !area.is_finite() || area.abs() < 1e-12 { - return; + return Ok(()); } let Some((x0, x1, y0, y1)) = self.bounds(&corners) else { - return; + return Ok(()); }; + let span = |lo: u32, hi: u32| u64::from(hi.saturating_sub(lo)).saturating_add(1); + self.work = self.work.checked_sub(span(x0, x1).saturating_mul(span(y0, y1))).ok_or(RenderError::TooMuchWork)?; for y in y0..=y1 { for x in x0..=x1 { let p = [f64::from(x) + 0.5, f64::from(y) + 0.5, 0.0]; @@ -144,6 +150,7 @@ impl Target<'_> { self.blend(i, key, shaded(colour, shade)); } } + Ok(()) } /// The pixel rows and columns `corners` may cover, clipped to the image; `None` when off it. @@ -259,10 +266,10 @@ mod tests { #[test] fn a_pixel_on_an_edge_two_triangles_share_is_blended_once() { let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]); - let mut t = Target { perspective: false, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth }; + let mut t = Target { perspective: false, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: u64::MAX }; let [a, b, c, d] = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)].map(|(x, y)| corner(x, y, 1.0, 1.0)); - t.fill([a, b, c], [255, 0, 0, 128]); - t.fill([a, c, d], [255, 0, 0, 128]); + t.fill([a, b, c], [255, 0, 0, 128]).unwrap(); + t.fill([a, c, d], [255, 0, 0, 128]).unwrap(); // The diagonal runs through four pixel centres; each must look like the rest. for px in rgba.chunks(4) { assert_eq!(px, [255, 0, 0, 128]); @@ -272,11 +279,23 @@ mod tests { #[test] fn a_perspective_shade_is_interpolated_on_the_surface_not_the_screen() { let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]); - let mut t = Target { perspective: true, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth }; + let mut t = Target { perspective: true, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: u64::MAX }; // The x = 4 corner is three times as far away as the others, and the only lit one. - t.fill([corner(0.0, 0.0, 1.0, 0.0), corner(4.0, 0.0, 1.0 / 3.0, 1.0), corner(0.0, 4.0, 1.0, 0.0)], [240, 240, 240, 255]); + t.fill([corner(0.0, 0.0, 1.0, 0.0), corner(4.0, 0.0, 1.0 / 3.0, 1.0), corner(0.0, 4.0, 1.0, 0.0)], [240, 240, 240, 255]).unwrap(); // At screen (1.5, 0.5) the weights are 0.5, 0.375, 0.125: on the surface the far corner // counts 0.125 / 0.75 = 1/6, where on the screen it would count 0.375. assert_eq!(rgba.get(4..8), Some(&[40, 40, 40, 255][..])); } + + #[test] + fn fills_stop_when_the_work_allowance_is_spent() { + let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]); + // Each fill's box is the whole 4x4 image: 16 pixel centres. + let mut t = Target { perspective: false, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: 40 }; + let tri = [corner(0.0, 0.0, 1.0, 1.0), corner(4.0, 0.0, 1.0, 1.0), corner(0.0, 4.0, 1.0, 1.0)]; + assert_eq!(t.fill(tri, [255, 0, 0, 255]), Ok(())); + assert_eq!(t.fill(tri, [255, 0, 0, 255]), Ok(())); + assert_eq!(t.fill(tri, [255, 0, 0, 255]), Err(RenderError::TooMuchWork)); + assert_eq!(t.work, 8); + } } diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs index 3670878e17..9d7da84ecd 100644 --- a/crates/prc-render/src/render.rs +++ b/crates/prc-render/src/render.rs @@ -11,6 +11,10 @@ use pdfcraft_prc::TriangleMesh; /// The largest image [`render`] draws, in pixels (width × height). pub const MAX_RENDER_PIXELS: u64 = 16 * 1024 * 1024; +/// The most pixel centres one [`render`] tests, summed over each drawn triangle's bounding box +/// clipped to the image: 128 full layers of the largest image. +pub const MAX_RENDER_WORK: u64 = 1 << 31; + /// [`Camera::fit`]'s framed extent over the model's: 5% clear on each side. const FIT_MARGIN: f64 = 1.1; @@ -66,10 +70,10 @@ impl Bounds { out } - /// The box's centre. + /// The box's centre, finite for any finite box. #[must_use] pub fn centre(&self) -> [f64; 3] { - [0, 1, 2].map(|i| (at(self.min, i) + at(self.max, i)) / 2.0) + [0, 1, 2].map(|i| at(self.min, i) / 2.0 + at(self.max, i) / 2.0) } /// Half the box's diagonal: the radius of a sphere holding it. @@ -79,16 +83,9 @@ impl Bounds { } } -/// Every finite vertex the meshes' triangles use. +/// Every corner of the triangles [`render`] draws: those whose three corners exist and are finite. fn vertices(meshes: &[WorldMesh]) -> impl Iterator + Clone + '_ { - meshes.iter().flat_map(|m| { - let mesh = &m.mesh; - mesh.triangles - .iter() - .flatten() - .filter_map(|&i| mesh.positions.get(usize::try_from(i).ok()?).copied()) - .filter(|p| p.iter().all(|c| c.is_finite())) - }) + meshes.iter().flat_map(|m| m.mesh.triangles.iter().filter_map(|tri| corners(&m.mesh, tri)).flatten()) } impl Camera { @@ -130,8 +127,7 @@ impl Camera { r if r.is_finite() && r > 0.0 => r, _ => 1.0, }; - let [right, upward, forward] = - Camera { eye: sub(centre, dir), target: centre, up, projection: Projection::Orthographic { height: 1.0 } }.basis()?; + let [right, upward, forward] = axes(dir, up)?; let view = |p: [f64; 3]| { let r = sub(p, centre); [dot(r, right), dot(r, upward), dot(r, forward)] @@ -171,11 +167,16 @@ impl Camera { /// Right, up and forward unit vectors. pub(crate) fn basis(&self) -> Result<[[f64; 3]; 3], RenderError> { let forward = normalize(sub(self.target, self.eye)).ok_or(RenderError::Camera("the eye and the target coincide"))?; - let right = normalize(cross(forward, self.up)).ok_or(RenderError::Camera("the up direction is zero or parallel to the view direction"))?; - Ok([right, cross(right, forward), forward]) + axes(forward, self.up) } } +/// Right, up and `forward` (a unit vector) for a camera whose up direction is `up`. +fn axes(forward: [f64; 3], up: [f64; 3]) -> Result<[[f64; 3]; 3], RenderError> { + let right = normalize(cross(forward, up)).ok_or(RenderError::Camera("the up direction is zero or parallel to the view direction"))?; + Ok([right, cross(right, forward), forward]) +} + /// How surfaces are lit. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[non_exhaustive] @@ -246,6 +247,9 @@ pub enum RenderError { /// texture coordinates. #[error("the placed model has more vertices than the assembly ceiling")] TooManyVertices, + /// Drawing would test more than [`MAX_RENDER_WORK`] pixel centres. + #[error("the render would test more pixels than the work ceiling")] + TooMuchWork, } /// Draw `meshes` as seen by `camera`. @@ -262,7 +266,8 @@ pub enum RenderError { /// # Errors /// [`RenderError::Size`] for an empty or over-ceiling image; [`RenderError::Camera`] for a camera /// that cannot form a view (eye on the target, `up` parallel to the view, a field of view outside -/// 0–180° or too small for a finite focal length, a non-positive orthographic height). +/// 0–180° or too small for a finite focal length, a non-positive orthographic height); +/// [`RenderError::TooMuchWork`] past [`MAX_RENDER_WORK`]. pub fn render(meshes: &[WorldMesh], camera: &Camera, options: &RenderOptions) -> Result { let (width, height) = (options.width, options.height); let size = RenderError::Size { width, height }; @@ -276,14 +281,14 @@ pub fn render(meshes: &[WorldMesh], camera: &Camera, options: &RenderOptions) -> let scene = Scene { eye: camera.eye, basis: camera.basis()?, view, lighting: options.lighting }; let mut rgba = options.background.repeat(pixels); let mut depth = vec![f64::NEG_INFINITY; pixels]; - let mut target = Target { perspective, width, height, rgba: &mut rgba, depth: &mut depth }; + let mut target = Target { perspective, width, height, rgba: &mut rgba, depth: &mut depth, work: MAX_RENDER_WORK }; let [r, g, b] = options.colour; for opaque in [true, false] { for m in meshes { for t in 0..m.mesh.triangles.len() { let colour = m.colour_of(t).unwrap_or([r, g, b, 255]); if (colour[3] == 255) == opaque && colour[3] != 0 { - scene.draw(&mut target, &m.mesh, t, colour); + scene.draw(&mut target, &m.mesh, t, colour)?; } } } @@ -313,13 +318,14 @@ impl Scene { [0.3 + 0.7 * towards.map_or(1.0, |v| dot(n, v).abs().min(1.0)); 3] } - /// Draw triangle `t` of `mesh` into `target` in straight-RGBA `colour`. - fn draw(&self, target: &mut Target, mesh: &TriangleMesh, t: usize, colour: [u8; 4]) { + /// Draw triangle `t` of `mesh` into `target` in straight-RGBA `colour`; refused once the + /// target's work allowance is spent. + fn draw(&self, target: &mut Target, mesh: &TriangleMesh, t: usize, colour: [u8; 4]) -> Result<(), RenderError> { let Some(world) = mesh.triangles.get(t).and_then(|tri| corners(mesh, tri)) else { - return; + return Ok(()); }; let Some(face) = unit_cross(sub(at3(&world, 1), at3(&world, 0)), sub(at3(&world, 2), at3(&world, 0))) else { - return; + return Ok(()); }; let stored = stored_normals(mesh, t); let normal = |k: usize| stored.and_then(|n| n.get(k).copied().flatten()).unwrap_or(face); @@ -336,10 +342,11 @@ impl Scene { if let Some((&a, rest)) = screen.split_first() { for pair in rest.windows(2) { if let [b, c] = pair { - target.fill([a, *b, *c], colour); + target.fill([a, *b, *c], colour)?; } } } + Ok(()) } } diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs index ec8104a260..24a2d6b936 100644 --- a/crates/prc-render/src/render/tests.rs +++ b/crates/prc-render/src/render/tests.rs @@ -254,7 +254,8 @@ fn non_finite_and_missing_vertices_are_skipped() { let m = WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2], [0, 2, 9], [0, 2, u32::MAX]])); let image = render(std::slice::from_ref(&m), &ortho(4.0), &small()).unwrap(); assert!(image.rgba.chunks(4).all(|p| p == [255, 255, 255, 255])); - assert_eq!(Bounds::of(&[m]).unwrap().max, [0.0, 1.0, 0.0]); + // No triangle can be drawn, so none frames the model. + assert_eq!(Bounds::of(&[m]), None); } #[test] @@ -276,3 +277,34 @@ fn a_huge_square_is_drawn() { assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]); assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255]); } + +#[test] +fn a_camera_very_far_away_still_draws() { + let camera = Camera { eye: [0.0, 0.0, 1e200], target: [0.0; 3], up: [0.0, 1.0, 0.0], projection: Projection::Perspective { fov_y: 30.0 } }; + let image = render(&[quad(0.0, 1e199, 0.0)], &camera, &small()).unwrap(); + // The headlight meets the square almost head-on at the centre pixel. + let [r, _, _, a] = pixel(&image, 20, 20); + assert!((185..=190).contains(&r) && a == 255, "{:?}", pixel(&image, 20, 20)); +} + +#[test] +fn the_centre_of_a_box_near_the_largest_float_is_finite() { + let b = Bounds { min: [1e308; 3], max: [1.5e308; 3] }; + assert!(b.centre().iter().all(|c| c.is_finite()), "{:?}", b.centre()); +} + +#[test] +fn a_triangle_that_is_not_drawn_does_not_widen_the_box() { + // The second triangle has a non-finite corner, so its finite far corner is not drawn either. + let m = mesh(vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [f64::NAN, 0.0, 0.0], [1e6, 0.0, 0.0]], vec![[0, 1, 2], [3, 4, 0]]); + let b = Bounds::of(&[WorldMesh::new(m)]).unwrap(); + assert_eq!(b.max, [1.0, 1.0, 0.0]); +} + +#[test] +fn a_small_model_far_from_the_origin_can_be_fitted() { + let (o, d) = (1e16, 4.0); + let m = mesh(vec![[o, o, o], [o + d, o, o], [o, o + d, o]], vec![[0, 1, 2]]); + let camera = Camera::fit_meshes(&[WorldMesh::new(m)], [1.0, 1.0, 1.0], [0.0, 1.0, 0.0], false, 1.0); + assert!(camera.is_ok(), "{camera:?}"); +} diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs index 7a673e0055..3bb438438a 100644 --- a/crates/prc-render/src/vec3.rs +++ b/crates/prc-render/src/vec3.rs @@ -23,32 +23,39 @@ pub(crate) fn cross([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 { [ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx] } -/// The Euclidean length of `a`. +/// The Euclidean length of `a`, taken with `a` divided by its largest component so squaring +/// cannot overflow or underflow; infinite or NaN when a component is. pub(crate) fn length(a: Vec3) -> f64 { - dot(a, a).sqrt() + let big = largest(a); + if !(big.is_finite() && big > 0.0) { + return dot(a, a).sqrt(); + } + let a = a.map(|c| c / big); + big * dot(a, a).sqrt() +} + +/// The largest component of `a` by magnitude; NaN components are passed over. +fn largest(a: Vec3) -> f64 { + a.iter().fold(0.0_f64, |m, c| m.max(c.abs())) } /// `a` scaled to unit length; `None` when its length is zero or not finite. `a` is first divided /// by its largest component, so squaring cannot overflow or underflow. pub(crate) fn normalize(a: Vec3) -> Option { - let big = a.iter().fold(0.0_f64, |m, c| m.max(c.abs())); + let big = largest(a); if !(big.is_finite() && big > 0.0) { return None; } let a = a.map(|c| c / big); - let l = length(a); + let l = dot(a, a).sqrt(); (l.is_finite() && l > 0.0).then(|| scale(a, 1.0 / l)) } /// The unit normal of the plane spanned by `a` and `b` (`a × b`, normalized); `None` when they are -/// parallel, zero or not finite. Both are first divided by their largest component, so the cross -/// product of two huge edges cannot overflow before it is normalized. +/// parallel, zero or not finite. Each is normalized first, so neither huge edges nor edges of +/// very different sizes overflow or underflow the cross product. pub(crate) fn unit_cross(a: Vec3, b: Vec3) -> Option { - let big = a.iter().chain(b.iter()).fold(0.0_f64, |m, c| m.max(c.abs())); - if !(big.is_finite() && big > 0.0) { - return None; - } - normalize(cross(a.map(|c| c / big), b.map(|c| c / big))) + normalize(cross(normalize(a)?, normalize(b)?)) } #[cfg(test)] @@ -82,4 +89,19 @@ mod tests { assert_eq!(unit_cross([2.0, 0.0, 0.0], [4.0, 0.0, 0.0]), None); assert_eq!(unit_cross([f64::INFINITY, 0.0, 0.0], [0.0, 1.0, 0.0]), None); } + + #[test] + fn unit_cross_survives_edges_of_very_different_sizes() { + assert_eq!(unit_cross([1e-170, 0.0, 0.0], [0.0, 1e160, 0.0]), Some([0.0, 0.0, 1.0])); + } + + #[test] + fn length_survives_huge_and_tiny_vectors() { + let near = |l: f64, want: f64| (l - want).abs() <= want * 1e-15; + assert!(near(length([3e200, 4e200, 0.0]), 5e200)); + assert!(near(length([0.0, 3e-200, 4e-200]), 5e-200)); + assert_eq!(length([0.0; 3]), 0.0); + assert!(length([f64::NAN, 1.0, 0.0]).is_nan()); + assert_eq!(length([f64::INFINITY, 1.0, 0.0]), f64::INFINITY); + } } From c16c6b5716db5fef30d1e17840ed3f37419bdbfa Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 04:56:35 -0500 Subject: [PATCH 22/27] M4: cut, copy and paste bookmark subtrees (#3994) The Bookmarks panel's item menu gets Cut, Copy and Paste, and Bookmark options gets Paste bookmarks (to the end of the outline, which is how you paste into a document without one). The clipboard lives in the app (`PdfCraftApp::bookmark_clipboard`), like the Organize Pages clipboard, so it works across tabs and on the web build. - Copy reads the subtree with `outline_tree`; a copy cut at the listing depth says so. - Cut copies first and deletes only if the copy succeeded: one undo step. - Paste is one `ImportBookmarks` edit after the bookmark it was asked on, at its level. A bookmark whose page the document lacks keeps its place without a destination, and a notice counts them (plural forms through `trn`). Parity: new `organize.bookmark-clipboard` entry with what is missing (shortcuts, system clipboard, multi-select, page-ordered insertion). Test: cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents. Reverting copy, cut, paste, the paste's parent, the one-step undo or the notice each fails it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/editing.rs | 52 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/panels/bookmarks.rs | 16 ++++++ crates/ui-egui/src/panels/right.rs | 6 +++ crates/ui-egui/tests/it/bookmarks.rs | 72 ++++++++++++++++++++++++++ parity/acrobat-features.toml | 12 +++++ 7 files changed, 162 insertions(+) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6ac8ea8783..43a1e5ce1d 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -260,6 +260,7 @@ impl PdfCraftApp { fonts_cjk: theme::CjkPreference::Default, control: None, bookmark_rename: None, + bookmark_clipboard: None, last_opened_url: None, community_card_closed: false, pending_link: None, diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4440637a81..f2b20f39ed 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -977,6 +977,20 @@ impl PdfCraftApp { let at = parent[parent.len() - 1] + 1; Edit::MoveBookmark { from: path, to_parent: grand, index: at } } + A::Copy(path) => { + self.copy_bookmark(id, &path); + return; + } + A::Cut(path) => { + if !self.copy_bookmark(id, &path) { + return; + } + Edit::DeleteBookmark { path } + } + A::Paste { parent, index } => { + self.paste_bookmarks(id, parent, index); + return; + } A::FromStructure => { self.right = Some(crate::RightPanel::Bookmarks); Edit::BookmarksFromStructure @@ -991,6 +1005,44 @@ impl PdfCraftApp { } } +impl PdfCraftApp { + /// Put the bookmark at `path` and its subtree on the bookmark clipboard; `false` if it could + /// not be read (the reason is shown). + fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> bool { + match self.session.outline_tree(id, path) { + Some(Ok(tree)) => { + if tree.truncated { + self.notify_tr("Bookmarks nested too deep to list were left out of the copy."); + } + self.bookmark_clipboard = Some(tree.nodes); + true + } + Some(Err(e)) => { + self.notify_error(e); + false + } + None => false, + } + } + + /// Paste the bookmark clipboard as one undoable edit. Bookmarks whose page this document lacks + /// arrive without a destination, and the toast says how many. + fn paste_bookmarks(&mut self, id: pdfcraft_engine::DocId, parent: Vec, index: usize) { + let Some(bookmarks) = self.bookmark_clipboard.clone() else { return }; + let pages = self.session.get(id).map_or(0, |d| d.info.pages.len()); + let unmapped = pdfcraft_engine::outline_unmapped(&bookmarks, pages).len(); + if self.apply_edit(Edit::ImportBookmarks { parent, index, bookmarks, replace: false }) && unmapped > 0 { + let n = u64::try_from(unmapped).unwrap_or(u64::MAX); + self.notify(crate::i18n::trn( + crate::i18n::current(), + n, + "{n} pasted bookmark points past the last page of this document and has no destination.", + "{n} pasted bookmarks point past the last page of this document and have no destination.", + )); + } + } +} + fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> Option<&'a pdfcraft_render::OutlineItem> { let (first, rest) = path.split_first()?; let item = items.get(*first)?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 643d5e90ff..28382ea255 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -611,6 +611,9 @@ pub struct PdfCraftApp { control: Option, /// A bookmark being renamed in the Bookmarks panel: (path, text so far). pub bookmark_rename: Option<(Vec, String)>, + /// Bookmarks copied or cut in the Bookmarks panel, with their subtrees, ready to paste into + /// any document. Destinations are page indices. + pub bookmark_clipboard: Option>, /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, pub(crate) word_count_draft: word_count_ui::Draft, diff --git a/crates/ui-egui/src/panels/bookmarks.rs b/crates/ui-egui/src/panels/bookmarks.rs index ccc45d4eb0..2b68b93158 100644 --- a/crates/ui-egui/src/panels/bookmarks.rs +++ b/crates/ui-egui/src/panels/bookmarks.rs @@ -19,6 +19,15 @@ pub enum BmAction { Indent(Vec), /// Move it out to follow its parent. Outdent(Vec), + /// Copy the bookmark and everything under it to the bookmark clipboard. + Copy(Vec), + /// Copy it, then delete it (one undo step: only the delete changes the document). + Cut(Vec), + /// Paste the bookmark clipboard under `parent` at `index` (past the end appends). + Paste { + parent: Vec, + index: usize, + }, /// Bookmarks from the tagged headings, under a new first "Untitled" bookmark. FromStructure, /// Open New bookmarks from text styles, to choose a style per bookmark level (#1693). @@ -55,6 +64,8 @@ pub(super) struct OutlineCtx<'a> { pub(super) action: &'a mut Option, pub(super) rename: &'a mut Option<(Vec, String)>, pub(super) editable: bool, + /// The bookmark clipboard holds something to paste. + pub(super) can_paste: bool, pub(super) current: usize, /// Expand all (`Some(usize::MAX)`), collapse all (`Some(0)`) or expand to a depth, this frame. pub(super) expand: Option, @@ -242,6 +253,11 @@ pub(super) fn outline_item( pick(ui, tl!("Indent"), i > 0, BmAction::Indent(path.to_vec())); pick(ui, tl!("Outdent"), depth > 0, BmAction::Outdent(path.to_vec())); ui.separator(); + pick(ui, tl!("Cut"), true, BmAction::Cut(path.to_vec())); + pick(ui, tl!("Copy"), true, BmAction::Copy(path.to_vec())); + let parent = path.get(..depth).unwrap_or_default().to_vec(); + pick(ui, tl!("Paste"), cx.can_paste, BmAction::Paste { parent, index: i.saturating_add(1) }); + ui.separator(); pick(ui, tl!("Delete"), true, BmAction::Delete(path.to_vec())); if let Some(a) = styled { *cx.action = Some(a); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 79769867d3..ae9fbb9266 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -24,6 +24,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut compare_action: Option = None; let mut bm_rename = app.bookmark_rename.clone(); let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none()); + let bm_can_paste = app.bookmark_clipboard.is_some(); // Prepare a form is open: the Fields panel orders tabs. let preparing = app.is_preparing(); { @@ -111,6 +112,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { bm_action = Some(BmAction::FromTextStyles); ui.close(); } + if bm_editable && ui.add_enabled(bm_can_paste, egui::Button::new(tl!("Paste bookmarks"))).clicked() { + bm_action = Some(BmAction::Paste { parent: Vec::new(), index: usize::MAX }); + ui.close(); + } if !info.outline.is_empty() { for (levels, label) in [ (usize::MAX, tl!("Expand all bookmarks")), @@ -195,6 +200,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { action: &mut bm_action, rename: &mut bm_rename, editable: bm_editable, + can_paste: bm_can_paste, current: view.current, expand: bm_expand, matches: &bookmark_matches, diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 1f07102a18..5251871401 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -459,3 +459,75 @@ trailer << /Root 1 0 R >> h.run_steps(3); assert!(!shown(&h, "Closed child B") && !shown(&h, "Open child B"), "search leaves the open state alone"); } + +/// The active document's outline, flattened like [`outline`]. +fn active_outline(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, Option, usize)> { + let i = h.state().active.unwrap(); + let id = h.state().views[i].id; + fn flat(items: &[pdfcraft_render::OutlineItem], depth: usize, out: &mut Vec<(String, Option, usize)>) { + for i in items { + out.push((i.title.clone(), i.page, depth)); + flat(&i.children, depth + 1, out); + } + } + let mut out = Vec::new(); + flat(&h.state().session.get(id).unwrap().info.outline, 0, &mut out); + out +} + +const ONE_PAGE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents() { + let mut h = harness(); + add(&mut h, "Intro"); + h.state_mut().set_option("page", "3").unwrap(); + h.run_steps(3); + add(&mut h, "Results"); + menu(&mut h, "Results", "Indent"); + let tree = [("Intro".to_string(), Some(0), 0), ("Results".to_string(), Some(2), 1)]; + assert_eq!(outline(&h), tree); + + // Copy and paste after itself: the whole subtree, as one undo step. + menu(&mut h, "Intro", "Copy"); + menu(&mut h, "Intro", "Paste"); + assert_eq!(outline(&h), [tree.clone(), tree.clone()].concat()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(outline(&h), tree, "a paste is one undo step"); + // Pasted on a child, it goes in after that child, at the child's level. + menu(&mut h, "Results", "Paste"); + let nested = [("Intro".to_string(), Some(0), 1), ("Results".to_string(), Some(2), 2)]; + assert_eq!(outline(&h), [tree.clone(), nested].concat()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(outline(&h), tree); + + // Cut takes the subtree away in one step and keeps it to paste. + menu(&mut h, "Intro", "Cut"); + assert!(outline(&h).is_empty()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(outline(&h), tree, "a cut is one undo step"); + + // Into a one-page document: Results' page 3 is not there, so it keeps its place without a + // destination rather than pointing at the wrong page, and the toast says so. + h.state_mut().open_bytes("one.pdf", None, ONE_PAGE.to_vec()).unwrap(); + h.run_steps(3); + h.get_by_label("Bookmark options").click(); + h.run_steps(2); + h.get_by_label("Paste bookmarks").click(); + h.run_steps(3); + assert_eq!(active_outline(&h), [("Intro".to_string(), Some(0), 0), ("Results".to_string(), None, 1)]); + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("1 pasted bookmark points past the last page"), "{toast}"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert!(active_outline(&h).is_empty()); + assert_eq!(outline(&h), tree, "the source is untouched"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 909cec18da..ac1ebdd20b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3433,6 +3433,18 @@ tools = ["bookmark_export", "bookmark_import", "bookmark_copy"] evidence = ["crates/organize/src/tests.rs::an_exported_outline_imports_into_another_document_unchanged", "crates/organize/src/tests.rs::a_page_the_target_lacks_keeps_its_bookmark_without_a_destination", "crates/organize/src/tests.rs::an_import_appends_inserts_or_replaces", "crates/organize/src/tests.rs::a_bad_node_anywhere_refuses_the_whole_import", "crates/automation/tests/it/automation.rs::bookmarks_export_import_and_copy_between_documents", "crates/automation/tests/it/automation.rs::a_bad_bookmark_tree_is_refused_whole_and_names_the_node"] notes = "#6018, headless only. The bookmark tree (or one bookmark's subtree) goes out as JSON with titles, nesting, open state, colour, bold/italic and destinations as page plus view in PDF user space, and comes back unchanged: into another document, under any bookmark, at any position, or in place of its outline, as one undoable step; bookmark_copy does the same between open documents by page number. A bookmark whose page the target lacks keeps its place without a destination and is reported, never dropped. Not yet: a panel command and preview, CSV/Markdown/pdftk formats, remapping by named destination (named destinations travel resolved to their page), actions other than go-to-page, structure links (/SE)" +[[feature]] +id = "organize.bookmark-clipboard" +title = "Cut, copy and paste bookmarks" +area = "D" +tier = "P2" +milestone = "M4" +status = "shipped" +commands = [] +tools = ["bookmark_copy", "bookmark_export", "bookmark_import", "bookmark_delete"] +evidence = ["crates/ui-egui/tests/it/bookmarks.rs::cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents", "crates/automation/tests/it/automation.rs::bookmarks_export_import_and_copy_between_documents"] +notes = "#3994 (the cut/copy/paste part). Cut, Copy and Paste in a bookmark's menu, and Paste bookmarks in Bookmark options, carry a bookmark with its whole subtree within a document or into another one, each as one undo step. A destination is a page index: a bookmark whose page the target lacks keeps its place without a destination and the toast counts them. Headless, bookmark_copy copies between open documents directly (there is no clipboard). Not yet: keyboard shortcuts in the panel, the system clipboard (the bookmarks stay inside the app), several bookmarks at once, copying out of a document that cannot be edited (the bookmark menu is hidden there), page-ordered insertion (the other half of #3994), actions other than go-to-page" + [[feature]] id = "organize.attachments-add" title = "Add file attachments" From 51b68a8b356a30588eb1e3b733d3f3c7d8e0880c Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 05:12:00 -0500 Subject: [PATCH 23/27] M12: prc-render: overflow-free projection and edges, shared near cuts, decode ceiling - View::project divides by z (or the orthographic half-height) before it multiplies, so an on-screen corner no longer turns NaN when x times the focal length, or the half-height times the aspect ratio, overflows. - View::clip cuts each edge from the same end whichever triangle meets it, so two triangles sharing an edge across the near plane get one cut point. - Scene::draw takes each edge with vec3::span, which halves the difference when it overflows, so a finite triangle wider than f64::MAX keeps its face normal. - assemble charges each file structure's meshes to the assembly ceilings as they are decoded, so decoding stops at the structure that passes one. Tests, each shown failing with its fix reverted: a_shared_edge_is_cut_at_the_same_point_from_either_triangle, an_on_screen_triangle_is_drawn_when_projecting_it_would_overflow, a_triangle_whose_edges_overflow_is_drawn, span_keeps_the_direction_of_a_difference_that_overflows, decoding_stops_at_the_structure_that_passes_the_ceiling. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc-render/src/assemble.rs | 22 ++++++++++++++++-- crates/prc-render/src/assemble/tests.rs | 13 +++++++++++ crates/prc-render/src/raster.rs | 30 ++++++++++++++++++++----- crates/prc-render/src/render.rs | 4 ++-- crates/prc-render/src/render/tests.rs | 21 +++++++++++++++++ crates/prc-render/src/vec3.rs | 12 ++++++++++ 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/crates/prc-render/src/assemble.rs b/crates/prc-render/src/assemble.rs index 7fbb9bd914..c9264d620e 100644 --- a/crates/prc-render/src/assemble.rs +++ b/crates/prc-render/src/assemble.rs @@ -49,9 +49,10 @@ pub fn place(mesh: &TriangleMesh, matrix: &Matrix, colour: Option<[u8; 4]>, tria /// # Errors /// [`RenderError::Model`] when a tessellation section or the tree cannot be read, or the tree /// places a tessellation its file structure does not have; [`RenderError::TooManyTriangles`] past -/// [`MAX_ASSEMBLED_TRIANGLES`] and [`RenderError::TooManyVertices`] past [`MAX_ASSEMBLED_VERTICES`]. +/// [`MAX_ASSEMBLED_TRIANGLES`] and [`RenderError::TooManyVertices`] past [`MAX_ASSEMBLED_VERTICES`], +/// counting both what is decoded and what is placed. pub fn assemble(prc: &PrcFile) -> Result, RenderError> { - let tessellations = prc.file_structures.iter().map(|fs| fs.tessellations()).collect::, _>>()?; + let tessellations = decode(prc.file_structures.iter().map(|fs| fs.tessellations()), Budget::default())?; let mut budget = Budget::default(); let mut out = Vec::new(); if !prc.stores_tree() { @@ -72,6 +73,23 @@ pub fn assemble(prc: &PrcFile) -> Result, RenderError> { Ok(out) } +/// Each file structure's tessellations, decoded one structure at a time and charged to `budget` +/// as they are, so decoding stops at the structure that passes a ceiling. +pub(crate) fn decode( + structures: impl IntoIterator, pdfcraft_prc::PrcError>>, + mut budget: Budget, +) -> Result>, RenderError> { + let mut out = Vec::new(); + for tessellations in structures { + let tessellations = tessellations?; + for mesh in tessellations.iter().filter_map(as_mesh) { + budget.spend(mesh)?; + } + out.push(tessellations); + } + Ok(out) +} + /// One placement as [`place_items`] reads it: a tessellation by file structure and index, where /// it goes and how it is coloured. pub(crate) struct Item<'a> { diff --git a/crates/prc-render/src/assemble/tests.rs b/crates/prc-render/src/assemble/tests.rs index 6947e78033..5018bdbe8c 100644 --- a/crates/prc-render/src/assemble/tests.rs +++ b/crates/prc-render/src/assemble/tests.rs @@ -112,3 +112,16 @@ fn a_placement_past_the_triangle_budget_is_an_error() { assert!(matches!(r, Err(RenderError::TooManyTriangles))); assert!(out.is_empty()); } + +#[test] +fn decoding_stops_at_the_structure_that_passes_the_ceiling() { + let pulled = std::cell::Cell::new(0); + let structures = std::iter::repeat_with(|| { + pulled.set(pulled.get() + 1); + Ok(vec![Tessellation::Mesh(triangle())]) + }) + .take(3); + let nearly_full = Budget { triangles: MAX_ASSEMBLED_TRIANGLES - 1, vertices: 0 }; + assert_eq!(decode(structures, nearly_full).err(), Some(RenderError::TooManyTriangles)); + assert_eq!(pulled.get(), 2, "the third structure is never decoded"); +} diff --git a/crates/prc-render/src/raster.rs b/crates/prc-render/src/raster.rs index e30105352c..4427df5973 100644 --- a/crates/prc-render/src/raster.rs +++ b/crates/prc-render/src/raster.rs @@ -70,9 +70,12 @@ impl View { out.push(p); } if p_in != q_in { - let t = (near - z(p)) / (z(q) - z(p)); - let mut cut = p; - for (c, d) in cut.iter_mut().zip(q) { + // Cut from the same end whichever way round the edge is met, so two triangles + // sharing it get the same cut point. + let (s, e) = if first(&p, &q) { (p, q) } else { (q, p) }; + let t = (near - z(s)) / (z(e) - z(s)); + let mut cut = s; + for (c, d) in cut.iter_mut().zip(e) { *c += t * (d - *c); } out.push(cut); @@ -85,13 +88,19 @@ impl View { /// in screen space and larger for nearer points. pub(crate) fn project(&self, [x, y, z, r, g, b]: ViewCorner, width: u32, height: u32) -> Corner { let (nx, ny, key) = match *self { - View::Perspective { focal, aspect, .. } => (x * focal / (z * aspect), y * focal / z, 1.0 / z), - View::Orthographic { half_height, aspect } => (x / (half_height * aspect), y / half_height, -z), + // Divided before multiplied, so an on-screen point's intermediate products stay finite. + View::Perspective { focal, aspect, .. } => (x / z * focal / aspect, y / z * focal, 1.0 / z), + View::Orthographic { half_height, aspect } => (x / half_height / aspect, y / half_height, -z), }; Corner { at: [(nx + 1.0) / 2.0 * f64::from(width), (1.0 - ny) / 2.0 * f64::from(height), key], shade: [r, g, b] } } } +/// Whether view corner `a`'s position comes before `b`'s, ordering x, then y, then z. +fn first(a: &ViewCorner, b: &ViewCorner) -> bool { + a.iter().zip(b).take(3).map(|(p, q)| p.total_cmp(q)).find(|o| o.is_ne()).is_none_or(std::cmp::Ordering::is_lt) +} + /// The image being drawn: `rgba` holds `width * height` straight-RGBA pixels and `depth` their /// depth keys, row by row from the top. pub(crate) struct Target<'b> { @@ -259,6 +268,17 @@ mod tests { assert_eq!(channel(127.5), 128); } + #[test] + fn a_shared_edge_is_cut_at_the_same_point_from_either_triangle() { + // Cutting this edge from P towards Q and from Q towards P rounds differently. + let view = View::Perspective { focal: 1.0, aspect: 1.0, near: 0.5 }; + let (p, q) = ([0.1, 0.1, 0.2, 1.0, 1.0, 1.0], [0.7, 0.2, 1.6, 1.0, 1.0, 1.0]); + let one = view.clip([p, q, [0.0, 3.0, 2.0, 1.0, 1.0, 1.0]]); + let other = view.clip([q, p, [3.0, 0.0, 2.0, 1.0, 1.0, 1.0]]); + let shared = one.iter().filter(|a| other.iter().any(|b| a[..3] == b[..3])).count(); + assert_eq!(shared, 2, "Q and the cut on PQ: {one:?} {other:?}"); + } + fn corner(x: f64, y: f64, key: f64, shade: f64) -> Corner { Corner { at: [x, y, key], shade: [shade; 3] } } diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs index 9d7da84ecd..a27cfbe5fa 100644 --- a/crates/prc-render/src/render.rs +++ b/crates/prc-render/src/render.rs @@ -5,7 +5,7 @@ use crate::WorldMesh; use crate::raster::{Corner, Target, View, ViewCorner, at}; -use crate::vec3::{cross, dot, length, normalize, scale, sub, unit_cross}; +use crate::vec3::{cross, dot, length, normalize, scale, span, sub, unit_cross}; use pdfcraft_prc::TriangleMesh; /// The largest image [`render`] draws, in pixels (width × height). @@ -324,7 +324,7 @@ impl Scene { let Some(world) = mesh.triangles.get(t).and_then(|tri| corners(mesh, tri)) else { return Ok(()); }; - let Some(face) = unit_cross(sub(at3(&world, 1), at3(&world, 0)), sub(at3(&world, 2), at3(&world, 0))) else { + let Some(face) = unit_cross(span(at3(&world, 0), at3(&world, 1)), span(at3(&world, 0), at3(&world, 2))) else { return Ok(()); }; let stored = stored_normals(mesh, t); diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs index 24a2d6b936..df089bfea5 100644 --- a/crates/prc-render/src/render/tests.rs +++ b/crates/prc-render/src/render/tests.rs @@ -308,3 +308,24 @@ fn a_small_model_far_from_the_origin_can_be_fitted() { let camera = Camera::fit_meshes(&[WorldMesh::new(m)], [1.0, 1.0, 1.0], [0.0, 1.0, 0.0], false, 1.0); assert!(camera.is_ok(), "{camera:?}"); } + +#[test] +fn a_triangle_whose_edges_overflow_is_drawn() { + // Its corners are finite but 2e308 apart, so the plain edge differences are infinite. + let m = mesh(vec![[-1e308, -1e308, 0.0], [1e308, -1e308, 0.0], [0.0, 1e308, 0.0]], vec![[0, 1, 2]]); + let image = render(&[WorldMesh::new(m)], &ortho(1e308), &small()).unwrap(); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]); +} + +#[test] +fn an_on_screen_triangle_is_drawn_when_projecting_it_would_overflow() { + let wide = RenderOptions { width: 30, height: 10, ..RenderOptions::default() }; + let drawn = |image: &Image| image.rgba.chunks(4).filter(|px| px != &[255, 255, 255, 255]).count(); + // A tiny field of view: x times the focal length (about 1.1e300) overflows, though x over z does not. + let camera = Camera { eye: [0.0; 3], target: [0.0, 0.0, 1e308], up: [0.0, 1.0, 0.0], projection: Projection::Perspective { fov_y: 1e-298 } }; + let far = mesh(vec![[1.5e8, -3e7, 1e308], [2.5e8, -3e7, 1e308], [2e8, 3e7, 1e308]], vec![[0, 1, 2]]); + assert!(drawn(&render(&[WorldMesh::new(far)], &camera, &wide).unwrap()) > 0); + // Half the orthographic height times the aspect ratio overflows. + let big = mesh(vec![[-5e307, -5e307, 0.0], [5e307, -5e307, 0.0], [0.0, 5e307, 0.0]], vec![[0, 1, 2]]); + assert_eq!(pixel(&render(&[WorldMesh::new(big)], &ortho(1.5e308), &wide).unwrap(), 15, 5), [190, 192, 200, 255]); +} diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs index 3bb438438a..bdbed70aed 100644 --- a/crates/prc-render/src/vec3.rs +++ b/crates/prc-render/src/vec3.rs @@ -8,6 +8,12 @@ pub(crate) fn sub([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 { [ax - bx, ay - by, az - bz] } +/// The direction from `from` to `to`: `to - from`, halved when the full difference overflows. +pub(crate) fn span(from: Vec3, to: Vec3) -> Vec3 { + let d = sub(to, from); + if d.iter().all(|c| c.is_finite()) { d } else { sub(scale(to, 0.5), scale(from, 0.5)) } +} + /// `a` times `s`. pub(crate) fn scale(a: Vec3, s: f64) -> Vec3 { a.map(|c| c * s) @@ -95,6 +101,12 @@ mod tests { assert_eq!(unit_cross([1e-170, 0.0, 0.0], [0.0, 1e160, 0.0]), Some([0.0, 0.0, 1.0])); } + #[test] + fn span_keeps_the_direction_of_a_difference_that_overflows() { + assert_eq!(span([1.0, 2.0, 3.0], [4.0, 0.0, 3.0]), [3.0, -2.0, 0.0]); + assert_eq!(span([-1e308, 0.0, 0.0], [1e308, 1e308, 0.0]), [1e308, 5e307, 0.0]); + } + #[test] fn length_survives_huge_and_tiny_vectors() { let near = |l: f64, want: f64| (l - want).abs() <= want * 1e-15; From bfa6db7bd39975fade5180404e5639dea47b9fa0 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 05:53:41 -0500 Subject: [PATCH 24/27] M4: a cut too deep to copy leaves the bookmarks in place (#3994) A cut copies the subtree, then deletes it. When the copy is truncated (bookmarks nested past the listing depth), paste cannot bring back what was left out, so the cut now keeps the copy, deletes nothing and says why. Test: cutting_a_subtree_too_deep_to_copy_keeps_it (40-level synthetic outline); fails when the truncated copy is allowed to delete. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/ui-egui/src/editing.rs | 24 ++++++++++-------- crates/ui-egui/tests/it/bookmarks.rs | 37 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index f2b20f39ed..879025b01d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -981,12 +981,15 @@ impl PdfCraftApp { self.copy_bookmark(id, &path); return; } - A::Cut(path) => { - if !self.copy_bookmark(id, &path) { + A::Cut(path) => match self.copy_bookmark(id, &path) { + Some(false) => Edit::DeleteBookmark { path }, + // Paste could not bring back what the copy left out, so the bookmarks stay. + Some(true) => { + self.notify_tr("Bookmarks nested too deep to list could not be copied, so nothing was cut."); return; } - Edit::DeleteBookmark { path } - } + None => return, + }, A::Paste { parent, index } => { self.paste_bookmarks(id, parent, index); return; @@ -1006,22 +1009,23 @@ impl PdfCraftApp { } impl PdfCraftApp { - /// Put the bookmark at `path` and its subtree on the bookmark clipboard; `false` if it could - /// not be read (the reason is shown). - fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> bool { + /// Put the bookmark at `path` and its subtree on the bookmark clipboard. `Some(true)` when + /// bookmarks nested too deep to list were left out; `None` if it could not be read (the reason + /// is shown). + fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> Option { match self.session.outline_tree(id, path) { Some(Ok(tree)) => { if tree.truncated { self.notify_tr("Bookmarks nested too deep to list were left out of the copy."); } self.bookmark_clipboard = Some(tree.nodes); - true + Some(tree.truncated) } Some(Err(e)) => { self.notify_error(e); - false + None } - None => false, + None => None, } } diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 5251871401..668227e8e7 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -531,3 +531,40 @@ fn cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents() { assert!(active_outline(&h).is_empty()); assert_eq!(outline(&h), tree, "the source is untouched"); } + +/// A one-page document whose single bookmark "Top" heads a chain `depth` bookmarks deep. +fn deep_outline_pdf(depth: usize) -> Vec { + let mut pdf = String::from( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Outlines /First 10 0 R /Last 10 0 R /Count 1 >> endobj +", + ); + for k in 0..depth { + let id = 10 + k; + let parent = if k == 0 { 4 } else { id - 1 }; + let title = if k == 0 { "Top".to_string() } else { format!("L{k}") }; + let kids = if k + 1 < depth { format!("/First {0} 0 R /Last {0} 0 R /Count 1", id + 1) } else { String::new() }; + pdf.push_str(&format!("{id} 0 obj << /Title ({title}) /Parent {parent} 0 R {kids} /Dest [3 0 R /Fit] >> endobj\n")); + } + pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + pdf.into_bytes() +} + +/// Cutting a subtree nested deeper than a copy can hold would lose what the copy left out, so +/// the cut copies and says why it deleted nothing. +#[test] +fn cutting_a_subtree_too_deep_to_copy_keeps_it() { + let mut h = harness(); + h.state_mut().open_bytes("deep.pdf", None, deep_outline_pdf(40)).unwrap(); + h.run_steps(3); + let before = active_outline(&h); + assert_eq!(before.first().map(|b| b.0.as_str()), Some("Top")); + menu(&mut h, "Top", "Cut"); + assert_eq!(active_outline(&h), before, "nothing was cut"); + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("so nothing was cut"), "{toast}"); + assert!(h.state().bookmark_clipboard.is_some(), "the copy is still on the bookmark clipboard"); +} From c2d8c5f32782d716ac944572190b650a336a9d07 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 06:53:36 -0500 Subject: [PATCH 25/27] M12: prc-render: fully transparent triangles are not framed Review fix from #6087. Bounds::of and Camera::fit_meshes framed every finite triangle, but render skips those whose colour has zero alpha, so a clear, distant mesh could shrink the visible model in the image. The vertices they frame now skip fully transparent triangles, as render does. Test, shown failing with the fix reverted: a_fully_transparent_triangle_does_not_widen_the_box_or_the_fit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc-render/src/render.rs | 17 +++++++++++++---- crates/prc-render/src/render/tests.rs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs index a27cfbe5fa..bcaf8acced 100644 --- a/crates/prc-render/src/render.rs +++ b/crates/prc-render/src/render.rs @@ -56,7 +56,8 @@ pub struct Bounds { } impl Bounds { - /// The box around every finite vertex the meshes' triangles use, or `None` when there is none. + /// The box around every finite vertex the meshes' drawn triangles use, or `None` when there + /// is none. #[must_use] pub fn of(meshes: &[WorldMesh]) -> Option { let mut out: Option = None; @@ -83,9 +84,17 @@ impl Bounds { } } -/// Every corner of the triangles [`render`] draws: those whose three corners exist and are finite. +/// Every corner of the triangles [`render`] draws: those not fully transparent whose three +/// corners exist and are finite. fn vertices(meshes: &[WorldMesh]) -> impl Iterator + Clone + '_ { - meshes.iter().flat_map(|m| m.mesh.triangles.iter().filter_map(|tri| corners(&m.mesh, tri)).flatten()) + meshes + .iter() + .flat_map(|m| m.mesh.triangles.iter().enumerate().filter(|&(t, _)| !invisible(m, t)).filter_map(|(_, tri)| corners(&m.mesh, tri)).flatten()) +} + +/// Whether triangle `t` of `m` is fully transparent, so [`render`] does not draw it. +fn invisible(m: &WorldMesh, t: usize) -> bool { + m.colour_of(t).is_some_and(|c| c[3] == 0) } impl Camera { @@ -100,7 +109,7 @@ impl Camera { Camera::frame(bounds, corners, direction, up, (perspective, aspect)) } - /// As [`Camera::fit`], framing every vertex the meshes' triangles use rather than their box, + /// As [`Camera::fit`], framing every vertex the meshes' drawn triangles use rather than their box, /// so an oblique view is not padded by empty box corners. /// /// # Errors diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs index df089bfea5..586292ef80 100644 --- a/crates/prc-render/src/render/tests.rs +++ b/crates/prc-render/src/render/tests.rs @@ -301,6 +301,22 @@ fn a_triangle_that_is_not_drawn_does_not_widen_the_box() { assert_eq!(b.max, [1.0, 1.0, 0.0]); } +#[test] +fn a_fully_transparent_triangle_does_not_widen_the_box_or_the_fit() { + // The far triangle is clear, so the camera frames the near one as if it were alone. + let m = mesh(vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1e6, 0.0, 0.0], [1e6, 1.0, 0.0]], vec![[0, 1, 2], [3, 4, 1]]); + let clear = WorldMesh { triangle_colours: Some(vec![None, Some([0, 0, 0, 0])]), ..WorldMesh::new(m.clone()) }; + assert_eq!(Bounds::of(std::slice::from_ref(&clear)).unwrap().max, [1.0, 1.0, 0.0]); + let alone = WorldMesh::new(mesh(m.positions, vec![[0, 1, 2]])); + let fit = |w: &WorldMesh| Camera::fit_meshes(std::slice::from_ref(w), [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], true, 1.0).unwrap(); + assert_eq!(fit(&clear), fit(&alone)); + // A whole mesh painted clear is not framed either. + assert_eq!( + Bounds::of(&[painted(WorldMesh::new(mesh(vec![[0.0; 3], [9.0, 0.0, 0.0], [0.0, 9.0, 0.0]], vec![[0, 1, 2]])), Some([1, 2, 3, 0]))]), + None + ); +} + #[test] fn a_small_model_far_from_the_origin_can_be_fitted() { let (o, d) = (1e16, 4.0); From 52d68d7844243cd04e0b1deac38f1492e5212c20 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:52:51 +0100 Subject: [PATCH 26/27] annot: share threed's MAX_MODEL_BYTES with model3d main's 3D-embed change (#5808) added crates/annot/src/threed.rs with its own pub MAX_MODEL_BYTES (same 256 MiB value), so both were re-exported from the crate root and pdfcraft-annot no longer compiled after merging main. model3d now uses threed's constant. --- crates/annot/src/lib.rs | 2 +- crates/annot/src/model3d.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index ea7b0c227f..4f24d04b08 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -38,7 +38,7 @@ use lookup::{annots, page_ref}; pub use lookup::{ has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image, }; -pub use model3d::{MAX_MODEL_BYTES, Model3d, Model3dKind, model_3d, model_3d_annotations}; +pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, diff --git a/crates/annot/src/model3d.rs b/crates/annot/src/model3d.rs index 60d2df0f2a..b50342898e 100644 --- a/crates/annot/src/model3d.rs +++ b/crates/annot/src/model3d.rs @@ -2,11 +2,9 @@ //! 3D stream, or a 3D reference dictionary naming the stream another annotation shares. use super::lookup::{annots, page_ref}; +use super::threed::MAX_MODEL_BYTES; use super::{AnnotError, Dict, Document, Object}; -/// The largest decoded 3D stream [`model_3d`] reads, in bytes. -pub const MAX_MODEL_BYTES: usize = 256 << 20; - /// The format of a 3D stream, from its `/Subtype`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Model3dKind { From d22641a875b4066a17638c093e8a7db846c12e1a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 17:44:52 -0500 Subject: [PATCH 27/27] Pass the Linux signing key to the release workflow (#6200) --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8549142f7..9403a1e044 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,3 +42,4 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}