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-287/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-287/before.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 12 additions & 5 deletions crates/office2pdf/src/parser/docx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ use self::styles::{
};
use self::tables::convert_table;
use self::text::{
extract_doc_default_text_style, extract_paragraph_style, extract_run_style,
extract_run_style_id, extract_run_text, extract_run_text_skip_layout_breaks,
extract_tab_stop_overrides, is_column_break, is_page_break, parse_hex_color,
resolve_hyperlink_url,
ThemeFonts, extract_doc_default_text_style_with_theme, extract_paragraph_style,
extract_run_style, extract_run_style_id, extract_run_text, extract_run_text_skip_layout_breaks,
extract_tab_stop_overrides, is_column_break, is_page_break, parse_hex_color, parse_theme_fonts,
resolve_hyperlink_url, resolve_theme_font_family,
};
#[cfg(test)]
use self::text::{extract_tab_stops, resolve_highlight_color};
Expand Down Expand Up @@ -179,6 +179,7 @@ struct ZipPreParseAssets {
column_layouts: Vec<Option<ColumnLayout>>,
header_footer_assets: HeaderFooterAssets,
metafile_images: ImageMap,
theme_fonts: ThemeFonts,
}

/// Build all pre-parse contexts from the DOCX ZIP in a single pass.
Expand Down Expand Up @@ -229,6 +230,10 @@ fn build_zip_preparse_assets(data: &[u8]) -> ZipPreParseAssets {
column_layouts,
header_footer_assets,
metafile_images,
theme_fonts: theme_xml
.as_deref()
.map(parse_theme_fonts)
.unwrap_or_default(),
}
}
Err(_) => ZipPreParseAssets {
Expand All @@ -249,6 +254,7 @@ fn build_zip_preparse_assets(data: &[u8]) -> ZipPreParseAssets {
column_layouts: Vec::new(),
header_footer_assets: HeaderFooterAssets::default(),
metafile_images: ImageMap::new(),
theme_fonts: ThemeFonts::default(),
},
}
}
Expand All @@ -267,6 +273,7 @@ impl Parser for DocxParser {
column_layouts,
header_footer_assets,
metafile_images,
theme_fonts,
} = build_zip_preparse_assets(data);

