DOCX: a style ID defined twice is its last definition (#2031)

Some generators write a placeholder Heading 1 and then their own,
under the same w:styleId. The reader kept the first definition and
skipped the rest, so headings came out in the placeholder's font,
size and colour. Word uses the last definition, whole: a synthetic
document with an italic red 20 pt first definition and a bold blue
12 pt second one shows bold blue 12 pt, with nothing of the first
(no italic, no centring), for a custom style and for Heading 1 alike.

The reader now replaces an earlier definition in place instead of
skipping the later one.

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Morzilla
2026-10-18 03:44:34 -04:00
committed by GitHub
co-authored by Daniele Mori Claude Opus 5.5
parent f6115ea7fa
commit ba8dc7bf1c
2 changed files with 27 additions and 4 deletions
+7 -4
View File
@@ -818,9 +818,6 @@ impl Reader<'_> {
for s in root.children("w:style").take(10_000) {
let Some(raw_id) = s.attr("w:styleId").filter(|i| !i.is_empty()) else { continue };
let id = self.pc.style_id(raw_id);
if sheet.get(&id).is_some() {
continue;
}
let kind = match s.attr("w:type").unwrap_or("paragraph") {
"character" => StyleKind::Character,
"table" => StyleKind::Table,
@@ -862,7 +859,13 @@ impl Reader<'_> {
let parts = self.table_style_parts(s, &mut st);
st.table = (parts != TableStyleParts::default()).then_some(parts);
}
sheet.styles.push(st);
// A style defined twice (some generators write their own Heading 1 after a
// placeholder one) is the last definition, whole: Word drops the earlier one rather
// than merging the two (observed with synthetic documents).
match sheet.styles.iter_mut().find(|s| s.id == id) {
Some(earlier) => *earlier = st,
None => sheet.styles.push(st),
}
}
// Styles that give only a list level take the list from the style they are based on
// (Heading 3: level 3 of Heading 2's outline list).
+20
View File
@@ -595,6 +595,26 @@ fn empty_tbl_borders_elements_leave_sides_unset() {
assert!(b.top.is_some() && b.between.is_some() && b.inside_v.is_some());
}
/// A style ID defined twice is its last definition, whole (Word drops the earlier one rather
/// than merging: observed with synthetic documents). Generators that write a placeholder
/// Heading 1 and then their own made headings come out in the placeholder's font and size.
#[test]
fn a_style_defined_twice_is_its_last_definition() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:pPr><w:jc w:val="center"/></w:pPr><w:rPr><w:i/><w:color w:val="FF0000"/><w:sz w:val="40"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:rPr><w:b/><w:color w:val="0000FF"/><w:sz w:val="24"/></w:rPr></w:style>
</w:styles>"#
);
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Probe</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
assert_eq!(d.styles.styles.iter().filter(|s| s.id == "Heading1").count(), 1);
let h = d.styles.get("Heading1").unwrap();
assert_eq!((h.chr.bold, h.chr.italic, h.chr.size), (Some(true), None, Some(12.0)));
assert_eq!(h.chr.color, Some(TextColor::Rgb(Rgb(0, 0, 0xFF))));
assert_eq!(h.para.align, None, "nothing kept from the first definition");
}
#[test]
fn vml_image_and_inline_drawing() {
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";