mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 02:17:24 -06:00
DOCX: a list level without a list takes its style chain's list (#926)
Found in NIH's monitoring-plan template, compared with Word for Windows. Before: a w:numPr that gives a level (w:ilvl) but no list (w:numId) was dropped. Outline-numbered templates do this all the time: Heading 3 is based on Heading 2 (which names the list) and only says "level 3", so every Heading 3 numbered as a Heading 2 (6.15 where Word shows 6.4.5). After: such a level takes the list of the nearest style in its based-on chain that names one (ECMA-376 17.3.1.19), in styles and in paragraphs; with no list anywhere in the chain there is no numbering. Saved files carry an ordinary list reference. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
45abb5ded3
commit
99561097d5
@@ -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) |
|
||||
|---|---|
|
||||
|
||||
@@ -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<Option<wordcraft_doc::props::NumRef>> = 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(())
|
||||
}
|
||||
|
||||
@@ -33,6 +33,27 @@ pub struct PropCtx {
|
||||
pub theme: Vec<Rgb>,
|
||||
}
|
||||
|
||||
/// 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<u32> {
|
||||
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)),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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#"<w:styles {W_NS}>
|
||||
<w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="15"/></w:numPr></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Heading2"/><w:pPr><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading4"><w:name w:val="heading 4"/><w:basedOn w:val="Heading3"/><w:pPr><w:numPr><w:ilvl w:val="3"/></w:numPr></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Loose"><w:name w:val="Loose"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="4"/></w:numPr></w:pPr></w:style>
|
||||
</w:styles>"#
|
||||
);
|
||||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading3"/></w:pPr><w:r><w:t>three</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="Heading2"/><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr><w:r><w:t>direct level</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/></w:numPr></w:pPr><w:r><w:t>no list anywhere</w:t></w:r></w:p>"#;
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user