Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/bugfixes/issue-461/after.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/bugfixes/issue-461/before.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/bugfixes/issue-461/gt.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions crates/office2pdf/src/parser/drawingml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,72 @@ pub(crate) fn parse_theme_color_scheme(xml: &str) -> HashMap<String, Color> {
colors
}

/// The Latin typefaces a theme part's `<a:fontScheme>` declares. A slot the
/// theme leaves empty stays `None`, because DrawingML spells "inherit" as an
/// empty `typeface` and an empty family would select nothing.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct ThemeFontScheme {
pub(crate) major_latin: Option<String>,
pub(crate) minor_latin: Option<String>,
}

impl ThemeFontScheme {
/// Resolve a DrawingML `typeface` attribute. The placeholders `+mj-lt`
/// and `+mn-lt` name this scheme's major and minor Latin fonts; any
/// other value is a literal family name.
pub(crate) fn resolve_typeface(&self, typeface: &str) -> Option<String> {
match typeface {
"" => None,
"+mj-lt" => self.major_latin.clone(),
"+mn-lt" => self.minor_latin.clone(),
literal => Some(literal.to_string()),
}
}
}

/// Parse just the `<a:fontScheme>` Latin typefaces out of a theme part
/// (`theme1.xml`).
///
/// The pptx parser keeps its own combined single-pass reader because it
/// also collects colors and fill styles from the same document.
pub(crate) fn parse_theme_font_scheme(xml: &str) -> ThemeFontScheme {
let mut fonts = ThemeFontScheme::default();
let mut reader = Reader::from_str(xml);
let mut in_major: bool = false;
let mut in_minor: bool = false;

loop {
match reader.read_event() {
Ok(Event::Start(ref e)) => match e.local_name().as_ref() {
b"majorFont" => in_major = true,
b"minorFont" => in_minor = true,
_ => {}
},
Ok(Event::End(ref e)) => match e.local_name().as_ref() {
b"majorFont" => in_major = false,
b"minorFont" => in_minor = false,
_ => {}
},
Ok(Event::Empty(ref e)) if e.local_name().as_ref() == b"latin" => {
let typeface: Option<String> =
get_attr_str(e, b"typeface").filter(|typeface| !typeface.is_empty());
// Only a slot's first `<a:latin>` is its Latin face; the
// script-specific `<a:font>` entries that follow are not.
if in_major {
fonts.major_latin = fonts.major_latin.take().or(typeface);
} else if in_minor {
fonts.minor_latin = fonts.minor_latin.take().or(typeface);
}
}
Ok(Event::Eof) => break,
Err(_) => break,
_ => {}
}
}

fonts
}

#[cfg(test)]
#[path = "drawingml_tests.rs"]
mod tests;
43 changes: 33 additions & 10 deletions crates/office2pdf/src/parser/xlsx_drawing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::io::Cursor;

use crate::ir::Chart;
use crate::parser::chart::parse_chart_xml;
use crate::parser::drawingml::ThemeFontScheme;
use crate::parser::xml_util;

