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"