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
@@ -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),@@ -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 },@@ -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]@@ -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)?; }@@ -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 {@@ -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,@@ -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();@@ -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();@@ -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();