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-318/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-318/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-318/gt.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions crates/office2pdf/src/parser/pptx_shape_style_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,3 +613,44 @@ fn test_split_textbox_preserves_alignment() {
typst_output.source,
);
}

#[test]
fn test_shape_style_lnref_outline_resolves_width_and_shaded_color() {
// A shape whose outline comes only from <p:style><a:lnRef idx=..> with a
// shaded scheme color (a Start-event color) must render a stroke: width
// from the theme lnStyleLst, color from the resolved scheme (issue #318).
let theme_xml = r#"<?xml version="1.0"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:themeElements>
<a:clrScheme name="X">
<a:dk1><a:srgbClr val="000000"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1>
<a:accent1><a:srgbClr val="4472C4"/></a:accent1>
</a:clrScheme>
<a:fontScheme name="X"><a:majorFont><a:latin typeface="Calibri"/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/></a:minorFont></a:fontScheme>
<a:fmtScheme name="X"><a:lnStyleLst>
<a:ln w="6350"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>
<a:ln w="12700"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>
<a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>
</a:lnStyleLst></a:fmtScheme>
</a:themeElements>
</a:theme>"#;
let shape = r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="Shape"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="4472C4"/></a:solidFill></p:spPr><p:style><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="50000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef></p:style></p:sp>"#.to_string();
let slide = make_slide_xml(&[shape]);
let data = build_test_pptx_with_theme(SLIDE_CX, SLIDE_CY, &[slide], theme_xml);

let parser = PptxParser;
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();
let page = first_fixed_page(&doc);
let FixedElementKind::Shape(ref s) = page.elements[0].kind else {
panic!("expected shape");
};
let stroke = s.stroke.as_ref().expect("lnRef must produce a stroke");
// idx=2 → theme lnStyleLst[1] = 12700 EMU = 1pt.
assert!(
(stroke.width - 1.0).abs() < 0.01,
"outline width from theme lnStyleLst idx 2, got {}",
stroke.width
);
// accent1 (4472C4) shaded 50% ≈ half each channel.
assert_eq!(stroke.color, Color::new(0x22, 0x39, 0x62));
}
41 changes: 39 additions & 2 deletions crates/office2pdf/src/parser/pptx_slides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,9 @@ struct ShapeState {
adj_values: Vec<f64>,
/// Fallback line color from `<p:style><a:lnRef>` scheme reference.
style_ln_color: Option<Color>,
/// `<a:lnRef idx>` (1-based) into the theme line style list, for the
/// fallback outline width when no explicit `<a:ln w>` is present.
style_ln_idx: Option<usize>,
/// Fallback fill color from `<p:style><a:fillRef>` scheme reference.
style_fill_color: Option<Color>,
/// Fallback text color from `<p:style><a:fontRef>` scheme reference.
Expand Down Expand Up @@ -595,6 +598,7 @@ impl Default for ShapeState {
tail_end: ArrowHead::None,
adj_values: Vec::new(),
style_ln_color: None,
style_ln_idx: None,
style_fill_color: None,
style_font_color: None,
explicit_no_fill: false,
Expand All @@ -613,6 +617,7 @@ impl ShapeState {
/// Finalize a shape element when `</p:sp>` is reached.
/// Returns elements to add: for shapes with text AND non-rectangular geometry,
/// returns two elements (shape background + transparent text overlay).
#[allow(clippy::too_many_arguments)]
fn finalize_shape(
shape: &mut ShapeState,
paragraphs: &mut Vec<PptxParagraphEntry>,
Expand All @@ -621,7 +626,20 @@ fn finalize_shape(
text_box_no_wrap: bool,
text_box_auto_fit: bool,
text_box_text_rotation_deg: Option<f64>,
theme_line_style_widths: &[i64],
) -> Vec<FixedElement> {
// Outline width: explicit `<a:ln w>` when present, otherwise the theme
// line style referenced by `<a:lnRef idx>` (issue #318).
let effective_ln_width_emu: i64 = if shape.ln_width_emu > 0 {
shape.ln_width_emu
} else {
shape
.style_ln_idx
.and_then(|idx| theme_line_style_widths.get(idx - 1).copied())
.unwrap_or(shape.ln_width_emu)
};
let effective_ln_width_pt: f64 = emu_to_pt(effective_ln_width_emu);

// Resolve effective fill: explicit > noFill > style fallback.
let effective_fill: Option<Color> = if shape.fill.is_some() {
shape.fill
Expand All @@ -640,7 +658,7 @@ fn finalize_shape(
// Use explicit line color, falling back to style-based color from <p:style><a:lnRef>.
let effective_ln_color: Option<Color> = shape.ln_color.or(shape.style_ln_color);
let stroke: Option<BorderSide> = effective_ln_color.map(|color| BorderSide {
width: emu_to_pt(shape.ln_width_emu),
width: effective_ln_width_pt,
color,
style: shape.ln_dash_style,
});
Expand Down Expand Up @@ -749,7 +767,7 @@ fn finalize_shape(
// Use explicit line color, falling back to style-based color from <p:style><a:lnRef>.
let effective_ln_color: Option<Color> = shape.ln_color.or(shape.style_ln_color);
let stroke: Option<BorderSide> = effective_ln_color.map(|color| BorderSide {
width: emu_to_pt(shape.ln_width_emu),
width: effective_ln_width_pt,
color,
style: shape.ln_dash_style,
});
Expand Down Expand Up @@ -1380,9 +1398,27 @@ impl<'a> SlideXmlParser<'a> {
&mut self.pic,
);
}
// Style-matrix ref colors (`<a:lnRef>`/`<a:fillRef>`/`<a:fontRef>`)
// can carry shade/tint transforms, which arrive as Start events;
// the Empty-event arms below would miss them.
b"srgbClr" | b"schemeClr" | b"sysClr" if self.in_style_ln_ref => {
let parsed = parse_color_from_start(reader, e, self.theme, self.color_map);
self.shape.style_ln_color = parsed.color;
}
b"srgbClr" | b"schemeClr" | b"sysClr" if self.in_style_fill_ref => {
let parsed = parse_color_from_start(reader, e, self.theme, self.color_map);
self.shape.style_fill_color = parsed.color;
}
b"srgbClr" | b"schemeClr" | b"sysClr" if self.in_style_font_ref => {
let parsed = parse_color_from_start(reader, e, self.theme, self.color_map);
self.shape.style_font_color = parsed.color;
}
// `<a:lnRef>` inside `<p:style>` provides fallback line color.
b"lnRef" if self.in_shape && !self.shape.in_sp_pr && !self.in_txbody => {
self.in_style_ln_ref = true;
self.shape.style_ln_idx = get_attr_str(e, b"idx")
.and_then(|value| value.parse::<usize>().ok())
.filter(|idx| *idx > 0);
}
// `<a:fillRef>` inside `<p:style>` provides fallback fill color.
b"fillRef" if self.in_shape && !self.shape.in_sp_pr && !self.in_txbody => {
Expand Down Expand Up @@ -1730,6 +1766,7 @@ impl<'a> SlideXmlParser<'a> {
self.text_box_no_wrap,
self.text_box_auto_fit,
self.text_box_text_rotation_deg,
&self.theme.line_style_widths,
));
}
self.in_shape = false;
Expand Down
39 changes: 39 additions & 0 deletions crates/office2pdf/src/parser/pptx_theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ pub(super) struct ThemeData {
/// Raw XML of each `<a:fmtScheme>/<a:bgFillStyleLst>` entry, for
/// `<p:bgRef>` idx ≥ 1001 resolution.
pub(super) bg_fill_styles: Vec<String>,
/// Line widths (EMU) of each `<a:fmtScheme>/<a:lnStyleLst>/<a:ln>` entry,
/// for `<a:lnRef idx="N">` outline width resolution.
pub(super) line_style_widths: Vec<i64>,
}

/// Effective scheme-color aliases for a slide part.
Expand Down Expand Up @@ -452,10 +455,46 @@ pub(super) fn parse_theme_xml(xml: &str) -> ThemeData {

theme.fill_styles = extract_fill_style_entries(xml, b"fillStyleLst");
theme.bg_fill_styles = extract_fill_style_entries(xml, b"bgFillStyleLst");
theme.line_style_widths = extract_line_style_widths(xml);

theme
}

/// Extract the `w` (EMU) of each `<a:ln>` inside the theme `<a:lnStyleLst>`.
fn extract_line_style_widths(xml: &str) -> Vec<i64> {
let mut reader = Reader::from_str(xml);
let mut widths: Vec<i64> = Vec::new();
let mut in_list = false;
loop {
match reader.read_event() {
Ok(Event::Start(ref e)) => match e.local_name().as_ref() {
b"lnStyleLst" => in_list = true,
b"ln" if in_list => {
widths.push(line_width_attr(e));
}
_ => {}
},
Ok(Event::Empty(ref e)) if in_list && e.local_name().as_ref() == b"ln" => {
widths.push(line_width_attr(e));
}
Ok(Event::End(ref e)) if e.local_name().as_ref() == b"lnStyleLst" => break,
Ok(Event::Eof) => break,
Err(_) => break,
_ => {}
}
}
widths
}

fn line_width_attr(e: &BytesStart<'_>) -> i64 {
e.attributes()
.flatten()
.find(|attr| attr.key.local_name().as_ref() == b"w")
.and_then(|attr| attr.unescape_value().ok())
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(0)
}

/// Extract the raw XML of each top-level fill entry (`<a:solidFill>`,
/// `<a:gradFill>`, ...) inside the named `<a:fmtScheme>` list.
fn extract_fill_style_entries(xml: &str, list_tag: &[u8]) -> Vec<String> {
Expand Down
22 changes: 22 additions & 0 deletions crates/office2pdf/src/parser/pptx_theme_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,3 +627,25 @@ fn test_layout_shape_uses_master_color_map_with_luminance_offset() {
let shape = get_shape(&page.elements[0]);
assert_eq!(shape.fill, Some(Color::new(0x80, 0x80, 0x80)));
}

#[test]
fn test_parse_theme_line_style_widths() {
// Theme lnStyleLst widths back <a:lnRef idx="N"> outline resolution
// (issue #318): idx=1/2/3 map to the 1st/2nd/3rd entry's EMU width.
let theme_xml = r#"<?xml version="1.0"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:themeElements>
<a:clrScheme name="X"><a:dk1><a:srgbClr val="000000"/></a:dk1></a:clrScheme>
<a:fontScheme name="X"><a:majorFont><a:latin typeface="Calibri"/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/></a:minorFont></a:fontScheme>
<a:fmtScheme name="X">
<a:lnStyleLst>
<a:ln w="6350"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>
<a:ln w="12700"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>
<a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>
</a:lnStyleLst>
</a:fmtScheme>
</a:themeElements>
</a:theme>"#;
let theme = parse_theme_xml(theme_xml);
assert_eq!(theme.line_style_widths, vec![6350, 12700, 19050]);
}
Loading