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

This commit is contained in:
aiongg
2026-10-22 01:45:09 +00:00
8 changed files with 227 additions and 13 deletions
+8
View File
@@ -1200,6 +1200,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
@@ -875,7 +875,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(
@@ -884,6 +884,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]
+70 -8
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,
}
}
}
@@ -297,19 +308,57 @@ fn contents(doc: &mut Document, page: &pdfcraft_model::Page) -> Result<Vec<Objec
/// Add the font (and an opacity state) to the page's own resources.
fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Option<f64>, content: Option<&[u8]>) -> Result<(), EditError> {
let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let mut faces: Vec<(Vec<u8>, &str)> = Vec::new();
for (name, base) in [(&b"PCHelv"[..], "Helvetica"), (b"PCTimes", "Times-Roman"), (b"PCCour", "Courier")] {
// Helvetica always (marks use it); the others only when the content names them.
if name != b"PCHelv" && !content.is_some_and(|c| c.windows(name.len() + 1).any(|w| w[0] == b'/' && &w[1..] == name)) {
continue;
}
faces.push((name.to_vec(), base));
}
add_page_resources(doc, page, opacity, &faces)
}
/// The fonts a page draws with: its own resources' or the ones it inherits.
fn page_fonts(doc: &Document, page: &pdfcraft_model::Page) -> Dict {
let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default()
}
/// A name in `fonts` for the standard face `base` that can't take over a font the page already
/// draws with: `preferred` when it is free or already names exactly the dictionary
/// [`add_page_resources`] writes for that face, otherwise the first such `preferred_1`,
/// `preferred_2`… (one of the first `fonts.len() + 1` always is). A font with anything more
/// (`/ToUnicode`, `/Widths`…) is someone else's, even in the same face.
fn free_font_name(doc: &Document, fonts: &Dict, preferred: &str, base: &str) -> String {
let usable = |name: &str| match fonts.get(name.as_bytes()) {
None => true,
Some(font) => doc.resolve(font).as_dict().is_some_and(|d| {
d.len() == 4
&& d.name(b"Type") == Some(b"Font")
&& d.name(b"Subtype") == Some(b"Type1")
&& d.name(b"BaseFont") == Some(base.as_bytes())
&& d.name(b"Encoding") == Some(b"WinAnsiEncoding")
}),
};
if usable(preferred) {
return preferred.to_string();
}
(1..=fonts.len().saturating_add(1)).map(|k| format!("{preferred}_{k}")).find(|name| usable(name)).unwrap_or_else(|| preferred.to_string())
}
/// Add the standard `faces` (resource name, base font) and an opacity state to the page's own
/// resources.
fn add_page_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Option<f64>, faces: &[(Vec<u8>, &str)]) -> Result<(), EditError> {
let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
for (name, base) in faces {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name(base));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
fonts.set(name.to_vec(), Object::Dict(font));
fonts.set(name.clone(), Object::Dict(font));
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
if let Some(o) = opacity {
@@ -645,7 +694,16 @@ 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 the face is added under: the usual name, unless the page already
// draws another font by it, which must not change under the header.
let preferred = match hf.font {
added::Family::Helvetica => "PCHelv",
added::Family::Times => "PCTimes",
added::Family::Courier => "PCCour",
};
let base = hf.font.base_font(false, false);
let resource = free_font_name(doc, &page_fonts(doc, page), preferred, base);
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 +713,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 +735,10 @@ 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)?;
// A name already there is that very font (see `free_font_name`): left as it is.
if !page_fonts(doc, page).contains(resource.as_bytes()) {
add_page_resources(doc, page, None, &[(resource.into_bytes(), base)])?;
}
let page = &page_list(doc)[i];
place(doc, page, MarkKind::HeaderFooter, content, false)?;
}
+93
View File
@@ -97,6 +97,99 @@ 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 a_header_never_takes_over_a_font_name_the_page_already_draws_with() {
// From review of #3756: a page that already draws a different font as /PCTimes keeps it; the
// header's Times goes under a free name, and an update reuses that name, not a third one.
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[0].obj;
let mut fonts = Dict::new();
let mut theirs = Dict::new();
theirs.set(b"Type".to_vec(), Object::name("Font"));
theirs.set(b"Subtype".to_vec(), Object::name("Type1"));
theirs.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold"));
fonts.set(b"PCTimes".to_vec(), Object::Dict(theirs));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap();
let hf = HeaderFooter {
text: ["Serif".into(), String::new(), String::new(), String::new(), String::new(), String::new()],
font: added::Family::Times,
..HeaderFooter::default()
};
let base_of = |doc: &Document, name: &[u8]| {
let fonts = page_fonts(doc, &pdfcraft_model::pages(doc)[0]);
fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().and_then(|d| d.name(b"BaseFont")).map(<[u8]>::to_vec))
};
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
let doc = reopen(&doc);
assert_eq!(base_of(&doc, b"PCTimes").as_deref(), Some(&b"Helvetica-Bold"[..]), "the page's own font is untouched");
assert_eq!(base_of(&doc, b"PCTimes_1").as_deref(), Some(&b"Times-Roman"[..]));
let mark = streams(&doc, 0).last().cloned().unwrap();
assert!(mark.contains("/PCTimes_1 8 Tf"), "{mark}");
// Updating the header finds its own name again.
let mut doc = doc;
add_header_footer(&mut doc, &[0], &hf, true, &cx()).unwrap();
let doc = reopen(&doc);
assert!(streams(&doc, 0).last().unwrap().contains("/PCTimes_1 8 Tf"));
assert_eq!(base_of(&doc, b"PCTimes_2"), None, "no new name on update");
assert_eq!(base_of(&doc, b"PCTimes").as_deref(), Some(&b"Helvetica-Bold"[..]));
// The same face with more to it (here a /ToUnicode map) is the page's own too: kept whole.
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[0].obj;
let map = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/CIDInit /ProcSet findresource begin")));
let mut theirs = Dict::new();
for (k, v) in [("Type", "Font"), ("Subtype", "Type1"), ("BaseFont", "Times-Roman"), ("Encoding", "WinAnsiEncoding")] {
theirs.set(k.as_bytes().to_vec(), Object::name(v));
}
theirs.set(b"ToUnicode".to_vec(), Object::Ref(map));
let mut fonts = Dict::new();
fonts.set(b"PCTimes".to_vec(), Object::Dict(theirs));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap();
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
let doc = reopen(&doc);
let fonts = page_fonts(&doc, &pdfcraft_model::pages(&doc)[0]);
let kept = doc.resolve(fonts.get(b"PCTimes").unwrap()).as_dict().cloned().unwrap();
assert!(kept.contains(b"ToUnicode"), "the page's map is kept");
assert!(streams(&doc, 0).last().unwrap().contains("/PCTimes_1 8 Tf"));
}
#[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"