/// Extract charts from the XLSX ZIP with their anchor positions per sheet.
Expand Down Expand Up @@ -671,7 +672,7 @@ pub(super) fn extract_text_boxes_with_anchors(
let sheet_rids = parse_workbook_sheet_rids(&workbook_xml);
let workbook_rels_xml = read_zip_entry_string(&mut archive, "xl/_rels/workbook.xml.rels");
let rid_to_target = parse_rels_targets(&workbook_rels_xml);
let theme_colors = workbook_theme_colors(&mut archive, &workbook_rels_xml);
let (theme_colors, theme_fonts) = workbook_theme(&mut archive, &workbook_rels_xml);

let mut result: HashMap<String, Vec<RawTextBoxAnchor>> = HashMap::new();

Expand All @@ -693,7 +694,7 @@ pub(super) fn extract_text_boxes_with_anchors(
if drawing_xml.is_empty() {
continue;
}
let boxes = parse_drawing_text_boxes(&drawing_xml, &theme_colors);
let boxes = parse_drawing_text_boxes(&drawing_xml, &theme_colors, &theme_fonts);
if !boxes.is_empty() {
result.entry(sheet_name.clone()).or_default().extend(boxes);
}
Expand Down Expand Up @@ -746,10 +747,12 @@ fn resolved_or_legacy(
}

/// Parse `<xdr:sp>` text boxes from a worksheet drawing, resolving scheme
/// colors against the workbook theme palette.
/// colors against the workbook theme palette and run typefaces against its
/// font scheme.
pub(super) fn parse_drawing_text_boxes(
xml: &str,
theme_colors: &HashMap<String, crate::ir::Color>,
theme_fonts: &ThemeFontScheme,
) -> Vec<RawTextBoxAnchor> {
use crate::ir::{
Alignment, BorderLineStyle, BorderSide, Paragraph, ParagraphStyle, Run, TextStyle,
Expand Down Expand Up @@ -895,6 +898,13 @@ pub(super) fn parse_drawing_text_boxes(
}
}
}
b"latin" if in_run => {
if let Some(typeface) = xml_util::get_attr_str(e, b"typeface")
&& let Some(family) = theme_fonts.resolve_typeface(&typeface)
{
current_style.font_family = Some(family);
}
}
b"pPr" if current_para.is_some() => {
for attr in e.attributes().flatten() {
if attr.key.local_name().as_ref() == b"algn"
Expand Down Expand Up @@ -948,9 +958,17 @@ pub(super) fn parse_drawing_text_boxes(
Ok(quick_xml::events::Event::Text(ref t)) => {
if in_text && let Ok(text) = t.xml_content() {
if let Some(para) = current_para.as_mut() {
let mut style = current_style.clone();
// An `<a:rPr>` without `<a:latin>` - how Excel writes
// plain shape labels - resolves to the theme's minor
// Latin font in DrawingML, not to a renderer default
// (issue #461).
if style.font_family.is_none() {
style.font_family = theme_fonts.minor_latin.clone();
}
para.runs.push(Run {
text: text.to_string(),
style: current_style.clone(),
style,
href: None,
footnote: None,
});
Expand Down Expand Up @@ -1025,19 +1043,24 @@ pub(super) fn parse_drawing_text_boxes(
result
}

/// Read the workbook theme palette (`xl/theme/theme1.xml` or the
/// rels-declared target). Missing or unreadable themes yield an empty map,
/// which downgrades scheme resolution to the legacy light/dark fallback.
fn workbook_theme_colors(
/// Read the workbook theme's color palette and Latin font scheme
/// (`xl/theme/theme1.xml`, or the rels-declared target). A missing or
/// unreadable theme yields an empty palette, which downgrades scheme
/// resolution to the legacy light/dark fallback, and an empty font scheme,
/// which leaves font selection to the renderer.
fn workbook_theme(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
workbook_rels_xml: &str,
) -> HashMap<String, crate::ir::Color> {
) -> (HashMap<String, crate::ir::Color>, ThemeFontScheme) {
let theme_path = parse_rels_by_type(workbook_rels_xml, "theme")
.first()
.map(|target| resolve_relative_xl_path("xl", target))
.unwrap_or_else(|| "xl/theme/theme1.xml".to_string());
let theme_xml = read_zip_entry_string(archive, &theme_path);
crate::parser::drawingml::parse_theme_color_scheme(&theme_xml)
(
crate::parser::drawingml::parse_theme_color_scheme(&theme_xml),
crate::parser::drawingml::parse_theme_font_scheme(&theme_xml),
)
}

#[cfg(test)]
Expand Down
132 changes: 131 additions & 1 deletion crates/office2pdf/src/parser/xlsx_drawing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::collections::HashMap;

use super::*;
use crate::ir::Color;
use crate::parser::drawingml::ThemeFontScheme;

fn accent_theme() -> HashMap<String, Color> {
// Office default theme slots a workbook actually ships in theme1.xml.
Expand Down Expand Up @@ -36,7 +37,11 @@ fn drawing_with_fill(color_markup: &str) -> String {
}

fn fill_of(color_markup: &str, theme: &HashMap<String, Color>) -> Option<Color> {
let boxes = parse_drawing_text_boxes(&drawing_with_fill(color_markup), theme);
let boxes = parse_drawing_text_boxes(
&drawing_with_fill(color_markup),
theme,
&ThemeFontScheme::default(),
);
assert_eq!(boxes.len(), 1, "fixture should yield one text box");
boxes[0].fill
}
Expand Down Expand Up @@ -131,3 +136,128 @@ fn theme_color_scheme_parses_from_theme_xml() {
assert_eq!(colors.get("hlink"), Some(&Color::new(0x05, 0x63, 0xC1)));
assert_eq!(colors.get("accent2"), None);
}

fn office_theme_fonts() -> ThemeFontScheme {
// The font scheme an Excel-saved workbook ships in theme1.xml.
ThemeFontScheme {
major_latin: Some("Calibri Light".to_string()),
minor_latin: Some("Calibri".to_string()),
}
}

fn drawing_with_run_properties(run_properties: &str) -> String {
format!(
r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<xdr:twoCellAnchor>
<xdr:from><xdr:col>1</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>1</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>
<xdr:to><xdr:col>4</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>4</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>
<xdr:sp>
<xdr:txBody>
<a:bodyPr/>
<a:p><a:r>{run_properties}<a:t>accent1</a:t></a:r></a:p>
</xdr:txBody>
</xdr:sp>
<xdr:clientData/>
</xdr:twoCellAnchor>
</xdr:wsDr>"#
)
}

fn font_of(run_properties: &str, fonts: &ThemeFontScheme) -> Option<String> {
let boxes = parse_drawing_text_boxes(
&drawing_with_run_properties(run_properties),
&HashMap::new(),
fonts,
);
assert_eq!(boxes.len(), 1, "fixture should yield one text box");
boxes[0].paragraphs[0].runs[0].style.font_family.clone()
}

#[test]
fn drawing_run_without_a_typeface_inherits_the_theme_minor_font() {
// Excel writes shape labels as `<a:rPr lang="en-US" sz="1100"/>` with no
// typeface at all; DrawingML resolves that to the theme's minor Latin
// font, not to the renderer's serif default (issue #461).
assert_eq!(
font_of(r#"<a:rPr lang="en-US" sz="1100"/>"#, &office_theme_fonts()),
Some("Calibri".to_string())
);
}

#[test]
fn drawing_run_without_run_properties_inherits_the_theme_minor_font() {
assert_eq!(
font_of("", &office_theme_fonts()),
Some("Calibri".to_string())
);
}

#[test]
fn drawing_run_uses_its_explicit_latin_typeface() {
assert_eq!(
font_of(
r#"<a:rPr lang="en-US" sz="1100"><a:latin typeface="Georgia"/></a:rPr>"#,
&office_theme_fonts()
),
Some("Georgia".to_string())
);
}

#[test]
fn drawing_run_resolves_theme_typeface_placeholders() {
// `+mj-lt` and `+mn-lt` name the theme's major and minor Latin fonts.
assert_eq!(
font_of(
r#"<a:rPr lang="en-US"><a:latin typeface="+mj-lt"/></a:rPr>"#,
&office_theme_fonts()
),
Some("Calibri Light".to_string())
);
assert_eq!(
font_of(
r#"<a:rPr lang="en-US"><a:latin typeface="+mn-lt"/></a:rPr>"#,
&office_theme_fonts()
),
Some("Calibri".to_string())
);
}

#[test]
fn drawing_run_font_stays_unset_without_a_theme_font_scheme() {
// A workbook with no readable theme part leaves font selection to the
// renderer's existing fallback rather than inventing a family.
assert_eq!(
font_of(r#"<a:rPr lang="en-US"/>"#, &ThemeFontScheme::default()),
None
);
}

#[test]
fn theme_font_scheme_parses_from_theme_xml() {
let theme_xml = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:themeElements>
<a:fontScheme name="Office">
<a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/></a:majorFont>
<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/></a:minorFont>
</a:fontScheme>
</a:themeElements>
</a:theme>"#;
let fonts = crate::parser::drawingml::parse_theme_font_scheme(theme_xml);
assert_eq!(fonts.major_latin.as_deref(), Some("Calibri Light"));
assert_eq!(fonts.minor_latin.as_deref(), Some("Calibri"));
}

#[test]
fn theme_font_scheme_ignores_empty_typefaces() {
// Themes spell "inherit" as an empty typeface; it must not become a font.
let theme_xml = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:themeElements>
<a:fontScheme name="Office">
<a:majorFont><a:latin typeface=""/></a:majorFont>
<a:minorFont><a:latin typeface=""/></a:minorFont>
</a:fontScheme>
</a:themeElements>
</a:theme>"#;
let fonts = crate::parser::drawingml::parse_theme_font_scheme(theme_xml);
assert_eq!(fonts, ThemeFontScheme::default());
}
17 changes: 17 additions & 0 deletions crates/office2pdf/src/render/font_subst.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,11 @@ fn collect_document_font_families(doc: &Document) -> BTreeSet<String> {
collect_header_footer_fonts(footer, &mut fonts);
}
collect_table_fonts(&page.table, &mut fonts);
for text_box in &page.text_boxes {
for paragraph in &text_box.paragraphs {
collect_block_fonts(&Block::Paragraph(paragraph.clone()), &mut fonts);
}
}
}
}
}
Expand Down Expand Up @@ -553,10 +558,22 @@ pub(crate) fn document_requests_font_families(doc: &Document) -> bool {
.as_ref()
.is_some_and(header_footer_requests_font_family)
|| table_requests_font_family(&page.table)
// Worksheet drawings carry their own runs; a workbook whose
// only font request comes from a shape label still needs the
// font search context, or the compiler never sees the
// directories that hold the requested face (issue #461).
|| page.text_boxes.iter().any(sheet_text_box_requests_font_family)
}
})
}

fn sheet_text_box_requests_font_family(text_box: &crate::ir::SheetTextBox) -> bool {
text_box
.paragraphs
.iter()
.any(|paragraph| block_requests_font_family(&Block::Paragraph(paragraph.clone())))
}

fn resolve_available_fallback(font_family: &str, context: &FontSearchContext) -> Option<String> {
if context.has_family(font_family) {
return None;
Expand Down
Loading