diff --git a/ROADMAP.md b/ROADMAP.md index f826a376..9caf72aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 67a2b71a..a51e9fe0 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -4,9 +4,10 @@ use std::collections::HashMap; use std::io::Read; use designcraft_doc::story::{BASIC_PARAGRAPH, NO_CHAR_STYLE}; -use designcraft_doc::{CharAttrs, CharFormat, ParaAttrs, ParaFormat, Story, StoryId, Table}; +use designcraft_doc::{CharAttrs, CharFormat, ListType, ParaAttrs, ParaFormat, Story, StoryId, Table}; use quick_xml::events::Event; +use crate::docx_numbering::{ListPara, Numbering}; use crate::{ImportError, Imported, ImportedStyle}; /// A minimal element tree (local names, attributes by local name). @@ -43,10 +44,10 @@ impl El { .collect() } /// `` → the value; a bare `` counts as "on". - fn val(&self, name: &str) -> Option { + pub(crate) fn val(&self, name: &str) -> Option { self.child(name).map(|e| e.attr("val").unwrap_or("true").to_string()) } - fn on(&self, name: &str) -> Option { + pub(crate) fn on(&self, name: &str) -> Option { self.val(name).map(|v| !matches!(v.as_str(), "0" | "false" | "none")) } } @@ -216,6 +217,12 @@ struct Ctx { footnotes: HashMap, warnings: Vec, next_table: u64, + numbering: Numbering, + /// A paragraph style's list (`w:numPr` numId and ilvl, through `w:basedOn`), by style id. + 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, } impl Ctx { @@ -236,10 +243,20 @@ impl Ctx { first = false; let ppr = e.child("pPr"); let style = self.para_style(ppr); - let attrs = para_attrs(ppr); + let mut attrs = para_attrs(ppr); + let list = self.list(ppr); + if let Some(l) = &list { + // The level's indents, unless the paragraph sets its own. + let direct = attrs; + attrs = l.attrs.clone(); + attrs.merge(&direct); + } 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) { + st.format_paras(at..at, |f| f.para.start_at = Some(Some(n))); + } } "tbl" => { let rows: Vec> = e @@ -271,6 +288,35 @@ impl Ctx { } } + /// The list a paragraph is in (its own `w:numPr`, else its style's), counting it. + fn list(&mut self, ppr: Option<&El>) -> Option { + let direct = ppr.and_then(|p| p.child("numPr")); + let style = ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.style_lists.get(&id)); + let num_id = direct.and_then(|n| n.val("numId")).or_else(|| style.and_then(|s| s.0.clone()))?; + let ilvl = direct.and_then(|n| n.val("ilvl")).or_else(|| style.and_then(|s| s.1.clone())); + // A level that isn't a small number is no list. + let ilvl = ilvl.map_or(Some(0), |l| l.trim().parse::().ok())?; + self.numbering.next(num_id.trim(), ilvl) + } + + /// 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 { + let Some(l) = list else { + self.model_counter = 0; + return None; + }; + // Bullets and empty paragraphs leave the counter alone (they show no number). + if l.attrs.list_type != Some(ListType::Numbers) || empty { + return None; + } + let want = l.number.max(1); + let next = self.model_counter.saturating_add(1); + self.model_counter = want; + (want != next).then_some(want) + } + /// Runs (and hyperlinks, fields' results) of a paragraph. fn inline(&mut self, p: &El, st: &mut Story, allow_notes: bool) { for e in p.els() { @@ -302,7 +348,9 @@ impl Ctx { "footnoteReference" if allow_notes => { if let Some(note) = e.attr("id").and_then(|id| self.footnotes.get(id).cloned()) { let mut ns = Story::new(StoryId(0)); + let counter = std::mem::take(&mut self.model_counter); self.blocks(¬e, &mut ns, false); + self.model_counter = counter; // Word puts the reference mark in the note text: we generate the number. let len = st.len(); let id = st.insert_note(len, "", ParaFormat::default()); @@ -328,17 +376,63 @@ impl Ctx { } } +/// Largest `word/numbering.xml` read. +const NUMBERING_LIMIT: u64 = 16 * 1024 * 1024; +/// Deepest `w:basedOn` chain followed (a cycle stops here too). +const MAX_STYLE_DEPTH: usize = 32; + +/// Each paragraph style's list: the first `w:numId` and `w:ilvl` on its `w:basedOn` chain. +fn style_lists(styles: &El) -> HashMap, Option)> { + let by_id: HashMap<&str, &El> = styles.els().filter(|s| s.name == "style").filter_map(|s| Some((s.attr("styleId")?, s))).collect(); + let mut out = HashMap::new(); + for (id, s) in &by_id { + let (mut num_id, mut ilvl) = (None, None); + let mut cur = Some(*s); + for _ in 0..MAX_STYLE_DEPTH { + let Some(c) = cur else { break }; + if let Some(n) = c.child("pPr").and_then(|p| p.child("numPr")) { + num_id = num_id.or_else(|| n.val("numId")); + ilvl = ilvl.or_else(|| n.val("ilvl")); + } + if num_id.is_some() && ilvl.is_some() { + break; + } + cur = c.val("basedOn").and_then(|b| by_id.get(b.as_str()).copied()); + } + if num_id.is_some() { + out.insert(id.to_string(), (num_id, ilvl)); + } + } + out +} + pub fn import(bytes: &[u8]) -> Result { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?; let doc = part(&mut zip, "word/document.xml")?.ok_or_else(|| ImportError::Corrupt("no word/document.xml (not a Word document)".into()))?; let doc = parse(&doc)?; // Styles. - let mut ctx = Ctx { para_names: HashMap::new(), char_names: HashMap::new(), footnotes: HashMap::new(), warnings: vec![], next_table: 0 }; + let mut ctx = Ctx { + para_names: HashMap::new(), + char_names: HashMap::new(), + footnotes: HashMap::new(), + warnings: vec![], + next_table: 0, + numbering: Numbering::default(), + style_lists: HashMap::new(), + model_counter: 0, + }; + // 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()) { + Ok(Some(n)) => ctx.numbering = Numbering::parse(&n), + Ok(None) => {} + Err(_) => ctx.warnings.push("The Word file's list definitions could not be read: lists were imported as plain paragraphs.".into()), + } let mut para_styles = Vec::new(); let mut char_styles = Vec::new(); if let Some(styles) = part(&mut zip, "word/styles.xml")?.map(|s| parse(&s)).transpose()? { let ids: HashMap = styles.els().filter(|s| s.name == "style").filter_map(|s| Some((s.attr("styleId")?.to_string(), s.val("name")?))).collect(); + ctx.style_lists = style_lists(&styles); for s in styles.els().filter(|s| s.name == "style") { let (Some(id), Some(name)) = (s.attr("styleId"), s.val("name")) else { continue }; let kind = s.attr("type").unwrap_or("paragraph"); @@ -404,9 +498,14 @@ mod tests { use std::io::Write; pub(crate) fn docx(document: &str, styles: &str, footnotes: &str) -> Vec { + docx_parts(&[("word/document.xml", document), ("word/styles.xml", styles), ("word/footnotes.xml", footnotes)]) + } + + /// A zip of `(part name, XML)`; empty parts are left out. + fn docx_parts(parts: &[(&str, &str)]) -> Vec { let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default(); - for (n, c) in [("word/document.xml", document), ("word/styles.xml", styles), ("word/footnotes.xml", footnotes)] { + for &(n, c) in parts { if c.is_empty() { continue; } @@ -510,4 +609,187 @@ mod tests { i.story.check().unwrap(); assert_eq!(i.story.text, "Kept text. MOVED HERE. Plain INSERTED\nEnd."); } + + /// A paragraph in a Word list: `w:numPr` with `numId` and, unless `None`, `ilvl`. + fn list_p(num_id: &str, ilvl: Option<&str>, text: &str) -> String { + let ilvl = ilvl.map(|l| format!(r#""#)).unwrap_or_default(); + format!(r#"{ilvl}{text}"#) + } + + fn plain_p(text: &str) -> String { + format!(r#"{text}"#) + } + + fn lists_docx(numbering: &str, styles: &str, body: &[String]) -> Imported { + let document = format!(r#"{}"#, body.concat()); + let i = import(&docx_parts(&[("word/document.xml", &document), ("word/styles.xml", styles), ("word/numbering.xml", numbering)])).unwrap(); + i.story.check().unwrap(); + 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(), + } + } + + /// Regression (#626): pandoc's Markdown list example lost its bullets and numbers. + #[test] + fn bullets_and_numbers_from_numbering_xml() { + let numbering = format!( + r#" + + + + + + + "# + ); + let body = [ + plain_p("Intro paragraph."), + list_p("1000", Some("0"), "first item"), + list_p("1000", Some("0"), "second item"), + list_p("1001", Some("0"), "step one"), + list_p("1001", Some("0"), "step two"), + ]; + 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!(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())); + // 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))); + } + + /// Level 1 inside level 0, parent numbers in a label, numbering that carries on past a plain + /// paragraph, and a `num` with a start override restarting. + #[test] + fn nested_levels_and_restarts() { + let numbering = format!( + r#" + + + + + + + + + + "# + ); + let body = [ + list_p("1", None, "A"), + list_p("1", Some("1"), "B"), + list_p("1", Some("1"), "C"), + list_p("1", Some("2"), "D"), + list_p("1", Some("0"), "E"), + plain_p("Between"), + list_p("1", Some("0"), "F"), + list_p("2", Some("0"), "G"), + list_p("3", Some("0"), "H"), + ]; + 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"); + assert_eq!(st.paras[1].para.left_indent, Some(72.0)); + assert_eq!(st.paras[1].para.first_line_indent, Some(-18.0)); + } + + /// 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] + fn style_lists_and_num_id_zero() { + let numbering = format!( + r#" + + + + + + "# + ); + let styles = format!( + r#" + + + + "# + ); + let styled = + |id: &str, extra: &str, text: &str| format!(r#"{extra}{text}"#); + let body = [ + styled("ListBullet", "", "one"), + styled("ListBullet2", "", "two"), + 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!(st.paras[2].para.list_type, None); + assert_eq!(st.paras[2].style, "List Bullet"); + } + + /// Hostile list definitions: missing abstractNums and levels, huge or negative levels and + /// starts, a long label, a `w:basedOn` cycle. No panic; what can't be a list is plain. + #[test] + fn hostile_numbering_falls_back_to_plain_paragraphs() { + let long = "x".repeat(10_000); + let numbering = format!( + r#" + + + + + + + + + + + "# + ); + let styles = format!( + r#" + + + "# + ); + let body = [ + list_p("2", None, "missing abstractNum"), + list_p("3", None, "num without abstractNum"), + list_p("404", None, "unknown numId"), + list_p("1", Some("99999999999999"), "huge level"), + list_p("1", Some("9"), "level 9"), + list_p("1", Some("-1"), "negative level"), + list_p("1", Some("1"), "undefined level"), + r#"cycle"#.to_string(), + list_p("1", None, "huge start"), + list_p("1", Some("3"), "negative start"), + ]; + let st = lists_docx(&numbering, &styles, &body).story; + assert!(st.paras[..8].iter().all(|p| p.para.list_type.is_none()), "{:?}", st.paras); + assert_eq!(st.paras[8].para.start_at, Some(Some(u32::MAX))); + 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"); + // 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(); + assert_eq!(i.story.paras[0].para.list_type, None); + assert_eq!(i.warnings.len(), 1); + } } diff --git a/crates/textimport/src/docx_numbering.rs b/crates/textimport/src/docx_numbering.rs new file mode 100644 index 00000000..31b855b0 --- /dev/null +++ b/crates/textimport/src/docx_numbering.rs @@ -0,0 +1,325 @@ +//! Word lists: `word/numbering.xml` and the `w:numPr` of paragraphs and paragraph styles. +//! +//! A `w:num` (numId) points at a `w:abstractNum`, whose `w:lvl` elements (levels 0–8) give +//! each level's format, label text, start number, indents and the text after the label. +//! +//! Numbering follows Word's main rules, simplified: +//! - Numbers count per level. A paragraph at level L restarts the levels below it (unless a +//! level's `w:lvlRestart` says otherwise). +//! - `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. + +use std::collections::HashMap; + +use designcraft_doc::{ListType, NumberStyle, ParaAttrs}; + +use crate::docx::El; + +/// Most `w:abstractNum` or `w:num` elements read; the rest are ignored. +pub(crate) const MAX_DEFS: usize = 10_000; +/// Word's list levels are 0–8. +const LEVELS: usize = 9; +/// Longest label text (`w:lvlText`) kept, in characters. +const MAX_LVL_TEXT: usize = 64; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Format { + #[default] + Bullet, + /// No number: the label is the level text alone. + Nothing, + Number(NumberStyle), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Suffix { + #[default] + Tab, + Space, + Nothing, +} + +#[derive(Clone, Debug, Default)] +struct Level { + format: Format, + text: String, + start: u32, + suffix: Suffix, + left: Option, + /// First-line indent in points (negative for a hanging indent). + first_line: Option, + /// `w:lvlRestart`: 0 = never restart, n = restart after level n (1-based) or a higher one. + restart: Option, + /// `w:isLgl`: parent levels in the label are written as decimal numbers. + legal: bool, + /// The font of the label (`w:rPr/w:rFonts`), which decides what a Symbol/Wingdings bullet is. + font: Option, + picture: bool, +} + +#[derive(Debug, Default)] +struct Num { + abstract_id: String, + /// `w:lvlOverride` per level: a start override and/or a replacement level. + start_override: [Option; LEVELS], + level_override: [Option; LEVELS], +} + +impl Num { + fn has_start_override(&self) -> bool { + self.start_override.iter().any(Option::is_some) + } +} + +/// A list paragraph: the paragraph attributes that make it one, and its number in Word. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ListPara { + pub(crate) attrs: ParaAttrs, + /// The paragraph's number at its level (also counted for bullets). + pub(crate) number: u32, +} + +/// The lists of a Word document and the numbering state while its paragraphs are read in order. +#[derive(Debug, Default)] +pub(crate) struct Numbering { + abstracts: HashMap; LEVELS]>, + nums: HashMap, + /// Current number per level, per counter key (an abstractNum, or a `num` with overrides). + counters: HashMap; LEVELS]>, +} + +fn level_index(e: &El) -> Option { + e.attr("ilvl").and_then(|v| v.trim().parse::().ok()).filter(|l| *l < LEVELS) +} + +fn parse_u32(v: Option) -> Option { + let v = v?; + let v = v.trim(); + // Negative starts count from 0; numbers too large for a u32 are clamped. + match v.parse::() { + Ok(n) => Some(u32::try_from(n.max(0)).unwrap_or(u32::MAX)), + Err(_) => None, + } +} + +fn number_style(fmt: &str) -> Format { + match fmt { + "bullet" => Format::Bullet, + "none" => Format::Nothing, + "decimalZero" => Format::Number(NumberStyle::ArabicLeadingZero), + "upperRoman" => Format::Number(NumberStyle::UpperRoman), + "lowerRoman" => Format::Number(NumberStyle::LowerRoman), + "upperLetter" => Format::Number(NumberStyle::UpperLetters), + "lowerLetter" => Format::Number(NumberStyle::LowerLetters), + "chicago" => Format::Number(NumberStyle::Symbols), + // decimal and every format the model has no style for (ordinal, cardinalText, + // decimalEnclosedCircle, the East Asian counting systems, …). + _ => Format::Number(NumberStyle::Arabic), + } +} + +fn parse_level(e: &El) -> Level { + let tw = |v: Option<&str>| v.and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0); + let ind = e.child("pPr").and_then(|p| p.child("ind")); + Level { + format: e.val("numFmt").map_or(Format::Number(NumberStyle::Arabic), |f| number_style(&f)), + text: e.child("lvlText").and_then(|t| t.attr("val")).unwrap_or_default().chars().take(MAX_LVL_TEXT).collect(), + start: parse_u32(e.val("start")).unwrap_or(1), + suffix: match e.val("suff").as_deref() { + Some("space") => Suffix::Space, + Some("nothing") => Suffix::Nothing, + _ => Suffix::Tab, + }, + left: ind.and_then(|i| tw(i.attr("left").or(i.attr("start")))), + first_line: ind.and_then(|i| tw(i.attr("firstLine")).or(tw(i.attr("hanging")).map(|h| -h))), + restart: parse_u32(e.val("lvlRestart")), + legal: e.on("isLgl") == Some(true), + font: e.child("rPr").and_then(|r| r.child("rFonts")).and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))).map(str::to_string), + picture: e.child("lvlPicBulletId").is_some(), + } +} + +/// The bullet Word shows for `text` in `font`. Symbol and Wingdings bullets are stored as +/// private-use characters (U+F0xx) or as the font's own code points; the common ones become +/// their Unicode look-alikes, unknown ones a plain bullet. +fn bullet_char(text: &str, font: Option<&str>) -> String { + let font = font.unwrap_or_default().to_ascii_lowercase(); + let symbol = font.contains("symbol"); + let wingdings = font.contains("wingdings"); + text.chars() + .map(|c| { + let code = u32::from(c); + let low = if (0xF000..=0xF0FF).contains(&code) { code - 0xF000 } else { code }; + if wingdings { + return match low { + 0x6C => '●', + 0x6E => '■', + 0x71 => '❑', + 0x76 => '❖', + 0xA7 => '▪', + 0xD8 => '➢', + 0xFC => '✓', + _ => '•', + }; + } + if symbol { + return match low { + 0x2D => '–', + 0x6F => '◦', + _ => '•', + }; + } + match c { + // Word's second-level bullet is a Courier New "o". + 'o' => '◦', + '\u{E000}'..='\u{F8FF}' => '•', + c => c, + } + }) + .filter(|c| *c != '\n' && *c != '\r') + .collect() +} + +impl Numbering { + /// Read `numbering.xml`'s root (`w:numbering`). + pub(crate) fn parse(root: &El) -> Self { + let mut n = Numbering::default(); + for e in root.els() { + match e.name.as_str() { + "abstractNum" if n.abstracts.len() < MAX_DEFS => { + let Some(id) = e.attr("abstractNumId") else { continue }; + let mut levels: [Option; LEVELS] = Default::default(); + for l in e.els().filter(|l| l.name == "lvl") { + if let Some(slot) = level_index(l).and_then(|i| levels.get_mut(i)) { + *slot = Some(parse_level(l)); + } + } + n.abstracts.insert(id.to_string(), levels); + } + "num" if n.nums.len() < MAX_DEFS => { + let (Some(id), Some(abstract_id)) = (e.attr("numId"), e.val("abstractNumId")) else { continue }; + let mut num = Num { abstract_id, ..Default::default() }; + for o in e.els().filter(|o| o.name == "lvlOverride") { + let Some(i) = level_index(o) else { continue }; + if let Some(slot) = num.start_override.get_mut(i) { + *slot = parse_u32(o.val("startOverride")); + } + if let (Some(slot), Some(l)) = (num.level_override.get_mut(i), o.child("lvl")) { + *slot = Some(parse_level(l)); + } + } + n.nums.insert(id.to_string(), num); + } + _ => {} + } + } + n + } + + /// The list formatting of the next paragraph with `w:numId` `num_id` at level `ilvl`, counting + /// it. `None` (a plain paragraph) for numId 0, an unknown list or level, or a level past 8. + pub(crate) fn next(&mut self, num_id: &str, ilvl: u32) -> Option { + let lv = usize::try_from(ilvl).ok().filter(|l| *l < LEVELS)?; + let num = self.nums.get(num_id)?; + let abs = self.abstracts.get(&num.abstract_id)?; + let level = |k: usize| num.level_override.get(k).and_then(Option::as_ref).or_else(|| abs.get(k).and_then(Option::as_ref)); + let def = level(lv)?; + let own = num.has_start_override(); + let key = if own { format!("n:{num_id}") } else { format!("a:{}", num.abstract_id) }; + let start = |k: usize| { + let o = if own { num.start_override.get(k).copied().flatten() } else { None }; + o.or_else(|| level(k).map(|l| l.start)).unwrap_or(1) + }; + let counters = self.counters.entry(key).or_default(); + let n = counters.get(lv).copied().flatten().map_or_else(|| start(lv), |v| v.saturating_add(1)); + if let Some(c) = counters.get_mut(lv) { + *c = Some(n); + } + for (m, c) in counters.iter_mut().enumerate().skip(lv + 1) { + let restart = match level(m).and_then(|l| l.restart) { + None => true, + Some(0) => false, + Some(r) => ilvl < r, + }; + if restart { + *c = None; + } + } + // Parent levels' current numbers, as text, for the label. + let parent = |k: usize| -> String { + let v = counters.get(k).copied().flatten().unwrap_or_else(|| start(k)); + match level(k).map(|l| l.format) { + Some(Format::Number(s)) if !def.legal => s.format(v), + Some(Format::Number(_)) => v.to_string(), + _ => String::new(), + } + }; + let label = |own_level: &str, escape: bool| -> String { + let mut out = String::new(); + let mut it = def.text.chars().peekable(); + while let Some(c) = it.next() { + if c == '%' + && let Some(k) = it.peek().and_then(|d| d.to_digit(10)).filter(|d| (1..=9).contains(d)) + { + it.next(); + let k = k as usize - 1; + if k == lv { + out.push_str(own_level); + } else { + let p = parent(k); + out.push_str(&if escape { p.replace('^', "^^") } else { p }); + } + } else if escape && c == '^' { + out.push_str("^^"); + } else if c != '\n' && c != '\r' { + out.push(c); + } + } + out + }; + // The model's defaults: `^t` after a number, a literal tab after a bullet. + let (number_sep, bullet_sep) = match def.suffix { + Suffix::Tab => ("^t", "\t"), + Suffix::Space => (" ", " "), + Suffix::Nothing => ("", ""), + }; + let mut a = ParaAttrs::default(); + 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))); + } + Format::Bullet => { + a.list_type = Some(ListType::Bullets); + a.bullet_char = Some(if def.picture { "•".into() } else { bullet_char(&def.text, def.font.as_deref()) }); + a.list_separator = Some(bullet_sep.into()); + } + Format::Nothing => { + a.list_type = Some(ListType::Bullets); + a.bullet_char = Some(label("", 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 }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn symbol_and_wingdings_bullets_become_unicode() { + assert_eq!(bullet_char("\u{F0B7}", Some("Symbol")), "•"); + assert_eq!(bullet_char("\u{F0A7}", Some("Wingdings")), "▪"); + assert_eq!(bullet_char("o", Some("Courier New")), "◦"); + assert_eq!(bullet_char("–", None), "–"); + assert_eq!(bullet_char("\u{F076}", None), "•"); + } +} diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs index 22f79c51..ba9cb92e 100644 --- a/crates/textimport/src/lib.rs +++ b/crates/textimport/src/lib.rs @@ -1,6 +1,6 @@ //! Text import (File › Place of text files): plain text, Word documents (`.docx`) and RTF become //! a frameless [`Story`] with paragraph and character styles, local bold/italic/underline/size, -//! footnotes (Word) and tables (Word), ready to flow into text frames. +//! footnotes, tables and bullets/numbering (Word), ready to flow into text frames. //! //! Word styles are imported by name (their font, size, bold/italic and space before/after become //! the style definition, like InDesign's "Use Word styles" import option). RTF keeps bold, @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] mod docx; +mod docx_numbering; pub mod export; mod rtf; pub mod tagged; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fc8fbc3a..ae8f8c91 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), 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; parent levels in a label are fixed text), 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% |