From 564a1b923ab65f313dba29a9dff661a6b60370d4 Mon Sep 17 00:00:00 2001 From: developer0hye Date: Thu, 6 Aug 2026 00:32:52 +0900 Subject: [PATCH] feat(xlsx): honour header/footer font and size codes &"Font,Style" and & select the face and size for the runs after them. parse_hf_format_string parsed both and discarded them -- the branches were "skip to closing quote" and "skip digits" -- so every header and footer printed in the fallback serif. Each section becomes a Vec rather than one String, so a code partway through a section applies from that point rather than to the whole slot. &"-,Bold" keeps the face; a style word replaces both flags, so Excel's Regular turns them off. bold/italic stay None when no style word appears, leaving the renderer's default. Measured on headerFooterTest.xlsx: embedded fonts go from LibertinusSerif-Regular + Carlito-Regular to Carlito alone, i.e. the header text stops taking the fallback serif. The & path is implemented and unit-tested but that fixture sets no size code, so the 11-vs-12pt difference #633 also reports is untouched: measured, it is Excel's default header size rather than a discarded code. Related: #633 Signed-off-by: developer0hye --- crates/office2pdf/src/parser/xlsx_hf.rs | 135 +++++++++++++++--- .../src/parser/xlsx_page_feature_tests.rs | 93 ++++++++++++ 2 files changed, 208 insertions(+), 20 deletions(-) diff --git a/crates/office2pdf/src/parser/xlsx_hf.rs b/crates/office2pdf/src/parser/xlsx_hf.rs index cf223218..d8e97b7f 100644 --- a/crates/office2pdf/src/parser/xlsx_hf.rs +++ b/crates/office2pdf/src/parser/xlsx_hf.rs @@ -3,6 +3,63 @@ use crate::ir::{ Alignment, HFInline, HeaderFooter, HeaderFooterParagraph, ParagraphStyle, Run, TextStyle, }; +/// One run of header/footer text and the face it takes. +/// +/// A section's `&"Font,Style"` and `&` codes apply to everything after them, +/// so a section is a sequence of these rather than one string (issue #633). +#[derive(Clone, Default)] +pub(super) struct HfSegment { + text: String, + family: Option, + size_pt: Option, + bold: bool, + italic: bool, +} + +impl HfSegment { + /// The style this segment's runs carry. `bold`/`italic` stay `None` when + /// unset so the renderer's own default survives, rather than being pinned + /// to `false` by a section that named no style word. + fn text_style(&self) -> TextStyle { + TextStyle { + font_family: self.family.clone(), + east_asian_font_family: self.family.clone(), + font_size: self.size_pt, + bold: self.bold.then_some(true), + italic: self.italic.then_some(true), + ..TextStyle::default() + } + } +} + +/// Append a string to the section's open segment. +fn push_str(section: &mut [HfSegment], text: &str) { + if let Some(last) = section.last_mut() { + last.text.push_str(text); + } +} + +/// Append a character to the section's open segment. +fn push_char(section: &mut [HfSegment], ch: char) { + if let Some(last) = section.last_mut() { + last.text.push(ch); + } +} + +/// Start a new segment carrying `style`, reusing the open one while it is still +/// empty so a run of codes does not leave blanks behind. +/// +/// `style` is cloned from the previous segment to inherit the face and size a +/// code did not change, so its text has to be dropped or the new segment +/// repeats what came before it. +fn open_segment(section: &mut Vec, mut style: HfSegment) { + style.text.clear(); + match section.last_mut() { + Some(last) if last.text.is_empty() => *last = style, + _ => section.push(style), + } +} + /// Parse an Excel header/footer format string into IR HeaderFooter. /// /// Excel format strings use `&L`, `&C`, `&R` to define left/center/right sections, @@ -29,10 +86,13 @@ pub(super) fn parse_hf_format_string( return None; } - // Split into left/center/right sections - let mut left = String::new(); - let mut center = String::new(); - let mut right = String::new(); + // Split into left/center/right sections. Each section is a run of + // segments rather than one string, because `&"Font,Style"` and `&` + // change the face and size partway through a section and every run after + // the code takes the new values (issue #633). + let mut left: Vec = vec![HfSegment::default()]; + let mut center: Vec = vec![HfSegment::default()]; + let mut right: Vec = vec![HfSegment::default()]; let mut current = &mut center; // Default section is center if no &L/&C/&R prefix let chars: Vec = s.chars().collect(); @@ -53,34 +113,62 @@ pub(super) fn parse_hf_format_string( i += 2; } 'P' => { - current.push('\x01'); // Sentinel for page number + push_char(current, '\x01'); // Sentinel for page number i += 2; } 'N' => { - current.push('\x02'); // Sentinel for total pages + push_char(current, '\x02'); // Sentinel for total pages i += 2; } '&' => { // Escaped ampersand: && → & - current.push('&'); + push_char(current, '&'); i += 2; } '"' => { - // Font name: &"FontName" — skip to closing quote + // `&"Calibri,Bold"` — the face, and optionally a style + // word, for every run after it. `-` means "keep the + // current face", which Excel writes as `&"-,Bold"`. i += 2; // skip &" + let start = i; while i < chars.len() && chars[i] != '"' { i += 1; } + let spec: String = chars[start..i].iter().collect(); if i < chars.len() { i += 1; // skip closing " } + let mut parts = spec.splitn(2, ','); + let family = parts.next().unwrap_or("").trim(); + let style = parts.next().unwrap_or("").trim(); + let mut next = current.last().cloned().unwrap_or_default(); + if !family.is_empty() && family != "-" { + next.family = Some(family.to_string()); + } + // A style word replaces both flags: Excel writes + // "Regular" to turn them off again. + if !style.is_empty() { + let lower = style.to_ascii_lowercase(); + next.bold = lower.contains("bold"); + next.italic = lower.contains("italic"); + } + open_segment(current, next); } c if c.is_ascii_digit() => { - // Font size: &NN — skip digits + // `&12` — the point size for every run after it. i += 1; // skip & + let start = i; while i < chars.len() && chars[i].is_ascii_digit() { i += 1; } + let digits: String = chars[start..i].iter().collect(); + let mut next = current.last().cloned().unwrap_or_default(); + if let Ok(size) = digits.parse::() + && size > 0.0 + { + next.size_pt = Some(size); + } + open_segment(current, next); } 'K' => { // Font color: &KRRGGBB (or &KTTSNN theme form) — skip the @@ -98,7 +186,7 @@ pub(super) fn parse_hf_format_string( // `&C&A`, so this turns up in files nobody customised; it // used to fall through the catch-all and, being the whole // section, took the paragraph with it (issue #690). - current.push_str(sheet_name); + push_str(current, sheet_name); i += 2; } 'F' | 'Z' | 'D' | 'T' | 'G' => { @@ -134,7 +222,7 @@ pub(super) fn parse_hf_format_string( } } } else { - current.push(chars[i]); + push_char(current, chars[i]); i += 1; } } @@ -149,7 +237,7 @@ pub(super) fn parse_hf_format_string( ]; for (text, alignment) in §ions { - if text.is_empty() { + if text.iter().all(|segment| segment.text.is_empty()) { continue; } let elements = build_hf_elements(text); @@ -178,8 +266,17 @@ pub(super) fn parse_hf_format_string( } /// Build HFInline elements from a section string, replacing sentinel chars. -pub(super) fn build_hf_elements(section: &str) -> Vec { +pub(super) fn build_hf_elements(section: &[HfSegment]) -> Vec { let mut elements = Vec::new(); + for segment in section { + let style: TextStyle = segment.text_style(); + build_segment_elements(&mut elements, &segment.text, &style); + } + elements +} + +/// Turn one segment's text into runs, expanding the page-number sentinels. +fn build_segment_elements(elements: &mut Vec, section: &str, style: &TextStyle) { let mut current_text = String::new(); for ch in section.chars() { @@ -189,24 +286,24 @@ pub(super) fn build_hf_elements(section: &str) -> Vec { if !current_text.is_empty() { elements.push(HFInline::Run(Run { text: std::mem::take(&mut current_text), - style: TextStyle::default(), + style: style.clone(), href: None, footnote: None, })); } - elements.push(HFInline::PageNumber(TextStyle::default())); + elements.push(HFInline::PageNumber(style.clone())); } '\x02' => { // Total pages sentinel if !current_text.is_empty() { elements.push(HFInline::Run(Run { text: std::mem::take(&mut current_text), - style: TextStyle::default(), + style: style.clone(), href: None, footnote: None, })); } - elements.push(HFInline::TotalPages(TextStyle::default())); + elements.push(HFInline::TotalPages(style.clone())); } _ => { current_text.push(ch); @@ -217,11 +314,9 @@ pub(super) fn build_hf_elements(section: &str) -> Vec { if !current_text.is_empty() { elements.push(HFInline::Run(Run { text: current_text, - style: TextStyle::default(), + style: style.clone(), href: None, footnote: None, })); } - - elements } diff --git a/crates/office2pdf/src/parser/xlsx_page_feature_tests.rs b/crates/office2pdf/src/parser/xlsx_page_feature_tests.rs index ea54e245..13e60b9c 100644 --- a/crates/office2pdf/src/parser/xlsx_page_feature_tests.rs +++ b/crates/office2pdf/src/parser/xlsx_page_feature_tests.rs @@ -595,3 +595,96 @@ fn test_hf_font_color_code_is_stripped() { .collect(); assert_eq!(texts, vec!["top left", "top center", "top right"]); } + +// --- Header/footer font and size codes (issue #633) --- + +/// The style of the first run of the first section. +fn hf_first_style(hf: &HeaderFooter) -> TextStyle { + hf.paragraphs + .iter() + .flat_map(|p| p.elements.iter()) + .find_map(|element| match element { + HFInline::Run(run) => Some(run.style.clone()), + _ => None, + }) + .expect("a run in the header") +} + +/// `&"Font,Style"` selects the face for the runs after it, and `&` the size. +/// Both used to be parsed and discarded, so every header printed in the +/// fallback face at the document default size. +#[test] +fn test_hf_font_and_size_codes_reach_the_runs() { + let hf = parse_hf("&L&\"Calibri,Bold\"&12left").expect("header parsed"); + let style = hf_first_style(&hf); + + assert_eq!(style.font_family.as_deref(), Some("Calibri")); + assert_eq!(style.east_asian_font_family.as_deref(), Some("Calibri")); + assert_eq!(style.font_size, Some(12.0)); + assert_eq!(style.bold, Some(true)); + assert_eq!( + style.italic, None, + "no italic word, so the default survives" + ); +} + +/// A section with no codes keeps every field unset, so the renderer's own +/// defaults apply rather than being pinned by the parser. +#[test] +fn test_hf_without_codes_carries_no_style() { + let style = hf_first_style(&parse_hf("&Lplain").expect("header parsed")); + + assert_eq!(style.font_family, None); + assert_eq!(style.font_size, None); + assert_eq!(style.bold, None); + assert_eq!(style.italic, None); +} + +/// Excel writes `&"-,Bold"` to change the style while keeping the face, and +/// `Regular` to turn the flags back off. +#[test] +fn test_hf_font_code_dash_keeps_the_face_and_regular_clears_the_style() { + let hf = parse_hf("&L&\"Calibri,Bold\"bold&\"-,Regular\"plain").expect("header parsed"); + let styles: Vec = hf + .paragraphs + .iter() + .flat_map(|p| p.elements.iter()) + .filter_map(|element| match element { + HFInline::Run(run) => Some(run.style.clone()), + _ => None, + }) + .collect(); + + assert_eq!(styles.len(), 2, "one run per style change, got {styles:?}"); + assert_eq!(styles[0].bold, Some(true)); + assert_eq!(styles[1].bold, None, "Regular turns bold back off"); + assert_eq!( + styles[1].font_family.as_deref(), + Some("Calibri"), + "`-` keeps the face the previous code set" + ); +} + +/// A code partway through a section applies only from that point on, so the +/// text before it keeps the earlier face. +#[test] +fn test_hf_code_midway_splits_the_section() { + let hf = parse_hf("&Lplain&\"Calibri,Regular\"styled").expect("header parsed"); + let texts: Vec<(String, Option)> = hf + .paragraphs + .iter() + .flat_map(|p| p.elements.iter()) + .filter_map(|element| match element { + HFInline::Run(run) => Some((run.text.clone(), run.style.font_family.clone())), + _ => None, + }) + .collect(); + + assert_eq!( + texts, + vec![ + ("plain".to_string(), None), + ("styled".to_string(), Some("Calibri".to_string())), + ] + ); +}