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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-06 08:53:15 +08:00
1 parent 102cae3bef
commit bf5bfbf707
3 files changed
+60 -1

No files matched your search

+20
View File
@@ -508,6 +508,7 @@ fn appearance_context(d: &Dict) -> Option<AppearanceContext<'_>> {
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<AppearanceContext<'_>> {
})
}
/// 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),
+3 -1
View File
@@ -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");
+37
View File
@@ -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<f64> {
d.get(key).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|o| o.as_f64()).collect()).unwrap_or_default()
}