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