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>
This commit is contained in:
Diego Guerini
2026-10-29 23:50:27 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent f774f6f5c0
commit e855daf279
4 changed files with 79 additions and 3 deletions
+18 -1
View File
@@ -42,6 +42,13 @@ pub(crate) fn ui_style_name(n: &str) -> String {
n.to_string()
}
/// The outline level (0 for Heading 1) of Word's built-in style named `heading 1` … `heading 9`.
fn builtin_heading_level(name: &str) -> Option<u8> {
let name = name.trim();
let digit = name.get(..8).filter(|p| p.eq_ignore_ascii_case("heading ")).and(name.get(8..))?;
digit.parse::<u8>().ok().filter(|d| digit.len() == 1 && (1..=9).contains(d)).map(|d| d - 1)
}
impl Reader<'_> {
pub(super) fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
@@ -115,7 +122,7 @@ impl Reader<'_> {
priority: s.child_val("w:uiPriority").and_then(u32_of),
quick: flag(s, "w:qFormat").unwrap_or(false),
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
builtin: !s.attr("w:customStyle").is_some_and(|v| matches!(v.trim(), "1" | "true" | "on")),
locked: flag(s, "w:locked").unwrap_or(false),
// Only where the writer couldn't tell from the id (it flags the English ids).
default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true")
@@ -131,6 +138,16 @@ impl Reader<'_> {
if let Some(p) = s.child("w:pPr") {
st.para = self.pc.ppr(p).0;
}
// Word gives its built-in heading styles their outline level whether or not the file
// writes `w:outlineLvl` (some producers leave it out): "heading 2" is level 2 in the
// Navigation pane and the table of contents, whatever its id (#4696).
if kind == StyleKind::Paragraph
&& st.builtin
&& st.para.outline_level.is_none()
&& let Some(level) = s.child_val("w:name").and_then(builtin_heading_level)
{
st.para.outline_level = Some(level);
}
if let Some(rp) = s.child("w:rPr") {
st.chr = self.pc.rpr(rp);
}
+57
View File
@@ -0,0 +1,57 @@
//! #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);
}
+1
View File
@@ -13,6 +13,7 @@ mod east_asian_lang;
mod east_asian_layout;
mod fixtures;
mod glossary;
mod heading_levels;
mod legacy_forms;
mod ligatures;
mod malformed;
File diff suppressed because one or more lines are too long