From 001a900ac71a46dbcb5680f5c3ea5720519d2747 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:41:46 +0800 Subject: [PATCH] =?UTF-8?q?M5:=20comments=20without=20an=20appearance=20dr?= =?UTF-8?q?aw=20their=20/Border=20dashes,=20Ink=20dashes=20and=20the=20car?= =?UTF-8?q?et's=20=C2=B6=20(ISO=2032000-2=20Tables=20166,=20168,=20183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/annot/src/appearance.rs | 128 +++++++++++++++++++++++++-------- crates/annot/src/tests.rs | 109 ++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 50 +++++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 258 insertions(+), 33 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 66a86aa30..0e0f3a510 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -180,23 +180,42 @@ fn interior_color(d: &Dict, fallback: Option) -> Option { 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> { + let a = o.as_array().filter(|a| !a.is_empty() && a.len() <= MAX_DASH)?; + let v: Vec = a.iter().map(Object::as_f64).collect::>()?; + (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::>().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 { 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 { 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 = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; 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 { 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 { // 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)) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 20cc89b10..c94be35d0 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -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 = 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()); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1..057350a29 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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> = (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 { + 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 +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..8a02e8760 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"