mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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(¬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<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=""/><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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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), "•");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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% |
|
||||
|
||||
Reference in New Issue
Block a user