let docx = docx_rs::read_docx(data).map_err(|e| {
Expand All @@ -280,7 +287,7 @@ impl Parser for DocxParser {
images.extend(metafile_images);
let hyperlinks = build_hyperlink_map(&docx);
let numberings = build_numbering_map(&docx.numberings);
let style_map = build_style_map(&docx.styles);
let style_map = build_style_map(&docx.styles, &theme_fonts);
let mut warnings: Vec<ConvertWarning> = Vec::new();

let mut elements: Vec<TaggedElement> = Vec::new();
Expand Down
31 changes: 31 additions & 0 deletions crates/office2pdf/src/parser/docx_style_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,34 @@ fn test_default_paragraph_style_applies_without_pstyle() {
);
assert_eq!(paragraph.runs[0].style.font_size, Some(12.0));
}

#[test]
fn test_doc_default_theme_font_resolves_via_theme() {
// docDefaults referencing asciiTheme="minorHAnsi" must resolve to the
// theme's minor latin typeface instead of falling back to the renderer
// default (issue #287). docx-rs's builder can't author theme slots, so
// exercise the resolver directly.
let theme_xml = r#"<?xml version="1.0"?>
<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:majorFont>
<a:minorFont><a:latin typeface="Calibri"/></a:minorFont>
</a:fontScheme></a:themeElements>
</a:theme>"#;
let theme = parse_theme_fonts(theme_xml);
assert_eq!(theme.minor_latin.as_deref(), Some("Calibri"));
assert_eq!(theme.major_latin.as_deref(), Some("Calibri Light"));

let run_property = serde_json::json!({ "fonts": { "asciiTheme": "minorHAnsi" } });
assert_eq!(
resolve_theme_font_family(&run_property, &theme).as_deref(),
Some("Calibri")
);
let heading_property = serde_json::json!({ "fonts": { "asciiTheme": "majorHAnsi" } });
assert_eq!(
resolve_theme_font_family(&heading_property, &theme).as_deref(),
Some("Calibri Light")
);
let no_theme = serde_json::json!({ "fonts": { "ascii": "Arial" } });
assert_eq!(resolve_theme_font_family(&no_theme, &theme), None);
}
20 changes: 12 additions & 8 deletions crates/office2pdf/src/parser/docx_styles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use std::collections::HashMap;
use crate::ir::{ParagraphStyle, TabStop, TextStyle};

use super::{
extract_doc_default_text_style, extract_paragraph_style, extract_run_style,
extract_tab_stop_overrides,
ThemeFonts, extract_doc_default_text_style_with_theme, extract_paragraph_style,
extract_run_style, extract_tab_stop_overrides, resolve_theme_font_family,
};

/// Resolved style formatting extracted from a document style definition.
Expand Down Expand Up @@ -33,9 +33,9 @@ use crate::defaults::HEADING_FONT_SIZES;

/// Build a map from style ID → resolved formatting by extracting formatting
/// from each style's run_property and paragraph_property.
pub(super) fn build_style_map(styles: &docx_rs::Styles) -> StyleMap {
pub(super) fn build_style_map(styles: &docx_rs::Styles, theme_fonts: &ThemeFonts) -> StyleMap {
let mut map = StyleMap::new();
let default_text: TextStyle = extract_doc_default_text_style(styles);
let default_text: TextStyle = extract_doc_default_text_style_with_theme(styles, theme_fonts);

map.insert(
DOC_DEFAULT_STYLE_ID.to_string(),
Expand All @@ -50,10 +50,14 @@ pub(super) fn build_style_map(styles: &docx_rs::Styles) -> StyleMap {
for style in &styles.styles {
match style.style_type {
docx_rs::StyleType::Paragraph => {
let text = merge_text_style(
&extract_run_style(&style.run_property),
map.get(DOC_DEFAULT_STYLE_ID),
);
let mut own_text = extract_run_style(&style.run_property);
if own_text.font_family.is_none()
&& let Ok(run_property_json) = serde_json::to_value(&style.run_property)
{
own_text.font_family =
resolve_theme_font_family(&run_property_json, theme_fonts);
}
let text = merge_text_style(&own_text, map.get(DOC_DEFAULT_STYLE_ID));
let paragraph = extract_paragraph_style(&style.paragraph_property);
let paragraph_tab_overrides =
extract_tab_stop_overrides(&style.paragraph_property.tabs);
Expand Down
86 changes: 84 additions & 2 deletions crates/office2pdf/src/parser/docx_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,10 @@ fn json_bool_or_val(value: &serde_json::Value) -> Option<bool> {
.or_else(|| value.get("val").and_then(serde_json::Value::as_bool))
}

pub(super) fn extract_doc_default_text_style(styles: &docx_rs::Styles) -> TextStyle {
pub(super) fn extract_doc_default_text_style_with_theme(
styles: &docx_rs::Styles,
theme_fonts: &ThemeFonts,
) -> TextStyle {
let Ok(json) = serde_json::to_value(&styles.doc_defaults) else {
return TextStyle::default();
};
Expand All @@ -195,7 +198,86 @@ pub(super) fn extract_doc_default_text_style(styles: &docx_rs::Styles) -> TextSt
return TextStyle::default();
};

extract_run_style_from_json(run_property)
let mut style = extract_run_style_from_json(run_property);
if style.font_family.is_none() {
style.font_family = resolve_theme_font_family(run_property, theme_fonts);
}
style
}

/// Latin typefaces of the document theme's minor (body) and major (heading)
/// font schemes, from `word/theme/theme1.xml`.
#[derive(Debug, Clone, Default)]
pub(super) struct ThemeFonts {
pub(super) minor_latin: Option<String>,
pub(super) major_latin: Option<String>,
}

/// Parse the theme's font scheme latin typefaces.
pub(super) fn parse_theme_fonts(theme_xml: &str) -> ThemeFonts {
use quick_xml::Reader;
use quick_xml::events::Event;

let mut fonts = ThemeFonts::default();
let mut reader = Reader::from_str(theme_xml);
let mut in_minor = false;
let mut in_major = false;

loop {
match reader.read_event() {
Ok(Event::Start(ref e)) => match e.local_name().as_ref() {
b"minorFont" => in_minor = true,
b"majorFont" => in_major = true,
_ => {}
},
Ok(Event::Empty(ref e)) if e.local_name().as_ref() == b"latin" => {
let typeface: Option<String> = e.attributes().flatten().find_map(|attr| {
(attr.key.local_name().as_ref() == b"typeface")
.then(|| attr.unescape_value().ok())
.flatten()
.map(|v| v.to_string())
.filter(|v| !v.is_empty())
});
if in_minor {
fonts.minor_latin = typeface;
} else if in_major {
fonts.major_latin = typeface;
}
}
Ok(Event::End(ref e)) => match e.local_name().as_ref() {
b"minorFont" => in_minor = false,
b"majorFont" => in_major = false,
_ => {}
},
Ok(Event::Eof) => break,
Err(_) => break,
_ => {}
}
}

fonts
}

/// Resolve rFonts theme slots (asciiTheme="minorHAnsi" etc.) against the
/// document theme when no literal font family is given.
pub(super) fn resolve_theme_font_family(
run_property_json: &serde_json::Value,
theme_fonts: &ThemeFonts,
) -> Option<String> {
let fonts = run_property_json.get("fonts")?;
let slot: &str = fonts
.get("asciiTheme")
.or_else(|| fonts.get("hiAnsiTheme"))
.or_else(|| fonts.get("eastAsiaTheme"))
.or_else(|| fonts.get("csTheme"))
.and_then(serde_json::Value::as_str)?;
if slot.starts_with("minor") {
theme_fonts.minor_latin.clone()
} else if slot.starts_with("major") {
theme_fonts.major_latin.clone()
} else {
None
}
}

pub(super) fn resolve_highlight_color(name: &str) -> Option<Color> {
Expand Down
Loading