diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 46aa3afb8..55dc0875d 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::io::Read; use designcraft_doc::story::{BASIC_PARAGRAPH, NO_CHAR_STYLE}; -use designcraft_doc::{CharAttrs, CharFormat, ListType, ParaAttrs, ParaFormat, Story, StoryId, Table}; +use designcraft_doc::{CharAttrs, CharFormat, ListCounter, ListType, ParaAttrs, ParaFormat, ParaProps, Story, StoryId, Table}; use quick_xml::events::Event; use crate::docx_numbering::{ListPara, Numbering}; @@ -222,7 +222,8 @@ struct Ctx { style_lists: HashMap, Option)>, /// The number the model's own list counter is at (it restarts after a paragraph that isn't /// in a list), to tell where a numbered paragraph needs an explicit Start At. - model_counter: u32, + /// The model's unnamed-list numbering as composition will count it. + model_counter: ListCounter, } impl Ctx { @@ -254,9 +255,13 @@ impl Ctx { let at = st.len(); st.format_paras(at..at, |f| *f = ParaFormat { style: style.clone(), para: attrs.clone(), ..Default::default() }); self.inline(e, st, allow_notes); - if let Some(n) = self.start_at(list.as_ref(), st.len() == at) { + let (start_at, expression) = self.start_at(list.as_ref(), st.len() == at); + if let Some(n) = start_at { st.format_paras(at..at, |f| f.para.start_at = Some(Some(n))); } + if let Some(e) = expression { + st.format_paras(at..at, |f| f.para.number_expression = Some(e.clone())); + } } "tbl" => { let rows: Vec> = e @@ -300,21 +305,32 @@ impl Ctx { } /// Start At for a paragraph, so that the model numbers it as Word does: only where the - /// model's counter (which follows the previous numbered paragraph, and restarts after one - /// that isn't in a list) would give a different number. - fn start_at(&mut self, list: Option<&ListPara>, empty: bool) -> Option { + /// model's counter (per level; restarted after a paragraph that isn't in a list) would give a + /// different number. And the number expression with parent levels as text, where the model's + /// numbers for those levels differ from Word's. + fn start_at(&mut self, list: Option<&ListPara>, empty: bool) -> (Option, Option) { let Some(l) = list else { - self.model_counter = 0; - return None; + self.model_counter = ListCounter::default(); + return (None, None); }; // Bullets and empty paragraphs leave the counter alone (they show no number). if l.attrs.list_type != Some(ListType::Numbers) || empty { - return None; + return (None, None); } let want = l.number.max(1); - let next = self.model_counter.saturating_add(1); - self.model_counter = want; - (want != next).then_some(want) + let mut pp = ParaProps::default(); + pp.apply(&l.attrs); + let mut model = self.model_counter.clone(); + let start = (model.advance(&pp) != want).then_some(want); + if start.is_some() { + model = self.model_counter.clone(); + pp.start_at = start; + model.advance(&pp); + } + let level_text = |k: u32| ParaProps { number_expression: format!("^{k}"), ..ParaProps::default() }.number_label_in(&model); + let expression = l.parents.iter().any(|(k, text)| level_text(*k) != *text).then(|| l.fixed_expression.clone()).flatten(); + self.model_counter = model; + (start, expression) } /// Runs (and hyperlinks, fields' results) of a paragraph. @@ -423,7 +439,7 @@ pub fn import(bytes: &[u8]) -> Result { next_table: 0, numbering: Numbering::default(), style_lists: HashMap::new(), - model_counter: 0, + model_counter: ListCounter::default(), }; // Lists that can't be read leave plain paragraphs, not a failed import. match part_capped(&mut zip, "word/numbering.xml", NUMBERING_LIMIT).and_then(|s| s.map(|s| parse(&s)).transpose()) { @@ -631,15 +647,28 @@ mod tests { i } - /// The label the model gives paragraph `i` when its number is `n`. - fn label(st: &Story, i: usize, n: u32) -> String { - let mut p = designcraft_doc::ParaProps::default(); - p.apply(&st.paras[i].para); - match p.list_type { - ListType::Numbers => p.number_label(n), - ListType::Bullets => p.bullet_label(), - ListType::None => String::new(), + /// The label composition gives paragraph `i`: numbered paragraphs count per level on one + /// counter that a paragraph outside a list restarts; bullets and empty paragraphs leave it. + fn label(st: &Story, i: usize) -> String { + let mut counter = designcraft_doc::ListCounter::default(); + let mut label = String::new(); + for (f, r) in st.paras.iter().zip(st.para_ranges()).take(i + 1) { + let mut p = designcraft_doc::ParaProps::default(); + p.apply(&f.para); + label = match p.list_type { + _ if r.is_empty() => String::new(), + ListType::Numbers => { + counter.advance(&p); + p.number_label_in(&counter) + } + ListType::Bullets => p.bullet_label(), + ListType::None => { + counter = designcraft_doc::ListCounter::default(); + String::new() + } + }; } + label } /// Regression (#626): pandoc's Markdown list example lost its bullets and numbers. @@ -665,9 +694,9 @@ mod tests { let st = lists_docx(&numbering, "", &body).story; let types: Vec> = st.paras.iter().map(|p| p.para.list_type).collect(); assert_eq!(types, [None, Some(ListType::Bullets), Some(ListType::Bullets), Some(ListType::Numbers), Some(ListType::Numbers)]); - assert_eq!(label(&st, 1, 1), "•\t"); + assert_eq!(label(&st, 1), "•\t"); assert_eq!(st.paras[3].para.number_style, Some(designcraft_doc::NumberStyle::Arabic)); - assert_eq!((label(&st, 3, 1), label(&st, 4, 2)), ("1.\t".to_string(), "2.\t".to_string())); + assert_eq!((label(&st, 3), label(&st, 4)), ("1.\t".to_string(), "2.\t".to_string())); // The model's own counter already starts at 1 and carries on: no Start At needed. assert_eq!((st.paras[3].para.start_at, st.paras[4].para.start_at), (None, None)); assert_eq!((st.paras[1].para.left_indent, st.paras[1].para.first_line_indent), (Some(24.0), Some(-24.0))); @@ -703,15 +732,44 @@ mod tests { ]; let st = lists_docx(&numbering, "", &body).story; let start: Vec>> = st.paras.iter().map(|p| p.para.start_at).collect(); - // Word's numbers: 1, a, b, 1.b.I, 2, –, 3, 1 (own counter), 4 (shares numId 1's). E's 2 - // follows the model's counter (1 after D). - assert_eq!(start, [None, Some(Some(1)), None, Some(Some(1)), None, None, Some(Some(3)), Some(Some(1)), Some(Some(4))]); - assert_eq!(label(&st, 1, 1), "a) "); - assert_eq!(label(&st, 3, 1), "1.b.I\t"); + // Word's numbers: 1, a, b, 1.b.I, 2, –, 3, 1 (own counter), 4 (shares numId 1's). The + // model numbers each level on its own, so only the paragraphs after the plain one need + // Start At. + assert_eq!(start, [None, None, None, None, None, None, Some(Some(3)), Some(Some(1)), Some(Some(4))]); + let levels: Vec> = st.paras.iter().map(|p| p.para.list_level).collect(); + assert_eq!(levels, [Some(1), Some(2), Some(2), Some(3), Some(1), None, Some(1), Some(1), Some(1)]); + assert_eq!(label(&st, 1), "a) "); + // Parent numbers are the model's level references. + assert_eq!(st.paras[3].para.number_expression.as_deref(), Some("^1.^2.^#^t")); + assert_eq!(label(&st, 3), "1.b.I\t"); + let labels: Vec = (0..st.paras.len()).map(|i| label(&st, i)).collect(); + assert_eq!(labels, ["1.\t", "a) ", "b) ", "1.b.I\t", "2.\t", "", "3.\t", "1.\t", "4.\t"]); assert_eq!(st.paras[1].para.left_indent, Some(72.0)); assert_eq!(st.paras[1].para.first_line_indent, Some(-18.0)); } + /// Parent numbers the model's levels can't give are written as text: a list that starts at + /// level 1 (level 0 never numbered), and a legal-style label (decimal parents). + #[test] + fn parent_numbers_the_model_lacks_are_text() { + let numbering = format!( + r#" + + + + + + + "# + ); + let body = [list_p("1", Some("1"), "A"), list_p("1", Some("0"), "B"), list_p("1", Some("1"), "C"), list_p("1", Some("2"), "D")]; + let st = lists_docx(&numbering, "", &body).story; + let labels: Vec = (0..st.paras.len()).map(|i| label(&st, i)).collect(); + assert_eq!(labels, ["I.1\t", "I.\t", "I.1\t", "1.1.1\t"]); + assert_eq!(st.paras[0].para.number_expression.as_deref(), Some("I.^#^t")); + assert_eq!(st.paras[2].para.number_expression.as_deref(), Some("^1.^#^t")); + } + /// A paragraph style's `w:numPr` (inherited through `w:basedOn`) makes its paragraphs a list; /// numId 0 on a paragraph takes it out of the list. #[test] @@ -740,8 +798,8 @@ mod tests { styled("ListBullet", r#""#, "not a list"), ]; let st = lists_docx(&numbering, &styles, &body).story; - assert_eq!(label(&st, 0, 1), "•\t"); - assert_eq!(label(&st, 1, 1), "◦\t"); + assert_eq!(label(&st, 0), "•\t"); + assert_eq!(label(&st, 1), "◦\t"); assert_eq!(st.paras[2].para.list_type, None); assert_eq!(st.paras[2].style, "List Bullet"); } @@ -789,7 +847,7 @@ mod tests { let expr = st.paras[8].para.number_expression.clone().unwrap_or_default(); assert!(expr.starts_with("^#x") && expr.chars().count() < 80, "{expr}"); // A start below 0 counts from 0, which the model shows as 1; `^` in a label is literal. - assert_eq!(label(&st, 9, 1), "1^\t"); + assert_eq!(label(&st, 9), "1^\t"); // Unreadable list definitions leave plain paragraphs and a warning, not a failed import. let document = format!(r#"{}"#, list_p("1", None, "x")); let i = import(&docx_parts(&[("word/document.xml", &document), ("word/numbering.xml", "")])).unwrap(); diff --git a/crates/textimport/src/docx_numbering.rs b/crates/textimport/src/docx_numbering.rs index 31b855b09..e3837e720 100644 --- a/crates/textimport/src/docx_numbering.rs +++ b/crates/textimport/src/docx_numbering.rs @@ -9,8 +9,10 @@ //! - `num` instances of one abstractNum share their counters, so a list carries on across //! them and across paragraphs that aren't in a list. A `num` with a `w:startOverride` counts //! on its own, starting at the override. -//! - Parent levels in a label (`%1.%2.`) are written as text: the model's labels number only -//! the paragraph's own level. +//! - Levels map to the model's list levels (`ilvl` + 1). Parent levels in a label (`%1.%2.`) +//! become the model's level references (`^1.^2.`); where the model's level numbers would differ +//! from Word's (a legal-style label, a level the model hasn't numbered), the importer writes +//! them as text instead ([`ListPara::fixed_expression`]). use std::collections::HashMap; @@ -80,6 +82,11 @@ pub(crate) struct ListPara { pub(crate) attrs: ParaAttrs, /// The paragraph's number at its level (also counted for bullets). pub(crate) number: u32, + /// Parent levels the number expression refers to (`^k`, 1-based) and the text Word shows + /// for each. + pub(crate) parents: Vec<(u32, String)>, + /// The number expression with Word's parent level numbers written as text. + pub(crate) fixed_expression: Option, } /// The lists of a Word document and the numbering state while its paragraphs are read in order. @@ -257,7 +264,9 @@ impl Numbering { _ => String::new(), } }; - let label = |own_level: &str, escape: bool| -> String { + // `refs`: a parent number level becomes the model's `^k`; otherwise Word's text. + let mut parents = Vec::new(); + let mut label = |own_level: &str, escape: bool, refs: bool| -> String { let mut out = String::new(); let mut it = def.text.chars().peekable(); while let Some(c) = it.next() { @@ -266,8 +275,13 @@ impl Numbering { { it.next(); let k = k as usize - 1; + let number = matches!(level(k).map(|l| l.format), Some(Format::Number(_))); if k == lv { out.push_str(own_level); + } else if refs && number && !def.legal { + out.push('^'); + out.push_str(&(k + 1).to_string()); + parents.push((k as u32 + 1, parent(k))); } else { let p = parent(k); out.push_str(&if escape { p.replace('^', "^^") } else { p }); @@ -287,11 +301,13 @@ impl Numbering { Suffix::Nothing => ("", ""), }; let mut a = ParaAttrs::default(); + let mut fixed_expression = None; match def.format { Format::Number(style) => { a.list_type = Some(ListType::Numbers); a.number_style = Some(style); - a.number_expression = Some(format!("{}{number_sep}", label("^#", true))); + a.number_expression = Some(format!("{}{number_sep}", label("^#", true, true))); + fixed_expression = Some(format!("{}{number_sep}", label("^#", true, false))); } Format::Bullet => { a.list_type = Some(ListType::Bullets); @@ -300,13 +316,19 @@ impl Numbering { } Format::Nothing => { a.list_type = Some(ListType::Bullets); - a.bullet_char = Some(label("", false)); + a.bullet_char = Some(label("", false, false)); a.list_separator = Some(bullet_sep.into()); } } a.left_indent = def.left; a.first_line_indent = def.first_line; - Some(ListPara { attrs: a, number: n }) + a.list_level = Some(ilvl + 1); + match def.restart { + None => {} + Some(0) => a.restart_numbers = Some(false), + Some(r) => a.restart_after_level = Some(r), + } + Some(ListPara { attrs: a, number: n, parents, fixed_expression }) } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5caf4be65..c8e5f72e4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -60,7 +60,7 @@ score below). | EPS | `.eps` | ✓ (PostScript) | ~ preview only (no interpreter) | | <1% | | SVG | `.svg` | ✓ | ✓ (usvg) | | <1% | | HEIC | `.heic` | ✓ | ✗ | | <1% | -| Word | `.docx` | ✓ styles, footnotes, tables, track changes, options | ✓ styles by name, footnotes, tables, tabs, breaks, moved text (#257), bullets and numbering from `numbering.xml` (#626; parent levels in a label are fixed text), style mapping and conflicts | `.doc` not supported (InDesign reads it) | 2% | +| Word | `.docx` | ✓ styles, footnotes, tables, track changes, options | ✓ styles by name, footnotes, tables, tabs, breaks, moved text (#257), bullets and numbering from `numbering.xml` (#626; levels become list levels, parent numbers in a label level references), style mapping and conflicts | `.doc` not supported (InDesign reads it) | 2% | | RTF | `.rtf` | ✓ | ✓ | | <1% | | Excel | `.xlsx`, `.xls` | ✓ | ~ `.xlsx` first sheet's used range | no sheet/range options; `.xls` ✗ | <1% | | Plain text, Markdown | `.txt`, `.md` | ✓ (txt) | ✓ | | <1% |