collective

Browse / github.com/storytold/pdfcraft / patch

feat(edit): add font selection for watermarks (#4956)

Watermark feature does not let user change font In the watermark editor, the user cannot select a font for the watermark. Only a default font is used. To customize the watermark font, the Watermark editor should have a small menu allowing the user to select a font from the ones installed in the system. It could be located close to the font size menu currently present

Status
open
Score
0.00
Adopters
0 other operators
Author
YusufMntm (imported from pull request #4965, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+91 −9 in 9 files
Review
pending
Shared
2026-10-28
Id
2a08427a7b4c1ceb2bdb0f78

How it went

No other operator has reported using it yet.

Diff

raw · crates/automation/src/documents.rs crates/automation/src/tools.rs crates/automation/tests/it/automation.rs crates/edit/src/apply.rs crates/edit/src/lib.rs crates/edit/src/marks.rs crates/edit/src/tests.rs crates/ui-egui/src/dialogs/marks_ui.rs crates/ui-egui/tests/it/marks.rs

diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs--- a/crates/automation/src/documents.rs+++ b/crates/automation/src/documents.rs@@ -461,8 +461,17 @@ impl Automation {                     Some(_) => a.opt_str("text")?.unwrap_or_default().to_string(),                     None => a.str("text")?.to_string(),                 };+                let font = match a.opt_str("font")? {+                    None | Some("Helvetica") | Some("helvetica") => pdfcraft_engine::FontFamily::Helvetica,+                    Some("Times") | Some("times") => pdfcraft_engine::FontFamily::Times,+                    Some("Courier") | Some("courier") => pdfcraft_engine::FontFamily::Courier,+                    Some(other) => {+                        return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}")));+                    }+                };                 let wm = Watermark {                     text,+                    font,                     source: None,                     scale: a.opt_num("scale")?.unwrap_or(d.scale).clamp(0.01, 1.0),                     font_size: a.opt_num("font_size")?.unwrap_or(0.0),diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs--- a/crates/automation/src/tools.rs+++ b/crates/automation/src/tools.rs@@ -975,7 +975,7 @@ pub fn tools() -> Vec<ToolDef> {             }),             &["doc"],         )),-        t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given, on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.")+        t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given, font \"Helvetica\" (default), \"Times\" or \"Courier\", on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.")             .cmd("edit.watermark")             .with(schema(                 json!({@@ -985,6 +985,7 @@ pub fn tools() -> Vec<ToolDef> {                     "file": { "type": "string" },                     "file_page": { "type": "integer", "minimum": 1 },                     "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },+                    "font": { "type": "string", "enum": ["Helvetica", "Times", "Courier"] },                     "font_size": { "type": "number", "exclusiveMinimum": 0 },                     "color": { "type": "string" },                     "opacity": { "type": "number", "minimum": 0, "maximum": 1 },diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs--- a/crates/automation/tests/it/automation.rs+++ b/crates/automation/tests/it/automation.rs@@ -2698,6 +2698,8 @@ fn watermarks_move_from_the_page_centre_through_tools() {     // Huge offsets are clamped, and a value that isn't a number is refused.     ok(&mut a, "doc_watermark", json!({ "doc": doc, "pages": [1], "text": "DRAFT", "offset_y": 1e300, "replace": true }));     assert!(matches!(a.call("doc_watermark", &json!({ "doc": doc, "text": "X", "offset_x": "left" })), Err(ToolError::InvalidArgs(_))));+    ok(&mut a, "doc_watermark", json!({ "doc": doc, "pages": [1], "text": "COURIER", "font": "Courier", "replace": true }));+    assert!(matches!(a.call("doc_watermark", &json!({ "doc": doc, "text": "X", "font": "ComicSans" })), Err(ToolError::InvalidArgs(_)))); }  #[test]diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs--- a/crates/edit/src/apply.rs+++ b/crates/edit/src/apply.rs@@ -164,14 +164,21 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac             place(doc, page, MarkKind::Watermark, content.into_bytes(), wm.behind)?;             continue;         }-        let widest = lines.iter().map(|l| helvetica_width(l, 1.0)).fold(0.0, f64::max).max(0.01);+        let preferred = match wm.font {+            added::Family::Helvetica => "PCHelv",+            added::Family::Times => "PCTimes",+            added::Family::Courier => "PCCour",+        };+        let base = wm.font.base_font(false, false);+        let resource = free_font_name(doc, &page_fonts(doc, &page), preferred, base);+        let widest = lines.iter().map(|l| wm.font.width(l, 1.0, false, false)).fold(0.0, f64::max).max(0.01);         let size = if wm.font_size > 0.0 { wm.font_size } else { ((w * w + h * h).sqrt() * 0.5 / widest).clamp(6.0, 300.0) };         let (s, c) = wm.rotation.to_radians().sin_cos();         let (cx, cy) = (w / 2.0 + wm.offset[0], h / 2.0 + wm.offset[1]);         let mut content = begin(MarkKind::Watermark, "Watermark", page.view_matrix(doc)).into_bytes();         content.extend(             format!(-                "/PCGS{} gs\n{} {} {} {} {} {} cm\nBT\n/PCHelv {} Tf\n{}\n",+                "/PCGS{} gs\n{} {} {} {} {} {} cm\nBT\n/{resource} {} Tf\n{}\n",                 (opacity * 100.0).round() as i64,                 n(c),                 n(s),@@ -187,12 +194,13 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac         let line_h = size * 1.15;         let total = line_h * (lines.len() as f64 - 1.0);         for (li, line) in lines.iter().enumerate() {-            let tw = helvetica_width(line, size);+            let tw = wm.font.width(line, size, false, false);             content.extend(text_op(-tw / 2.0, total / 2.0 - li as f64 * line_h - size * 0.35, line));         }         content.extend_from_slice(b"ET\n");         content.extend_from_slice(END.as_bytes());-        add_resources(doc, &page, Some(opacity), None)?;+        let faces = if !page_fonts(doc, &page).contains(resource.as_bytes()) { vec![(resource.into_bytes(), base)] } else { Vec::new() };+        add_page_resources(doc, &page, Some(opacity), &faces)?;         let page = &page_list(doc)[i];         place(doc, page, MarkKind::Watermark, content, wm.behind)?;     }diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs--- a/crates/edit/src/lib.rs+++ b/crates/edit/src/lib.rs@@ -18,7 +18,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]  use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};-use pdfcraft_fonts::{helvetica_width, literal, win_ansi};+use pdfcraft_fonts::{literal, win_ansi};  #[derive(Debug, thiserror::Error, PartialEq)] pub enum EditError {diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs--- a/crates/edit/src/marks.rs+++ b/crates/edit/src/marks.rs@@ -121,12 +121,17 @@ pub struct MarkSource { /// A watermark: text, or a picture from a file (`source`; the text is then ignored). #[derive(Clone, Debug, PartialEq)] pub struct Watermark {+    /// Text drawn when there is no picture source.     pub text: String,+    /// The standard font family used for text watermarks (default Helvetica).+    pub font: added::Family,+    /// Optional picture from a file or another page; when present, text is ignored.     pub source: Option<MarkSource>,     /// Pictures: size relative to the page (1.0 fits the page).     pub scale: f64,     /// 0 = fit: about half the page diagonal.     pub font_size: f64,+    /// Colour for text watermarks.     pub color: Rgb,     /// 0–1.     pub opacity: f64,@@ -143,8 +148,8 @@ impl Watermark {     /// standard fonts can't draw it (#125). A picture watermark draws no text.     pub fn first_undrawable(&self) -> Option<char> {         match self.source {-            Some(_) => None,             None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi),+            Some(_) => None,         }     } }@@ -153,6 +158,7 @@ impl Default for Watermark {     fn default() -> Self {         Self {             text: String::new(),+            font: added::Family::Helvetica,             source: None,             scale: 0.5,             font_size: 0.0,diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs--- a/crates/edit/src/tests.rs+++ b/crates/edit/src/tests.rs@@ -462,6 +462,28 @@ fn a_watermark_offset_that_is_not_finite_is_refused() {     } } +#[test]+fn watermarks_support_font_selection() {+    let mut doc = fixture();+    let wm_times = Watermark { text: "TIMES WATERMARK".into(), font: added::Family::Times, font_size: 24.0, rotation: 0.0, ..Watermark::default() };+    add_watermark(&mut doc, &[0], &wm_times, false).unwrap();+    let s = streams(&doc, 0);+    let mark = s.iter().find(|c| c.contains("/PCMark /Watermark")).expect("the watermark stream");+    assert!(mark.contains("/PCTimes 24 Tf"), "uses Times font: {mark}");+    let res = page_fonts(&doc, &page_list(&doc)[0]);+    assert!(res.contains(b"PCTimes"), "page resources include PCTimes");++    let mut doc = fixture();+    let wm_cour =+        Watermark { text: "COURIER WATERMARK".into(), font: added::Family::Courier, font_size: 24.0, rotation: 0.0, ..Watermark::default() };+    add_watermark(&mut doc, &[0], &wm_cour, false).unwrap();+    let s = streams(&doc, 0);+    let mark = s.iter().find(|c| c.contains("/PCMark /Watermark")).expect("the watermark stream");+    assert!(mark.contains("/PCCour 24 Tf"), "uses Courier font: {mark}");+    let res = page_fonts(&doc, &page_list(&doc)[0]);+    assert!(res.contains(b"PCCour"), "page resources include PCCour");+}+ #[test] fn invalid_requests_change_nothing() {     let mut doc = fixture();diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs--- a/crates/ui-egui/src/dialogs/marks_ui.rs+++ b/crates/ui-egui/src/dialogs/marks_ui.rs@@ -314,8 +314,16 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M                         );                     });                     ui.horizontal(|ui| {+                        ui.add_enabled_ui(!d.use_file, |ui| {+                            ui.label(tl!("Font"));+                            egui::ComboBox::from_id_salt("wm-font").selected_text(d.wm.font.label()).width(100.0).show_ui(ui, |ui| {+                                for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] {+                                    ui.selectable_value(&mut d.wm.font, f, f.label());+                                }+                            });+                        });                         ui.add_enabled_ui(!d.use_file, |ui| ui.add(widgets::checkbox(&mut d.fit, tl!("Fit to page"))));-                        ui.add_enabled_ui(!d.fit, |ui| {+                        ui.add_enabled_ui(!d.fit && !d.use_file, |ui| {                             ui.label(tl!("Size"));                             if d.wm.font_size == 0.0 {                                 d.wm.font_size = 72.0;@@ -436,7 +444,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M                 } else {                     (wm.font_size as f32 * s).max(5.0)                 };-                let galley = p.layout_no_wrap(text.to_string(), egui::FontId::proportional(size), rgb32(wm.color).gamma_multiply(wm.opacity as f32));+                let font_id = if wm.font == FontFamily::Courier { egui::FontId::monospace(size) } else { egui::FontId::proportional(size) };+                let galley = p.layout_no_wrap(text.to_string(), font_id, rgb32(wm.color).gamma_multiply(wm.opacity as f32));                 let angle = -(wm.rotation as f32).to_radians();                 let half = galley.size() / 2.0;                 let (sn, cs) = angle.sin_cos();diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs--- a/crates/ui-egui/tests/it/marks.rs+++ b/crates/ui-egui/tests/it/marks.rs@@ -123,6 +123,31 @@ fn watermark_and_background_dialogs() {     assert_eq!(marks(&h).len(), 2); } +#[test]+fn watermark_dialog_draws_in_the_chosen_face() {+    let mut h = harness();+    assert!(h.state_mut().execute("edit.watermark"));+    h.run_steps(3);+    h.get_by_label("Font");+    h.get_by_value("Helvetica").click();+    h.run_steps(2);+    h.get_by_label("Times").click();+    h.run_steps(2);+    assert_eq!(h.state().marks_draft.wm.font, pdfcraft_engine::FontFamily::Times);+    h.state_mut().marks_draft.wm.text = "Serif Watermark".into();+    h.run_steps(2);+    h.get_by_label("OK").click();+    h.run_steps(4);+    assert_eq!(marks(&h), [MarkKind::Watermark]);+    let s = h.state();+    let doc = pdfcraft_cos::Document::open(s.session.get(s.views[0].id).unwrap().bytes.clone()).unwrap();+    let page = &pdfcraft_model::pages(&doc)[0];+    let res = doc.resolve(page.dict.get(b"Resources").unwrap());+    let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap();+    let face = doc.resolve(fonts.get(b"PCTimes").expect("Times is added")).as_dict().cloned().unwrap();+    assert_eq!(face.name(b"BaseFont"), Some(&b"Times-Roman"[..]));+}+ #[test] fn backgrounds_and_watermarks_from_a_file() {     let mut h = harness();