M5: comments without an appearance draw their /Border dashes, Ink dashes and the caret's ¶ (ISO 32000-2 Tables 166, 168, 183)

Comments that arrive without /AP are drawn from their dictionaries by
appearance::build, which lost three entries:

- /Border [hr vr w [dash]] (Table 166): the dash array made the whole
  array unreadable, so the width fell back to 1 and the border was solid.
  The width is now element 2 whatever follows, and without /BS the
  /Border dash array is drawn. /BS, when present, wins for both (Table
  166: "the Border entry is ignored"), so a /BS without /W means 1.
- Dash arrays are drawn only when finite, non-negative, not all zero
  (§8.4.3.6) and at most 64 long; anything else draws solid.
- Ink applies its /BS dash pattern (Table 168, Table 182), like the
  other stroked shapes.
- A caret with /Sy /P (Table 183) shows a ¶ (Helvetica, WinAnsi 0xB6) to
  its right in its colour, inside /Rect: in the room /RD leaves, or by
  narrowing the caret. /Sy /None draws as before.

The Helvetica font resource, written out three times, is one helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
mtlcl410
2026-10-24 10:41:46 +08:00
co-authored by Claude Opus 5.5
parent a2ec27b477
commit 001a900ac7
4 changed files with 258 additions and 33 deletions
+97 -31
View File
@@ -180,23 +180,42 @@ fn interior_color(d: &Dict, fallback: Option<Rgb>) -> Option<Rgb> {
color(d, b"IC").flatten().or(fallback)
}
/// The border width (ISO 32000-2 §12.5.4): `/BS /W` when there is a border style dictionary, which
/// makes `/Border` ignored (Table 166), else the third element of `/Border [hr vr w [dash]]`, read
/// whether or not the optional fourth element, a dash array, follows. 1 when neither says, and
/// when the number given is negative or not finite.
pub(crate) fn border_width(d: &Dict) -> f64 {
if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) {
return w.max(0.0);
}
match nums(d, b"Border") {
Some(b) if b.len() >= 3 => b[2].max(0.0),
_ => 1.0,
}
let width = match d.get(b"BS").and_then(Object::as_dict) {
Some(bs) => bs.get(b"W").and_then(Object::as_f64),
None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_f64),
};
width.filter(|w| w.is_finite() && *w >= 0.0).unwrap_or(1.0)
}
/// Dash pattern operator for a dashed border, if any.
/// Elements a dash array may have before it is drawn solid instead: the lengths are written into
/// the appearance as they are, so a file can't make it arbitrarily large.
const MAX_DASH: usize = 64;
/// A dash array (§8.4.3.6) as drawn: every element finite and non-negative, not all zero (that
/// pattern is invalid) and at most [`MAX_DASH`] long. `None` for anything else, which is drawn solid.
fn dash_array(o: &Object) -> Option<Vec<f64>> {
let a = o.as_array().filter(|a| !a.is_empty() && a.len() <= MAX_DASH)?;
let v: Vec<f64> = a.iter().map(Object::as_f64).collect::<Option<_>>()?;
(v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0)).then_some(v)
}
/// Dash pattern operator for a dashed border, if any: `/BS /S /D` with its `/D` (default `[3]`,
/// Table 168), or, without `/BS`, the dash array that may follow the width in `/Border` (Table 166).
fn dash(d: &Dict) -> String {
let Some(bs) = d.get(b"BS").and_then(|b| b.as_dict()) else { return String::new() };
if bs.name(b"S") != Some(b"D") {
return String::new();
}
let arr = nums(bs, b"D").filter(|a| !a.is_empty() && a.iter().all(|x| *x >= 0.0)).unwrap_or_else(|| vec![3.0]);
let arr = match d.get(b"BS").and_then(Object::as_dict) {
Some(bs) if bs.name(b"S") != Some(b"D") => return String::new(),
Some(bs) => match bs.get(b"D") {
None => Some(vec![3.0]),
Some(o) => dash_array(o),
},
None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(3)).and_then(dash_array),
};
let Some(arr) = arr else { return String::new() };
format!("[{}] 0 d\n", arr.iter().map(|x| n(*x)).collect::<Vec<_>>().join(" "))
}
@@ -299,6 +318,55 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
Some((what, first_undrawable(&text)?))
}
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
fn helvetica_resource() -> Object {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
Object::Dict(fonts)
}
/// Where a caret and its paragraph symbol go within `rect`: the box the caret is drawn in, and for
/// `/Sy /P` the symbol's baseline origin and font size (ISO 32000-2 Table 183).
///
/// The caret lies within `/Rect` less `/RD` (left, top, right, bottom), when `/RD` is four finite,
/// non-negative numbers that leave a box; otherwise within `/Rect`. The ¶ sits to the right of the
/// caret, its height a little over half the caret's, in the room `/RD` leaves on the right; when
/// that is too narrow, the caret gives up to half its width and the symbol shrinks to what is left,
/// so both stay inside `/Rect` (the form's `/BBox`).
fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) {
let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0));
let mut caret = match rd {
Some(rd) => [rect[0] + rd[0], rect[1] + rd[3], rect[2] - rd[2], rect[3] - rd[1]],
None => rect,
};
if !(caret[2] > caret[0] && caret[3] > caret[1]) {
caret = rect;
}
if d.name(b"Sy") != Some(b"P") {
return (caret, None);
}
let size = (caret[3] - caret[1]) * 0.6;
let (gap, glyph) = (size * 0.1, text_width("\u{b6}", size));
let need = gap + glyph;
let room = rect[2] - caret[2];
if room < need {
caret[2] -= (need - room).min((caret[2] - caret[0]) * 0.5);
}
let room = rect[2] - caret[2];
let scale = if need > room { room / need } else { 1.0 };
let size = size * scale;
if !(size.is_finite() && size > 0.0) {
return (caret, None);
}
// The pilcrow descends about a sixth of the size below its baseline.
(caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size)))
}
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
/// 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`).
@@ -512,7 +580,8 @@ pub fn build(d: &Dict) -> Option<Stream> {
b"Caret" => {
// A filled caret: two curved flanks meeting at the top centre.
let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
let [x0, y0, x1, y1] = rect;
let (caret, pilcrow) = caret_layout(d, rect);
let [x0, y0, x1, y1] = caret;
let (cx, h) = ((x0 + x1) / 2.0, y1 - y0);
c.push_str(&format!(
"{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n",
@@ -532,11 +601,22 @@ pub fn build(d: &Dict) -> Option<Stream> {
n(x1),
n(y0)
));
if let Some((x, y, size)) = pilcrow {
// `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour.
// 0xB6 is the pilcrow in WinAnsiEncoding.
let mut out = c.into_bytes();
out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes());
out.push(0xB6);
out.extend_from_slice(b") Tj ET\n");
res.set(b"Font".to_vec(), helvetica_resource());
return Some(form(rect, &out, res));
}
}
b"Ink" => {
let col = stroke?;
let list = d.get(b"InkList")?.as_array()?;
c.push_str(&format!("{}{} w 1 J 1 j\n", rg_stroke(col), n(w)));
// `/BS` gives an ink stroke its width and dash pattern (Table 182).
c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d)));
for s in list {
let pts: Vec<f64> = s.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<_>>()?;
let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
@@ -773,14 +853,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
y -= size * 1.2;
}
out.extend_from_slice(b"ET\n");
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
res.set(b"Font".to_vec(), Object::Dict(fonts));
res.set(b"Font".to_vec(), helvetica_resource());
let mut stream = form(full, &out, res);
if let Some(m) = matrix {
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
@@ -803,14 +876,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
// WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8.
out.extend(literal(&win_ansi(&value)));
out.extend_from_slice(b" Tj ET\n");
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
res.set(b"Font".to_vec(), Object::Dict(fonts));
res.set(b"Font".to_vec(), helvetica_resource());
}
}
Some(form(rect, &out, res))
+109
View File
@@ -1675,3 +1675,112 @@ fn a_star_note_keeps_its_star_when_recoloured() {
let Object::Stream(s) = &*doc.get(n) else { panic!() };
assert_eq!(s.decoded().unwrap(), note_drawing(Some("Star"), [0.0, 0.0, 1.0]));
}
/// The built appearance's content for an annotation dictionary written in PDF syntax.
fn content_of(src: &str) -> String {
let doc = document_of(&[src.as_bytes()]);
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("a dictionary");
let stream = appearance::build(&d).expect("drawn");
String::from_utf8_lossy(&stream.decoded().unwrap()).into_owned()
}
/// ISO 32000-2 Table 166: `/Border [hr vr w [dash]]`; the optional fourth element, a dash array,
/// used to hide the width (drawn 1 pt solid instead of 3 pt dashed).
#[test]
fn legacy_border_width_and_dash_are_drawn() {
let square = |border: &str| content_of(&format!("<< /Subtype /Square /Rect [0 0 100 100] /C [1 0 0] {border} >>"));
let c = square("/Border [0 0 3 [6 3]]");
assert!(c.contains("3 w") && c.contains("[6 3] 0 d"), "{c}");
assert!(c.contains("1.5 1.5 97 97 re"), "the stroke is inset by half its width: {c}");
let c = square("/Border [0 0 3]");
assert!(c.contains("3 w") && !c.contains(" d\n"), "no dash array: solid: {c}");
// `/BS` overrides `/Border` (Table 166), for the width and the dashes alike.
let c = square("/Border [0 0 3 [6 3]] /BS << /W 2 >>");
assert!(c.contains("2 w") && !c.contains(" d\n"), "{c}");
let c = square("/Border [0 0 3 [6 3]] /BS << /W 2 /S /D /D [4 1] >>");
assert!(c.contains("2 w") && c.contains("[4 1] 0 d"), "{c}");
// A /BS without /W means its default width 1, not the ignored /Border's.
let c = square("/Border [0 0 3] /BS << /S /S >>");
assert!(c.contains("1 w") && !c.contains("3 w"), "{c}");
// A FreeText border and a line take the legacy dash array too.
let c = content_of("<< /Subtype /FreeText /Rect [0 0 100 40] /Contents (x) /Border [0 0 2 [5 2]] >>");
assert!(c.contains("2 w") && c.contains("[5 2] 0 d"), "{c}");
let c = content_of("<< /Subtype /Line /Rect [0 0 100 100] /L [10 10 90 90] /C [0 0 1] /Border [0 0 2 [5 2]] >>");
assert!(c.contains("[5 2] 0 d"), "{c}");
}
/// Dash arrays that can't be drawn (§8.4.3.6: all zeros is invalid; negative or non-numeric
/// lengths, and absurdly long arrays) draw solid, without a panic. A non-finite or negative
/// width falls back to 1.
#[test]
fn bad_dash_arrays_draw_solid() {
let square = |border: &str| content_of(&format!("<< /Subtype /Square /Rect [0 0 100 100] /C [1 0 0] {border} >>"));
let long = format!("[{}]", vec!["1"; 65].join(" "));
for bad in ["[0 0]", "[-3 2]", "[]", "[(a) 2]", "5", long.as_str()] {
let c = square(&format!("/Border [0 0 3 {bad}]"));
assert!(c.contains("3 w") && !c.contains(" d\n"), "{bad}: {c}");
let c = square(&format!("/BS << /W 3 /S /D /D {bad} >>"));
assert!(c.contains("3 w") && !c.contains(" d\n"), "{bad}: {c}");
}
assert!(square(&format!("/Border [0 0 3 [{}]]", vec!["1"; 64].join(" "))).contains(" 0 d\n"), "64 elements are drawn");
// `/S /D` without `/D` uses the default `[3]` (Table 168).
assert!(square("/BS << /W 3 /S /D >>").contains("[3] 0 d"));
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Square"));
d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 100.0, 100.0].map(Object::Real).to_vec()));
d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].map(Object::Real).to_vec()));
for w in [f64::NAN, f64::INFINITY, -2.0] {
let dash = Object::Array([f64::NAN, 2.0].map(Object::Real).to_vec());
d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(w), dash]));
let c = String::from_utf8_lossy(&appearance::build(&d).unwrap().decoded().unwrap()).into_owned();
assert!(c.contains("1 w") && !c.contains(" d\n") && !c.contains("NaN") && !c.contains("inf"), "{w}: {c}");
}
}
/// Table 182: an ink annotation's `/BS` gives the width and dash pattern its paths are drawn with.
#[test]
fn ink_draws_its_border_style_dashes() {
let ink = |style: &str| content_of(&format!("<< /Subtype /Ink /Rect [0 0 100 100] /C [1 0 0] /InkList [[5 5 50 95 95 5]] {style} >>"));
let c = ink("/BS << /W 3 /S /D /D [6 3] >>");
assert!(c.contains("3 w 1 J 1 j\n[6 3] 0 d"), "{c}");
assert!(!ink("/BS << /W 3 >>").contains(" d\n"));
assert!(ink("/Border [0 0 2 [4 4]]").contains("[4 4] 0 d"));
}
/// Table 183: `/Sy /P` associates a new paragraph symbol (¶) with the caret. It is drawn to the
/// caret's right in the caret's colour, inside `/Rect`; `/Sy /None` (the default) draws none.
#[test]
fn a_caret_with_a_paragraph_symbol_draws_the_pilcrow() {
let caret = |extra: &str| {
let doc = document_of(&[format!("<< /Subtype /Caret /Rect [30 30 60 60] /C [0.9 0.1 0.1] {extra} >>").as_bytes()]);
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap();
let s = appearance::build(&d).unwrap();
(s.decoded().unwrap(), s.dict.get(b"Resources").and_then(Object::as_dict).is_some_and(|r| r.contains(b"Font")))
};
let has_pilcrow = |c: &[u8]| c.windows(5).any(|w| w == b"(\xb6) T");
let (c, font) = caret("/Sy /P");
assert!(has_pilcrow(&c) && font, "{}", String::from_utf8_lossy(&c));
let text = String::from_utf8_lossy(&c).into_owned();
assert!(text.starts_with("0.9 0.1 0.1 rg"), "{text}");
// The caret gives up room on the right for the symbol, which stays within /Rect.
let td: Vec<f64> = text.split("Tf ").nth(1).unwrap().split_whitespace().take(2).map(|v| v.parse().unwrap()).collect();
let size: f64 = text.split("/Helv ").nth(1).unwrap().split_whitespace().next().unwrap().parse().unwrap();
assert!(td[0] > 45.0 && td[0] + appearance::text_width("\u{b6}", size) <= 60.0 + 1e-6 && td[1] >= 30.0, "{text}");
assert!(!text.contains("60 30 c h f"), "the caret no longer reaches the right edge: {text}");
for none in ["/Sy /None", ""] {
let (c, font) = caret(none);
assert!(!has_pilcrow(&c) && !font, "{none}");
assert!(String::from_utf8_lossy(&c).contains("60 30 c h f"), "{none}: the caret fills /Rect as before");
}
// With /RD leaving room on the right, the caret keeps its box and the symbol fits beside it.
let (c, _) = caret("/Sy /P /RD [0 0 15 0]");
let text = String::from_utf8_lossy(&c).into_owned();
assert!(text.contains("45 30 c h f") && has_pilcrow(&c), "{text}");
// Malformed /RD and a degenerate /Rect don't panic.
for rd in ["/RD [40 0 40 0]", "/RD [-1 0 0 0]", "/RD [1 2]", "/RD (x)"] {
assert!(has_pilcrow(&caret(&format!("/Sy /P {rd}")).0), "{rd}");
}
let doc = document_of(&[b"<< /Subtype /Caret /Rect [30 30 30 30] /Sy /P >>"]);
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap();
assert!(appearance::build(&d).is_some());
}
+50
View File
@@ -7438,3 +7438,53 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() {
assert!(pages[0].images.is_empty(), "the picture is past the budget");
assert_eq!(truncated, [0]);
}
/// ISO 32000-2 Tables 166 and 183, drawn on the page: a square without an appearance whose
/// `/Border [0 0 3 [6 3]]` names its width before a dash array is stroked 3 pt wide and dashed
/// (it was 1 pt and solid), and a caret with `/Sy /P` shows a ¶ to its right.
#[test]
fn legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn() {
let original = Arc::new(pdf_with_annots(&[
b"<< /Type /Annot /Subtype /Square /Rect [20 180 120 280] /Border [0 0 3 [6 3]] /C [1 0 0] >>",
b"<< /Type /Annot /Subtype /Caret /Rect [20 20 80 80] /Sy /P /C [0 0 1] >>",
]));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("noap.pdf", None, original, None).expect("opens");
let p = shown(&s, id, 0);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
// y down: the bottom edge's stroke is centred 1.5 pt inside /Rect, on row 118.
let row: Vec<Vec<u8>> = (24..116).map(|x| px(x, 118)).collect();
let red = row.iter().filter(|c| c[0] > 200 && c[1] < 80).count();
let white = row.iter().filter(|c| c[1] > 200).count();
assert!(red > 40 && white > 15, "3 pt and dashed: {red} red, {white} white");
// The ¶ sits in the right part of the caret's /Rect, where the caret no longer reaches.
let blue = (250..270).flat_map(|y| (68..80).map(move |x| (x, y))).filter(|&(x, y)| {
let c = px(x, y);
c[2] > 150 && c[0] < 100
});
assert!(blue.count() > 20, "the pilcrow is drawn beside the caret");
}
/// A one-page 200 × 300 document whose page lists `annots` (objects 4, 5, …).
fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
let list: String = (0..annots.len()).map(|i| format!("{} 0 R ", i + 4)).collect();
let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{list}] >>");
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 200 300] >>", page.as_bytes()];
objs.extend_from_slice(annots);
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(o);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
bytes
}
+2 -2
View File
@@ -3572,8 +3572,8 @@ milestone = "M5"
status = "partial"
commands = []
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text", "crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn"]
notes = "appearances for every type PdfCraft creates (highlight multiplies; clouds, callouts, stamps, polygons, carets); rich text not yet"
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text", "crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::legacy_border_width_and_dash_are_drawn", "crates/annot/src/tests.rs::ink_draws_its_border_style_dashes", "crates/annot/src/tests.rs::a_caret_with_a_paragraph_symbol_draws_the_pilcrow", "crates/engine/src/tests.rs::legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn"]
notes = "appearances for every type PdfCraft creates (highlight multiplies; clouds, callouts, stamps, polygons, carets). Comments other files carry without an appearance are drawn from their dictionaries the same way, including a legacy /Border width and dash array (ISO 32000-2 Table 166), dashed ink (/BS, Table 182) and a caret's ¶ for /Sy /P (Table 183). Not yet: rich text, beveled, inset and underline border styles, sound icons"
[[feature]]
id = "comment.sticky-note"