mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 19:19:10 -06:00
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:
co-authored by
Claude Opus 5.5
parent
22bedbf2af
commit
fecfc774f2
@@ -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();
|
||||
|
||||
@@ -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)." },
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user