diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 0e2e0dd69..3d52d0ff2 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1199,6 +1199,14 @@ impl Automation { if let Some(s) = a.opt_num("font_size")? { hf.font_size = s; } + hf.font = match a.opt_str("font")? { + None | Some("Helvetica") => pdfcraft_engine::FontFamily::Helvetica, + Some("Times") => pdfcraft_engine::FontFamily::Times, + Some("Courier") => pdfcraft_engine::FontFamily::Courier, + Some(other) => { + return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}"))); + } + }; hf.color = color("color", hf.color)?; if let Some(m) = a.get("margins").and_then(Value::as_array) { let m: Vec = m.iter().filter_map(Value::as_f64).collect(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e154e1eeb..f5130437c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -869,7 +869,7 @@ pub fn tools() -> Vec { t( "doc_header_footer", "Add header and footer", - "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> (and more), Bates <>. replace: true swaps out existing ones (Update). Undoable.", + "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> (and more), Bates <>. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.", ) .cmd("edit.header_footer") .with(schema( @@ -878,6 +878,7 @@ pub fn tools() -> Vec { "pages": pages("to mark (default: all)"), "header_left": { "type": "string" }, "header_center": { "type": "string" }, "header_right": { "type": "string" }, "footer_left": { "type": "string" }, "footer_center": { "type": "string" }, "footer_right": { "type": "string" }, + "font": { "type": "string", "enum": ["Helvetica", "Times", "Courier"] }, "font_size": { "type": "number", "exclusiveMinimum": 0 }, "color": { "type": "string", "description": "#RRGGBB or a colour name." }, "margins": { "type": "array", "items": { "type": "number", "minimum": 0 }, "minItems": 4, "maxItems": 4, "description": "Top, bottom, left, right in points (default 36, 36, 72, 72)." }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index cbf40f196..9bbb5543c 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2239,6 +2239,20 @@ fn headers_watermarks_and_backgrounds_through_tools() { assert!(matches!(a.call("doc_remove_marks", &json!({ "doc": doc, "kind": "watermark" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("doc_header_footer", &json!({ "doc": doc })), Err(ToolError::Failed(_))), "no text"); ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); + // A standard face other than Helvetica (#3754), kept through save and reopen; one it doesn't + // offer is refused with the faces it does. + let mono = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_header_footer", json!({ "doc": mono, "header_left": "MONO", "font": "Courier" })); + assert!(page_text(&mut a, mono)[0].contains("MONO")); + ok(&mut a, "doc_save", json!({ "doc": mono, "path": "courier.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("courier.pdf")).unwrap())).unwrap(); + let page = &pdfcraft_model::pages(&saved)[0]; + let res = saved.resolve(page.dict.get(b"Resources").unwrap()); + let fonts = saved.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap(); + let face = saved.resolve(fonts.get(b"PCCour").expect("Courier is added")).as_dict().cloned().unwrap(); + assert_eq!(face.name(b"BaseFont"), Some(&b"Courier"[..])); + let refused = a.call("doc_header_footer", &json!({ "doc": mono, "header_left": "X", "font": "Arial" })); + assert!(matches!(refused, Err(ToolError::InvalidArgs(e)) if e.contains("Courier"))); } #[test] diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index c1062b410..90d8f24cd 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -11,7 +11,8 @@ //! so its graphics state cannot reach the mark. The closing stream also ends known unfinished //! marked-content sequences before the new mark. //! -//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`. +//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`; headers and +//! footers may use standard Times (`/PCTimes`) or Courier (`/PCCour`) instead (#3754). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -58,6 +59,8 @@ pub struct HeaderFooter { /// `<>`, `<>`, and Bates numbers `<>` /// (digits, start, prefix, suffix). pub text: [String; 6], + /// The standard face the text is drawn in (#3754): Helvetica (the default), Times or Courier. + pub font: added::Family, pub font_size: f64, pub color: Rgb, pub underline: bool, @@ -70,7 +73,15 @@ pub struct HeaderFooter { impl Default for HeaderFooter { /// Acrobat's defaults: 8 pt black, margins 0.5 in top and bottom, 1 in left and right. fn default() -> Self { - Self { text: Default::default(), font_size: 8.0, color: [0.0; 3], underline: false, margins: [36.0, 36.0, 72.0, 72.0], start_number: 1 } + Self { + text: Default::default(), + font: added::Family::Helvetica, + font_size: 8.0, + color: [0.0; 3], + underline: false, + margins: [36.0, 36.0, 72.0, 72.0], + start_number: 1, + } } } @@ -645,7 +656,13 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?; let size = hf.font_size; let [top, bottom, left, right] = hf.margins; - let mut body: Vec = format!("BT\n/PCHelv {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes(); + // The page resource each face is added under (see `add_resources`). + let resource = match hf.font { + added::Family::Helvetica => "PCHelv", + added::Family::Times => "PCTimes", + added::Family::Courier => "PCCour", + }; + let mut body: Vec = format!("BT\n/{resource} {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes(); let mut underlines = String::new(); for (slot, template) in hf.text.iter().enumerate() { if template.trim().is_empty() { @@ -655,7 +672,8 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, let header = slot < 3; let line_h = size * 1.2; for (li, line) in lines.iter().enumerate() { - let tw = helvetica_width(line, size); + // Centred and right-aligned text is placed by its width in the chosen face. + let tw = hf.font.width(line, size, false, false); let x = match slot % 3 { 0 => left, 1 => (w - tw) / 2.0, @@ -676,7 +694,8 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, let mut content = begin(MarkKind::HeaderFooter, "Header", page.view_matrix(doc)).into_bytes(); content.extend(body); content.extend_from_slice(END.as_bytes()); - add_resources(doc, page, None, None)?; + // The content names the face, so Times or Courier is added beside Helvetica. + add_resources(doc, page, None, Some(&content))?; let page = &page_list(doc)[i]; place(doc, page, MarkKind::HeaderFooter, content, false)?; } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 879ebc6c6..7e16f22ce 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -97,6 +97,40 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() assert_eq!(marks_present(&doc), [MarkKind::HeaderFooter]); } +#[test] +fn header_and_footer_are_drawn_in_the_chosen_standard_face() { + // #3754: Times or Courier instead of Helvetica. The content names the face, the page's fonts + // gain it, and right-aligned text is placed by that face's widths (not Helvetica's). + let text = "Wide words"; + for (font, resource, base) in [(added::Family::Times, "PCTimes", "Times-Roman"), (added::Family::Courier, "PCCour", "Courier")] { + let mut doc = fixture(); + let hf = HeaderFooter { + text: [String::new(), String::new(), text.into(), String::new(), String::new(), String::new()], + font, + ..HeaderFooter::default() + }; + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + let doc = reopen(&doc); + let mark = streams(&doc, 0).last().cloned().unwrap(); + assert!(mark.contains(&format!("/{resource} 8 Tf")), "{base}: {mark}"); + let x = 600.0 - 72.0 - font.width(text, 8.0, false, false); + assert!((x - (600.0 - 72.0 - pdfcraft_fonts::helvetica_width(text, 8.0))).abs() > 1.0, "{base}: the faces differ in width"); + assert!(mark.contains(&format!("1 0 0 1 {} ", n(x))), "{base}: right-aligned by its own widths: {mark}"); + 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(resource.as_bytes()).unwrap()).as_dict().cloned().unwrap(); + assert_eq!(face.name(b"BaseFont"), Some(base.as_bytes()), "{base}"); + } + // The default is still Helvetica, and only Helvetica is added. + let mut doc = fixture(); + let hf = + HeaderFooter { text: [text.into(), String::new(), String::new(), String::new(), String::new(), String::new()], ..HeaderFooter::default() }; + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + let mark = streams(&reopen(&doc), 0).last().cloned().unwrap(); + assert!(mark.contains("/PCHelv 8 Tf") && !mark.contains("/PCTimes") && !mark.contains("/PCCour"), "{mark}"); +} + #[test] fn header_start_number_that_would_wrap_is_refused_without_drawing() { // u32::MAX is a valid start_number on its own, but there is no room for a second page diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index d91a69f93..bc84aa158 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -3,7 +3,7 @@ //! preview of the current page. "Update" opens the same dialog in replace mode. use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; -use pdfcraft_engine::{Background, Edit, HeaderFooter, MarkKind, Watermark}; +use pdfcraft_engine::{Background, Edit, FontFamily, HeaderFooter, MarkKind, Watermark}; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; @@ -160,7 +160,13 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M match kind { MarkKind::HeaderFooter => { ui.horizontal(|ui| { - ui.label(tl!("Font: Helvetica")); + // The standard faces a header can be drawn in (#3754). + ui.label(tl!("Font:")); + egui::ComboBox::from_id_salt("hf-font").selected_text(d.hf.font.label()).width(100.0).show_ui(ui, |ui| { + for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { + ui.selectable_value(&mut d.hf.font, f, f.label()); + } + }); ui.label(tl!("Size")); ui.add(egui::DragValue::new(&mut d.hf.font_size).range(4.0..=72.0).speed(0.5)); ui.add(widgets::checkbox(&mut d.hf.underline, tl!("Underline"))); @@ -322,7 +328,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M 1 => (r.center().x, egui::Align2::CENTER_CENTER), _ => (r.right() - hf.margins[3] as f32 * s, egui::Align2::RIGHT_CENTER), }; - p.text(pos2(x, y), align, shown, egui::FontId::proportional((hf.font_size as f32 * s).max(5.0)), rgb32(hf.color)); + // Courier previews in the monospaced UI face, the others proportional. + let size = (hf.font_size as f32 * s).max(5.0); + let font = if hf.font == FontFamily::Courier { egui::FontId::monospace(size) } else { egui::FontId::proportional(size) }; + p.text(pos2(x, y), align, shown, font, rgb32(hf.color)); } } MarkKind::Watermark if file_name.is_some() => { diff --git a/crates/ui-egui/tests/ui_group/marks.rs b/crates/ui-egui/tests/ui_group/marks.rs index 1f1515ad6..e0d86c4ab 100644 --- a/crates/ui-egui/tests/ui_group/marks.rs +++ b/crates/ui-egui/tests/ui_group/marks.rs @@ -58,6 +58,33 @@ fn header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset() { assert!(marks(&h).is_empty()); } +#[test] +fn header_and_footer_dialog_draws_in_the_chosen_face() { + // #3754: the dialog offers Helvetica, Times and Courier (it only said "Font: Helvetica"), + // and the face chosen is the one the page's header is drawn in. + let mut h = harness(); + assert!(h.state_mut().execute("edit.header_footer")); + 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.hf.font, pdfcraft_engine::FontFamily::Times); + h.state_mut().marks_draft.hf.text[0] = "Serif".into(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(4); + assert_eq!(marks(&h), [MarkKind::HeaderFooter]); + 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 watermark_and_background_dialogs() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8..13aeeb582 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2389,7 +2389,7 @@ status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] evidence = ["crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] -notes = "Helvetica only (no font choice yet); drawn in display space on rotated pages; artifact-tagged" +notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged" [[feature]] id = "edit.header-footer-update-remove"