From 0839c1e76144e04765f43154394559aeafd39b9e Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:08:08 +0100 Subject: [PATCH] Fill & Sign: a Border control for typed text, and a border colour of its own (#4146) * Fill & Sign: a Border control for typed text, and a border colour of its own Rebased onto main as one commit: main's Fill & Sign text colour (#2444) runs through the same places as the border (the open box, typed(), the page input, the toolbar and the preferences), so every step of the branch met it again. Text colour and border now travel together as TypedLook (typed_with), which keeps typed() and the new function under Clippy's argument limit; a border without its own colour takes the text's. Make Current Properties Default on typed text also keeps its colour for new text now. The branch's commits, in order: - Typewriter text: a border change grows its box on every side, so the text keeps its place - Fill & Sign: a Border control for typed text and dates - Parity: the typewriter border's evidence - i18n: the Border label in every catalog, recorded in ATTRIBUTION.toml - Text boxes, typewriter text and callouts: a border colour of their own - Fill & Sign: the toolbar follows the selected text; a Border colour button - fill_sign_add and comment_edit take `border_color` - Parity: the border colour and the toolbar following the selection - i18n: Border colour and Same as text in every catalog, recorded in ATTRIBUTION.toml - Typed text's border: /Border's width is read, a non-finite border colour refused, a saved Same as text restored - Fill & Sign: the toolbar changes only the selected text; Make Current Properties Default keeps new text's border - Review: Make Properties Default gives a text box's or callout's tool its border colour - i18n: the new labels in the Persian catalog, recorded in ATTRIBUTION.toml - Review: a test that dragging the Font size control resizes the selected text to where the drag ends - Rebase: the typed text's border in main's annot and comments modules (Style's border colour, exports), and craft_ui's ComboBox Co-Authored-By: Claude Opus 5.5 * Open the Font size popup first in the drag test: main moved the size into a popup --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps --- crates/annot/src/appearance.rs | 31 ++- crates/annot/src/create.rs | 7 +- crates/annot/src/info.rs | 3 + crates/annot/src/lib.rs | 5 +- crates/annot/src/modify.rs | 50 ++++- crates/annot/src/tests.rs | 79 ++++++++ crates/annot/src/types.rs | 5 +- crates/automation/src/comments.rs | 27 ++- crates/automation/src/tools.rs | 7 +- crates/automation/tests/it/automation.rs | 42 ++++ crates/engine/src/edit.rs | 7 + crates/engine/src/edit/labels.rs | 1 + crates/engine/src/edit/run.rs | 2 + crates/engine/src/history.rs | 1 + crates/ui-egui/src/app.rs | 3 + crates/ui-egui/src/app/prefs.rs | 30 +++ crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/canvas/page_input.rs | 2 + crates/ui-egui/src/dialogs/comment_props.rs | 46 ++++- crates/ui-egui/src/editing.rs | 4 +- crates/ui-egui/src/hotbar.rs | 75 +++++++ crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/panels/comments_panel.rs | 2 +- crates/ui-egui/src/tools/comments.rs | 27 ++- crates/ui-egui/src/tools/comments/menu.rs | 31 ++- crates/ui-egui/src/tools/fill_sign.rs | 89 +++++++-- crates/ui-egui/tests/it/comments.rs | 31 +++ crates/ui-egui/tests/it/control.rs | 1 + crates/ui-egui/tests/it/fill_sign.rs | 208 +++++++++++++++++++- crates/ui-egui/tests/it/ui.rs | 18 ++ crates/ui-egui/tests/it/viewer.rs | 10 +- parity/acrobat-features.toml | 8 +- 32 files changed, 811 insertions(+), 51 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index c2f7f5e53d..9cdb548360 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -232,6 +232,31 @@ fn rg_stroke(c: Rgb) -> String { format!("{} {} {} RG\n", n(c[0]), n(c[1]), n(c[2])) } +/// A FreeText's border colour where it has its own: the stroking colour in `/DA` (`RG`, or `G` +/// for grey). `None`: the border takes the text colour. +pub fn da_border(d: &Dict) -> Option { + let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned())?; + let toks: Vec<&str> = da.split_whitespace().collect(); + let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()).filter(|x| x.is_finite()); + let mut col = None; + for (i, t) in toks.iter().enumerate() { + match *t { + "RG" if i >= 3 => { + if let (Some(r), Some(g), Some(b)) = (num(i - 3), num(i - 2), num(i - 1)) { + col = Some([r, g, b].map(|x| x.clamp(0.0, 1.0))); + } + } + "G" if i >= 1 => { + if let Some(g) = num(i - 1) { + col = Some([g.clamp(0.0, 1.0); 3]); + } + } + _ => {} + } + } + col +} + /// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt. pub fn parse_da(d: &Dict) -> (Rgb, f64) { let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default(); @@ -921,6 +946,8 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin let mut res = std::mem::take(&mut drawing.resources); let _tail = std::mem::take(&mut drawing.tail); let (text_color, size) = parse_da(d); + // The box's border and a callout's leader line: their own colour, else the text's. + let line_color = da_border(d).unwrap_or(text_color); let bg = stroke; let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; // A callout: the leader line (arrowhead at its first point), and the text box inside @@ -959,7 +986,7 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { return None; } - c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); + c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(line_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); for (i, p) in pts.iter().enumerate() { c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); } @@ -975,7 +1002,7 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin let h = bw / 2.0; c.push_str(&format!( "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", - rg_stroke(text_color), + rg_stroke(line_color), n(bw), dash(d), n(rect[0] + h), diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 298dc67f41..f12b2bba3b 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,6 +1,6 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; -use super::modify::{n, rich_text, set_part_keys}; +use super::modify::{free_text_da, n, rich_text, set_part_keys}; use super::types::{ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, }; @@ -385,8 +385,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter")); } let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 }; - let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + d.set(b"DA".to_vec(), free_text_da(style.color, style.border_color, size)); d.set(b"Q".to_vec(), Object::Int(0)); if let Some(f) = style.fill { d.set(b"C".to_vec(), rgb(f)); @@ -426,7 +425,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } fn validate_style(style: &Style) -> Result<(), AnnotError> { - if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { + if !finite(&style.color) || style.border_color.is_some_and(|c| !finite(&c)) || !style.opacity.is_finite() || !style.width.is_finite() { return Err(AnnotError::Invalid("invalid style".into())); } Ok(()) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index ce001bcf29..cf1b607664 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -217,6 +217,8 @@ pub struct Props { pub fill: Option, /// Text boxes, typewriter text and callouts: the `/DA` font size in points. pub font_size: Option, + /// Text boxes, typewriter text and callouts: the border's own colour (`None`: the text's). + pub border_color: Option, /// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its /// line's or border's). pub fill_opacity: Option, @@ -253,6 +255,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { fillable: fillable(&subtype), fill: fill_of(d), font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), + border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(), fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill), text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text), subtype, diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 6d555dbb9c..270be0115d 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -41,8 +41,9 @@ pub use lookup::{ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ - add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, - set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, + add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, + set_border_color, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, + set_style, with_replies_and_popups, }; pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 469815af7f..8dbfd4c5b9 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -528,6 +528,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } } +/// The border width as the appearance draws it: `/BS /W`, else the third number of `/Border` +/// (another app's), else 1. fn border_width_of(d: &Dict) -> f64 { appearance::border_width(d) } @@ -582,13 +584,24 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha } refuse_foreign_text(doc, &d)?; let free_text = subtype == "FreeText"; + // Typewriter text has no box of its own: a border goes around its text, so the box grows + // (or shrinks) by the change on every side and the text keeps its place and its lines. + let typewriter = free_text && d.name(b"IT") == Some(b"FreeTextTypeWriter") && !d.contains(b"CL"); + let regrow = width.filter(|_| typewriter).and_then(|w| { + let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { border_width_of(&d) } else { 0.0 }; + let rect: Vec = d.get(b"Rect")?.as_array()?.iter().filter_map(|o| o.as_f64()).collect(); + let rect = grow(normalize(four(&rect)?), by); + (by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some(rect) + }); let before = d; doc.update_dict(r, |d| { + if let Some(rect) = regrow { + d.set(b"Rect".to_vec(), num_array(&rect)); + } if let Some(c) = color { if free_text { let (_, size) = appearance::parse_da(d); - let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + d.set(b"DA".to_vec(), free_text_da(c, appearance::da_border(d), size)); rich_text(d); } else { d.set(b"C".to_vec(), rgb(c)); @@ -627,6 +640,37 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha redraw_or_restore(doc, r, before) } +/// A FreeText's `/DA`: the text colour, the border's own colour when it has one (the stroking +/// colour, `RG`), Helvetica at `size`. +pub(crate) fn free_text_da(text: Rgb, border: Option, size: f64) -> PdfString { + let [r, g, b] = text.map(|x| x.clamp(0.0, 1.0)); + let stroke = border.map(|c| c.map(|x| x.clamp(0.0, 1.0))).map(|[r, g, b]| format!("{} {} {} RG ", n(r), n(g), n(b))).unwrap_or_default(); + PdfString::literal(format!("{} {} {} rg {stroke}/Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()) +} + +/// Give a text box, typewriter text or callout a border colour of its own (`None`: the text's +/// again), and redraw it. +pub fn set_border_color(doc: &mut Document, page: usize, index: usize, color: Option, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"FreeText") { + return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a border colour".into())); + } + if color.is_some_and(|c| !finite(&c)) { + return Err(AnnotError::Invalid("invalid colour".into())); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let (text, size) = appearance::parse_da(d); + d.set(b"DA".to_vec(), free_text_da(text, color, size)); + rich_text(d); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + /// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the /// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new /// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt. @@ -644,7 +688,7 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m let before = d; doc.update_dict(r, |d| { let (col, _) = appearance::parse_da(d); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes())); + d.set(b"DA".to_vec(), free_text_da(col, appearance::da_border(d), size)); rich_text(d); touch(d, meta); })?; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 91b36550cf..f451ad0f8e 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -668,6 +668,85 @@ fn fill_and_sign_items_are_drawn() { assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err()); } +/// A border chosen for typewriter text goes around it in the text's colour. Changing it grows or +/// shrinks the box on every side, so the text stays where it was typed; a text box keeps its box. +#[test] +fn a_typewriter_border_goes_around_its_text_which_stays_put() { + let mut doc = fixture(); + let shape = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 }; + let style = Style { width: 1.0, ..Style::default_for(&shape) }; + let t = add_annotation(&mut doc, &NewAnnotation { page: 1, shape, style, contents: "Ada".into(), author: "Ada".into() }, &meta("x")).unwrap(); + // The text starts 2 pt inside the border: 100 + 1 + 2 across, 716 - 1 - 2 - 0.9 em down. + let at = "1 0 0 1 103 703.1 Tm"; + let d = &list(&doc, 1)[t]; + let ap = ap_content(&doc, d); + assert!(ap.contains("1 w\n") && ap.contains(" re S"), "a 1 pt border: {ap}"); + assert!(ap.contains(at), "{ap}"); + for (width, expected) in [(2.0, [99.0, 699.0, 201.0, 717.0]), (0.0, [101.0, 701.0, 199.0, 715.0])] { + set_style(&mut doc, 1, t, StyleChange::new(None, None, Some(width), None), &meta("")).unwrap(); + let d = &list(&doc, 1)[t]; + assert_eq!(rect(d), expected, "{width} pt border"); + let ap = ap_content(&doc, d); + assert!(ap.contains(at), "{width} pt: the text keeps its place: {ap}"); + assert_eq!(ap.contains(" re S"), width > 0.0, "{width} pt: {ap}"); + } + let shape = Shape::TextBox { rect: [100.0, 600.0, 200.0, 650.0], font_size: 11.0 }; + let b = add_annotation(&mut doc, &new(1, shape), &meta("b")).unwrap(); + set_style(&mut doc, 1, b, StyleChange::new(None, None, Some(3.0), None), &meta("")).unwrap(); + assert_eq!(rect(&list(&doc, 1)[b]), [100.0, 600.0, 200.0, 650.0], "a text box keeps the box it was drawn with"); + // Another app's typed text with its width in /Border (no /BS): 4 pt, so 2 pt shrinks the box + // by 2 on every side, as it is drawn. + let shape = Shape::Typewriter { rect: [100.0, 500.0, 200.0, 530.0], font_size: 11.0 }; + let o = add_annotation( + &mut doc, + &NewAnnotation { page: 1, style: Style::default_for(&shape), shape: shape.clone(), contents: "Bo".into(), author: "Ada".into() }, + &meta("o"), + ) + .unwrap(); + let (_, r) = annot_ref(&mut doc, 1, o).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"BS"); + d.set(b"Border".to_vec(), num_array(&[0.0, 0.0, 4.0])); + }) + .unwrap(); + set_style(&mut doc, 1, o, StyleChange::new(None, None, Some(2.0), None), &meta("")).unwrap(); + assert_eq!(rect(&list(&doc, 1)[o]), [102.0, 502.0, 198.0, 528.0], "the imported 4 pt border is read from /Border"); + // A border colour that isn't a colour is refused before anything is written. + let mut bad = NewAnnotation { page: 1, style: Style::default_for(&shape), shape, contents: "x".into(), author: "Ada".into() }; + bad.style.border_color = Some([f64::NAN, 0.0, 0.0]); + let before = list(&doc, 1).len(); + assert!(matches!(add_annotation(&mut doc, &bad, &meta("n")), Err(AnnotError::Invalid(_)))); + assert_eq!(list(&doc, 1).len(), before); +} + +/// Text keeps its colour and the border gets its own: the stroking colour in `/DA`, which a new +/// text colour or font size keeps, and which `None` gives back to the text's. +#[test] +fn a_text_border_can_have_its_own_colour() { + let mut doc = fixture(); + let shape = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 }; + let style = Style { color: [0.0, 0.5, 0.0], width: 1.0, border_color: Some([1.0, 0.0, 0.0]), ..Style::default_for(&shape) }; + let t = add_annotation(&mut doc, &NewAnnotation { page: 1, shape, style, contents: "Ada".into(), author: "Ada".into() }, &meta("x")).unwrap(); + let da = |doc: &Document| text(&list(doc, 1)[t], b"DA"); + assert_eq!(da(&doc), "0 0.5 0 rg 1 0 0 RG /Helv 11 Tf"); + let ap = ap_content(&doc, &list(&doc, 1)[t]); + assert!(ap.contains("1 0 0 RG\n1 w"), "the border in red: {ap}"); + assert!(ap.contains("0 0.5 0 rg"), "the text in green: {ap}"); + assert_eq!(props(&doc, 1, t).unwrap().border_color, Some([1.0, 0.0, 0.0])); + set_style(&mut doc, 1, t, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + assert_eq!(da(&doc), "0 0 1 rg 1 0 0 RG /Helv 11 Tf", "a new text colour keeps the border's"); + set_font_size(&mut doc, 1, t, 14.0, &meta("")).unwrap(); + assert_eq!(da(&doc), "0 0 1 rg 1 0 0 RG /Helv 14 Tf", "so does a new size"); + set_border_color(&mut doc, 1, t, Some([0.5, 0.5, 0.5]), &meta("")).unwrap(); + assert!(ap_content(&doc, &list(&doc, 1)[t]).contains("0.5 0.5 0.5 RG\n1 w")); + set_border_color(&mut doc, 1, t, None, &meta("")).unwrap(); + assert_eq!(da(&doc), "0 0 1 rg /Helv 14 Tf"); + assert!(ap_content(&doc, &list(&doc, 1)[t]).contains("0 0 1 RG\n1 w"), "the text's colour again"); + assert_eq!(props(&doc, 1, t).unwrap().border_color, None); + let m = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("m")).unwrap(); + assert!(set_border_color(&mut doc, 0, m, Some([1.0, 0.0, 0.0]), &meta("")).is_err(), "a rectangle's colour is its border"); +} + #[test] fn author_subject_and_note_icon_change() { let mut doc = fixture(); diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 935eff8759..abc5c17601 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -588,6 +588,9 @@ pub struct Style { pub width: f64, /// Interior fill for rectangles and ovals (`/IC`), background for text boxes. pub fill: Option, + /// A text box's, typewriter text's or callout's border colour where it differs from the + /// text's (`None`: the text colour), the stroking colour in its `/DA`. + pub border_color: Option, /// The fill's own opacity, 0–1 (`/ca`), where it differs from `opacity` (which is then the /// line's or border's); `None`: as `opacity`. pub fill_opacity: Option, @@ -624,7 +627,7 @@ pub struct TextReplacement<'a> { impl Default for Style { fn default() -> Self { - Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None } + Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None, border_color: None } } } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 6adc95b2ab..b8b815ac37 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -553,6 +553,18 @@ impl Automation { if !(4.0..=300.0).contains(&size) { return Err(ToolError::InvalidArgs("font_size must be 4–300 pt".into())); } + let requested_border = a.opt_num("border")?; + if requested_border.is_some() && !matches!(kind, "text" | "date") { + return Err(ToolError::InvalidArgs("border is only for type text or date".into())); + } + let border = requested_border.unwrap_or(0.0); + if !(0.0..=12.0).contains(&border) { + return Err(ToolError::InvalidArgs("border must be 0–12 pt".into())); + } + let border_color = a.opt_str("border_color")?.map(parse_color).transpose()?; + if border_color.is_some() && border <= 0.0 { + return Err(ToolError::InvalidArgs("border_color needs a border".into())); + } if let Some(path) = a.opt_str("path")? { if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() { return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into())); @@ -581,8 +593,9 @@ impl Automation { let w = (longest + 8.0).clamp(20.0, 600.0); let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1); let h = rows as f64 * size * 1.2 + 6.0; - let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); - Shape::Typewriter { rect, font_size: size } + let [x0, y0, x1, y1] = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); + // A border goes around the box, so the text sits where it would without one. + Shape::Typewriter { rect: [x0 - border, y0 - border, x1 + border, y1 + border], font_size: size } }; let (shape, contents) = match kind { "text" => { @@ -628,6 +641,10 @@ impl Automation { if let Some(color) = requested_color { style.color = color; } + if matches!(shape, Shape::Typewriter { .. }) { + style.width = border; + style.border_color = border_color; + } self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author })) } @@ -694,6 +711,10 @@ impl Automation { let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) }; edits.push(Edit::FillAnnotation { page, index, fill }); } + if let Some(c) = a.opt_str("border_color")? { + let color = if c.eq_ignore_ascii_case("text") { None } else { Some(parse_color(c)?) }; + edits.push(Edit::BorderColorAnnotation { page, index, color }); + } // After `opacity`, which sets the whole comment's. let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?); if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) { @@ -735,7 +756,7 @@ impl Automation { } if edits.is_empty() { return Err(ToolError::InvalidArgs( - "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, font_size, rect, move or points".into(), + "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, border_color, font_size, rect, move or points".into(), )); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b3b149fbc2..d15c1d915f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -994,7 +994,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), @@ -1003,6 +1003,7 @@ pub fn tools() -> Vec { "text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity (text box, typewriter text, callout)." }, "width": { "type": "number", "minimum": 0 }, "fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." }, + "border_color": { "type": "string", "description": "A text box's, typewriter text's or callout's border colour: #RRGGBB, a colour name, or \"text\" for the text's colour." }, "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), @@ -1407,7 +1408,7 @@ pub fn tools() -> Vec { t( "fill_sign_add", "Fill & Sign: type text or place a mark", - "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", ) .cmd("sign.fill.text") .with(schema( @@ -1419,6 +1420,8 @@ pub fn tools() -> Vec { "text": { "type": "string", "minLength": 1 }, "font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." }, "color": color(), + "border": { "type": "number", "minimum": 0, "maximum": 12, "description": "For type text or date: a border around it in points, in the text's colour (default 0, none)." }, + "border_color": { "type": "string", "description": "With border: its own colour, #RRGGBB or a colour name." }, "path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." }, "scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." }, "sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index f0426fa3fc..a4ee239a62 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4074,6 +4074,48 @@ fn fill_sign_text_and_date_colours_are_saved_and_validated() { } } +#[test] +fn fill_sign_text_border_is_saved_and_validated() { + let dir = workdir("fill-sign-text-border"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": "Plain" })); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 80], "text": "Plain", "border": 1.5 })); + let comments = ok(&mut a, "comment_list", json!({ "doc": doc })); + let width = |k: usize| { + let rect = comments["comments"][k]["rect"].as_array().unwrap(); + rect[2].as_f64().unwrap() - rect[0].as_f64().unwrap() + }; + assert!((width(1) - width(0) - 3.0).abs() < 1e-6, "1.5 pt on each side: {comments}"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "framed.pdf" })); + let saved = std::fs::read(dir.join("framed.pdf")).unwrap(); + let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(saved)).unwrap(); + let page = pdfcraft_model::pages(&pdf).remove(0); + let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap()); + let annotation = pdf.resolve(annotations.as_array().unwrap().get(1).unwrap()); + let bs = annotation.as_dict().unwrap().get(b"BS").unwrap().as_dict().unwrap().get(b"W").unwrap().as_f64().unwrap(); + assert_eq!(bs, 1.5, "saved annotation has the requested border"); + // Its own colour, then the text's again through comment_edit. + ok( + &mut a, + "fill_sign_add", + json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 160], "text": "Red", "border": 1, "border_color": "#ff0000" }), + ); + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + let red = list["comments"].as_array().unwrap().iter().find(|c| c["contents"] == "Red").unwrap()["index"].clone(); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": red, "border_color": "text" })); + assert!( + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 200], "text": "No", "border_color": "red" })).is_err(), + "no border" + ); + for (kind, border) in [("text", -1.0), ("text", 13.0), ("check", 1.0)] { + assert!(matches!( + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": kind, "at": [20, 120], "text": "No", "border": border })), + Err(ToolError::InvalidArgs(_)) + )); + } +} + #[test] fn image_signatures_through_tools_preserve_transparency_and_survive_save() { let dir = workdir("image-signatures"); diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index e398604766..ab3393a0f5 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -278,6 +278,13 @@ pub enum Edit { index: usize, size: f64, }, + /// Give a text box, typewriter text or callout a border colour of its own (`None`: the + /// text's again). + BorderColorAnnotation { + page: usize, + index: usize, + color: Option, + }, /// Comment properties ▸ General / note icon. SetAnnotationInfo { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 0c7046cdbb..a244d70c93 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -236,6 +236,7 @@ impl Edit { | Edit::FillAnnotation { .. } | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } + | Edit::BorderColorAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 27eee7e428..3d24a22fa2 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -67,6 +67,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::FillAnnotation { .. } | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } + | Edit::BorderColorAnnotation { .. } | Edit::SetAnnotationInfo { .. } | Edit::SetMeasurementScale { .. } => { if p.annotate() { @@ -587,6 +588,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, Edit::PartOpacity { page, index, fill, text } => pdfcraft_annot::set_part_opacity(doc, *page, *index, *fill, *text, &cx.meta())?, Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, + Edit::BorderColorAnnotation { page, index, color } => pdfcraft_annot::set_border_color(doc, *page, *index, *color, &cx.meta())?, Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 643f1d66a3..c679406154 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -71,6 +71,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::FillAnnotation { .. } | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } + | Edit::BorderColorAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 9dd3efac6e..7c7710b5b8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -110,6 +110,9 @@ impl PdfCraftApp { fill_text_size: fill_sign::TEXT_SIZE, fill_tool: fill_sign::FillTool::Signature, fill_text_color: [0.0; 3], + fill_text_border: 0.0, + fill_text_border_color: None, + fill_text_default: fill_sign::TextBorder::NONE, dialog: None, updates: updates::Updates::default(), printer_source: None, diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 5f9297c2ab..ee33caeb5c 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -103,6 +103,8 @@ impl PdfCraftApp { "ui_scale": self.ui_scale, "flatten_fill_sign": self.flatten_fill_sign_on_save, "fill_text_size": self.fill_text_size, + "fill_text_border": self.fill_text_default.width, + "fill_text_border_color": self.fill_text_default.color, "date_format": self.session.date_format(), // Null follows the interface language. "date_language": self.session.date_language(), @@ -120,6 +122,7 @@ impl PdfCraftApp { "text_opacity": s.text_opacity, "width": s.width, "fill": s.fill, + "border_color": s.border_color, })) .collect::>(), // Drawn signatures keep their original form (older settings read the same). @@ -310,6 +313,22 @@ impl PdfCraftApp { if let Some(show) = v["show_page_edge_shadows"].as_bool() { self.show_page_edge_shadows = show; } + if let Some(width) = v["fill_text_border"].as_f64().and_then(fill_sign::valid_text_border) { + self.fill_text_default.width = width; + } + // `null` is a saved "the text's colour"; a missing or malformed one keeps the setting. + match v.get("fill_text_border_color") { + Some(serde_json::Value::Null) => self.fill_text_default.color = None, + Some(c) => { + if let Some(c) = rgb_triple(c) { + self.fill_text_default.color = Some(c.map(|x| x.clamp(0.0, 1.0))); + } + } + None => {} + } + // Fill & Sign text starts from the saved border. + self.fill_text_border = self.fill_text_default.width; + self.fill_text_border_color = self.fill_text_default.color; if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) { self.redact_prefs = prefs.clone(); self.redact_defaults = prefs; @@ -375,6 +394,17 @@ impl PdfCraftApp { s.fill = fill; self.comment_prefs.set_style(tool, s); } + // `"border_color": null` is a saved "Same as text", which clears a tool's own border + // colour; a missing or malformed one keeps it. + match e.get("border_color") { + Some(serde_json::Value::Null) => self.comment_prefs.set_border_color(tool, None), + Some(c) => { + if let Some(c) = rgb_triple(c) { + self.comment_prefs.set_border_color(tool, Some(c)); + } + } + None => {} + } } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2207adbf59..9681126048 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1214,6 +1214,7 @@ struct PageEnv<'a> { y_shift: f32, fill_text_size: f64, fill_text_color: [f64; 3], + fill_text_border: crate::fill_sign::TextBorder, today: (i64, u32, u32), can_paste_image: bool, } @@ -3160,6 +3161,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { y_shift, fill_text_size: app.fill_text_size, fill_text_color: app.fill_text_color, + fill_text_border: crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color }, today, can_paste_image, }; diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index d366967b9f..e1c9719668 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -266,6 +266,7 @@ fn tool_input( units, fill_text_size, fill_text_color, + fill_text_border, .. } = frame.env; let PageSlot { i, xf, .. } = *slot; @@ -295,6 +296,7 @@ fn tool_input( tool: ft, text_size: fill_text_size, text_color: fill_text_color, + text_border: fill_text_border, signature: signature.as_ref(), initials: initials.as_ref(), preview: frame.signature_preview, diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 285423cbca..e3762ce0c0 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -102,6 +102,22 @@ impl PdfCraftApp { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && !a.is_reply()) else { return }; + // Typed text has no comment tool: Fill & Sign gives new text its size, colour and border, + // kept across launches. + if crate::comments::typed_text(a) { + let kind = crate::i18n::in_sentence(tl!(crate::comments_panel::kind_label(a))).to_string(); + if let Some(size) = p.font_size.and_then(crate::fill_sign::valid_text_size) { + self.fill_text_size = size; + } + if let Some(color) = p.color.and_then(crate::fill_sign::valid_text_color) { + self.fill_text_color = color; + } + let width = p.width.and_then(crate::fill_sign::valid_text_border).unwrap_or(0.0); + self.fill_text_default = crate::fill_sign::TextBorder { width, color: p.border_color }; + (self.fill_text_border, self.fill_text_border_color) = (width, p.border_color); + self.notify_fmt("New {tool} comments will look like this one", &[("tool", &kind)]); + return; + } let Some(tool) = crate::comments::tool_for_default(a, p.endings.as_deref()) else { return }; let mut style = self.comment_prefs.style(tool); if let Some(c) = p.color { @@ -111,6 +127,8 @@ impl PdfCraftApp { style.fill = p.fill; } self.comment_prefs.set_style(tool, style); + // A text box's or callout's own border colour (cleared for tools that have none). + self.comment_prefs.set_border_color(tool, p.border_color); // The fill's and text's own opacities where they show (else the opacity is the whole // comment's). With neither a border nor a fill the text is the whole comment: its drawn // opacity, which Comment Properties shows, is the whole comment's default. @@ -249,6 +267,9 @@ fn changes(o: &CommentProps, e: &CommentProps, page: usize, index: usize, t: &Co { out.push(Edit::FontSizeAnnotation { page, index, size }); } + if t.restylable && t.font_size.is_some() && e.border_color != o.border_color { + out.push(Edit::BorderColorAnnotation { page, index, color: e.border_color }); + } let author = (e.author != o.author).then(|| e.author.clone()); let subject = (e.subject != o.subject).then(|| e.subject.clone()); let icon = (e.icon != o.icon).then_some(e.icon).flatten().filter(|_| t.icon.is_some()); @@ -273,11 +294,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo .collect() }) .unwrap_or_default(); - let default_tool = app.comment_props.as_ref().and_then(|d| { - let (_, id) = app.active_ids()?; - let doc = app.session.get(id)?; - let a = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())?; - crate::comments::tool_for_default(a, d.edited.endings.as_deref()) + let has_default = app.comment_props.as_ref().is_some_and(|d| { + let doc = app.active_ids().and_then(|(_, id)| app.session.get(id)); + doc.and_then(|doc| doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())) + .is_some_and(|a| crate::comments::tool_for_default(a, d.edited.endings.as_deref()).is_some() || crate::comments::typed_text(a)) }); let Some(d) = app.comment_props.as_mut() else { return (false, true) }; let kind = tl!(crate::comments_panel::subtype_label(&d.edited.subtype)).to_string(); @@ -320,6 +340,17 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo e.color = Some(c); } ui.end_row(); + // Text boxes, typewriter text and callouts: the border's own colour. + if e.font_size.is_some() { + ui.label(tl!("Border colour")); + let text = e.color.unwrap_or([0.0; 3]); + ui.horizontal(|ui| { + if let Some(c) = crate::comments::border_colour_button(ui, e.border_color, text) { + e.border_color = c; + } + }); + ui.end_row(); + } if e.fillable { ui.label(tl!("Fill colour")); if let Some(f) = crate::comments::fill_picker(ui, e.fill) { @@ -428,8 +459,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.horizontal(|ui| { ui.add(widgets::checkbox(&mut d.edited.locked, tl!("Locked"))); ui.add_space(16.0); - // Acrobat's box beside Locked. Only a comment whose look can change, made by a tool. - let defaultable = d.edited.restylable && default_tool.is_some(); + // Acrobat's box beside Locked. Only a comment whose look can change, made by a tool (or + // typed text, for Fill & Sign). + let defaultable = d.edited.restylable && has_default; ui.add_enabled(defaultable, widgets::checkbox(&mut d.make_default, tl!("Make Properties Default"))); }); let (mut apply, mut cancel) = (false, false); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6a788e765d..e5e2d49b0d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -810,7 +810,8 @@ impl PdfCraftApp { } let author = self.comment_prefs.author.clone(); let rotation = self.session.get(id).map_or(0, |doc| crate::comments::page_rotation(&doc.info, tb.page)); - let edit = crate::fill_sign::typed_with_color(tb.page, tb.at, &text, tb.size, rotation, &author, tb.color); + let look = crate::fill_sign::TypedLook { color: tb.color, border: tb.border }; + let edit = crate::fill_sign::typed_with(tb.page, tb.at, &text, tb.size, rotation, &author, look); if self.apply_edit(edit) { if let Some(view) = self.views.get_mut(i) { view.fill_text = None; @@ -929,6 +930,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::FillAnnotation { page, .. } | Edit::PartOpacity { page, .. } | Edit::FontSizeAnnotation { page, .. } + | Edit::BorderColorAnnotation { page, .. } | Edit::SetAnnotationInfo { page, .. } => Some(*page), _ => None, } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index f27b1e035b..fafae309ff 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -323,6 +323,62 @@ fn selected_fill_text(app: &PdfCraftApp) -> Option<(usize, usize)> { .then_some((page, annotation)) } +/// Fill & Sign's Border controls for typed text: its width and colour, for the selected text +/// (`picked`), or for new text when none is selected. The label shows in a vertical bar, as the +/// font size's does. +fn fill_text_border_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, picked: Option<(usize, usize)>, vertical: bool) { + // The selected text's own border (Comment Properties may have changed it + // since): the controls below show it and change only it. Without one they + // set what new text gets until PdfCraft quits; Make Current Properties + // Default on typed text keeps it. + let selected = picked.and_then(|(page, annotation)| { + let p = app.active_ids().and_then(|(_, id)| app.session.get(id))?.comment_props(page, annotation)?; + Some((page, annotation, crate::fill_sign::TextBorder { width: p.width.unwrap_or(0.0), color: p.border_color })) + }); + let was = selected.map_or(crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color }, |(_, _, own)| own); + let mut border = was; + // A border around the text, in its colour; the text keeps its place. + let border_label = |w: f64| if w > 0.0 { format!("{w} pt") } else { tl!("None").to_string() }; + let label = vertical.then(|| ui.label(tl!("Border:"))); + let combo = craft_ui::ComboBox::from_id_salt("fill-text-border") + .selected_text(border_label(was.width)) + .width(72.0) + .show_ui(ui, |ui| { + for w in crate::fill_sign::TEXT_BORDERS { + ui.selectable_value(&mut border.width, w, border_label(w)); + } + }) + .response; + match label { + Some(label) => { + combo.labelled_by(label.id); + } + None => { + combo.on_hover_text(tl!("Border:")); + } + } + // The border's own colour (the text's unless picked). + if was.width > 0.0 + && let Some(color) = comments::border_colour_button(ui, was.color, [0.0; 3]) + { + border.color = color; + } + match selected { + Some((page, index, _)) => { + if border.width != was.width { + app.apply_edit(Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(border.width), endings: None }); + } + if border.color != was.color { + app.apply_edit(Edit::BorderColorAnnotation { page, index, color: border.color }); + } + } + None => { + app.fill_text_border = border.width; + app.fill_text_border_color = border.color; + } + } +} + /// Extra controls rendered below/beside the buttons when a comment or fill tool is active. fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, vertical: bool) { let current_fill = match app.quick_tool { @@ -365,9 +421,28 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve { app.apply_edit(Edit::FontSizeAnnotation { page, index, size }); } + // After the text's own controls, so they keep their place in a bar that scrolls. + fill_text_border_controls(ui, app, selected_fill_text(app), vertical); } if let QuickTool::Comment(tool) = app.quick_tool { controls_divider(ui, t, vertical); + // Text boxes and callouts: the border's own colour, for new ones and the selected one + // this tool made (not a locked one). + if tool.has_border_color() { + let style = app.comment_prefs.style(tool); + if let Some(color) = comments::border_colour_button(ui, style.border_color, style.color) { + app.comment_prefs.set_border_color(tool, color); + if let Some((index, id)) = app.active_ids() + && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) + && app.session.get(id).is_some_and(|doc| { + doc.allows_annotation() + && doc.info.annotations.iter().any(|a| a.page == page && a.index == annotation && !a.locked && comments::made_by(a, tool)) + }) + { + app.apply_edit(Edit::BorderColorAnnotation { page, index: annotation, color }); + } + } + } // The selected comments this tool made take the colour too (locked ones keep theirs). if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs) && let Some((_, id)) = app.active_ids() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5b6465f1df..0cb7237376 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -337,6 +337,14 @@ pub struct PdfCraftApp { pub fill_tool: fill_sign::FillTool, /// Colour for new Fill & Sign text and dates, kept across launches. pub fill_text_color: [f64; 3], + /// Border around new Fill & Sign text and dates, in points (0: none), until PdfCraft quits: + /// each launch starts from [`Self::fill_text_default`]. + pub fill_text_border: f64, + /// That border's own colour (`None`: the text's). + pub fill_text_border_color: Option, + /// The border new Fill & Sign text starts each launch with, kept across launches: what Make + /// Current Properties Default last took from typed text (none at first). + pub fill_text_default: fill_sign::TextBorder, pub dialog: Option, /// How to ask for the latest release (the desktop app sets it; see `updates`). pub(crate) updates: updates::Updates, diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index 0ac648d59c..430bdbec85 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -616,7 +616,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti view.comments.props_request = Some((a.page, a.index)); ui.close(); } - if ui.add_enabled(crate::comments::tool_for(a).is_some(), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { + if ui.add_enabled(crate::comments::defaultable(a), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { view.comments.default_request = Some((a.page, a.index)); ui.close(); } diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 7e6d88b520..451e5e4000 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -39,7 +39,7 @@ pub use geometry::{drawn_text_box_rect, text_box_rect}; use input::hover_tip; pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; -pub(crate) use menu::{MenuRights, attachment_items, context_menu, quick_bar_colour, quick_bar_controls}; +pub(crate) use menu::{MenuRights, attachment_items, border_colour_button, context_menu, quick_bar_colour, quick_bar_controls}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; pub(crate) use paint::paint_page; @@ -247,6 +247,12 @@ impl CommentTool { if matches!(self, Self::TextBox | Self::Callout) { 0.0 } else { 0.5 } } + /// Whether the border-colour control applies to this tool: text boxes and callouts, whose + /// border takes the text colour unless it has its own. + pub fn has_border_color(self) -> bool { + matches!(self, Self::TextBox | Self::Callout) + } + /// Whether the fill-colour control applies to this tool. pub fn has_fill(self) -> bool { matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud | Self::TextBox | Self::Callout) @@ -369,6 +375,14 @@ impl CommentPrefs { } } + /// The border's own colour for new text boxes and callouts (`None`: the text colour). + pub fn set_border_color(&mut self, tool: CommentTool, color: Option) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.border_color = + if tool.has_border_color() { color.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0))) } else { None }; + } + } + pub fn set_style(&mut self, tool: CommentTool, style: Style) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { *s = style; @@ -400,6 +414,17 @@ fn arrow_ending(ending: LineEnding) -> bool { matches!(ending, LineEnding::OpenArrow | LineEnding::ClosedArrow | LineEnding::ROpenArrow | LineEnding::RClosedArrow) } +/// Typed text (Fill & Sign's, or a typewriter's): no comment tool makes it, so Make Current +/// Properties Default sets what Fill & Sign gives new text. +pub(crate) fn typed_text(a: &Annotation) -> bool { + a.subtype == "FreeText" && a.intent.as_deref() == Some("FreeTextTypeWriter") +} + +/// Whether Make Current Properties Default has defaults to set from `a`. +pub(crate) fn defaultable(a: &Annotation) -> bool { + tool_for(a).is_some() || typed_text(a) +} + /// The tool that makes comments like `a` (for Make Current Properties Default). pub fn tool_for(a: &Annotation) -> Option { Some(match (a.subtype.as_str(), a.intent.as_deref()) { diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index ecbe36d6ae..a2c38b2628 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -3,7 +3,7 @@ use super::paint::edit_anchor; use super::state::{Composer, ComposerKind, Gesture}; use super::{ Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32, - icons, theme, tool_for, vec2, + defaultable, icons, theme, vec2, }; /// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's @@ -153,7 +153,7 @@ pub(crate) fn context_menu( action = Some(CanvasAction::Properties(page, index)); ui.close(); } - if ui.add_enabled(tool_for(a).is_some(), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { + if ui.add_enabled(defaultable(a), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { view.comments.default_request = Some((page, index)); ui.close(); } @@ -400,6 +400,33 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m selected_color } +/// A border-colour button: a hollow square in the border's colour (`current`, else the text's, +/// `text`), with a menu of "Same as text" and the swatches. Returns the choice (`Some(None)`: +/// the text's colour again). +pub(crate) fn border_colour_button(ui: &mut egui::Ui, current: Option, text: Rgb) -> Option> { + let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); + let sq = Rect::from_center_size(r.center(), vec2(15.0, 15.0)); + ui.painter().rect_stroke(sq, 1.0, Stroke::new(3.0, color32(current.unwrap_or(text))), egui::StrokeKind::Inside); + if resp.hovered() { + ui.painter().rect_stroke(r.shrink(2.0), 4.0, Stroke::new(1.0, Color32::from_black_alpha(40)), egui::StrokeKind::Inside); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Border colour"))); + let resp = resp.on_hover_text(tl!("Border colour")); + let mut picked = None; + egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).show(|ui| { + ui.label(egui::RichText::new(tl!("Border colour")).font(theme::semibold(12.0))); + if ui.add(egui::Button::new(tl!("Same as text")).selected(current.is_none())).clicked() { + picked = Some(None); + ui.close(); + } + if let Some(c) = swatch_grid(ui, current) { + picked = Some(Some(c)); + ui.close(); + } + }); + picked +} + /// The quick bar's fill button: a filled square in the fill colour, or a crossed-out outline /// for no fill. fn fill_button(ui: &mut egui::Ui, fill: Option) -> egui::Response { diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index af64f63adf..7e98eda05b 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -6,7 +6,7 @@ use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError}; -use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, SignatureImage, SignaturePhoto, Style, Turn}; +use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Rgb, Shape, SignatureImage, SignaturePhoto, Style, Turn}; use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageToolContext, PageXform}; @@ -215,9 +215,23 @@ pub struct TypeBox { pub text: String, pub size: f64, pub color: [f64; 3], + /// The border around the text. + pub border: TextBorder, pub focus: bool, } +/// The border around typed text: its width in points (0: none) and its own colour (`None`: the +/// text's). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TextBorder { + pub width: f64, + pub color: Option, +} + +impl TextBorder { + pub const NONE: Self = Self { width: 0.0, color: None }; +} + /// The default size Fill & Sign uses for typed text. pub const TEXT_SIZE: f64 = 10.0; pub const MIN_TEXT_SIZE: f64 = 4.0; @@ -231,6 +245,14 @@ pub fn valid_text_color(color: [f64; 3]) -> Option<[f64; 3]> { color.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)).then_some(color) } +/// The borders the toolbar offers around typed text, in points (0: none). +pub const TEXT_BORDERS: [f64; 6] = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0]; +pub const MAX_TEXT_BORDER: f64 = 12.0; + +pub fn valid_text_border(width: f64) -> Option { + (width.is_finite() && (0.0..=MAX_TEXT_BORDER).contains(&width)).then_some(width) +} + fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { let (vx, vy) = xf.screen_to_view(p); let u = info.pages[page].view_to_user(vx, vy); @@ -243,17 +265,30 @@ fn new(page: usize, shape: Shape, contents: String, author: &str) -> Edit { } /// A typewriter annotation sized to its text, its displayed top-left at `at` on a page turned by -/// `rotation` (its `/Rotate`). +/// `rotation` (its `/Rotate`), in black and without a border. pub fn typed(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str) -> Edit { - typed_with_color(page, at, text, size, rotation, author, [0.0; 3]) + typed_with(page, at, text, size, rotation, author, TypedLook::default()) } -/// Typed text/date colour applies to its annotation appearance and survives saving. -pub fn typed_with_color(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str, color: [f64; 3]) -> Edit { - let rect = crate::comments::text_box_rect(at, text, size, rotation); - let shape = Shape::Typewriter { rect, font_size: size }; - let mut style = Style::default_for(&shape); - style.color = valid_text_color(color).unwrap_or([0.0; 3]); +/// How typed text and dates look: their colour, and the border around them. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TypedLook { + pub color: [f64; 3], + pub border: TextBorder, +} + +/// [`typed`] in `look`: the colour applies to the annotation's appearance and survives saving; a +/// border goes around the box sized to the text, so the text sits where it would without one. +pub fn typed_with(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str, look: TypedLook) -> Edit { + let width = valid_text_border(look.border.width).unwrap_or(0.0); + let [x0, y0, x1, y1] = crate::comments::text_box_rect(at, text, size, rotation); + let shape = Shape::Typewriter { rect: [x0 - width, y0 - width, x1 + width, y1 + width], font_size: size }; + let style = Style { + color: valid_text_color(look.color).unwrap_or([0.0; 3]), + width, + border_color: look.border.color.filter(|_| width > 0.0), + ..Style::default_for(&shape) + }; Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: text.to_string(), author: author.to_string() }) } @@ -453,6 +488,7 @@ pub(crate) struct PageInput<'a> { pub(crate) tool: FillTool, pub(crate) text_size: f64, pub(crate) text_color: [f64; 3], + pub(crate) text_border: TextBorder, pub(crate) signature: Option<&'a SavedSig>, pub(crate) initials: Option<&'a SavedSig>, pub(crate) preview: &'a mut Option<(SavedSig, egui::TextureHandle)>, @@ -463,7 +499,7 @@ pub(crate) struct PageInput<'a> { /// Preview and clicks with a Fill & Sign tool on one page. pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bool, view: &mut DocView) -> Option { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; - let PageInput { tool, text_size, text_color, signature, initials, preview, author, date_text } = input; + let PageInput { tool, text_size, text_color, text_border, signature, initials, preview, author, date_text } = input; let pointer = ptr.within(xf.rect)?; let p = info.pages.get(page)?; let at = to_user(xf, info, page, pointer); @@ -495,21 +531,37 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bo // deselects, as a comment tool's note or text box does: Escape then closes the box and // leaves the tool in two presses. view.comments.clear_selection(); - let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, color: text_color, focus: true }); + let open = view.fill_text.replace(TypeBox { + page, + at: top, + text: String::new(), + size: text_size, + color: text_color, + border: text_border, + focus: true, + }); open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| { - FillAction::Edit(Box::new(typed_with_color( + FillAction::Edit(Box::new(typed_with( tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, - tb.color, + TypedLook { color: tb.color, border: tb.border }, ))) }) } FillTool::Date => Some(match date_text { - Ok(text) => FillAction::Edit(Box::new(typed_with_color(page, top, text, text_size, rotation, author, text_color))), + Ok(text) => FillAction::Edit(Box::new(typed_with( + page, + top, + text, + text_size, + rotation, + author, + TypedLook { color: text_color, border: text_border }, + ))), Err(why) => FillAction::Refused(why.clone()), }), FillTool::Signature => match signature { @@ -586,6 +638,12 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, .id_salt("fill-text-edit"), ) .accessible_name(tl!("Text to place")); + // The border the text will get, around the box being typed into. + if t.border.width > 0.0 { + let w = (t.border.width as f32 * zoom).max(1.0); + let color = t.border.color.map_or(Color32::BLACK, crate::comments::color32); + ui.painter().rect_stroke(r.rect, 0.0, Stroke::new(w, color), egui::StrokeKind::Outside); + } if t.focus { r.request_focus(); t.focus = false; @@ -604,7 +662,8 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, if commit { let tb = view.fill_text.take()?; if !tb.text.trim().is_empty() { - return Some(typed_with_color(tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, tb.color)); + let look = TypedLook { color: tb.color, border: tb.border }; + return Some(typed_with(tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, look)); } } None diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index cff01c0014..0396714c18 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2382,6 +2382,36 @@ fn a_text_only_text_box_made_default_gives_the_tool_its_texts_opacity() { assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h)); } +/// Make Properties Default on a text box with a border colour of its own gives the Text box tool +/// that colour. +#[test] +fn a_text_box_made_default_gives_the_tool_its_border_colour() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 }; + let style = Style { width: 1.0, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "Framed".into(), + author: "Tester".into() + }))); + h.run_steps(2); + let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Framed")).map(|a| a.index).expect("the text box"); + let red = Some([0.89, 0.13, 0.13]); + assert!(h.state_mut().apply_edit(Edit::BorderColorAnnotation { page: 0, index, color: red })); + assert_ne!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red); + h.state_mut().open_comment_props(0, index); + h.run_steps(2); + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red); +} + /// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines. #[test] fn an_arrows_default_does_not_restyle_lines() { @@ -3130,6 +3160,7 @@ fn a_moved_comment_is_drawn_where_it_goes_before_release() { fill: Some([1.0, 0.0, 0.0]), fill_opacity: None, text_opacity: None, + border_color: None, }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page: 0, diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 04f67ce216..1b9cb85a39 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -1801,6 +1801,7 @@ fn control_quit_retains_inactive_fill_sign_typing_after_cancel() { text: "Unsaved signature note".into(), size: 10.0, color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, focus: false, }); h.state_mut().active = None; diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index b5ad9f0522..55b6bdafd9 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -292,6 +292,50 @@ fn fill_text_size_resizes_the_selected_text() { assert!(after > before * 2.0, "36 pt text has a taller box than 10 pt text: {before} -> {after}"); } +/// Dragging the Font size control with a placed text selected resizes it once, when the drag +/// ends, to the size it was dragged to: the frame of the release must not put the old size back. +#[test] +fn dragging_the_font_size_resizes_the_selected_text_to_where_the_drag_ends() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Drag me".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (id, annotation) = { + let s = h.state(); + let id = s.views[0].id; + let a = s.session.get(id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Drag me")).map(|a| (a.page, a.index)); + (id, a.unwrap()) + }; + h.state_mut().views[0].comments.selected = Some(annotation); + h.run_steps(2); + // The size is set in the Font size button's popup. + h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click(); + h.run_steps(2); + let from = h + .query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.value().is_some_and(|v| v.ends_with(" pt"))) + .expect("the Font size control") + .rect() + .center(); + let before = h.state().fill_text_size; + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for dx in [10.0, 20.0, 30.0, 40.0] { + h.hover_at(from + egui::vec2(dx, 0.0)); + h.run_steps(1); + } + h.drop_at(from + egui::vec2(40.0, 0.0)); + h.run_steps(4); + let dragged = h.state().fill_text_size; + assert!(dragged > before, "the drag made the size larger: {before} -> {dragged}"); + let placed = h.state().session.get(id).unwrap().comment_props(annotation.0, annotation.1).and_then(|p| p.font_size); + assert_eq!(placed, Some(dragged), "the selected text has the size the drag ended at"); +} + /// The font sizes of the texts `text` drawn in the last frame. fn drawn_sizes(h: &Harness<'static, PdfCraftApp>, text: &str) -> Vec { fn walk(shape: &egui::Shape, text: &str, out: &mut Vec) { @@ -337,6 +381,150 @@ fn the_type_box_text_matches_the_page_zoom_in_a_turned_view() { } } +/// With a placed text selected, the toolbar shows its own border, also after Comment Properties +/// changed it, and changes only that text: the Border colour button gives its border its own +/// colour ("Same as text" takes it back), and new text keeps the toolbar's own border. +#[test] +fn the_toolbar_shows_and_changes_the_selected_texts_border() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Framed".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (page, index) = { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Framed")).cloned().unwrap(); + (a.page, a.index) + }; + let props = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + let shows = |h: &Harness<'static, PdfCraftApp>, value: &str| { + h.query_all_by_role(egui::accesskit::Role::ComboBox).any(|n| n.value().as_deref() == Some(value)) + }; + h.state_mut().views[0].comments.selected = Some((page, index)); + // As Comment Properties ▸ Thickness does. + h.state_mut().apply_edit(pdfcraft_engine::Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(2.0), endings: None }); + h.run_steps(3); + assert!(shows(&h, "2 pt"), "the toolbar shows the selected text's border"); + assert_eq!(h.state().fill_text_border, 0.0, "new text keeps none"); + + h.get_by_label("Border colour").click(); + h.run_steps(2); + h.get_by_label("Red").click(); + h.run_steps(4); + let red = Some([0.89, 0.13, 0.13]); + assert_eq!(props(&h).border_color, red, "the selected text's border is red"); + assert_eq!(h.state().fill_text_border_color, None, "new text's border keeps the text's colour"); + h.get_by_label("Border colour").click(); + h.run_steps(2); + h.get_by_label("Same as text").click(); + h.run_steps(4); + assert_eq!(props(&h).border_color, None); + // Nothing selected, the toolbar shows new text's border again. + h.state_mut().views[0].comments.selected = None; + h.run_steps(2); + assert!(shows(&h, "None")); +} + +/// The Border control puts a frame around new Fill & Sign text, and around the text that is +/// selected; the box grows outwards, so the text stays where it was typed. The choice persists. +#[test] +fn fill_text_border_frames_new_and_selected_text() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + h.run_steps(2); + h.get_by_label("Border:"); + let type_at = |h: &mut Harness<'static, PdfCraftApp>, y: f32| { + click(h, 50.0, y); + h.event(egui::Event::Text("Same".into())); + h.run_steps(1); + commit_typed_text(h); + }; + // The two texts, oldest first: (page, index, rect). + let texts = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let mut v: Vec<_> = + doc.info.annotations.iter().filter(|a| a.contents.as_deref() == Some("Same")).map(|a| (a.page, a.index, a.rect)).collect(); + v.sort_by_key(|t| t.1); + v + }; + let width = |r: [f32; 4]| r[2] - r[0]; + type_at(&mut h, 150.0); + // Nothing selected, so the toolbar sets new text's border only (it shows a selected text's). + h.state_mut().views[0].comments.selected = None; + h.state_mut().fill_text_border = 1.5; + type_at(&mut h, 250.0); + let [(page, index, plain), (_, _, framed)] = texts(&h)[..] else { panic!("two texts") }; + assert!((width(framed) - width(plain) - 3.0).abs() < 0.01, "1.5 pt on each side: {plain:?} {framed:?}"); + + // The plain text selected, then framed with 2 pt from the toolbar. + h.state_mut().views[0].comments.selected = Some((page, index)); + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.value().as_deref() == Some("1.5 pt")) + .expect("the Border control, showing the border for new text") + .click(); + h.run_steps(2); + h.get_by_label("2 pt").click(); + h.run_steps(4); + assert_eq!(h.state().fill_text_border, 1.5, "new text keeps its own"); + let (_, _, now) = texts(&h)[0]; + assert!((width(now) - width(plain) - 4.0).abs() < 0.01, "2 pt on each side: {plain:?} {now:?}"); + assert!((plain[0] - now[0] - 2.0).abs() < 0.01 && (now[3] - plain[3] - 2.0).abs() < 0.01, "grown outwards, the text in place: {plain:?} {now:?}"); + + // The toolbar's border lasts until PdfCraft quits; a saved default is read back. + let settings = h.state().persist(); + let mut restored = PdfCraftApp::new(); + restored.restore(&settings); + assert_eq!(restored.fill_text_border, 0.0); + restored.restore(r#"{"fill_text_border":2}"#); + assert_eq!(restored.fill_text_border, 2.0); + restored.restore(r#"{"fill_text_border":100}"#); + assert_eq!(restored.fill_text_border, 2.0, "invalid settings do not change the border"); +} + +/// Make Properties Default on typed text sets what Fill & Sign gives new text, its size and +/// border, and keeps it across launches. +#[test] +fn typed_text_made_default_is_what_new_text_gets() { + use pdfcraft_engine::Edit; + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + let type_at = |h: &mut Harness<'static, PdfCraftApp>, y: f32, text: &str| { + click(h, 50.0, y); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + commit_typed_text(h); + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).cloned().unwrap(); + (a.page, a.index) + }; + let (page, index) = type_at(&mut h, 150.0, "Model"); + let red = Some([0.89, 0.13, 0.13]); + for edit in [ + Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(1.5), endings: None }, + Edit::BorderColorAnnotation { page, index, color: red }, + Edit::FontSizeAnnotation { page, index, size: 14.0 }, + ] { + assert!(h.state_mut().apply_edit(edit)); + } + h.state_mut().open_comment_props(page, index); + h.run_steps(2); + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!((s.fill_text_size, s.fill_text_border, s.fill_text_border_color), (14.0, 1.5, red)); + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + assert_eq!((restored.fill_text_size, restored.fill_text_border, restored.fill_text_border_color), (14.0, 1.5, red), "kept across launches"); + h.state_mut().views[0].comments.selected = None; + let (page, index) = type_at(&mut h, 350.0, "Next"); + let p = h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + assert_eq!((p.font_size, p.width, p.border_color), (Some(14.0), Some(1.5), red)); +} + #[test] fn fill_text_colour_survives_preview_commit_settings_and_save() { let mut h = harness(); @@ -1211,7 +1399,15 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() { app.open_bytes("form.pdf", None, FIXTURE.to_vec()).unwrap(); let id = app.views[0].id; app.session.apply(id, mark).unwrap(); - app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, color: [0.0; 3], focus: false }); + app.views[0].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 200.0], + text: "Typed".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); let kept = dir.join("kept.pdf"); app.save_override = Some(kept.to_string_lossy().into_owned()); assert!(app.save_active(SaveTarget::InPlace)); @@ -1222,7 +1418,15 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() { assert!(texts.iter().any(|t| t == "Typed"), "an open type box is saved: {texts:?}"); assert_eq!(reopened.session.get(reopened.views[0].id).unwrap().info.annotations.len(), 2, "the check mark stays too"); - app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 160.0], text: "More".into(), size: 10.0, color: [0.0; 3], focus: false }); + app.views[0].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 160.0], + text: "More".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); app.flatten_fill_sign_on_save = true; let flat = dir.join("flat.pdf"); app.save_override = Some(flat.to_string_lossy().into_owned()); diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 12989e9e73..a7f5b4cf7f 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -889,6 +889,24 @@ fn a_saved_no_fill_clears_a_comment_tool_default_fill() { assert_eq!(c.comment_prefs.style(CommentTool::Rectangle).fill, None); } +/// A tool's saved "Same as text" border colour (`"border_color": null`) clears an own border +/// colour it had; a missing or malformed one keeps it. +#[test] +fn a_saved_same_as_text_clears_a_comment_tool_border_colour() { + use pdfcraft_ui_egui::comments::CommentTool; + let red = Some([0.89, 0.13, 0.13]); + let mut a = PdfCraftApp::new(); + a.comment_prefs.set_border_color(CommentTool::TextBox, red); + a.restore(r#"{"comment_styles":[{"tool":"comment.freetext","border_color":"red"}]}"#); + assert_eq!(a.comment_prefs.style(CommentTool::TextBox).border_color, red, "malformed keeps it"); + a.restore(r#"{"comment_styles":[{"tool":"comment.freetext","border_color":null}]}"#); + assert_eq!(a.comment_prefs.style(CommentTool::TextBox).border_color, None, "null clears it"); + let mut b = PdfCraftApp::new(); + b.comment_prefs.set_border_color(CommentTool::TextBox, red); + b.restore(&a.persist()); + assert_eq!(b.comment_prefs.style(CommentTool::TextBox).border_color, None, "a round trip keeps it cleared"); +} + /// Persisted per-tool comment styles go through their clamping setters: hostile or malformed /// values are clamped or ignored, never trusted (#340). #[test] diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index d383582915..9a06a0bc12 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1046,7 +1046,15 @@ fn the_window_title_marks_a_draft_that_is_not_applied_yet() { h.run_steps(2); assert_eq!(h.state().window_title, "b.pdf — PdfCraft"); let i = h.state().active.unwrap(); - h.state_mut().views[i].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, color: [0.0; 3], focus: false }); + h.state_mut().views[i].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 200.0], + text: "Typed".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); h.run_steps(2); let s = h.state(); assert!(!s.session.get(s.views[i].id).unwrap().dirty, "nothing applied to the document yet"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e95e7252d9..cf6a3db0a9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3758,8 +3758,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/it/automation.rs::fill_sign_text_font_size_is_saved_and_validated"] -notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches. fill_sign_add accepts font_size for text and dates." +evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/it/automation.rs::fill_sign_text_font_size_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_border_frames_new_and_selected_text", "crates/annot/src/tests.rs::a_typewriter_border_goes_around_its_text_which_stays_put", "crates/automation/tests/it/automation.rs::fill_sign_text_border_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::the_toolbar_shows_and_changes_the_selected_texts_border", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_made_default_is_what_new_text_gets", "crates/annot/src/tests.rs::a_text_border_can_have_its_own_colour"] +notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches (with a placed text selected it resizes that text too). A Border control (none to 3 pt) frames new text or the selected text, in the text's colour or a Border colour of its own; the box grows outwards so the text keeps its place. With a placed text selected, the border controls show its own border and border colour (also after Comment Properties changed them) and change only that text; without one they set new text's until PdfCraft quits. Make Current Properties Default on typed text (its menus, or the box in Comment Properties) sets what new text gets, its size and border, kept across launches. Text boxes and callouts take a border colour too (quick bar, Comment Properties), the stroking colour in /DA. fill_sign_add accepts font_size, border and border_color for text and dates; comment_edit border_color." [[feature]] id = "comment.text-box" @@ -4010,8 +4010,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = [] -evidence = ["crates/ui-egui/tests/it/comments.rs::make_current_properties_default", "crates/ui-egui/tests/it/comments.rs::properties_dialog_makes_the_strikethrough_colour_default"] -notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)" +evidence = ["crates/ui-egui/tests/it/comments.rs::make_current_properties_default", "crates/ui-egui/tests/it/comments.rs::properties_dialog_makes_the_strikethrough_colour_default", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_made_default_is_what_new_text_gets"] +notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340). Typed text, which no comment tool makes, gives Fill & Sign's new text its size and border" [[feature]] id = "comment.keep-tool-selected"