From 20a395c0aecc40b7bcbedf276029bac5486d0a05 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:42:26 +0800 Subject: [PATCH] M5: sticky notes draw the icon they name (ISO 32000-2 Table 175) (#4182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A note's /Name picks its icon, and ISO 32000-2 §12.5.6.4, Table 175 asks every viewer to provide icons for seven standard names: Comment, Key, Note, Help, NewParagraph, Paragraph, Insert ("Additional names may be supported as well"). PdfCraft drew two shapes: Comment, and a page for everything else. So choosing Key, Help, Insert, NewParagraph or Paragraph in Properties changed nothing visible, and notes in received files that use these names, or further ones such as Check or Star, all looked like the same page. Recolouring such a note redrew it as a page. note_icon now draws a shape of its own for each standard name and for seven further names in common use (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow). They are simple geometric drawings written for PdfCraft, filled in the note's colour with a dark outline like the existing two. NoteIcon gains the further names (NoteIcon::ALL); the Properties chooser and comment_add offer all fourteen. Another name still draws as Note, the table's default. Tests: every_note_icon_draws_its_own_shape (all fourteen differ; an unknown name and a missing one draw Note) and a_star_note_keeps_its_star_when_recoloured. Mutants, each failing them: from_name limited to the standard seven, Star or Key drawn as Note, an unknown name drawn as Comment. One survivor reshaped Circle while keeping it distinct, which the distinctness test rightly allows. Co-authored-by: Claude Opus 5.5 --- crates/annot/src/appearance.rs | 105 ++++++++++++++++++++++++++-- crates/annot/src/lib.rs | 38 +++++++++- crates/annot/src/tests.rs | 53 ++++++++++++++ crates/automation/src/tools.rs | 2 +- crates/ui-egui/src/comment_props.rs | 3 +- parity/acrobat-features.toml | 4 +- 6 files changed, 192 insertions(+), 13 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 59cacbbdd..66a86aa30 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -975,14 +975,107 @@ fn attach_icon(name: &str, col: Rgb) -> String { s } +/// A closed circle path (four Bézier quarters) centred on (`x`, `y`). +fn circle_path(x: f64, y: f64, r: f64) -> String { + let k = r * 0.5523; + let p = |v: f64| n(v); + format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h\n", + p(x + r), + p(y), + p(x + r), + p(y + k), + p(x + k), + p(y + r), + p(x), + p(y + r), + p(x - k), + p(y + r), + p(x - r), + p(y + k), + p(x - r), + p(y), + p(x - r), + p(y - k), + p(x - k), + p(y - r), + p(x), + p(y - r), + p(x + k), + p(y - r), + p(x + r), + p(y - k), + p(x + r), + p(y) + ) +} + +/// A note's icon in a 20 × 20 box: filled in `col`, outlined in dark grey. Each name of +/// [`crate::NoteIcon`] has its own shape, so the icon a note names is the one it shows (ISO +/// 32000-2 §12.5.6.4, Table 175); a name PdfCraft doesn't know draws as `Note`. fn note_icon(name: &str, col: Rgb) -> String { let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col)); - if name == "Comment" { - s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n"); - s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n"); - } else { - s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n"); - s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n"); + match name { + "Comment" => { + s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n"); + s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n"); + } + // A key lying flat: a round bow with a hole, a shaft and two bits. + "Key" => { + s.push_str(&circle_path(5.5, 10.0, 4.5)); + s.push_str(&circle_path(5.0, 10.0, 1.5)); + s.push_str( + "9.5 11.2 m 18.5 11.2 l 18.5 8.8 l 17 8.8 l 17 6 l 15 6 l 15 8.8 l 13.5 8.8 l 13.5 6.8 l 11.5 6.8 l 11.5 8.8 l 9.5 8.8 l h B*\n", + ); + } + // A disc with a question mark. + "Help" => { + s.push_str(&circle_path(10.0, 10.0, 8.5)); + s.push_str("B\n1.6 w 7 12.8 m 7 15.6 13 15.6 13 12.8 c 13 10.6 10 10.8 10 8.2 c S\n10 5 m 10 5.1 l S\n"); + } + // An insertion caret. + "Insert" => s.push_str("1.5 3 m 10 18 l 18.5 3 l 14.5 3 l 10 11 l 5.5 3 l h B\n"), + // A caret over a bar: a new paragraph goes here. + "NewParagraph" => s.push_str("3.5 9 m 10 18.5 l 16.5 9 l h B\n3 2 14 4 re B\n"), + // A pilcrow: a filled bowl and two stems. + "Paragraph" => { + s.push_str("11 18.5 m 7 18.5 4 16.3 4 13.2 c 4 10.1 7 8 11 8 c h B\n"); + s.push_str("11 18.5 m 16 18.5 l 16 1.5 l 14 1.5 l 14 16.5 l 13 16.5 l 13 1.5 l 11 1.5 l h B\n"); + } + // A tick. + "Check" => s.push_str("1.5 10.5 m 4.5 13.5 l 8 9.5 l 16 18.5 l 18.5 15.5 l 8 3.5 l h B\n"), + // A ring. + "Circle" => { + s.push_str(&circle_path(10.0, 10.0, 8.5)); + s.push_str(&circle_path(10.0, 10.0, 5.0)); + s.push_str("B*\n"); + } + // A thick X. + "Cross" => s.push_str("4 1.5 m 10 7.5 l 16 1.5 l 18.5 4 l 12.5 10 l 18.5 16 l 16 18.5 l 10 12.5 l 4 18.5 l 1.5 16 l 7.5 10 l 1.5 4 l h B\n"), + // A ring with sighting lines through it. + "CrossHairs" => { + s.push_str(&circle_path(10.0, 10.0, 7.0)); + s.push_str(&circle_path(10.0, 10.0, 4.5)); + s.push_str("B*\n1.2 w 10 0.5 m 10 19.5 l 0.5 10 m 19.5 10 l S\n"); + } + // A five-pointed star. + "Star" => { + let points: Vec = (0..10) + .map(|i| { + let r = if i % 2 == 0 { 9.5 } else { 3.9 }; + let a = std::f64::consts::FRAC_PI_2 + f64::from(i) * std::f64::consts::PI / 5.0; + format!("{} {}", n(10.0 + r * a.cos()), n(9.5 + r * a.sin())) + }) + .collect(); + s.push_str(&format!("{} m {} l h B\n", points[0], points[1..].join(" l "))); + } + // Block arrows. + "RightArrow" => s.push_str("1.5 7 m 11 7 l 11 2.5 l 18.5 10 l 11 17.5 l 11 13 l 1.5 13 l h B\n"), + "UpArrow" => s.push_str("7 1.5 m 7 11 l 2.5 11 l 10 18.5 l 17.5 11 l 13 11 l 13 1.5 l h B\n"), + _ => { + s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n"); + s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n"); + } } s } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index d1d74ce41..720d39cd8 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -66,7 +66,9 @@ impl Markup { } } -/// The standard text-annotation icon names (§12.5.6.4, Table 175). +/// Text-annotation icon names (`/Name`). The first seven are the standard names ISO 32000-2 +/// §12.5.6.4, Table 175 asks every viewer to draw; the rest are further names in common use, which +/// the same table allows ("Additional names may be supported as well"). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum NoteIcon { #[default] @@ -77,6 +79,13 @@ pub enum NoteIcon { Key, NewParagraph, Paragraph, + Check, + Circle, + Cross, + CrossHairs, + Star, + RightArrow, + UpArrow, } impl NoteIcon { @@ -89,11 +98,36 @@ impl NoteIcon { NoteIcon::Key => "Key", NoteIcon::NewParagraph => "NewParagraph", NoteIcon::Paragraph => "Paragraph", + NoteIcon::Check => "Check", + NoteIcon::Circle => "Circle", + NoteIcon::Cross => "Cross", + NoteIcon::CrossHairs => "CrossHairs", + NoteIcon::Star => "Star", + NoteIcon::RightArrow => "RightArrow", + NoteIcon::UpArrow => "UpArrow", } } + /// Every icon, the standard names first. + pub const ALL: [NoteIcon; 14] = [ + Self::Comment, + Self::Note, + Self::Help, + Self::Insert, + Self::Key, + Self::NewParagraph, + Self::Paragraph, + Self::Check, + Self::Circle, + Self::Cross, + Self::CrossHairs, + Self::Star, + Self::RightArrow, + Self::UpArrow, + ]; + pub fn from_name(n: &str) -> Option { - [Self::Comment, Self::Note, Self::Help, Self::Insert, Self::Key, Self::NewParagraph, Self::Paragraph].into_iter().find(|i| i.name() == n) + Self::ALL.into_iter().find(|i| i.name() == n) } } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index b39e41c60..20cc89b10 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1622,3 +1622,56 @@ fn cloud_outline_has_a_bump_budget() { let bumps = appearance::cloud_path(&pts, 1.0).lines().filter(|l| l.ends_with(" c")).count(); assert!((pts.len()..=appearance::MAX_CLOUD_BUMPS + 2 * pts.len()).contains(&bumps), "{bumps} bumps"); } + +/// A note's drawn icon (its built appearance's content) for `/Name` (none when `None`) and `/C`. +fn note_drawing(name: Option<&str>, color: [f64; 3]) -> Vec { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Text")); + d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 20.0, 20.0].map(Object::Real).to_vec())); + d.set(b"C".to_vec(), Object::Array(color.map(Object::Real).to_vec())); + if let Some(n) = name { + d.set(b"Name".to_vec(), Object::name(n)); + } + crate::appearance::build(&d).expect("a note is drawn").decoded().unwrap() +} + +/// ISO 32000-2 §12.5.6.4, Table 175: a viewer shall provide icons for the seven standard names, +/// and may support more. Each of the fourteen names PdfCraft offers draws a shape of its own, so +/// a note shows the icon it names; a name it doesn't know, and a note without one, draw `Note` +/// (the table's default). +#[test] +fn every_note_icon_draws_its_own_shape() { + let yellow = [1.0, 0.82, 0.0]; + let drawn: Vec> = NoteIcon::ALL.iter().map(|i| note_drawing(Some(i.name()), yellow)).collect(); + for (i, a) in drawn.iter().enumerate() { + for (j, b) in drawn.iter().enumerate().skip(i + 1) { + assert_ne!(a, b, "{} and {} look alike", NoteIcon::ALL[i].name(), NoteIcon::ALL[j].name()); + } + } + let note = note_drawing(Some("Note"), yellow); + assert_eq!(note_drawing(Some("Sparkle"), yellow), note, "an unknown name"); + assert_eq!(note_drawing(None, yellow), note, "no name"); + for icon in NoteIcon::ALL { + assert_eq!(NoteIcon::from_name(icon.name()), Some(icon)); + } +} + +/// A note named with one of the further icons reads as that icon, and a colour change redraws it +/// as itself, not as a page. +#[test] +fn a_star_note_keeps_its_star_when_recoloured() { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Name /Star /C [1 0.82 0] /F 4 >>", + ]); + assert_eq!(props(&doc, 0, 0).and_then(|p| p.icon), Some(NoteIcon::Star)); + set_style(&mut doc, 0, 0, Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); + let doc = reopen(&doc); + let d = &list(&doc, 0)[0]; + assert_eq!(d.name(b"Name"), Some(&b"Star"[..])); + let n = d.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + assert_eq!(s.decoded().unwrap(), note_drawing(Some("Star"), [0.0, 0.0, 1.0])); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index d945727e6..535a35b23 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -694,7 +694,7 @@ pub fn tools() -> Vec { "stamp": { "type": "string" }, "dynamic": { "type": "boolean" }, "strokes": { "type": "array", "items": { "type": "array", "items": point() } }, - "icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph; attachment: PushPin, Paperclip, Graph, Tag." }, + "icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph (the standard names), Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow; attachment: PushPin, Paperclip, Graph, Tag." }, "color": color(), "fill": color(), "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs index 19f7187af..f66885d36 100644 --- a/crates/ui-egui/src/comment_props.rs +++ b/crates/ui-egui/src/comment_props.rs @@ -28,8 +28,7 @@ pub struct PropsDraft { pub make_default: bool, } -const ICONS: [NoteIcon; 7] = - [NoteIcon::Comment, NoteIcon::Note, NoteIcon::Help, NoteIcon::Insert, NoteIcon::Key, NoteIcon::NewParagraph, NoteIcon::Paragraph]; +const ICONS: [NoteIcon; 14] = NoteIcon::ALL; /// The name shown in the line-ending combo. [`LineEnding::name`] stays the PDF `/LE` token. fn ending_label(ending: LineEnding) -> &'static str { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3f8a5d693..3532d3e78 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3584,8 +3584,8 @@ milestone = "M5" status = "shipped" commands = ["comment.note"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] -notes = "our own Comment/Note icon drawings, with a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline" +evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured"] +notes = "our own icon drawings for the seven standard /Name values of ISO 32000-2 Table 175 (Comment, Note, Help, Insert, Key, NewParagraph, Paragraph) and seven further names (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow), each its own shape, chosen in Properties or comment_add, and kept when the colour changes; another name draws as Note. With a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Not yet: opening the pop-ups a file saves open (/Open)." [[feature]] id = "comment.highlight"