Files
wordcraft/crates/docx/tests/it/heading_levels.rs
T
e855daf279 Built-in heading styles have their outline level without w:outlineLvl, so the Navigation pane lists them (#4696) (#4706)
* #4696: built-in heading styles have their outline level without w:outlineLvl

Some producers write "heading 1"…"heading 9" in styles.xml without w:outlineLvl. Word
gives those built-in styles their outline level anyway (checked: Word for Windows reports
OutlineLevel 1 and 2, whatever the style id), so their headings show in the Navigation
pane. WordCraft read no level, and the Headings tab stayed empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #4696: review — w:customStyle="on" marks a custom style too

A custom style named "heading 4" with customStyle="on" (ST_OnOff) was taken for Word's
built-in heading and got an outline level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:50:27 -04:00

58 lines
4.0 KiB
Rust

//! #4696: Word's built-in heading styles have their outline level even when the file's
//! `styles.xml` doesn't write `w:outlineLvl` (some producers leave it out). Word for Windows,
//! opening such a file, reports `OutlineLevel` 1 and 2 for "heading 1" and "heading 2" (whatever
//! their style ids) and body text for a custom style.
use std::io::Write;
use wordcraft_doc::Paragraph;
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
fn package(styles: &str, body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}"><w:body>{body}</w:body></w:document>"#);
let styles = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}">{styles}</w:styles>"#);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in
[("_rels/.rels", root), ("word/document.xml", doc.as_str()), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles.as_str())]
{
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(body.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn para(style: &str, text: &str) -> String {
format!(r#"<w:p><w:pPr><w:pStyle w:val="{style}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#)
}
fn levels(d: &wordcraft_doc::Document) -> Vec<Option<u8>> {
d.body.iter().filter_map(|b| b.as_para()).map(|p: &Paragraph| d.styles.resolve_para(&p.props).outline_level).collect()
}
#[test]
fn built_in_heading_styles_have_their_outline_level_without_outline_lvl() {
let styles = r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Titolo2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading9"><w:name w:val="Heading 9"/></w:style>
<w:style w:type="paragraph" w:styleId="Below"><w:name w:val="Below heading 2"/><w:basedOn w:val="Titolo2"/></w:style>
<w:style w:type="paragraph" w:customStyle="1" w:styleId="MyHead"><w:name w:val="My heading 3"/><w:basedOn w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading10"><w:name w:val="heading 10"/></w:style>
<w:style w:type="paragraph" w:customStyle="on" w:styleId="Fake4"><w:name w:val="heading 4"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:pPr><w:outlineLvl w:val="5"/></w:pPr></w:style>"#;
let body = ["Heading1", "Titolo2", "Heading9", "Below", "MyHead", "Heading10", "Fake4", "Heading3", "Normal"]
.iter()
.map(|s| para(s, "x"))
.collect::<String>();
let d = wordcraft_docx::read(&package(styles, &body)).unwrap();
// Heading 1, 2 and 9 by name; a style based on a heading inherits its level; a custom style,
// "heading 10", a custom style named "heading 4" (`w:customStyle="on"`) and Normal have none; a
// level the file gives is kept.
let want = vec![Some(0), Some(1), Some(8), Some(1), None, None, None, Some(5), None];
assert_eq!(levels(&d), want);
// The level survives a save and reopen.
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(levels(&again), want);
}