From fecfc774f256ede59f33f722b112b54f3f503ab9 Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Wed, 21 Oct 2026 00:48:48 +0200 Subject: [PATCH 1/3] 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 --- crates/automation/src/lib.rs | 8 ++++++ crates/automation/src/tools.rs | 3 ++- crates/automation/tests/automation.rs | 14 +++++++++++ crates/edit/src/lib.rs | 29 ++++++++++++++++++---- crates/edit/src/tests.rs | 34 ++++++++++++++++++++++++++ crates/ui-egui/src/marks_ui.rs | 15 +++++++++--- crates/ui-egui/tests/ui_group/marks.rs | 27 ++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 8 files changed, 122 insertions(+), 10 deletions(-) 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" From 4e8e17c7e63336d6d708c936a0f8c22dd76928cf Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Wed, 21 Oct 2026 01:13:00 +0200 Subject: [PATCH 2/3] fix(edit): a header never takes over a font name the page already draws with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review of #3756: add_resources set /PCTimes (or /PCCour, /PCHelv) in the page's fonts whatever was there, so a page that already drew another font by that name would have its own text change face. Headers and footers now pick the usual name when it is free or already names the same standard face, otherwise the first free name_1, name_2…, and add only that face, never replacing an entry. An update finds its own name again. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 48 ++++++++++++++++++++++++++++++++++------ crates/edit/src/tests.rs | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 90d8f24cd..a37ed4d1e 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -308,19 +308,51 @@ fn contents(doc: &mut Document, page: &pdfcraft_model::Page) -> Result, 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, &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 that face, otherwise the first such +/// `preferred_1`, `preferred_2`… (one of the first `fonts.len() + 1` always is). +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.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, faces: &[(Vec, &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 { @@ -656,12 +688,15 @@ 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; - // The page resource each face is added under (see `add_resources`). - let resource = match hf.font { + // 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 = 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() { @@ -694,8 +729,7 @@ 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()); - // The content names the face, so Times or Courier is added beside Helvetica. - add_resources(doc, page, None, Some(&content))?; + add_page_resources(doc, page, None, &[(resource.into_bytes(), base)])?; 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 7e16f22ce..a0f4272ec 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -131,6 +131,45 @@ fn header_and_footer_are_drawn_in_the_chosen_standard_face() { 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"[..])); +} + #[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 From 14b3978344007130d5395c74e0ce49425c2d9ffd Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Wed, 21 Oct 2026 07:56:53 +0200 Subject: [PATCH 3/3] fix(edit): a header reuses a font name only for its very own dictionary From review of #3756: a /PCTimes naming Times-Roman with more to it (a /ToUnicode map, /Widths) passed as the same face and was replaced, so the page's own text could lose its map. A name is reused only when it holds exactly the dictionary a header writes, and then it is left as it is; anything else gets a free suffixed name. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 17 +++++++++++++---- crates/edit/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index a37ed4d1e..7b6410619 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -326,13 +326,19 @@ fn page_fonts(doc: &Document, page: &pdfcraft_model::Page) -> Dict { } /// 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 that face, otherwise the first such -/// `preferred_1`, `preferred_2`… (one of the first `fonts.len() + 1` always is). +/// 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.name(b"Subtype") == Some(b"Type1") && d.name(b"BaseFont") == Some(base.as_bytes()) && d.name(b"Encoding") == Some(b"WinAnsiEncoding") + 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) { @@ -729,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_page_resources(doc, page, None, &[(resource.into_bytes(), base)])?; + // 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)?; } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a0f4272ec..bc6b84e19 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -168,6 +168,26 @@ fn a_header_never_takes_over_a_font_name_the_page_already_draws_with() { 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]