feat(edit): Header & Footer can be drawn in Times or Courier (#3754)

Headers and footers were Helvetica only. HeaderFooter takes the same
standard-face Family as Add text: the content selects /PCHelv, /PCTimes
or /PCCour, the page gains that font, and centred and right-aligned text
is placed by the chosen face's standard-14 widths. doc_header_footer
takes font, and the dialog's fixed "Font: Helvetica" label becomes a
Helvetica / Times / Courier menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lombardi2003
2026-10-21 00:48:48 +02:00
co-authored by Claude Opus 5.5
parent 22bedbf2af
commit fecfc774f2
8 changed files with 122 additions and 10 deletions
+8
View File
@@ -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<f64> = m.iter().filter_map(Value::as_f64).collect();
+2 -1
View File
@@ -869,7 +869,7 @@ pub fn tools() -> Vec<ToolDef> {
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, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. 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, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. 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<ToolDef> {
"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)." },
+14
View File
@@ -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]
+24 -5
View File
@@ -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 {
/// `<<m/d/yy>>`, `<<mmmm d, yyyy>>`, and Bates numbers `<<Bates Number#6#1#PRE#SUF>>`
/// (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<u8> = 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<u8> = 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)?;
}
+34
View File
@@ -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
+12 -3
View File
@@ -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() => {
+27
View File
@@ -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();
+1 -1
View File
@@ -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"