Word import: bullets and numbering from numbering.xml (#626)

Paragraphs with w:numPr, directly or through their paragraph style, become
bullet or numbered paragraphs: Word's number formats, label text, start
numbers and overrides, level restarts, the text after the label and the
level's indents. Parent levels in a label are written as text. Unreadable or
hostile list definitions leave plain paragraphs.
This commit is contained in:
aiongg
2026-10-12 20:59:17 +00:00
parent 17cf5b4e91
commit 7515dfeaf5
5 changed files with 617 additions and 8 deletions
+1
View File
@@ -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.
+288 -6
View File
@@ -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()
}
/// `<w:x w:val="…"/>` → the value; a bare `<w:b/>` counts as "on".
fn val(&self, name: &str) -> Option<String> {
pub(crate) fn val(&self, name: &str) -> Option<String> {
self.child(name).map(|e| e.attr("val").unwrap_or("true").to_string())
}
fn on(&self, name: &str) -> Option<bool> {
pub(crate) fn on(&self, name: &str) -> Option<bool> {
self.val(name).map(|v| !matches!(v.as_str(), "0" | "false" | "none"))
}
}
@@ -216,6 +217,12 @@ struct Ctx {
footnotes: HashMap<String, El>,
warnings: Vec<String>,
next_table: u64,
numbering: Numbering,
/// A paragraph style's list (`w:numPr` numId and ilvl, through `w:basedOn`), by style id.
style_lists: HashMap<String, (Option<String>, Option<String>)>,
/// 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<Vec<String>> = 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<ListPara> {
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::<u32>().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<u32> {
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(&note, &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<String, (Option<String>, Option<String>)> {
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<Imported, ImportError> {
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<String, String> =
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<u8> {
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<u8> {
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#"<w:ilvl w:val="{l}"/>"#)).unwrap_or_default();
format!(r#"<w:p><w:pPr><w:numPr>{ilvl}<w:numId w:val="{num_id}"/></w:numPr></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#)
}
fn plain_p(text: &str) -> String {
format!(r#"<w:p><w:r><w:t>{text}</w:t></w:r></w:p>"#)
}
fn lists_docx(numbering: &str, styles: &str, body: &[String]) -> Imported {
let document = format!(r#"<w:document {W}><w:body>{}</w:body></w:document>"#, 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#"<w:numbering {W}>
<w:abstractNum w:abstractNumId="990"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/>
<w:pPr><w:ind w:left="480" w:hanging="480"/></w:pPr></w:lvl></w:abstractNum>
<w:abstractNum w:abstractNumId="991"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/>
<w:pPr><w:ind w:left="480" w:hanging="480"/></w:pPr></w:lvl></w:abstractNum>
<w:num w:numId="1000"><w:abstractNumId w:val="990"/></w:num>
<w:num w:numId="1001"><w:abstractNumId w:val="991"/><w:lvlOverride w:ilvl="0"><w:startOverride w:val="1"/></w:lvlOverride></w:num>
</w:numbering>"#
);
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<Option<ListType>> = 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#"<w:numbering {W}>
<w:abstractNum w:abstractNumId="1">
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/></w:lvl>
<w:lvl w:ilvl="1"><w:start w:val="1"/><w:numFmt w:val="lowerLetter"/><w:lvlText w:val="%2)"/><w:suff w:val="space"/>
<w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl>
<w:lvl w:ilvl="2"><w:start w:val="1"/><w:numFmt w:val="upperRoman"/><w:lvlText w:val="%1.%2.%3"/></w:lvl>
</w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num>
<w:num w:numId="2"><w:abstractNumId w:val="1"/><w:lvlOverride w:ilvl="0"><w:startOverride w:val="1"/></w:lvlOverride></w:num>
<w:num w:numId="3"><w:abstractNumId w:val="1"/></w:num>
</w:numbering>"#
);
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<Option<Option<u32>>> = 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#"<w:numbering {W}>
<w:abstractNum w:abstractNumId="7">
<w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/><w:lvlText w:val="&#xF0B7;"/><w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol"/></w:rPr></w:lvl>
<w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:lvlText w:val="o"/><w:rPr><w:rFonts w:ascii="Courier New"/></w:rPr></w:lvl>
</w:abstractNum>
<w:num w:numId="5"><w:abstractNumId w:val="7"/></w:num>
</w:numbering>"#
);
let styles = format!(
r#"<w:styles {W}>
<w:style w:type="paragraph" w:styleId="ListBullet"><w:name w:val="List Bullet"/><w:pPr><w:numPr><w:numId w:val="5"/></w:numPr></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="ListBullet2"><w:name w:val="List Bullet 2"/><w:basedOn w:val="ListBullet"/>
<w:pPr><w:numPr><w:ilvl w:val="1"/></w:numPr></w:pPr></w:style>
</w:styles>"#
);
let styled =
|id: &str, extra: &str, text: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{id}"/>{extra}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
let body = [
styled("ListBullet", "", "one"),
styled("ListBullet2", "", "two"),
styled("ListBullet", r#"<w:numPr><w:ilvl w:val="0"/><w:numId w:val="0"/></w:numPr>"#, "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#"<w:numbering {W}>
<w:abstractNum w:abstractNumId="1">
<w:lvl w:ilvl="0"><w:start w:val="99999999999999"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1{long}%9%"/></w:lvl>
<w:lvl w:ilvl="99999999999999"><w:numFmt w:val="decimal"/></w:lvl>
<w:lvl w:ilvl="-1"><w:numFmt w:val="decimal"/></w:lvl>
<w:lvl w:ilvl="3"><w:start w:val="-5"/><w:numFmt w:val="ordinalText"/><w:lvlText w:val="%4^"/><w:lvlRestart w:val="99"/></w:lvl>
</w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num>
<w:num w:numId="2"><w:abstractNumId w:val="404"/></w:num>
<w:num w:numId="3"/>
<w:num><w:abstractNumId w:val="1"/></w:num>
</w:numbering>"#
);
let styles = format!(
r#"<w:styles {W}>
<w:style w:type="paragraph" w:styleId="A"><w:name w:val="A"/><w:basedOn w:val="B"/></w:style>
<w:style w:type="paragraph" w:styleId="B"><w:name w:val="B"/><w:basedOn w:val="A"/></w:style>
</w:styles>"#
);
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#"<w:p><w:pPr><w:pStyle w:val="A"/><w:numPr><w:ilvl w:val="0"/></w:numPr></w:pPr><w:r><w:t>cycle</w:t></w:r></w:p>"#.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#"<w:document {W}><w:body>{}</w:body></w:document>"#, list_p("1", None, "x"));
let i = import(&docx_parts(&[("word/document.xml", &document), ("word/numbering.xml", "<w:numbering></w:num>")])).unwrap();
assert_eq!(i.story.paras[0].para.list_type, None);
assert_eq!(i.warnings.len(), 1);
}
}
+325
View File
@@ -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<f64>,
/// First-line indent in points (negative for a hanging indent).
first_line: Option<f64>,
/// `w:lvlRestart`: 0 = never restart, n = restart after level n (1-based) or a higher one.
restart: Option<u32>,
/// `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<String>,
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<u32>; LEVELS],
level_override: [Option<Level>; 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<String, [Option<Level>; LEVELS]>,
nums: HashMap<String, Num>,
/// Current number per level, per counter key (an abstractNum, or a `num` with overrides).
counters: HashMap<String, [Option<u32>; LEVELS]>,
}
fn level_index(e: &El) -> Option<usize> {
e.attr("ilvl").and_then(|v| v.trim().parse::<usize>().ok()).filter(|l| *l < LEVELS)
}
fn parse_u32(v: Option<String>) -> Option<u32> {
let v = v?;
let v = v.trim();
// Negative starts count from 0; numbers too large for a u32 are clamped.
match v.parse::<i64>() {
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::<f64>().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<Level>; 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<ListPara> {
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), "•");
}
}
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -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% |