diff --git a/ROADMAP.md b/ROADMAP.md index 5ee3fefefe..4787271d12 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,6 +145,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Table-of-contents links look like Word's: plain text, not blue and underlined (Word ignores the Hyperlink style on TOC entry links; measured in 9 cases) | | 2026-10-12 | Line heights like Word's: spaces and tabs never make a line taller (13 cases measured in Word for Windows); tables of contents with big tab runs were a third taller | | 2026-10-12 | `.doc` import: tab stops (centred titles, right-aligned dates, leaders) and sections ending at their section marks (continuous breaks, footers); EPA .doc templates now paginate like Word | +| 2026-10-12 | DOCX headings numbered like Word when a heading style gives only a list level (6.4.5, not 6.15) | | 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged | | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 368b1d4337..5f16f82365 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -792,6 +792,25 @@ impl Reader<'_> { } 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). + let fixed: Vec> = sheet + .styles + .iter() + .map(|s| match s.para.numbering { + Some(n) if n.num == props::LEVEL_ONLY => { + let base = s.based_on.as_deref().and_then(|b| props::chain_list(&sheet, b)); + base.map(|num| wordcraft_doc::props::NumRef { num, level: n.level }) + } + other => other, + }) + .collect(); + for (s, n) in sheet.styles.iter_mut().zip(fixed) { + s.para.numbering = n; + } + if sheet.default_para.numbering.is_some_and(|n| n.num == props::LEVEL_ONLY) { + sheet.default_para.numbering = None; + } self.doc.styles = sheet; Ok(()) } diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 3cbab1bac7..a8147bf6a6 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -33,6 +33,27 @@ pub struct PropCtx { pub theme: Vec, } +/// A `w:numPr` that gives a level (`w:ilvl`) and no list (`w:numId`): the list is the one the +/// paragraph's style chain uses (ECMA-376 §17.3.1.19). A placeholder while reading, never left in +/// a document (`resolve_list_levels`). +pub(crate) const LEVEL_ONLY: u32 = u32::MAX; + +/// The list a level-only `numPr` refers to: the nearest numbering in style `style`'s based-on chain +/// (its own included) that names a list. `None` when no style in the chain has one. +pub(crate) fn chain_list(sheet: &wordcraft_doc::styles::StyleSheet, style: &str) -> Option { + sheet.chain(style).iter().rev().filter_map(|s| s.para.numbering).map(|n| n.num).find(|n| *n != LEVEL_ONLY) +} + +/// Resolve level-only numbering in `p` against its style chain (`style` when it has none). +pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &mut ParaProps, style: &str) { + if let Some(n) = p.numbering.as_mut().filter(|n| n.num == LEVEL_ONLY) { + match chain_list(sheet, p.style.as_deref().unwrap_or(style)) { + Some(num) => n.num = num, + None => p.numbering = None, + } + } +} + impl PropCtx { pub fn style_id(&self, id: &str) -> String { self.alias.get(id).cloned().unwrap_or_else(|| id.to_string()) @@ -194,6 +215,7 @@ impl PropCtx { /// `w:pPr` (without `w:sectPr`; `w:rPr` is the paragraph mark, returned separately). pub fn ppr(&self, e: &El) -> (ParaProps, CharProps) { + // (`w:numPr` with a level and no list sets `LEVEL_ONLY`, resolved by the caller.) let mut p = ParaProps::default(); let mut mark = CharProps::default(); for k in e.els() { @@ -222,9 +244,15 @@ impl PropCtx { } "w:numPr" => { let num = k.child_val("w:numId").and_then(u32_of); - let level = k.child_val("w:ilvl").and_then(u32_of).unwrap_or(0).min(8) as u8; - if let Some(num) = num { - p.numbering = Some(NumRef { num, level }); + let ilvl = k.child_val("w:ilvl").and_then(u32_of); + let level = ilvl.unwrap_or(0).min(8) as u8; + match (num, ilvl) { + (Some(num), _) => p.numbering = Some(NumRef { num, level }), + // A level without a list: the list comes from the based-on style, as in + // a Heading 3 that only says "level 3" of Heading 2's list. Resolved once + // the styles are read (`resolve_list_levels`). + (None, Some(_)) => p.numbering = Some(NumRef { num: LEVEL_ONLY, level }), + (None, None) => {} } } "w:pBdr" => p.borders = Some(borders(k)), diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index c20a062542..fc6302e4be 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -334,7 +334,8 @@ impl Reader<'_> { pb.push_obj(o, &CharProps::default()); } if let Some(ppr) = e.child("w:pPr") { - let (props, mark) = self.pc.ppr(ppr); + let (mut props, mark) = self.pc.ppr(ppr); + super::props::resolve_level_only(&self.doc.styles, &mut props, "Normal"); para.props = props; para.mark = mark; // A tracked paragraph mark (ECMA-376 §17.13.5.16 / §17.13.5.15): `w:ins` / `w:del` @@ -356,7 +357,14 @@ impl Reader<'_> { // Tracked formatting changes (§17.13.5.29, §17.13.5.19): the paragraph properties // before the change, and a change of the list numbering. if let Some(ch) = ppr.child("w:pPrChange") { - let old = ch.child("w:pPr").map(|o| self.pc.ppr(o).0.formatting()).unwrap_or_default(); + let old = ch + .child("w:pPr") + .map(|o| { + let mut old = self.pc.ppr(o).0; + super::props::resolve_level_only(&self.doc.styles, &mut old, "Normal"); + old.formatting() + }) + .unwrap_or_default(); para.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old); } if para.props.numbering.is_some() diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index fb862cc715..8b9ab9431a 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1850,3 +1850,35 @@ fn toc_entry_links_lose_the_hyperlink_style() { assert_eq!(run("quoted").style.as_deref(), Some("Hyperlink"), "another field: kept"); assert_eq!(run("plain").style.as_deref(), Some("Hyperlink"), "outside fields: kept"); } + +/// A style or paragraph whose `w:numPr` gives only a level (`w:ilvl`) takes the list from its +/// style chain (ECMA-376 §17.3.1.19): a Heading 3 based on a numbered Heading 2 is level 3 of the +/// same list. WordCraft dropped the level, so Heading 3s numbered as Heading 2s (6.15 where Word +/// shows 6.4.5 in NIH's monitoring-plan template). +#[test] +fn a_list_level_without_a_list_takes_the_style_s_list() { + let styles = format!( + r#" + + + + + + "# + ); + let body = r#"three + direct level + no list anywhere"#; + let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap(); + let num = |id: &str| d.styles.get(id).and_then(|s| s.para.numbering).map(|n| (n.num, n.level)); + assert_eq!(num("Heading3"), Some((15, 2)), "Heading 3 is level 3 of Heading 2's list"); + assert_eq!(num("Heading4"), Some((15, 3)), "through two level-only styles"); + assert_eq!(num("Loose"), None, "no list in the chain: no numbering"); + let p = paras(&d); + assert_eq!(d.styles.resolve_para(&p[0].props).numbering.map(|n| (n.num, n.level)), Some((15, 2))); + assert_eq!(p[1].props.numbering.map(|n| (n.num, n.level)), Some((15, 2)), "a paragraph's own level, its style's list"); + assert_eq!(p[2].props.numbering, None); + // Saved and read again it is an ordinary list reference. + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(again.styles.get("Heading3").and_then(|s| s.para.numbering).map(|n| (n.num, n.level)), Some((15, 2))); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 48240086b9..653c8a72f5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -168,6 +168,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) | | 2026-10-12 | trivial | DOCX: a table of contents' entry links (Hyperlink-styled runs in hyperlinks inside a TOC field) import without the Hyperlink style, as Word draws them; measured in Word for Windows | | 2026-10-12 | trivial | `.doc`: custom tab stops read from styles and paragraphs (`sprmPChgTabsPapx`, `sprmPChgTabs`, with the alignment and leader of each stop; `sprmPChgTabs`' operand size fixed), and a section mark ends its section's last paragraph instead of becoming a page break, so the sections (continuous breaks, footers) are kept. Found by comparing EPA templates with Word for Windows; two of them now paginate like Word | +| 2026-10-12 | trivial | DOCX: a style or paragraph `w:numPr` with a level and no list takes the list of its style chain (Heading 3 based on a numbered Heading 2 numbers 6.4.5, not 6.15), found in NIH's monitoring-plan template against Word for Windows | | 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% | | 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) | | 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly |