From e855daf27908094d90f603e474cd515c4be36788 Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:50:27 +0100 Subject: [PATCH] Built-in heading styles have their outline level without w:outlineLvl, so the Navigation pane lists them (#4696) (#4706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #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 * #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 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/docx/src/read/styles.rs | 19 ++++++++- crates/docx/tests/it/heading_levels.rs | 57 ++++++++++++++++++++++++++ crates/docx/tests/it/main.rs | 1 + docs/file-format-parity.md | 5 ++- 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 crates/docx/tests/it/heading_levels.rs diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index 55d01e8a4..cf9bccf51 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -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 { + let name = name.trim(); + let digit = name.get(..8).filter(|p| p.eq_ignore_ascii_case("heading ")).and(name.get(8..))?; + digit.parse::().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); } diff --git a/crates/docx/tests/it/heading_levels.rs b/crates/docx/tests/it/heading_levels.rs new file mode 100644 index 000000000..e32059dce --- /dev/null +++ b/crates/docx/tests/it/heading_levels.rs @@ -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 { + let root = r#""#; + let rels = r#""#; + let doc = format!(r#"{body}"#); + let styles = format!(r#"{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#"{text}"#) +} + +fn levels(d: &wordcraft_doc::Document) -> Vec> { + 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#" + + + + + + + + "#; + let body = ["Heading1", "Titolo2", "Heading9", "Below", "MyHead", "Heading10", "Fake4", "Heading3", "Normal"] + .iter() + .map(|s| para(s, "x")) + .collect::(); + 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); +} diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index d69bb7099..cff24dab6 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -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; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 93eeac5e1..385eb8437 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -75,7 +75,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Feature | Read | Write | Notes | |---|---|---|---| -| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…); runs that mix right-to-left and left-to-right text are split on save where the direction changes, and the right-to-left pieces get `w:rtl` and a bidi language (`w:lang w:bidi`, e.g. he-IL), so Word lays Hebrew around Latin names, numbers and e-mail addresses out as WordCraft does and doesn't call it Arabic (#1543, checked in Word for Mac 16.113.4), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats and Arabic letters (`arabicAlpha`, `arabicAbjad`) are drawn, the rest (Hebrew, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | +| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…); runs that mix right-to-left and left-to-right text are split on save where the direction changes, and the right-to-left pieces get `w:rtl` and a bidi language (`w:lang w:bidi`, e.g. he-IL), so Word lays Hebrew around Latin names, numbers and e-mail addresses out as WordCraft does and doesn't call it Arabic (#1543, checked in Word for Mac 16.113.4), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats and Arabic letters (`arabicAlpha`, `arabicAbjad`) are drawn, the rest (Hebrew, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are). Word's built-in heading styles (`heading 1`…`heading 9`, by name, whatever their id) have their outline level when `styles.xml` leaves out `w:outlineLvl`, as in Word, so their paragraphs show in the Navigation pane and the table of contents (#4696) | | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats and Arabic letters (`arabicAlpha`, `arabicAbjad`) are drawn, the rest (Hebrew, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | | Positional tabs (`w:ptab`: alignment, margin or indent, leader) | ✅ | ✅ | Laid out at their place whatever the tab stops; one with an alignment or base the spec doesn't define reads as a plain tab (#2367). `crates/docx/tests/it/positional_tabs.rs` | @@ -271,6 +271,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Built-in heading styles (`heading 1`…`heading 9`) have their outline level when `styles.xml` leaves out `w:outlineLvl`, as in Word: their headings show in the Navigation pane (#4696) | | 2026-10-29 | trivial | DOCX: comments.xml is read before footnotes and endnotes, so a comment range inside a note keeps its comment (it was dropped) (#4548) | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | | 2026-10-17 | trivial | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files; a file that fails to open (command line, open event, drop) names the file and the cause on the status bar and in the log (#1103) |