From bf5bfbf7073078edc190a28d62b8853623c841b9 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Fri, 6 Nov 2026 08:53:15 +0800 Subject: [PATCH 1/7] M5: a text markup whose QuadPoints reach past its Rect is drawn over them (view.render-annotation-appearances) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/annot/src/appearance.rs | 20 ++++++++++++++++++ crates/annot/src/draw.rs | 4 +++- crates/annot/src/tests.rs | 37 ++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 7148e9fc2e..bcacc27a70 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -508,6 +508,7 @@ fn appearance_context(d: &Dict) -> Option> { let subtype = d.name(b"Subtype")?; let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?; let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let rect = markup_reach(d).unwrap_or(rect); // Cloudy borders (§12.5.4) are drawn for polygons only. let cloudy = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); if cloudy && subtype != b"Polygon" { @@ -525,6 +526,25 @@ fn appearance_context(d: &Dict) -> Option> { }) } +/// A text markup annotation's `/Rect` grown to take in its `/QuadPoints`, where they reach past +/// it; `None` when they don't (or aren't usable), and for every other subtype. The quadrilaterals +/// are what the markup marks (ISO 32000-2 Table 182), and the appearance is mapped onto `/Rect` +/// (§12.5.5), so a `/Rect` that leaves them out would cut off what is drawn: a highlight whose +/// `/Rect` lies beside its text drew nothing. +pub(crate) fn markup_reach(d: &Dict) -> Option<[f64; 4]> { + if !matches!(d.name(b"Subtype"), Some(b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly")) { + return None; + } + let rect = nums(d, b"Rect").filter(|r| r.len() == 4 && r.iter().all(|v| v.is_finite()))?; + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0 && q.iter().all(|v| v.is_finite()))?; + let mut out = rect; + for p in q.as_chunks::<2>().0 { + out = [out[0].min(p[0]), out[1].min(p[1]), out[2].max(p[0]), out[3].max(p[1])]; + } + (out != rect).then_some(out) +} + fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { match context.subtype { b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing), diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index 5e698332da..2b5cedaef0 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -78,9 +78,11 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); [x, top - NOTE_SIZE, x + NOTE_SIZE, top] }); + // Text markup whose /QuadPoints reach past /Rect: its appearance covers them, so /Rect does too. + let markup_rect = appearance::markup_reach(d); doc.update_dict(r, |d| { d.set(b"AP".to_vec(), Object::Dict(apd)); - if let Some(rect) = icon_rect { + if let Some(rect) = icon_rect.or(markup_rect) { d.set(b"Rect".to_vec(), num_array(&rect)); } d.remove(b"AS"); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index ff7f8925f0..4afd042481 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2779,3 +2779,40 @@ fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() { check(&doc); check(&reopen(&doc)); } + +/// A text markup annotation is drawn over its `/QuadPoints` (ISO 32000-2 Table 182), and an +/// appearance is mapped onto `/Rect` (§12.5.5). A highlight whose `/Rect` lies beside its text +/// (here written with its corners swapped, as a real file has it) got an appearance cut to that +/// `/Rect`, so nothing showed. The display copy's appearance and `/Rect` now take in the +/// quadrilaterals; a markup whose `/Rect` already holds them keeps it. +#[test] +fn text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [335.1 370.83 324.6 471.78] /C [1 1 0] /QuadPoints [370 287 471 287 370 277 471 277] >>", + b"<< /Type /Annot /Subtype /Underline /Rect [10 10 20 20] /C [0 0 1] /QuadPoints [100 120 200 120 100 100 200 100] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [50 50 150 70] /C [1 1 0] /QuadPoints [60 68 140 68 60 52 140 52] >>", + ]); + let display = with_missing_appearances(&doc).expect("three markups need an appearance"); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + let bbox = |d: &Dict| { + let n = d.get(b"AP").map(|a| display.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N"))).unwrap(); + let obj = display.get(n); + let Object::Stream(s) = &*obj else { panic!("AP is not a stream") }; + nums_of(&s.dict, b"BBox") + }; + assert_eq!(rect(&shown[0]), [324.6, 277.0, 471.0, 471.78], "the highlight's /Rect takes in its text"); + assert_eq!(bbox(&shown[0]), rect(&shown[0])); + assert!(ap_content(&display, &shown[0]).contains("370 287 m 471 287 l 471 277 l 370 277 l h f"), "the quad is filled"); + assert_eq!(rect(&shown[1]), [10.0, 10.0, 200.0, 120.0], "an underline's too"); + assert_eq!(bbox(&shown[1]), rect(&shown[1])); + assert_eq!(rect(&shown[2]), [50.0, 50.0, 150.0, 70.0], "a /Rect that holds the quads stays as it is"); + assert_eq!(rect(&list(&doc, 0)[0]), [335.1, 370.83, 324.6, 471.78], "the source document is unchanged"); +} + +fn nums_of(d: &Dict, key: &[u8]) -> Vec { + d.get(key).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|o| o.as_f64()).collect()).unwrap_or_default() +} From 96b96c53da58f22e810400ad8a691c16bf59d7ce Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Fri, 6 Nov 2026 08:53:35 +0800 Subject: [PATCH 2/7] parity: text markup drawn over its QuadPoints past /Rect Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ec144d3a07..1a176b5453 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -818,8 +818,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" +evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/annot/src/tests.rs::text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup; text markup over its /QuadPoints where they reach past /Rect) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] id = "view.render-error-isolation" From 572cec5dca60b9db90f4eaa4aa7c6cc1719eac79 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Fri, 6 Nov 2026 09:11:02 +0800 Subject: [PATCH 3/7] A markup's quads too far apart for a width keep its /Rect; StrikeOut and Squiggly in the test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/annot/src/appearance.rs | 4 +++- crates/annot/src/tests.rs | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index bcacc27a70..7d5fde14d0 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -542,7 +542,9 @@ pub(crate) fn markup_reach(d: &Dict) -> Option<[f64; 4]> { for p in q.as_chunks::<2>().0 { out = [out[0].min(p[0]), out[1].min(p[1]), out[2].max(p[0]), out[3].max(p[1])]; } - (out != rect).then_some(out) + // Finite corners can still be too far apart for a width or height (`-1e308` to `1e308`). + let usable = (out[2] - out[0]).is_finite() && (out[3] - out[1]).is_finite(); + (usable && out != rect).then_some(out) } fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 4afd042481..8a6db33f94 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2790,12 +2790,15 @@ fn text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them() { let doc = document_of(&[ b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", - b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>", b"<< /Type /Annot /Subtype /Highlight /Rect [335.1 370.83 324.6 471.78] /C [1 1 0] /QuadPoints [370 287 471 287 370 277 471 277] >>", b"<< /Type /Annot /Subtype /Underline /Rect [10 10 20 20] /C [0 0 1] /QuadPoints [100 120 200 120 100 100 200 100] >>", b"<< /Type /Annot /Subtype /Highlight /Rect [50 50 150 70] /C [1 1 0] /QuadPoints [60 68 140 68 60 52 140 52] >>", + b"<< /Type /Annot /Subtype /StrikeOut /Rect [10 10 20 20] /C [1 0 0] /QuadPoints [100 220 200 220 100 200 200 200] >>", + b"<< /Type /Annot /Subtype /Squiggly /Rect [10 10 20 20] /C [0 1 0] /QuadPoints [100 320 200 320 100 300 200 300] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [-1e308 20 1e308 20 -1e308 10 1e308 10] >>", ]); - let display = with_missing_appearances(&doc).expect("three markups need an appearance"); + let display = with_missing_appearances(&doc).expect("the markups need an appearance"); let shown = list(&display, 0); let rect = |d: &Dict| nums_of(d, b"Rect"); let bbox = |d: &Dict| { @@ -2810,6 +2813,11 @@ fn text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them() { assert_eq!(rect(&shown[1]), [10.0, 10.0, 200.0, 120.0], "an underline's too"); assert_eq!(bbox(&shown[1]), rect(&shown[1])); assert_eq!(rect(&shown[2]), [50.0, 50.0, 150.0, 70.0], "a /Rect that holds the quads stays as it is"); + assert_eq!(rect(&shown[3]), [10.0, 10.0, 200.0, 220.0], "a strikeout's too"); + assert_eq!(bbox(&shown[3]), rect(&shown[3])); + assert_eq!(rect(&shown[4]), [10.0, 10.0, 200.0, 320.0], "a squiggly's too"); + assert_eq!(bbox(&shown[4]), rect(&shown[4])); + assert_eq!(rect(&shown[5]), [10.0, 10.0, 20.0, 20.0], "quads too far apart for a width keep the /Rect"); assert_eq!(rect(&list(&doc, 0)[0]), [335.1, 370.83, 324.6, 471.78], "the source document is unchanged"); } From 734bff509a550c924e74f38b904821fe65707607 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 07:22:15 +0800 Subject: [PATCH 4/7] M5: a text markup's widened box stops at the crop box and the quad cap; the edit path saves it (view.render-annotation-appearances) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The widened appearance box and /Rect reach the /QuadPoints only as far as the page's visible region (crop box within media box, ISO 32000-2 §14.11.2), never shrink /Rect, and are not widened at all by quads wholly off the page or by more than MAX_MARKUP_QUADS (10,000) quadrilaterals (counted before the numbers are read). - One /Rect normalisation (normalised_rect) for appearance_context and the reach; markup_reach takes the normalised /Rect and the page box, and apply_appearance takes the saved /Rect from the built /BBox (widened_rect) instead of computing the reach a second time. - Tests: the restyle (set_appearance) path saves the widened /Rect, stops at the page and keeps a fitting /Rect as written; a markup with its own /AP and a non-markup subtype are not widened; crop box and cap cases. Co-Authored-By: Claude Opus 5.5 --- crates/annot/src/appearance.rs | 85 ++++++++++++++++++++++++++-------- crates/annot/src/draw.rs | 29 ++++++++---- crates/annot/src/tests.rs | 83 ++++++++++++++++++++++++++++++++- parity/acrobat-features.toml | 4 +- 4 files changed, 171 insertions(+), 30 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 7d5fde14d0..2c54560da7 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -485,7 +485,15 @@ impl AppearanceDrawing { /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). pub fn build(d: &Dict) -> Option { - let context = appearance_context(d)?; + build_on_page(d, None) +} + +/// [`build`] for an annotation on a page whose visible region is `page` (`Page::crop` in +/// pdfcraft-model): a text markup whose `/QuadPoints` reach past its `/Rect` is drawn over them +/// as far as the page shows ([`markup_reach`]), and its appearance's `/BBox` says how far +/// ([`widened_rect`]). Without a page it is drawn within `/Rect`. +pub(crate) fn build_on_page(d: &Dict, page: Option<[f64; 4]>) -> Option { + let context = appearance_context(d, page)?; let drawing = AppearanceDrawing::new(&context); match context.subtype { b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing), @@ -499,16 +507,15 @@ pub fn build(d: &Dict) -> Option { finish_appearance(&context, drawing) } -fn appearance_context(d: &Dict) -> Option> { +fn appearance_context(d: &Dict, page: Option<[f64; 4]>) -> Option> { // Imported measurement appearances may include leaders, captions and formatting that // this builder cannot reproduce. Preserve them rather than silently losing detail. if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") { return None; } let subtype = d.name(b"Subtype")?; - let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?; - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let rect = markup_reach(d).unwrap_or(rect); + let rect = normalised_rect(d)?; + let rect = page.and_then(|page| markup_reach(d, rect, page)).unwrap_or(rect); // Cloudy borders (§12.5.4) are drawn for polygons only. let cloudy = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); if cloudy && subtype != b"Polygon" { @@ -526,27 +533,67 @@ fn appearance_context(d: &Dict) -> Option> { }) } -/// A text markup annotation's `/Rect` grown to take in its `/QuadPoints`, where they reach past -/// it; `None` when they don't (or aren't usable), and for every other subtype. The quadrilaterals -/// are what the markup marks (ISO 32000-2 Table 182), and the appearance is mapped onto `/Rect` -/// (§12.5.5), so a `/Rect` that leaves them out would cut off what is drawn: a highlight whose -/// `/Rect` lies beside its text drew nothing. -pub(crate) fn markup_reach(d: &Dict) -> Option<[f64; 4]> { - if !matches!(d.name(b"Subtype"), Some(b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly")) { +/// `/Rect` as lower-left and upper-right corners (a rectangle may be written with either pair of +/// opposite corners, ISO 32000-2 §7.9.5); `None` unless it is four numbers. +fn normalised_rect(d: &Dict) -> Option<[f64; 4]> { + let [x0, y0, x1, y1] = <[f64; 4]>::try_from(nums(d, b"Rect")?).ok()?; + Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]) +} + +/// The text markup subtypes, drawn over their `/QuadPoints` (ISO 32000-2 §12.5.6.10). +pub(crate) fn is_text_markup(d: &Dict) -> bool { + matches!(d.name(b"Subtype"), Some(b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly")) +} + +/// At most this many quadrilaterals widen a text markup's box ([`markup_reach`]). Each takes in a +/// word or a group of contiguous words (ISO 32000-2 Table 182), usually a line, so even written +/// word by word a markup over a whole dense page stays well below it; a larger one is drawn +/// within its `/Rect`, as before the box was widened. +pub(crate) const MAX_MARKUP_QUADS: usize = 10_000; + +/// A text markup annotation's `/Rect` (normalised, `rect`) grown to take in its `/QuadPoints` +/// where they reach past it, as far as the page's visible region `page` goes; `None` when they +/// don't, aren't usable or number more than [`MAX_MARKUP_QUADS`], and for every other subtype. +/// The quadrilaterals are what the markup marks (ISO 32000-2 Table 182), and the appearance is +/// mapped onto `/Rect` (§12.5.5), so a `/Rect` that leaves them out would cut off what is drawn: +/// a highlight whose `/Rect` lies beside its text drew nothing. Nothing outside the crop box is +/// shown or printed (§14.11.2), so the box grows no further than it; it never shrinks `rect`. +fn markup_reach(d: &Dict, rect: [f64; 4], page: [f64; 4]) -> Option<[f64; 4]> { + if !is_text_markup(d) || !rect.iter().all(|v| v.is_finite()) { return None; } - let rect = nums(d, b"Rect").filter(|r| r.len() == 4 && r.iter().all(|v| v.is_finite()))?; - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0 && q.iter().all(|v| v.is_finite()))?; - let mut out = rect; - for p in q.as_chunks::<2>().0 { - out = [out[0].min(p[0]), out[1].min(p[1]), out[2].max(p[0]), out[3].max(p[1])]; + // Counted before the numbers are read. + let count = d.get(b"QuadPoints")?.as_array()?.len(); + if count == 0 || count % 8 != 0 || count / 8 > MAX_MARKUP_QUADS { + return None; } - // Finite corners can still be too far apart for a width or height (`-1e308` to `1e308`). + let q = nums(d, b"QuadPoints").filter(|q| q.iter().all(|v| v.is_finite()))?; + let mut reach = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + for p in q.as_chunks::<2>().0 { + reach = [reach[0].min(p[0]), reach[1].min(p[1]), reach[2].max(p[0]), reach[3].max(p[1])]; + } + let reach = [reach[0].max(page[0]), reach[1].max(page[1]), reach[2].min(page[2]), reach[3].min(page[3])]; + // Quadrilaterals wholly off the page widen nothing. + if reach[0] > reach[2] || reach[1] > reach[3] { + return None; + } + let out = [rect[0].min(reach[0]), rect[1].min(reach[1]), rect[2].max(reach[2]), rect[3].max(reach[3])]; + // A `/Rect` of its own can still be too wide for a width or height (`-1e308` to `1e308`). let usable = (out[2] - out[0]).is_finite() && (out[3] - out[1]).is_finite(); (usable && out != rect).then_some(out) } +/// The `/Rect` a text markup takes with its built appearance `stream`: the appearance's `/BBox` +/// where [`markup_reach`] widened it past `/Rect`, as the appearance is mapped onto `/Rect` +/// (§12.5.5); `None` (keep `/Rect`) otherwise and for every other subtype. +pub(crate) fn widened_rect(d: &Dict, stream: &Stream) -> Option<[f64; 4]> { + if !is_text_markup(d) { + return None; + } + let bbox = <[f64; 4]>::try_from(nums(&stream.dict, b"BBox")?).ok()?; + (bbox.iter().all(|v| v.is_finite()) && Some(bbox) != normalised_rect(d)).then_some(bbox) +} + fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { match context.subtype { b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing), diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index 2b5cedaef0..d10c4bb6c4 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -33,14 +33,22 @@ pub(crate) fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) }; + let page = appearance::is_text_markup(&d).then(|| page_box_listing(doc, r)).flatten(); + let Some(stream) = build_appearance(doc, &d, page) else { return Err(AnnotError::Unsupported(subtype)) }; apply_appearance(doc, r, &d, stream) } -/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`]. -/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it. -fn build_appearance(doc: &Document, d: &Dict) -> Option { - let mut stream = appearance::build(d)?; +/// The visible region (`Page::crop`) of the first page that lists `r` among its annotations. +fn page_box_listing(doc: &Document, r: ObjRef) -> Option<[f64; 4]> { + pdfcraft_model::pages(doc).into_iter().find(|p| annots(doc, p.obj).iter().any(|a| a.as_ref() == Some(r))).map(|p| p.crop(doc)) +} + +/// Draw the appearance for an annotation dictionary on a page whose visible region is `page` +/// (used by text markup, [`appearance::build_on_page`]): the expensive half of [`set_appearance`]. +/// Pure from the dictionary, the objects it points at and the page, so [`AppearanceCache`] can +/// reuse it. +fn build_appearance(doc: &Document, d: &Dict, page: Option<[f64; 4]>) -> Option { + let mut stream = appearance::build_on_page(d, page)?; // Picture-stamp restyling must retain the placement's page-axis correction. if d.get(b"PCPicture").is_some() { let matrix = d.get(b"AP").and_then(|ap| { @@ -60,6 +68,8 @@ fn build_appearance(doc: &Document, d: &Dict) -> Option { fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. let missing = appearance::undrawable_text(d).map(|(_, c)| c); + // Text markup whose /QuadPoints reach past /Rect: its appearance covers them, so /Rect does too. + let markup_rect = appearance::widened_rect(d, &stream); let ap = doc.add(Object::Stream(stream)); // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover // appearances would show the previous look on press or hover, so they go with the old /N. @@ -78,8 +88,6 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); [x, top - NOTE_SIZE, x + NOTE_SIZE, top] }); - // Text markup whose /QuadPoints reach past /Rect: its appearance covers them, so /Rect does too. - let markup_rect = appearance::markup_reach(d); doc.update_dict(r, |d| { d.set(b"AP".to_vec(), Object::Dict(apd)); if let Some(rect) = icon_rect.or(markup_rect) { @@ -165,6 +173,8 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac // reads `doc`, which never gets the appearance, so every repeat used to add another stream to // the copy, and the display bytes grew with the number of listings. let mut seen = std::collections::HashSet::new(); + // Pages' visible regions, read once and only when a text markup is drawn. + let mut crops: Option> = None; for page in page_refs(doc).ok()? { for r in annots(doc, page).iter().filter_map(Object::as_ref) { if !seen.insert(r) { @@ -190,7 +200,10 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac bare.remove(b"AP"); appearance::display_stamp(&bare) }; - let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue }; + let crop = appearance::is_text_markup(&d) + .then(|| crops.get_or_insert_with(|| pdfcraft_model::pages(doc).iter().map(|p| (p.obj, p.crop(doc))).collect()).get(&page).copied()) + .flatten(); + let Some(stream) = build_appearance(doc, &d, crop).or_else(other_stamp) else { continue }; cache.insert(r, &stream); // Fails only when `r` isn't a dictionary, which the build just ruled out. apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 8a6db33f94..66a563d83a 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2817,10 +2817,91 @@ fn text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them() { assert_eq!(bbox(&shown[3]), rect(&shown[3])); assert_eq!(rect(&shown[4]), [10.0, 10.0, 200.0, 320.0], "a squiggly's too"); assert_eq!(bbox(&shown[4]), rect(&shown[4])); - assert_eq!(rect(&shown[5]), [10.0, 10.0, 20.0, 20.0], "quads too far apart for a width keep the /Rect"); + assert_eq!(rect(&shown[5]), [0.0, 10.0, 612.0, 20.0], "quads far past the page reach only as far as it shows"); assert_eq!(rect(&list(&doc, 0)[0]), [335.1, 370.83, 324.6, 471.78], "the source document is unchanged"); } +/// `n` copies of the quadrilateral `[100 120 200 120 100 100 200 100]`, as `/QuadPoints`. +fn repeated_quads(n: usize) -> String { + format!("[{}]", vec!["100 120 200 120 100 100 200 100"; n].join(" ")) +} + +/// The `/BBox` of `d`'s normal appearance in `doc`. +fn ap_bbox(doc: &Document, d: &Dict) -> Vec { + let n = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N"))).expect("has /AP /N"); + let obj = doc.get(n); + let Object::Stream(s) = &*obj else { panic!("AP is not a stream") }; + nums_of(&s.dict, b"BBox") +} + +/// The widened box stops at the page's visible region (its crop box, ISO 32000-2 §14.11.2), and +/// quadrilaterals wholly outside it widen nothing. A markup with more than +/// `appearance::MAX_MARKUP_QUADS` quadrilaterals keeps its `/Rect`. An annotation with an +/// appearance of its own, and one that isn't text markup, keep theirs whatever `/QuadPoints` say. +#[test] +fn text_markup_reach_stops_at_the_page_and_the_quad_cap() { + let cap = appearance::MAX_MARKUP_QUADS; + let at_cap = format!("<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints {} >>", repeated_quads(cap)); + let past_cap = format!("<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints {} >>", repeated_quads(cap + 1)); + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /CropBox [0 0 300 400] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 380 500 380 100 360 500 360] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [400 500 450 500 400 480 450 480] >>", + at_cap.as_bytes(), + past_cap.as_bytes(), + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 120 200 120 100 100 200 100] /AP << /N 10 0 R >> >>", + b"<< /Type /Annot /Subtype /Square /Rect [10 10 20 20] /C [0 0 1] /QuadPoints [100 120 200 120 100 100 200 100] >>", + b"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 0 >>\nstream\n\nendstream", + ]); + let display = with_missing_appearances(&doc).expect("the markups need an appearance"); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [10.0, 10.0, 300.0, 380.0], "the box stops at the crop box"); + assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); + assert_eq!(rect(&shown[1]), [10.0, 10.0, 20.0, 20.0], "quads off the page widen nothing"); + assert_eq!(rect(&shown[2]), [10.0, 10.0, 200.0, 120.0], "as many quads as the cap widen the box"); + assert_eq!(rect(&shown[3]), [10.0, 10.0, 20.0, 20.0], "more quads than the cap keep the /Rect"); + assert_eq!(ap_bbox(&display, &shown[3]), rect(&shown[3])); + assert_eq!(rect(&shown[4]), [10.0, 10.0, 20.0, 20.0], "a markup with an appearance of its own keeps its /Rect"); + assert_eq!( + shown[4].get(b"AP").map(|a| display.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N"))), + Some(ObjRef::new(10, 0)), + "and its appearance" + ); + assert_eq!(rect(&shown[5]), [10.0, 10.0, 20.0, 20.0], "a square is not text markup"); + assert_eq!(ap_bbox(&display, &shown[5]), rect(&shown[5])); +} + +/// A restyle (the edit path, `set_appearance`) redraws a text markup over its quadrilaterals and +/// saves the `/Rect` its appearance covers, as far as the page shows, since the appearance is +/// mapped onto `/Rect` (ISO 32000-2 §12.5.5). A markup whose `/Rect` already holds its quads keeps +/// that `/Rect` as written, swapped corners and all. +#[test] +fn restyled_text_markup_saves_the_rect_its_appearance_covers() { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [335.1 370.83 324.6 471.78] /C [1 1 0] /QuadPoints [370 287 471 287 370 277 471 277] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [150 70 50 50] /C [1 1 0] /QuadPoints [60 68 140 68 60 52 140 52] >>", + b"<< /Type /Annot /Subtype /Underline /Rect [10 10 20 20] /C [0 0 1] /QuadPoints [500 120 700 120 500 100 700 100] >>", + ]); + for index in 0..3 { + set_style(&mut doc, 0, index, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); + } + let saved = reopen(&doc); + let shown = list(&saved, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [324.6, 277.0, 471.0, 471.78], "the saved /Rect takes in the highlighted text"); + assert_eq!(ap_bbox(&saved, &shown[0]), rect(&shown[0])); + assert!(ap_content(&saved, &shown[0]).contains("370 287 m 471 287 l 471 277 l 370 277 l h f"), "the quad is filled"); + assert_eq!(rect(&shown[1]), [150.0, 70.0, 50.0, 50.0], "a /Rect that holds the quads is kept as written"); + assert_eq!(rect(&shown[2]), [10.0, 10.0, 612.0, 120.0], "the saved /Rect stops at the page"); + assert_eq!(ap_bbox(&saved, &shown[2]), rect(&shown[2])); +} + fn nums_of(d: &Dict, key: &[u8]) -> Vec { d.get(key).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|o| o.as_f64()).collect()).unwrap_or_default() } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index df3045c604..94f6e66118 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -818,8 +818,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/annot/src/tests.rs::text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup; text markup over its /QuadPoints where they reach past /Rect) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" +evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/annot/src/tests.rs::text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them", "crates/annot/src/tests.rs::text_markup_reach_stops_at_the_page_and_the_quad_cap", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup; text markup over its /QuadPoints where they reach past /Rect, as far as the crop box) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] id = "view.render-error-isolation" From e783d5106f21de6a43b753c154878ffec455ee21 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 08:34:44 +0800 Subject: [PATCH 5/7] M5: a text markup with an overflowing /Rect is not widened by its quads (view.render-annotation-appearances) The markup test gains a highlight whose /Rect spans -1e308 to 1e308 and whose on-page quad lies above it, so the finite-size guard in markup_reach is what keeps its /Rect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/annot/src/tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 66a563d83a..53b3c2d1d4 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2790,13 +2790,14 @@ fn text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them() { let doc = document_of(&[ b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", - b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >>", b"<< /Type /Annot /Subtype /Highlight /Rect [335.1 370.83 324.6 471.78] /C [1 1 0] /QuadPoints [370 287 471 287 370 277 471 277] >>", b"<< /Type /Annot /Subtype /Underline /Rect [10 10 20 20] /C [0 0 1] /QuadPoints [100 120 200 120 100 100 200 100] >>", b"<< /Type /Annot /Subtype /Highlight /Rect [50 50 150 70] /C [1 1 0] /QuadPoints [60 68 140 68 60 52 140 52] >>", b"<< /Type /Annot /Subtype /StrikeOut /Rect [10 10 20 20] /C [1 0 0] /QuadPoints [100 220 200 220 100 200 200 200] >>", b"<< /Type /Annot /Subtype /Squiggly /Rect [10 10 20 20] /C [0 1 0] /QuadPoints [100 320 200 320 100 300 200 300] >>", b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [-1e308 20 1e308 20 -1e308 10 1e308 10] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [-1e308 10 1e308 20] /C [1 1 0] /QuadPoints [100 120 200 120 100 100 200 100] >>", ]); let display = with_missing_appearances(&doc).expect("the markups need an appearance"); let shown = list(&display, 0); @@ -2818,6 +2819,9 @@ fn text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them() { assert_eq!(rect(&shown[4]), [10.0, 10.0, 200.0, 320.0], "a squiggly's too"); assert_eq!(bbox(&shown[4]), rect(&shown[4])); assert_eq!(rect(&shown[5]), [0.0, 10.0, 612.0, 20.0], "quads far past the page reach only as far as it shows"); + // A /Rect too wide for its width to be a number keeps its size: the on-page quad above it + // doesn't make it taller. + assert_eq!(rect(&shown[6]), [-1e308, 10.0, 1e308, 20.0], "an overflowing /Rect is not widened"); assert_eq!(rect(&list(&doc, 0)[0]), [335.1, 370.83, 324.6, 471.78], "the source document is unchanged"); } From 98f163652a1a29d14a499761d7cd95d51c0b8829 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 06:42:51 +0800 Subject: [PATCH 6/7] M5: lines, polylines, polygons and ink whose points reach past their Rect are drawn through them (view.render-annotation-appearances) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The box a comment without an appearance is drawn in now takes in the points it is drawn through: /L and its leader lines (ISO 32000-2 §12.5.6.7, Table 178), /Vertices (§12.5.6.9, Table 181) and /InkList (§12.5.6.13, Table 185), as text markup already did with /QuadPoints. Once a point lies outside /Rect, the box also takes in half the border width, line endings and cloudy borders, up to the crop box. A /Rect that holds every point is kept as written. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/annot/src/appearance.rs | 134 +++++++++++++++++++++++++-------- crates/annot/src/draw.rs | 10 +-- crates/annot/src/tests.rs | 112 +++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 222 insertions(+), 38 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 2c54560da7..a15194d6e6 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -489,9 +489,9 @@ pub fn build(d: &Dict) -> Option { } /// [`build`] for an annotation on a page whose visible region is `page` (`Page::crop` in -/// pdfcraft-model): a text markup whose `/QuadPoints` reach past its `/Rect` is drawn over them -/// as far as the page shows ([`markup_reach`]), and its appearance's `/BBox` says how far -/// ([`widened_rect`]). Without a page it is drawn within `/Rect`. +/// pdfcraft-model): a text markup, line, polyline, polygon or ink annotation whose points reach +/// past its `/Rect` is drawn through them as far as the page shows ([`point_reach`]), and its +/// appearance's `/BBox` says how far ([`widened_rect`]). Without a page it is drawn within `/Rect`. pub(crate) fn build_on_page(d: &Dict, page: Option<[f64; 4]>) -> Option { let context = appearance_context(d, page)?; let drawing = AppearanceDrawing::new(&context); @@ -515,7 +515,7 @@ fn appearance_context(d: &Dict, page: Option<[f64; 4]>) -> Option Option<[f64; 4]> { Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]) } -/// The text markup subtypes, drawn over their `/QuadPoints` (ISO 32000-2 §12.5.6.10). -pub(crate) fn is_text_markup(d: &Dict) -> bool { - matches!(d.name(b"Subtype"), Some(b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly")) +/// The subtypes drawn through points of their own rather than within `/Rect`: text markup over +/// its `/QuadPoints` (ISO 32000-2 §12.5.6.10), lines along `/L` (§12.5.6.7), polygons and +/// polylines through `/Vertices` (§12.5.6.9) and ink through `/InkList` (§12.5.6.13). +pub(crate) fn is_drawn_from_points(d: &Dict) -> bool { + matches!(d.name(b"Subtype"), Some(b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" | b"Line" | b"PolyLine" | b"Polygon" | b"Ink")) } -/// At most this many quadrilaterals widen a text markup's box ([`markup_reach`]). Each takes in a +/// At most this many quadrilaterals widen a text markup's box ([`point_reach`]). Each takes in a /// word or a group of contiguous words (ISO 32000-2 Table 182), usually a line, so even written /// word by word a markup over a whole dense page stays well below it; a larger one is drawn /// within its `/Rect`, as before the box was widened. pub(crate) const MAX_MARKUP_QUADS: usize = 10_000; -/// A text markup annotation's `/Rect` (normalised, `rect`) grown to take in its `/QuadPoints` -/// where they reach past it, as far as the page's visible region `page` goes; `None` when they -/// don't, aren't usable or number more than [`MAX_MARKUP_QUADS`], and for every other subtype. -/// The quadrilaterals are what the markup marks (ISO 32000-2 Table 182), and the appearance is -/// mapped onto `/Rect` (§12.5.5), so a `/Rect` that leaves them out would cut off what is drawn: -/// a highlight whose `/Rect` lies beside its text drew nothing. Nothing outside the crop box is -/// shown or printed (§14.11.2), so the box grows no further than it; it never shrinks `rect`. -fn markup_reach(d: &Dict, rect: [f64; 4], page: [f64; 4]) -> Option<[f64; 4]> { - if !is_text_markup(d) || !rect.iter().all(|v| v.is_finite()) { +/// At most this many points widen a line's, polyline's, polygon's or ink annotation's box +/// ([`point_reach`]): as many as [`MAX_MARKUP_QUADS`] quadrilaterals have. A larger one is drawn +/// within its `/Rect`. +pub(crate) const MAX_REACH_POINTS: usize = 4 * MAX_MARKUP_QUADS; + +/// The points `d` is drawn through, as `x y` pairs, and how far its strokes reach past them: +/// half the border width, and for a line ending (`/LE`) or a cloudy border (§12.5.4) the size +/// they are drawn at. `None` for other subtypes, more points than the caps allow, and points that +/// aren't all finite numbers. +fn drawn_points(d: &Dict) -> Option<(Vec, f64)> { + let w = border_width(d); + let has_ending = || d.get(b"LE").and_then(Object::as_array).is_some_and(|e| e.iter().any(|e| e.as_name().is_some_and(|e| e != b"None"))); + let (points, margin) = match d.name(b"Subtype")? { + b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => { + // Counted before the numbers are read. + let count = d.get(b"QuadPoints")?.as_array()?.len(); + if count == 0 || count % 8 != 0 || count / 8 > MAX_MARKUP_QUADS { + return None; + } + (nums(d, b"QuadPoints")?, 0.0) + } + b"Ink" => { + let strokes = d.get(b"InkList")?.as_array()?; + let count = strokes.iter().try_fold(0usize, |sum, s| sum.checked_add(s.as_array()?.len()))?; + if count / 2 > MAX_REACH_POINTS { + return None; + } + let mut points = Vec::with_capacity(count); + for s in strokes { + let stroke: Vec = s.as_array()?.iter().map(Object::as_f64).collect::>()?; + // A stroke's odd last number is no point (`draw_ink` skips it too). + points.extend(stroke.as_chunks::<2>().0.iter().flatten()); + } + (points, w / 2.0) + } + subtype @ (b"PolyLine" | b"Polygon") => { + let count = d.get(b"Vertices")?.as_array()?.len(); + if count < 4 || count % 2 != 0 || count / 2 > MAX_REACH_POINTS { + return None; + } + let cloudy = d.get(b"BE").and_then(Object::as_dict).is_some_and(|b| b.name(b"S") == Some(b"C")); + // A cloud's bumps are drawn at up to twice their nominal radius (`draw_polygon`). + let reach = match subtype { + b"Polygon" if cloudy => 2.0 * cloud_radius(w), + b"PolyLine" if has_ending() => arrow_size(w), + _ => 0.0, + }; + (nums(d, b"Vertices")?, w / 2.0 + reach) + } + b"Line" => { + let [x0, y0, x1, y1] = <[f64; 4]>::try_from(nums(d, b"L")?).ok()?; + if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) { + return None; + } + // Leader lines move the line off `/L` (§12.5.6.7, Table 178). + let layout = line_layout(d, [(x0, y0), (x1, y1)], w); + let mut points = vec![x0, y0, x1, y1]; + for (x, y) in layout.line.iter().chain(layout.leaders.iter().flatten()) { + points.extend([*x, *y]); + } + (points, w / 2.0 + if has_ending() { arrow_size(w) } else { 0.0 }) + } + _ => return None, + }; + (points.iter().all(|v| v.is_finite()) && margin.is_finite()).then_some((points, margin)) +} + +/// An annotation's `/Rect` (normalised, `rect`) grown to take in the points it is drawn through +/// ([`drawn_points`]) where they reach past it, as far as the page's visible region `page` goes; +/// `None` when they don't, aren't usable or number more than the caps allow, and for subtypes +/// drawn within `/Rect` ([`is_drawn_from_points`]). The points are what the annotation draws (ISO +/// 32000-2 Table 182 `/QuadPoints`, Table 178 `/L`, Table 181 `/Vertices`, Table 185 `/InkList`), +/// and the appearance is mapped onto `/Rect` (§12.5.5), so a `/Rect` that leaves them out would +/// cut off what is drawn: a highlight or ink stroke whose `/Rect` lies beside it drew nothing. +/// Once a point is outside, the box takes in the strokes' width and line endings too. Nothing +/// outside the crop box is shown or printed (§14.11.2), so the box grows no further than it; it +/// never shrinks `rect`, and a `/Rect` that holds every point is kept as it is. +fn point_reach(d: &Dict, rect: [f64; 4], page: [f64; 4]) -> Option<[f64; 4]> { + if !rect.iter().all(|v| v.is_finite()) { return None; } - // Counted before the numbers are read. - let count = d.get(b"QuadPoints")?.as_array()?.len(); - if count == 0 || count % 8 != 0 || count / 8 > MAX_MARKUP_QUADS { - return None; - } - let q = nums(d, b"QuadPoints").filter(|q| q.iter().all(|v| v.is_finite()))?; + let (points, margin) = drawn_points(d)?; let mut reach = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; - for p in q.as_chunks::<2>().0 { + for p in points.as_chunks::<2>().0 { reach = [reach[0].min(p[0]), reach[1].min(p[1]), reach[2].max(p[0]), reach[3].max(p[1])]; } - let reach = [reach[0].max(page[0]), reach[1].max(page[1]), reach[2].min(page[2]), reach[3].min(page[3])]; - // Quadrilaterals wholly off the page widen nothing. - if reach[0] > reach[2] || reach[1] > reach[3] { + let clip = |b: [f64; 4]| [b[0].max(page[0]), b[1].max(page[1]), b[2].min(page[2]), b[3].min(page[3])]; + let shown = clip(reach); + // Points wholly off the page widen nothing, and neither do points `/Rect` already holds. + if shown[0] > shown[2] || shown[1] > shown[3] { return None; } + if rect[0] <= shown[0] && rect[1] <= shown[1] && shown[2] <= rect[2] && shown[3] <= rect[3] { + return None; + } + let reach = clip([reach[0] - margin, reach[1] - margin, reach[2] + margin, reach[3] + margin]); let out = [rect[0].min(reach[0]), rect[1].min(reach[1]), rect[2].max(reach[2]), rect[3].max(reach[3])]; // A `/Rect` of its own can still be too wide for a width or height (`-1e308` to `1e308`). let usable = (out[2] - out[0]).is_finite() && (out[3] - out[1]).is_finite(); (usable && out != rect).then_some(out) } -/// The `/Rect` a text markup takes with its built appearance `stream`: the appearance's `/BBox` -/// where [`markup_reach`] widened it past `/Rect`, as the appearance is mapped onto `/Rect` -/// (§12.5.5); `None` (keep `/Rect`) otherwise and for every other subtype. +/// The `/Rect` an annotation takes with its built appearance `stream`: the appearance's `/BBox` +/// where [`point_reach`] widened it past `/Rect`, as the appearance is mapped onto `/Rect` +/// (§12.5.5); `None` (keep `/Rect`) otherwise and for subtypes drawn within `/Rect`. pub(crate) fn widened_rect(d: &Dict, stream: &Stream) -> Option<[f64; 4]> { - if !is_text_markup(d) { + if !is_drawn_from_points(d) { return None; } let bbox = <[f64; 4]>::try_from(nums(&stream.dict, b"BBox")?).ok()?; diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index d10c4bb6c4..0965cf6e21 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -33,7 +33,7 @@ pub(crate) fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let page = appearance::is_text_markup(&d).then(|| page_box_listing(doc, r)).flatten(); + let page = appearance::is_drawn_from_points(&d).then(|| page_box_listing(doc, r)).flatten(); let Some(stream) = build_appearance(doc, &d, page) else { return Err(AnnotError::Unsupported(subtype)) }; apply_appearance(doc, r, &d, stream) } @@ -44,7 +44,7 @@ fn page_box_listing(doc: &Document, r: ObjRef) -> Option<[f64; 4]> { } /// Draw the appearance for an annotation dictionary on a page whose visible region is `page` -/// (used by text markup, [`appearance::build_on_page`]): the expensive half of [`set_appearance`]. +/// (used by annotations drawn through points of their own, [`appearance::build_on_page`]): the expensive half of [`set_appearance`]. /// Pure from the dictionary, the objects it points at and the page, so [`AppearanceCache`] can /// reuse it. fn build_appearance(doc: &Document, d: &Dict, page: Option<[f64; 4]>) -> Option { @@ -68,7 +68,7 @@ fn build_appearance(doc: &Document, d: &Dict, page: Option<[f64; 4]>) -> Option< fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. let missing = appearance::undrawable_text(d).map(|(_, c)| c); - // Text markup whose /QuadPoints reach past /Rect: its appearance covers them, so /Rect does too. + // Points (/QuadPoints, /L, /Vertices, /InkList) past /Rect: its appearance covers them, so /Rect does too. let markup_rect = appearance::widened_rect(d, &stream); let ap = doc.add(Object::Stream(stream)); // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover @@ -173,7 +173,7 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac // reads `doc`, which never gets the appearance, so every repeat used to add another stream to // the copy, and the display bytes grew with the number of listings. let mut seen = std::collections::HashSet::new(); - // Pages' visible regions, read once and only when a text markup is drawn. + // Pages' visible regions, read once and only when an annotation drawn through its points is. let mut crops: Option> = None; for page in page_refs(doc).ok()? { for r in annots(doc, page).iter().filter_map(Object::as_ref) { @@ -200,7 +200,7 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac bare.remove(b"AP"); appearance::display_stamp(&bare) }; - let crop = appearance::is_text_markup(&d) + let crop = appearance::is_drawn_from_points(&d) .then(|| crops.get_or_insert_with(|| pdfcraft_model::pages(doc).iter().map(|p| (p.obj, p.crop(doc))).collect()).get(&page).copied()) .flatten(); let Some(stream) = build_appearance(doc, &d, crop).or_else(other_stamp) else { continue }; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 53b3c2d1d4..0f57713804 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2906,6 +2906,118 @@ fn restyled_text_markup_saves_the_rect_its_appearance_covers() { assert_eq!(ap_bbox(&saved, &shown[2]), rect(&shown[2])); } +/// A one-page document whose page lists `annots` (objects 4 on) and the display copy +/// `with_missing_appearances` draws for it. +fn displayed(annots: &[&[u8]]) -> (Document, Document) { + let kids: Vec = (0..annots.len()).map(|i| format!("{} 0 R", i + 4)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" ")); + let mut objs: Vec<&[u8]> = + vec![b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", page.as_bytes()]; + objs.extend_from_slice(annots); + let doc = document_of(&objs); + let display = with_missing_appearances(&doc).expect("the annotations need an appearance"); + (doc, display) +} + +/// Ink strokes (`/InkList`, ISO 32000-2 Table 185) outside an ink annotation's `/Rect` are drawn: +/// the display copy's `/Rect` and `/BBox` take in the strokes and half their width. Shaped like +/// the pencil marks of `issue13447.pdf`, whose `/Rect` has x and y swapped. An ink annotation +/// whose `/Rect` holds its points keeps it, even where its wide stroke reaches past it. +#[test] +fn ink_whose_strokes_reach_past_its_rect_is_drawn_through_them() { + let (doc, display) = displayed(&[ + b"<< /Type /Annot /Subtype /Ink /Rect [109.25 260.01 21.25 313.51] /BS << /W 3 >> /C [1 0 0] /InkList [[260 502.75 300 537 330 520] [280 510]] >>", + b"<< /Type /Annot /Subtype /Ink /Rect [0 0 100 100] /BS << /W 30 >> /C [1 0 0] /InkList [[10 10 90 90]] >>", + ]); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [21.25, 260.01, 331.5, 538.5], "the ink's /Rect takes in its strokes"); + assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); + assert!(ap_content(&display, &shown[0]).contains("260 502.75 m"), "the stroke is drawn"); + assert_eq!(rect(&shown[1]), [0.0, 0.0, 100.0, 100.0], "a /Rect that holds the points stays as it is"); + assert_eq!(rect(&list(&doc, 0)[0]), [109.25, 260.01, 21.25, 313.51], "the source document is unchanged"); +} + +/// A polyline's `/Vertices` (Table 181) outside its `/Rect` are drawn, with its line endings +/// (`/LE`): the box takes in the arrow at the last vertex. +#[test] +fn polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them() { + let (_, display) = displayed(&[ + b"<< /Type /Annot /Subtype /PolyLine /Rect [667.5 250.69 567.75 344.44] /BS << /W 2 >> /C [0 0 1] /Vertices [250 44 350 94 285 138] >>", + b"<< /Type /Annot /Subtype /PolyLine /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /LE [/None /OpenArrow] /Vertices [100 100 200 100 200 200] >>", + ]); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [249.0, 43.0, 667.5, 344.44], "the polyline's /Rect takes in its vertices"); + assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); + assert!(ap_content(&display, &shown[0]).contains("250 44 m"), "the polyline is drawn"); + // Half the width (1) and the arrow (6 + 3 × 2) past the vertices. + assert_eq!(rect(&shown[1]), [10.0, 10.0, 213.0, 213.0], "and its arrow"); +} + +/// A polygon's `/Vertices` outside its `/Rect` are drawn, its cloudy border (§12.5.4) included. +#[test] +fn polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them() { + let (_, display) = displayed(&[ + b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 1 >> /C [0 0 1] /IC [1 1 0] /Vertices [100 100 200 100 150 180] >>", + b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C >> /Vertices [100 100 200 100 150 180] >>", + ]); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [10.0, 10.0, 200.5, 180.5], "the polygon's /Rect takes in its vertices"); + assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); + assert!(ap_content(&display, &shown[0]).contains("100 100 m"), "the polygon is drawn"); + // Half the width (1) and twice the cloud radius (2 × (6 + 1.5 × 2)) past the vertices. + assert_eq!(rect(&shown[1]), [10.0, 10.0, 219.0, 199.0], "and its cloudy border"); +} + +/// A line's `/L` (Table 178) outside its `/Rect` is drawn, with its line ending and its leader +/// lines. Shaped like the arrows of `issue13447.pdf`. +#[test] +fn line_whose_points_reach_past_its_rect_is_drawn_through_them() { + let (_, display) = displayed(&[ + b"<< /Type /Annot /Subtype /Line /Rect [598.31 146.63 537 316.13] /BS << /W 6 >> /C [0 0 1] /LE [/None /ClosedArrow] /L [176 45 178 154] >>", + b"<< /Type /Annot /Subtype /Line /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /L [100 100 200 100] /LL 20 /LLE 5 >>", + b"<< /Type /Annot /Subtype /Line /Rect [90 90 210 110] /BS << /W 2 >> /C [0 0 1] /LE [/OpenArrow /OpenArrow] /L [100 100 200 100] >>", + ]); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + // Half the width (3) and the arrow (6 + 3 × 6) past `/L`. + assert_eq!(rect(&shown[0]), [149.0, 18.0, 598.31, 316.13], "the line's /Rect takes in /L and its arrow"); + assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); + assert!(ap_content(&display, &shown[0]).contains("176 45 m"), "the line is drawn"); + // The leaders rise 20 + 5 above `/L`; half the width (1) past them. + assert_eq!(rect(&shown[1]), [10.0, 10.0, 201.0, 126.0], "and its leader lines"); + assert_eq!(rect(&shown[2]), [90.0, 90.0, 210.0, 110.0], "a /Rect that holds /L stays as it is"); +} + +/// Points past the caps (`appearance::MAX_REACH_POINTS`) leave `/Rect` as it is, as do points +/// that aren't numbers; a restyle (the edit path) saves the widened `/Rect` of an ink annotation. +#[test] +fn path_reach_stops_at_the_point_cap_and_is_saved_on_restyle() { + let cap = appearance::MAX_REACH_POINTS; + let stroke = |n: usize| format!("[{}]", vec!["100 120"; n].join(" ")); + let at_cap = format!("<< /Type /Annot /Subtype /Ink /Rect [10 10 20 20] /BS << /W 0 >> /C [1 0 0] /InkList [{} [200 200]] >>", stroke(cap - 1)); + let past_cap = format!("<< /Type /Annot /Subtype /Ink /Rect [10 10 20 20] /BS << /W 0 >> /C [1 0 0] /InkList [{} [200 200]] >>", stroke(cap)); + let (_, display) = displayed(&[at_cap.as_bytes(), past_cap.as_bytes()]); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [10.0, 10.0, 200.0, 200.0], "as many points as the cap widen the box"); + assert_eq!(rect(&shown[1]), [10.0, 10.0, 20.0, 20.0], "more points than the cap keep the /Rect"); + + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Ink /Rect [109.25 260.01 21.25 313.51] /BS << /W 3 >> /C [1 0 0] /InkList [[260 502.75 300 537 330 520]] >>", + ]); + set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + let saved = reopen(&doc); + let shown = list(&saved, 0); + assert_eq!(rect(&shown[0]), [21.25, 260.01, 331.5, 538.5], "the saved /Rect takes in the strokes"); + assert_eq!(ap_bbox(&saved, &shown[0]), rect(&shown[0])); +} + fn nums_of(d: &Dict, key: &[u8]) -> Vec { d.get(key).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|o| o.as_f64()).collect()).unwrap_or_default() } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 77e4cda4d7..67195d8dbf 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -818,8 +818,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/annot/src/tests.rs::text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them", "crates/annot/src/tests.rs::text_markup_reach_stops_at_the_page_and_the_quad_cap", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup; text markup over its /QuadPoints where they reach past /Rect, as far as the crop box) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" +evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/annot/src/tests.rs::text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them", "crates/annot/src/tests.rs::text_markup_reach_stops_at_the_page_and_the_quad_cap", "crates/annot/src/tests.rs::ink_whose_strokes_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::line_whose_points_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::path_reach_stops_at_the_point_cap_and_is_saved_on_restyle", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup; text markup, lines, polylines, polygons and ink through their /QuadPoints, /L, /Vertices or /InkList where these reach past /Rect, as far as the crop box) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] id = "view.render-error-isolation" From 1015a51645b9dfdd59d7e2395b5f2cc986052353 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 11:44:01 +0800 Subject: [PATCH 7/7] M5: a widened comment box takes in squiggle peaks, ink curve bulges, line captions and strokes reaching onto the page; a redraw reads only the comment's page (view.render-annotation-appearances) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Text markup reaches past its quads by half its line width, and a squiggle by its peaks over a low quad; the drawing and the reach share the stroke sizes. - Ink takes in the turning points of its smooth curve (cubic extrema), not only its points. - A line takes in its caption's box and the caption tick. - Points off the page whose strokes reach onto it widen the box as far as the page shows. - page_box_listing reads the page /P names when that page lists the comment (ISO 32000-2 §12.5.2: one page lists an annotation), inheriting its boxes up its /Parent chain, before walking the page tree. - AppearanceCache entries carry the crop they were drawn for, so a changed crop box redraws. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/annot/src/appearance.rs | 96 +++++++++++++++++++++++++---- crates/annot/src/draw.rs | 57 ++++++++++++++---- crates/annot/src/tests.rs | 106 +++++++++++++++++++++++++++++++-- 3 files changed, 230 insertions(+), 29 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index a15194d6e6..69c24df0a6 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -120,6 +120,29 @@ pub fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> { .collect() } +/// The points where the cubic Bézier segment from `p0` (control points `c1`, `c2`) to `p3` turns +/// back in x or in y, strictly between its ends: with its ends, they bound the segment. +fn cubic_extremes(p0: (f64, f64), c1: (f64, f64), c2: (f64, f64), p3: (f64, f64)) -> Vec<(f64, f64)> { + let at = |t: f64| { + let u = 1.0 - t; + let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + (w[0] * p0.0 + w[1] * c1.0 + w[2] * c2.0 + w[3] * p3.0, w[0] * p0.1 + w[1] * c1.1 + w[2] * c2.1 + w[3] * p3.1) + }; + let mut out = Vec::new(); + for (q0, q1, q2, q3) in [(p0.0, c1.0, c2.0, p3.0), (p0.1, c1.1, c2.1, p3.1)] { + // The derivative over 3: a t² + 2 b t + c. + let (a, b, c) = (-q0 + 3.0 * q1 - 3.0 * q2 + q3, q0 - 2.0 * q1 + q2, q1 - q0); + let roots = if a.abs() < 1e-12 { + if b.abs() < 1e-12 { vec![] } else { vec![-c / (2.0 * b)] } + } else { + let disc = b * b - a * c; + if disc < 0.0 { vec![] } else { vec![(-b + disc.sqrt()) / a, (-b - disc.sqrt()) / a] } + }; + out.extend(roots.into_iter().filter(|t| *t > 0.0 && *t < 1.0).map(at)); + } + out +} + /// `c` operators for [`smooth_segments`] (the path already starts at the first point). fn smooth_curve(pts: &[(f64, f64)]) -> String { smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect() @@ -566,13 +589,17 @@ fn drawn_points(d: &Dict) -> Option<(Vec, f64)> { let w = border_width(d); let has_ending = || d.get(b"LE").and_then(Object::as_array).is_some_and(|e| e.iter().any(|e| e.as_name().is_some_and(|e| e != b"None"))); let (points, margin) = match d.name(b"Subtype")? { - b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => { + subtype @ (b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly") => { // Counted before the numbers are read. let count = d.get(b"QuadPoints")?.as_array()?.len(); if count == 0 || count % 8 != 0 || count / 8 > MAX_MARKUP_QUADS { return None; } - (nums(d, b"QuadPoints")?, 0.0) + let quads = nums(d, b"QuadPoints")?; + // How far each quad's stroke reaches past it: half the line's width, and a squiggle's + // peaks above a quad lower than they are (`draw_markup`). + let margin = quads.as_chunks::<8>().0.iter().map(|q| markup_overreach(subtype, (q[0] - q[4]).hypot(q[1] - q[5]))).fold(0.0, f64::max); + (quads, margin) } b"Ink" => { let strokes = d.get(b"InkList")?.as_array()?; @@ -580,11 +607,21 @@ fn drawn_points(d: &Dict) -> Option<(Vec, f64)> { if count / 2 > MAX_REACH_POINTS { return None; } - let mut points = Vec::with_capacity(count); + let mut points = Vec::with_capacity(count.saturating_mul(3)); for s in strokes { let stroke: Vec = s.as_array()?.iter().map(Object::as_f64).collect::>()?; // A stroke's odd last number is no point (`draw_ink` skips it too). - points.extend(stroke.as_chunks::<2>().0.iter().flatten()); + let pts: Vec<(f64, f64)> = stroke.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + points.extend(pts.iter().flat_map(|p| [p.0, p.1])); + // The smooth curve through three or more points (`smooth_segments`) can bulge + // past them: its turning points count too. + if pts.len() > 2 { + let mut start = pts.first().copied().unwrap_or_default(); + for [c1, c2, end] in smooth_segments(&pts) { + points.extend(cubic_extremes(start, c1, c2, end).iter().flat_map(|p| [p.0, p.1])); + start = end; + } + } } (points, w / 2.0) } @@ -610,9 +647,17 @@ fn drawn_points(d: &Dict) -> Option<(Vec, f64)> { // Leader lines move the line off `/L` (§12.5.6.7, Table 178). let layout = line_layout(d, [(x0, y0), (x1, y1)], w); let mut points = vec![x0, y0, x1, y1]; - for (x, y) in layout.line.iter().chain(layout.leaders.iter().flatten()) { + for (x, y) in layout.line.iter().chain(layout.leaders.iter().flatten()).chain(layout.tick.iter().flatten()) { points.extend([*x, *y]); } + // The caption's box: its width along the text matrix, from the descender to the + // cap height and a little over (Helvetica, as `draw_line` sets it). + if let Some((text, m)) = &layout.caption { + let width = text_width(text, CAPTION_SIZE); + for (x, y) in [(0.0, -0.25), (width, -0.25), (0.0, 1.0), (width, 1.0)].map(|(x, y)| (x, y * CAPTION_SIZE)) { + points.extend([m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]); + } + } (points, w / 2.0 + if has_ending() { arrow_size(w) } else { 0.0 }) } _ => return None, @@ -641,14 +686,18 @@ fn point_reach(d: &Dict, rect: [f64; 4], page: [f64; 4]) -> Option<[f64; 4]> { } let clip = |b: [f64; 4]| [b[0].max(page[0]), b[1].max(page[1]), b[2].min(page[2]), b[3].min(page[3])]; let shown = clip(reach); - // Points wholly off the page widen nothing, and neither do points `/Rect` already holds. - if shown[0] > shown[2] || shown[1] > shown[3] { + // Strokes wholly off the page widen nothing. Points `/Rect` already holds don't either, and + // points off the page whose strokes reach onto it only as far as `/Rect` holds. + let stroked = clip([reach[0] - margin, reach[1] - margin, reach[2] + margin, reach[3] + margin]); + if stroked[0] > stroked[2] || stroked[1] > stroked[3] { return None; } - if rect[0] <= shown[0] && rect[1] <= shown[1] && shown[2] <= rect[2] && shown[3] <= rect[3] { + let inside = |b: [f64; 4]| rect[0] <= b[0] && rect[1] <= b[1] && b[2] <= rect[2] && b[3] <= rect[3]; + let on_page = shown[0] <= shown[2] && shown[1] <= shown[3]; + if inside(if on_page { shown } else { stroked }) { return None; } - let reach = clip([reach[0] - margin, reach[1] - margin, reach[2] + margin, reach[3] + margin]); + let reach = stroked; let out = [rect[0].min(reach[0]), rect[1].min(reach[1]), rect[2].max(reach[2]), rect[3].max(reach[3])]; // A `/Rect` of its own can still be too wide for a width or height (`-1e308` to `1e308`). let usable = (out[2] - out[0]).is_finite() && (out[3] - out[1]).is_finite(); @@ -701,6 +750,30 @@ fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDra Some(form(context.rect, &out, drawing.resources)) } +/// The line width of an underline or strike-out over a quad `h` high. +fn markup_line_width(h: f64) -> f64 { + (h * 0.07).clamp(0.5, 3.0) +} + +/// A squiggle's line width and amplitude over a quad `h` high: its peaks stand twice the +/// amplitude above the quad's bottom edge. +fn squiggle(h: f64) -> (f64, f64) { + ((h * 0.05).clamp(0.5, 2.0), (h * 0.06).max(0.75)) +} + +/// How far a text markup of `subtype` drawn over a quad `h` high reaches past the quad: half its +/// line's width, and for a squiggle, how far its peaks stand above a quad lower than they are. +fn markup_overreach(subtype: &[u8], h: f64) -> f64 { + match subtype { + b"Underline" | b"StrikeOut" => markup_line_width(h) / 2.0, + b"Squiggly" => { + let (lw, amp) = squiggle(h); + lw / 2.0 + (amp * 2.0 - h).max(0.0) + } + _ => 0.0, + } +} + fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { let d = context.dict; let subtype = context.subtype; @@ -720,15 +793,14 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1))); } b"Underline" | b"StrikeOut" => { - let lw = (h * 0.07).clamp(0.5, 3.0); + let lw = markup_line_width(h); let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; let (a, b) = (at(p3, p1, t), at(p4, p2, t)); c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); } _ => { // Squiggly: a zigzag along the bottom of the quad. - let lw = (h * 0.05).clamp(0.5, 2.0); - let amp = (h * 0.06).max(0.75); + let (lw, amp) = squiggle(h); let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); let step = (h / 4.0).max(1.5); let steps = ((len / step).ceil() as usize).clamp(1, 10_000); diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index 0965cf6e21..d82851dc25 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -38,11 +38,44 @@ pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), Annot apply_appearance(doc, r, &d, stream) } -/// The visible region (`Page::crop`) of the first page that lists `r` among its annotations. +/// The visible region (`Page::crop`) of the page that lists `r` among its annotations: the page +/// its `/P` names when that page lists it, read up its own `/Parent` chain, else the first page +/// of the tree that lists it. An annotation is listed by one page only (ISO 32000-2 §12.5.2), so +/// both find the same page, and the first reads one page instead of walking the whole tree for +/// every comment redrawn. fn page_box_listing(doc: &Document, r: ObjRef) -> Option<[f64; 4]> { + let named = annot_dict(doc, r).reference(b"P").filter(|p| annots(doc, *p).iter().any(|a| a.as_ref() == Some(r))); + if let Some(page) = named { + return Some(page_with_inherited_boxes(doc, page).crop(doc)); + } pdfcraft_model::pages(doc).into_iter().find(|p| annots(doc, p.obj).iter().any(|a| a.as_ref() == Some(r))).map(|p| p.crop(doc)) } +/// The page `page` with the `/MediaBox` and `/CropBox` it inherits from its ancestors filled in +/// (ISO 32000-2 §7.7.3.4), enough for [`pdfcraft_model::Page::crop`]. Follows at most 64 +/// `/Parent` links and stops at a loop. +fn page_with_inherited_boxes(doc: &Document, page: ObjRef) -> pdfcraft_model::Page { + let mut dict = doc.get(page).as_dict().cloned().unwrap_or_default(); + let mut seen = std::collections::HashSet::from([page]); + let mut up = dict.reference(b"Parent"); + while let Some(node) = up { + if seen.len() > 64 || !seen.insert(node) { + break; + } + let obj = doc.get(node); + let Some(d) = obj.as_dict() else { break }; + for key in [&b"MediaBox"[..], b"CropBox"] { + if !dict.contains(key) + && let Some(v) = d.get(key) + { + dict.set(key.to_vec(), v.clone()); + } + } + up = d.reference(b"Parent"); + } + pdfcraft_model::Page { obj: page, dict } +} + /// Draw the appearance for an annotation dictionary on a page whose visible region is `page` /// (used by annotations drawn through points of their own, [`appearance::build_on_page`]): the expensive half of [`set_appearance`]. /// Pure from the dictionary, the objects it points at and the page, so [`AppearanceCache`] can @@ -111,7 +144,9 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> /// refreshes) and keep it for content-only ones. #[derive(Clone, Debug, Default)] pub struct AppearanceCache { - entries: std::collections::HashMap, + /// Each appearance with the page region it was drawn for (see [`appearance::build_on_page`]): + /// a changed crop box draws it again. + entries: std::collections::HashMap, Stream)>, bytes: usize, hits: u64, builds: u64, @@ -126,20 +161,20 @@ impl AppearanceCache { *self = Self::default(); } - fn get(&mut self, r: ObjRef) -> Option { - let stream = self.entries.get(&r)?.clone(); + fn get(&mut self, r: ObjRef, crop: Option<[f64; 4]>) -> Option { + let stream = self.entries.get(&r).filter(|(drawn_for, _)| *drawn_for == crop)?.1.clone(); self.hits += 1; Some(stream) } - fn insert(&mut self, r: ObjRef, stream: &Stream) { + fn insert(&mut self, r: ObjRef, crop: Option<[f64; 4]>, stream: &Stream) { self.builds += 1; if self.entries.len() >= 1_000_000 { self.clear(); } if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES { self.bytes += stream.raw.len(); - self.entries.insert(r, stream.clone()); + self.entries.insert(r, (crop, stream.clone())); } // Else over budget: keep serving the entries already cached instead of growing. } @@ -188,7 +223,10 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac if has_normal { continue; } - if let Some(stream) = cache.get(r) { + let crop = appearance::is_drawn_from_points(&d) + .then(|| crops.get_or_insert_with(|| pdfcraft_model::pages(doc).iter().map(|p| (p.obj, p.crop(doc))).collect()).get(&page).copied()) + .flatten(); + if let Some(stream) = cache.get(r, crop) { // Fails only when `r` isn't a dictionary, which the cached build ruled out. apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); continue; @@ -200,11 +238,8 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac bare.remove(b"AP"); appearance::display_stamp(&bare) }; - let crop = appearance::is_drawn_from_points(&d) - .then(|| crops.get_or_insert_with(|| pdfcraft_model::pages(doc).iter().map(|p| (p.obj, p.crop(doc))).collect()).get(&page).copied()) - .flatten(); let Some(stream) = build_appearance(doc, &d, crop).or_else(other_stamp) else { continue }; - cache.insert(r, &stream); + cache.insert(r, crop, &stream); // Fails only when `r` isn't a dictionary, which the build just ruled out. apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 0f57713804..062d4c0dde 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2811,12 +2811,13 @@ fn text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them() { assert_eq!(rect(&shown[0]), [324.6, 277.0, 471.0, 471.78], "the highlight's /Rect takes in its text"); assert_eq!(bbox(&shown[0]), rect(&shown[0])); assert!(ap_content(&display, &shown[0]).contains("370 287 m 471 287 l 471 277 l 370 277 l h f"), "the quad is filled"); - assert_eq!(rect(&shown[1]), [10.0, 10.0, 200.0, 120.0], "an underline's too"); + // Half the underline's width (0.07 × 20 / 2) past the quad. + assert_eq!(rect(&shown[1]), [10.0, 10.0, 200.7, 120.7], "an underline's too"); assert_eq!(bbox(&shown[1]), rect(&shown[1])); assert_eq!(rect(&shown[2]), [50.0, 50.0, 150.0, 70.0], "a /Rect that holds the quads stays as it is"); - assert_eq!(rect(&shown[3]), [10.0, 10.0, 200.0, 220.0], "a strikeout's too"); + assert_eq!(rect(&shown[3]), [10.0, 10.0, 200.7, 220.7], "a strikeout's too"); assert_eq!(bbox(&shown[3]), rect(&shown[3])); - assert_eq!(rect(&shown[4]), [10.0, 10.0, 200.0, 320.0], "a squiggly's too"); + assert_eq!(rect(&shown[4]), [10.0, 10.0, 200.5, 320.5], "a squiggly's too"); assert_eq!(bbox(&shown[4]), rect(&shown[4])); assert_eq!(rect(&shown[5]), [0.0, 10.0, 612.0, 20.0], "quads far past the page reach only as far as it shows"); // A /Rect too wide for its width to be a number keeps its size: the on-page quad above it @@ -2902,7 +2903,7 @@ fn restyled_text_markup_saves_the_rect_its_appearance_covers() { assert_eq!(ap_bbox(&saved, &shown[0]), rect(&shown[0])); assert!(ap_content(&saved, &shown[0]).contains("370 287 m 471 287 l 471 277 l 370 277 l h f"), "the quad is filled"); assert_eq!(rect(&shown[1]), [150.0, 70.0, 50.0, 50.0], "a /Rect that holds the quads is kept as written"); - assert_eq!(rect(&shown[2]), [10.0, 10.0, 612.0, 120.0], "the saved /Rect stops at the page"); + assert_eq!(rect(&shown[2]), [10.0, 10.0, 612.0, 120.7], "the saved /Rect stops at the page"); assert_eq!(ap_bbox(&saved, &shown[2]), rect(&shown[2])); } @@ -2931,7 +2932,8 @@ fn ink_whose_strokes_reach_past_its_rect_is_drawn_through_them() { ]); let shown = list(&display, 0); let rect = |d: &Dict| nums_of(d, b"Rect"); - assert_eq!(rect(&shown[0]), [21.25, 260.01, 331.5, 538.5], "the ink's /Rect takes in its strokes"); + // The smooth curve rises past its highest point (537) to 537.33; half the width (1.5) more. + assert_rect_near(&rect(&shown[0]), [21.25, 260.01, 331.5, 538.825], "the ink's /Rect takes in its strokes"); assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); assert!(ap_content(&display, &shown[0]).contains("260 502.75 m"), "the stroke is drawn"); assert_eq!(rect(&shown[1]), [0.0, 0.0, 100.0, 100.0], "a /Rect that holds the points stays as it is"); @@ -3014,10 +3016,102 @@ fn path_reach_stops_at_the_point_cap_and_is_saved_on_restyle() { set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); let saved = reopen(&doc); let shown = list(&saved, 0); - assert_eq!(rect(&shown[0]), [21.25, 260.01, 331.5, 538.5], "the saved /Rect takes in the strokes"); + assert_rect_near(&rect(&shown[0]), [21.25, 260.01, 331.5, 538.825], "the saved /Rect takes in the strokes"); assert_eq!(ap_bbox(&saved, &shown[0]), rect(&shown[0])); } +/// A squiggle's peaks stand 1.5 above a quad's bottom edge however low the quad is, so over a quad +/// 1 high they reach past it: the widened box takes them in, with half the line's width. +#[test] +fn squiggly_peaks_over_a_low_quad_are_inside_the_widened_box() { + let (_, display) = + displayed(&[b"<< /Type /Annot /Subtype /Squiggly /Rect [10 10 20 20] /C [0 1 0] /QuadPoints [100 301 200 301 100 300 200 300] >>"]); + let shown = list(&display, 0); + // Peak 300 + 1.5, half the 0.5 line width: 301.75; the sides reach 0.75 past the quad. + assert_eq!(nums_of(&shown[0], b"Rect"), [10.0, 10.0, 200.75, 301.75]); + assert_eq!(ap_bbox(&display, &shown[0]), nums_of(&shown[0], b"Rect")); +} + +/// A line's caption (`/Cap`, ISO 32000-2 Table 178) is drawn beside `/L`: the widened box takes +/// in the caption's text as well as the line. +#[test] +fn a_line_caption_is_inside_the_widened_box() { + let (_, display) = displayed(&[ + b"<< /Type /Annot /Subtype /Line /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /L [100 100 200 100] /Cap true /CP /Top /Contents (Caption) >>", + ]); + let shown = list(&display, 0); + let rect = nums_of(&shown[0], b"Rect"); + // The caption sits on top of the line: its 10-point text rises well past the stroke's 101. + assert!(rect[3] > 108.0, "{rect:?}"); + assert_eq!(ap_bbox(&display, &shown[0]), rect); +} + +/// A line just above the crop box whose wide stroke reaches down onto the page is drawn as far as +/// the page shows, not cut to its `/Rect`. +#[test] +fn a_stroke_that_reaches_onto_the_page_from_points_off_it_widens_the_box() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /CropBox [0 0 300 400] /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Line /Rect [10 10 20 20] /BS << /W 6 >> /C [0 0 1] /L [100 401 200 401] >>", + ]); + let display = with_missing_appearances(&doc).expect("the line needs an appearance"); + let shown = list(&display, 0); + // The stroke spans 398 to 404 across and 97 to 203 along; the page shows up to 400. + assert_eq!(nums_of(&shown[0], b"Rect"), [10.0, 10.0, 203.0, 400.0]); +} + +/// A cached display appearance was drawn for its page's crop box: once the crop box grows, the +/// next refresh draws the annotation again for the larger region. +#[test] +fn a_cached_appearance_is_redrawn_for_a_changed_crop_box() { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /CropBox [0 0 300 400] /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 380 500 380 100 360 500 360] >>", + ]); + let mut cache = AppearanceCache::default(); + let display = with_missing_appearances_cached(&doc, &mut cache).expect("drawn"); + assert_eq!(nums_of(&list(&display, 0)[0], b"Rect"), [10.0, 10.0, 300.0, 380.0]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"CropBox".to_vec(), Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()))).unwrap(); + let display = with_missing_appearances_cached(&doc, &mut cache).expect("drawn"); + let shown = list(&display, 0); + assert_eq!(nums_of(&shown[0], b"Rect"), [10.0, 10.0, 500.0, 380.0], "drawn for the larger crop box"); + assert_eq!(ap_bbox(&display, &shown[0]), nums_of(&shown[0], b"Rect")); +} + +/// Redrawing a comment drawn through its points finds its page through `/P` when that page +/// lists it, reading that one page's `/Annots` rather than every page's: here the last of 300. +#[test] +fn restyling_a_comment_drawn_through_its_points_reads_only_its_page() { + let pages = 300; + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 612 792] >>", kids.join(" "))); + for i in 0..pages { + let (page, annot) = (3 + 2 * i, 4 + 2 * i); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{annot} 0 R] >>")); + objs.push(format!( + "<< /Type /Annot /Subtype /Highlight /P {page} 0 R /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 120 200 120 100 100 200 100] >>" + )); + } + let bytes: Vec<&[u8]> = objs.iter().map(|o| o.as_bytes()).collect(); + let mut doc = document_of(&bytes); + let last = pages - 1; + ANNOTS_ENTRIES_COPIED.with(|c| c.set(0)); + set_style(&mut doc, last, 0, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); + let copied = ANNOTS_ENTRIES_COPIED.with(std::cell::Cell::get); + assert!(copied < 20, "{copied} annotation entries copied for one restyle"); + assert_eq!(nums_of(&list(&doc, last)[0], b"Rect"), [10.0, 10.0, 200.0, 120.0], "drawn for its page"); +} + +/// `got` is `want` to within 0.001 in every coordinate. +fn assert_rect_near(got: &[f64], want: [f64; 4], what: &str) { + assert!(got.len() == 4 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{what}: {got:?}"); +} + fn nums_of(d: &Dict, key: &[u8]) -> Vec { d.get(key).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|o| o.as_f64()).collect()).unwrap_or_default() }