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() +}