mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 04:36:47 -06:00
960 lines
48 KiB
Rust
960 lines
48 KiB
Rust
//! Word (.docx, Office Open XML) → story.
|
||
|
||
use std::collections::HashMap;
|
||
use std::io::Read;
|
||
|
||
use designcraft_doc::story::{BASIC_PARAGRAPH, NO_CHAR_STYLE};
|
||
use designcraft_doc::{CharAttrs, CharFormat, ListCounter, ListType, ParaAttrs, ParaFormat, ParaProps, 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).
|
||
#[derive(Debug, Default, Clone)]
|
||
pub(crate) struct El {
|
||
pub(crate) name: String,
|
||
attrs: Vec<(String, String)>,
|
||
kids: Vec<Node>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
enum Node {
|
||
El(El),
|
||
Text(String),
|
||
}
|
||
|
||
impl El {
|
||
pub(crate) fn attr(&self, k: &str) -> Option<&str> {
|
||
self.attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str())
|
||
}
|
||
pub(crate) fn els(&self) -> impl Iterator<Item = &El> {
|
||
self.kids.iter().filter_map(|n| if let Node::El(e) = n { Some(e) } else { None })
|
||
}
|
||
pub(crate) fn child(&self, name: &str) -> Option<&El> {
|
||
self.els().find(|e| e.name == name)
|
||
}
|
||
pub(crate) fn text(&self) -> String {
|
||
self.kids
|
||
.iter()
|
||
.map(|n| match n {
|
||
Node::Text(t) => t.clone(),
|
||
Node::El(e) => e.text(),
|
||
})
|
||
.collect()
|
||
}
|
||
/// `<w:x w:val="…"/>` → the value; a bare `<w:b/>` counts as "on".
|
||
pub(crate) fn val(&self, name: &str) -> Option<String> {
|
||
self.child(name).map(|e| e.attr("val").unwrap_or("true").to_string())
|
||
}
|
||
pub(crate) fn on(&self, name: &str) -> Option<bool> {
|
||
self.val(name).map(|v| !matches!(v.as_str(), "0" | "false" | "none"))
|
||
}
|
||
}
|
||
|
||
fn local(n: &[u8]) -> String {
|
||
let s = String::from_utf8_lossy(n);
|
||
s.rsplit(':').next().unwrap_or(&s).to_string()
|
||
}
|
||
|
||
pub(crate) fn parse(xml: &str) -> Result<El, ImportError> {
|
||
let mut r = quick_xml::Reader::from_str(xml);
|
||
let mut stack: Vec<El> = vec![El::default()];
|
||
loop {
|
||
match r.read_event().map_err(|e| ImportError::Corrupt(e.to_string()))? {
|
||
Event::Start(e) => {
|
||
let el = El {
|
||
name: local(e.name().as_ref()),
|
||
attrs: e
|
||
.attributes()
|
||
.flatten()
|
||
.map(|a| {
|
||
(local(a.key.as_ref()), a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map(|v| v.to_string()).unwrap_or_default())
|
||
})
|
||
.collect(),
|
||
kids: vec![],
|
||
};
|
||
stack.push(el);
|
||
}
|
||
Event::Empty(e) => {
|
||
let el = El {
|
||
name: local(e.name().as_ref()),
|
||
attrs: e
|
||
.attributes()
|
||
.flatten()
|
||
.map(|a| {
|
||
(local(a.key.as_ref()), a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map(|v| v.to_string()).unwrap_or_default())
|
||
})
|
||
.collect(),
|
||
kids: vec![],
|
||
};
|
||
if let Some(p) = stack.last_mut() {
|
||
p.kids.push(Node::El(el));
|
||
}
|
||
}
|
||
Event::End(_) => {
|
||
let el = stack.pop().unwrap_or_default();
|
||
match stack.last_mut() {
|
||
Some(p) => p.kids.push(Node::El(el)),
|
||
None => return Ok(el),
|
||
}
|
||
}
|
||
Event::Text(t) => {
|
||
let s = t.decode().map(|s| s.to_string()).unwrap_or_default();
|
||
let s = quick_xml::escape::unescape(&s).map(|s| s.to_string()).unwrap_or(s);
|
||
if let Some(p) = stack.last_mut() {
|
||
p.kids.push(Node::Text(s));
|
||
}
|
||
}
|
||
Event::GeneralRef(e) => {
|
||
let name = String::from_utf8_lossy(&e).to_string();
|
||
let s = match name.as_str() {
|
||
"amp" => "&".to_string(),
|
||
"lt" => "<".into(),
|
||
"gt" => ">".into(),
|
||
"quot" => "\"".into(),
|
||
"apos" => "'".into(),
|
||
n if n.starts_with("#x") => u32::from_str_radix(&n[2..], 16).ok().and_then(char::from_u32).map(String::from).unwrap_or_default(),
|
||
n if n.starts_with('#') => n[1..].parse::<u32>().ok().and_then(char::from_u32).map(String::from).unwrap_or_default(),
|
||
_ => String::new(),
|
||
};
|
||
if let Some(p) = stack.last_mut() {
|
||
p.kids.push(Node::Text(s));
|
||
}
|
||
}
|
||
Event::Eof => break,
|
||
_ => {}
|
||
}
|
||
}
|
||
let mut root = stack.pop().unwrap_or_default();
|
||
Ok(root.kids.drain(..).find_map(|n| if let Node::El(e) = n { Some(e) } else { None }).unwrap_or_default())
|
||
}
|
||
|
||
/// Largest decompressed zip part read (a small file can inflate to gigabytes).
|
||
pub(crate) const PART_LIMIT: u64 = 64 * 1024 * 1024;
|
||
|
||
/// A zip part as text. `Ok(None)` when the part is missing or isn't text; an error when it
|
||
/// decompresses to more than [`PART_LIMIT`].
|
||
pub(crate) fn part(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str) -> Result<Option<String>, ImportError> {
|
||
part_capped(zip, name, PART_LIMIT)
|
||
}
|
||
|
||
pub(crate) fn part_capped(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str, limit: u64) -> Result<Option<String>, ImportError> {
|
||
let Ok(f) = zip.by_name(name) else { return Ok(None) };
|
||
let mut s = String::new();
|
||
// Read one byte past the limit to tell "exactly at the limit" from "over it".
|
||
if f.take(limit.saturating_add(1)).read_to_string(&mut s).is_err() {
|
||
return Ok(None);
|
||
}
|
||
if s.len() as u64 > limit {
|
||
return Err(ImportError::Corrupt(format!("{name} is larger than {} MiB uncompressed", limit / (1024 * 1024))));
|
||
}
|
||
Ok(Some(s))
|
||
}
|
||
|
||
/// Character attributes from `<w:rPr>`.
|
||
fn run_attrs(rpr: Option<&El>) -> CharAttrs {
|
||
let mut a = CharAttrs::default();
|
||
let Some(r) = rpr else { return a };
|
||
let bold = r.on("b");
|
||
let italic = r.on("i");
|
||
a.font_style = match (bold, italic) {
|
||
(Some(true), Some(true)) => Some("Bold Italic".into()),
|
||
(Some(true), _) => Some("Bold".into()),
|
||
(_, Some(true)) => Some("Italic".into()),
|
||
(Some(false), Some(false)) | (Some(false), None) | (None, Some(false)) => Some("Regular".into()),
|
||
_ => None,
|
||
};
|
||
if let Some(u) = r.val("u") {
|
||
a.underline = Some(u != "none" && u != "0" && u != "false");
|
||
}
|
||
if let Some(s) = r.on("strike") {
|
||
a.strikethrough = Some(s);
|
||
}
|
||
if let Some(sz) = r.val("sz").and_then(|v| v.parse::<f64>().ok()) {
|
||
a.size = Some(sz / 2.0);
|
||
}
|
||
if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) {
|
||
a.font_family = Some(f.to_string());
|
||
}
|
||
if r.on("caps") == Some(true) {
|
||
a.capitalization = Some(designcraft_doc::Capitalization::AllCaps);
|
||
}
|
||
if let Some(v) = r.val("vertAlign") {
|
||
a.position = Some(match v.as_str() {
|
||
"superscript" => designcraft_doc::Position::Superscript,
|
||
"subscript" => designcraft_doc::Position::Subscript,
|
||
_ => designcraft_doc::Position::Normal,
|
||
});
|
||
}
|
||
a
|
||
}
|
||
|
||
/// Paragraph attributes from `<w:pPr>` (spacing in twips = 1/20 pt, indents likewise).
|
||
fn para_attrs(ppr: Option<&El>) -> ParaAttrs {
|
||
let mut a = ParaAttrs::default();
|
||
let Some(p) = ppr else { return a };
|
||
let tw = |v: Option<&str>| v.and_then(|v| v.parse::<f64>().ok()).map(|v| v / 20.0);
|
||
if let Some(sp) = p.child("spacing") {
|
||
a.space_before = tw(sp.attr("before"));
|
||
a.space_after = tw(sp.attr("after"));
|
||
}
|
||
if let Some(ind) = p.child("ind") {
|
||
a.left_indent = tw(ind.attr("left").or(ind.attr("start")));
|
||
a.right_indent = tw(ind.attr("right").or(ind.attr("end")));
|
||
a.first_line_indent = tw(ind.attr("firstLine")).or(tw(ind.attr("hanging")).map(|h| -h));
|
||
}
|
||
if let Some(j) = p.val("jc") {
|
||
a.align = Some(match j.as_str() {
|
||
"center" => designcraft_doc::Align::Center,
|
||
"right" | "end" => designcraft_doc::Align::Right,
|
||
"both" | "distribute" => designcraft_doc::Align::LeftJustified,
|
||
_ => designcraft_doc::Align::Left,
|
||
});
|
||
}
|
||
a
|
||
}
|
||
|
||
struct Ctx {
|
||
para_names: HashMap<String, String>,
|
||
char_names: HashMap<String, String>,
|
||
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.
|
||
/// The model's unnamed-list numbering as composition will count it.
|
||
model_counter: ListCounter,
|
||
/// The style of paragraphs without `w:pStyle` (Word's default paragraph style, usually Normal).
|
||
default_para: String,
|
||
}
|
||
|
||
impl Ctx {
|
||
fn para_style(&self, ppr: Option<&El>) -> String {
|
||
ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.para_names.get(&id).cloned()).unwrap_or_else(|| self.default_para.clone())
|
||
}
|
||
|
||
/// Append the paragraphs and tables of `body` to `st`.
|
||
fn blocks(&mut self, body: &El, st: &mut Story, allow_notes: bool) {
|
||
let mut first = st.is_empty();
|
||
for e in body.els() {
|
||
match e.name.as_str() {
|
||
"p" => {
|
||
if !first {
|
||
let len = st.len();
|
||
st.insert(len, "\n");
|
||
}
|
||
first = false;
|
||
let ppr = e.child("pPr");
|
||
let style = self.para_style(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);
|
||
let (start_at, expression) = self.start_at(list.as_ref(), st.len() == at);
|
||
if let Some(n) = start_at {
|
||
st.format_paras(at..at, |f| f.para.start_at = Some(Some(n)));
|
||
}
|
||
if let Some(e) = expression {
|
||
st.format_paras(at..at, |f| f.para.number_expression = Some(e.clone()));
|
||
}
|
||
}
|
||
"tbl" => {
|
||
let mut rows: Vec<Vec<String>> = Vec::new();
|
||
for r in e.els().filter(|r| r.name == "tr") {
|
||
let mut row = Vec::new();
|
||
for c in r.els().filter(|c| c.name == "tc") {
|
||
let mut paras = Vec::new();
|
||
self.cell_paras(c, &mut paras);
|
||
row.push(paras.join("\n"));
|
||
}
|
||
rows.push(row);
|
||
}
|
||
if rows.is_empty() {
|
||
continue;
|
||
}
|
||
self.next_table += 1;
|
||
let t = Table::from_strings(self.next_table, &rows, 300.0, &ParaFormat::default(), &CharFormat::default());
|
||
let len = st.len();
|
||
st.insert_table(len, t);
|
||
first = false;
|
||
}
|
||
"sdt" => {
|
||
if let Some(c) = e.child("sdtContent") {
|
||
self.blocks(c, st, allow_notes);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The text of each paragraph in a table cell, read by the same run rules as body text.
|
||
/// Cells are plain text, so a page or column break in a cell ends its paragraph.
|
||
fn cell_paras(&mut self, el: &El, out: &mut Vec<String>) {
|
||
for e in el.els() {
|
||
match e.name.as_str() {
|
||
"p" => {
|
||
let mut st = Story::new(StoryId(0));
|
||
self.inline(e, &mut st, false);
|
||
let is_break = |c: char| matches!(c, designcraft_doc::PAGE_BREAK | designcraft_doc::COLUMN_BREAK);
|
||
out.push(st.text.chars().map(|c| if is_break(c) { '\n' } else { c }).collect());
|
||
}
|
||
"sdt" => {
|
||
if let Some(c) = e.child("sdtContent") {
|
||
self.cell_paras(c, out);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 (per level; restarted after a paragraph that isn't in a list) would give a
|
||
/// different number. And the number expression with parent levels as text, where the model's
|
||
/// numbers for those levels differ from Word's.
|
||
fn start_at(&mut self, list: Option<&ListPara>, empty: bool) -> (Option<u32>, Option<String>) {
|
||
let Some(l) = list else {
|
||
self.model_counter = ListCounter::default();
|
||
return (None, None);
|
||
};
|
||
// Bullets and empty paragraphs leave the counter alone (they show no number).
|
||
if l.attrs.list_type != Some(ListType::Numbers) || empty {
|
||
return (None, None);
|
||
}
|
||
let want = l.number.max(1);
|
||
let mut pp = ParaProps::default();
|
||
pp.apply(&l.attrs);
|
||
let mut model = self.model_counter.clone();
|
||
let start = (model.advance(&pp) != want).then_some(want);
|
||
if start.is_some() {
|
||
model = self.model_counter.clone();
|
||
pp.start_at = start;
|
||
model.advance(&pp);
|
||
}
|
||
let level_text = |k: u32| ParaProps { number_expression: format!("^{k}"), ..ParaProps::default() }.number_label_in(&model);
|
||
let expression = l.parents.iter().any(|(k, text)| level_text(*k) != *text).then(|| l.fixed_expression.clone()).flatten();
|
||
self.model_counter = model;
|
||
(start, expression)
|
||
}
|
||
|
||
/// Runs (and hyperlinks, fields' results) of a paragraph.
|
||
fn inline(&mut self, p: &El, st: &mut Story, allow_notes: bool) {
|
||
for e in p.els() {
|
||
match e.name.as_str() {
|
||
"r" => self.run(e, st, allow_notes),
|
||
// Tracked changes import as accepted: insertions and move destinations are kept,
|
||
// deletions (`del`) and move sources (`moveFrom`) are dropped.
|
||
"hyperlink" | "smartTag" | "ins" | "moveTo" | "fldSimple" | "customXml" => self.inline(e, st, allow_notes),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn run(&mut self, r: &El, st: &mut Story, allow_notes: bool) {
|
||
let rpr = r.child("rPr");
|
||
let style = rpr.and_then(|p| p.val("rStyle")).and_then(|id| self.char_names.get(&id).cloned()).unwrap_or_else(|| NO_CHAR_STYLE.into());
|
||
let fmt = CharFormat { style, over: run_attrs(rpr) };
|
||
for e in r.els() {
|
||
let text = match e.name.as_str() {
|
||
"t" => e.text(),
|
||
"tab" => "\t".into(),
|
||
"br" => match e.attr("type") {
|
||
Some("page") => designcraft_doc::PAGE_BREAK.to_string(),
|
||
Some("column") => designcraft_doc::COLUMN_BREAK.to_string(),
|
||
_ => designcraft_doc::FORCED_LINE_BREAK.to_string(),
|
||
},
|
||
"noBreakHyphen" => "\u{2011}".into(),
|
||
"softHyphen" => "\u{AD}".into(),
|
||
"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();
|
||
match st.insert_note(len, "", ParaFormat::default()) {
|
||
Some(id) => {
|
||
if let Some(n) = st.note_mut(id) {
|
||
n.text = ns;
|
||
}
|
||
}
|
||
None => self.warnings.push("A footnote in the Word file could not be imported.".into()),
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
"drawing" | "pict" | "object" => {
|
||
if !self.warnings.iter().any(|w| w.starts_with("Images")) {
|
||
self.warnings.push("Images and drawings in the Word file were not imported (place them separately).".into());
|
||
}
|
||
continue;
|
||
}
|
||
_ => continue,
|
||
};
|
||
if !text.is_empty() {
|
||
let len = st.len();
|
||
st.insert_with(len, &text, fmt.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
numbering: Numbering::default(),
|
||
style_lists: HashMap::new(),
|
||
model_counter: ListCounter::default(),
|
||
default_para: BASIC_PARAGRAPH.into(),
|
||
};
|
||
// 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);
|
||
// Word's document defaults sit beneath every paragraph style that isn't based on another.
|
||
let defaults = styles.child("docDefaults");
|
||
let default_para = para_attrs(defaults.and_then(|d| d.child("pPrDefault")).and_then(|d| d.child("pPr")));
|
||
let default_chars = run_attrs(defaults.and_then(|d| d.child("rPrDefault")).and_then(|d| d.child("rPr")));
|
||
let is_para = |s: &&El| s.name == "style" && s.attr("type").unwrap_or("paragraph") == "paragraph";
|
||
let default_id = styles
|
||
.els()
|
||
.filter(is_para)
|
||
.find(|s| matches!(s.attr("default"), Some("1" | "true" | "on")))
|
||
.or_else(|| styles.els().filter(is_para).find(|s| s.attr("styleId") == Some("Normal")))
|
||
.and_then(|s| s.attr("styleId"));
|
||
if let Some(name) = default_id.and_then(|id| ids.get(id)) {
|
||
ctx.default_para = name.clone();
|
||
}
|
||
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");
|
||
// Word's default paragraph font is "no character style": our [None].
|
||
let name = match (kind, name.as_str()) {
|
||
("character", "Default Paragraph Font") => NO_CHAR_STYLE.to_string(),
|
||
_ => name.clone(),
|
||
};
|
||
let based_on = s.val("basedOn").and_then(|b| ids.get(&b).cloned());
|
||
let mut imp = ImportedStyle { name: name.clone(), based_on, para: para_attrs(s.child("pPr")), chars: run_attrs(s.child("rPr")) };
|
||
match kind {
|
||
"paragraph" => {
|
||
ctx.para_names.insert(id.to_string(), name.clone());
|
||
if imp.based_on.is_none() {
|
||
let (mut para, mut chars) = (default_para.clone(), default_chars.clone());
|
||
para.merge(&imp.para);
|
||
chars.merge(&imp.chars);
|
||
(imp.para, imp.chars) = (para, chars);
|
||
}
|
||
para_styles.push(imp);
|
||
}
|
||
"character" => {
|
||
ctx.char_names.insert(id.to_string(), name.clone());
|
||
if name != NO_CHAR_STYLE {
|
||
char_styles.push(imp);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
// Footnotes (ids -1 / 0 are the separators).
|
||
if let Some(f) = part(&mut zip, "word/footnotes.xml")?.map(|s| parse(&s)).transpose()? {
|
||
for n in f.els().filter(|n| n.name == "footnote") {
|
||
if let Some(id) = n.attr("id").filter(|id| id.parse::<i64>().is_ok_and(|v| v > 0)) {
|
||
ctx.footnotes.insert(id.to_string(), n.clone());
|
||
}
|
||
}
|
||
}
|
||
let body = doc.child("body").ok_or_else(|| ImportError::Corrupt("no document body".into()))?;
|
||
let mut st = Story::new(StoryId(0));
|
||
ctx.blocks(body, &mut st, true);
|
||
// A page or column break ends its paragraph.
|
||
st.end_paragraphs_at_breaks();
|
||
// Only styles that are used, plus what they are based on.
|
||
let stories = || std::iter::once(&st).chain(st.notes.iter().map(|n| &n.text));
|
||
let used_p: std::collections::HashSet<String> = stories().flat_map(|s| s.paras.iter().map(|p| p.style.clone())).collect();
|
||
let used_c: std::collections::HashSet<String> = stories().flat_map(|s| s.runs().map(|(_, f)| f.style.clone())).collect();
|
||
let keep = |list: Vec<ImportedStyle>, used: &std::collections::HashSet<String>| {
|
||
let mut want: std::collections::HashSet<String> = used.clone();
|
||
loop {
|
||
let more: Vec<String> =
|
||
list.iter().filter(|s| want.contains(&s.name)).filter_map(|s| s.based_on.clone()).filter(|b| !want.contains(b)).collect();
|
||
if more.is_empty() {
|
||
break;
|
||
}
|
||
want.extend(more);
|
||
}
|
||
list.into_iter().filter(|s| want.contains(&s.name)).collect::<Vec<_>>()
|
||
};
|
||
Ok(Imported { story: st, para_styles: keep(para_styles, &used_p), char_styles: keep(char_styles, &used_c), warnings: ctx.warnings })
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
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 parts {
|
||
if c.is_empty() {
|
||
continue;
|
||
}
|
||
w.start_file(n, o).unwrap();
|
||
w.write_all(c.as_bytes()).unwrap();
|
||
}
|
||
w.finish().unwrap().into_inner()
|
||
}
|
||
|
||
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||
|
||
#[test]
|
||
fn oversized_part_is_an_error_not_a_huge_read() {
|
||
// A highly compressible part: 64 KiB of spaces inflates past a 4 KiB cap.
|
||
let big = docx(&" ".repeat(64 * 1024), "", "");
|
||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(big.as_slice())).unwrap();
|
||
assert!(matches!(part_capped(&mut zip, "word/document.xml", 4096), Err(ImportError::Corrupt(_))));
|
||
// At or under the cap reads fine; a missing part is `None`.
|
||
assert_eq!(part_capped(&mut zip, "word/document.xml", 64 * 1024).unwrap().map(|s| s.len()), Some(64 * 1024));
|
||
assert_eq!(part_capped(&mut zip, "word/missing.xml", 4096).unwrap(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn imports_styles_runs_footnotes_and_tables() {
|
||
let styles = format!(
|
||
r#"<w:styles {W}>
|
||
<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/>
|
||
<w:pPr><w:spacing w:before="240" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Unused"><w:name w:val="Unused"/></w:style>
|
||
<w:style w:type="character" w:styleId="Emph"><w:name w:val="Emphasis"/><w:rPr><w:i/></w:rPr></w:style>
|
||
</w:styles>"#
|
||
);
|
||
let document = format!(
|
||
r#"<w:document {W}><w:body>
|
||
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Title & more</w:t></w:r></w:p>
|
||
<w:p><w:r><w:t xml:space="preserve">Plain </w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>bold</w:t></w:r>
|
||
<w:r><w:rPr><w:rStyle w:val="Emph"/></w:rPr><w:t> styled</w:t></w:r><w:r><w:tab/><w:t>x</w:t></w:r>
|
||
<w:r><w:footnoteReference w:id="1"/></w:r></w:p>
|
||
<w:tbl><w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
|
||
<w:p><w:r><w:t>After</w:t></w:r></w:p>
|
||
</w:body></w:document>"#
|
||
);
|
||
let notes = format!(
|
||
r#"<w:footnotes {W}><w:footnote w:id="0"><w:p/></w:footnote>
|
||
<w:footnote w:id="1"><w:p><w:r><w:t>The note.</w:t></w:r></w:p></w:footnote></w:footnotes>"#
|
||
);
|
||
let i = import(&docx(&document, &styles, ¬es)).unwrap();
|
||
let st = &i.story;
|
||
st.check().unwrap();
|
||
assert!(st.text.starts_with("Title & more\nPlain bold styled\tx"), "{:?}", st.text);
|
||
assert!(st.text.ends_with("After"));
|
||
assert_eq!(st.paras[0].style, "heading 1");
|
||
assert_eq!(st.paras[1].style, "Normal");
|
||
let at = st.text.find("bold").unwrap();
|
||
assert_eq!(st.format_after(at).over.font_style.as_deref(), Some("Bold"));
|
||
assert_eq!(st.format_after(st.text.find("styled").unwrap()).style, "Emphasis");
|
||
assert_eq!(st.notes.len(), 1);
|
||
assert_eq!(st.notes[0].text.text, "The note.");
|
||
assert_eq!(st.tables.len(), 1);
|
||
let names: Vec<&str> = i.para_styles.iter().map(|s| s.name.as_str()).collect();
|
||
assert_eq!(names, ["Normal", "heading 1"], "unused styles are skipped");
|
||
assert_eq!(i.para_styles[1].based_on.as_deref(), Some("Normal"));
|
||
assert_eq!(i.para_styles[1].chars.size, Some(16.0));
|
||
assert_eq!(i.para_styles[1].para.space_before, Some(12.0));
|
||
assert_eq!(i.char_styles[0].chars.font_style.as_deref(), Some("Italic"));
|
||
}
|
||
|
||
#[test]
|
||
fn tracked_changes_import_as_accepted_including_moves() {
|
||
// Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import.
|
||
let document = format!(
|
||
r#"<w:document {W}><w:body>
|
||
<w:p><w:r><w:t xml:space="preserve">Kept text. </w:t></w:r>
|
||
<w:moveTo w:id="1" w:author="A"><w:r><w:t xml:space="preserve">MOVED HERE.</w:t></w:r>
|
||
<w:del w:id="5" w:author="A"><w:r><w:delText>MOVED THEN DELETED</w:delText></w:r></w:del></w:moveTo>
|
||
<w:r><w:t xml:space="preserve"> Plain </w:t></w:r>
|
||
<w:ins w:id="2" w:author="A"><w:r><w:t>INSERTED</w:t></w:r></w:ins>
|
||
<w:del w:id="3" w:author="A"><w:r><w:delText>DELETED</w:delText></w:r></w:del></w:p>
|
||
<w:p><w:moveFrom w:id="4" w:author="A"><w:r><w:t>MOVED HERE.</w:t></w:r></w:moveFrom><w:r><w:t>End.</w:t></w:r></w:p>
|
||
</w:body></w:document>"#
|
||
);
|
||
let i = import(&docx(&document, "", "")).unwrap();
|
||
i.story.check().unwrap();
|
||
assert_eq!(i.story.text, "Kept text. MOVED HERE. Plain INSERTED\nEnd.");
|
||
}
|
||
|
||
/// A page break ends its paragraph: the text after it is a paragraph of its own (same style).
|
||
#[test]
|
||
fn page_break_ends_its_paragraph() {
|
||
let styles = format!(r#"<w:styles {W}><w:style w:type="paragraph" w:styleId="H"><w:name w:val="Chapter"/></w:style></w:styles>"#);
|
||
let document = format!(
|
||
r#"<w:document {W}><w:body>
|
||
<w:p><w:r><w:t>End of one</w:t></w:r><w:r><w:br w:type="page"/></w:r></w:p>
|
||
<w:p><w:pPr><w:pStyle w:val="H"/></w:pPr><w:r><w:br w:type="page"/><w:t>Two</w:t><w:br w:type="column"/></w:r></w:p>
|
||
</w:body></w:document>"#
|
||
);
|
||
let st = import(&docx(&document, &styles, "")).unwrap().story;
|
||
st.check().unwrap();
|
||
let (pb, cb) = (designcraft_doc::PAGE_BREAK, designcraft_doc::COLUMN_BREAK);
|
||
assert_eq!(st.text, format!("End of one{pb}\n{pb}\nTwo{cb}\n"));
|
||
let styles: Vec<&str> = st.paras.iter().map(|p| p.style.as_str()).collect();
|
||
assert_eq!(styles, [BASIC_PARAGRAPH, "Chapter", "Chapter", "Chapter"]);
|
||
}
|
||
|
||
/// 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 composition gives paragraph `i`: numbered paragraphs count per level on one
|
||
/// counter that a paragraph outside a list restarts; bullets and empty paragraphs leave it.
|
||
fn label(st: &Story, i: usize) -> String {
|
||
let mut counter = designcraft_doc::ListCounter::default();
|
||
let mut label = String::new();
|
||
for (f, r) in st.paras.iter().zip(st.para_ranges()).take(i + 1) {
|
||
let mut p = designcraft_doc::ParaProps::default();
|
||
p.apply(&f.para);
|
||
label = match p.list_type {
|
||
_ if r.is_empty() => String::new(),
|
||
ListType::Numbers => {
|
||
counter.advance(&p);
|
||
p.number_label_in(&counter)
|
||
}
|
||
ListType::Bullets => p.bullet_label(),
|
||
ListType::None => {
|
||
counter = designcraft_doc::ListCounter::default();
|
||
String::new()
|
||
}
|
||
};
|
||
}
|
||
label
|
||
}
|
||
|
||
/// Regression (#626): pandoc's Markdown list example lost its bullets and numbers.
|
||
#[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), "•\t");
|
||
assert_eq!(st.paras[3].para.number_style, Some(designcraft_doc::NumberStyle::Arabic));
|
||
assert_eq!((label(&st, 3), label(&st, 4)), ("1.\t".to_string(), "2.\t".to_string()));
|
||
// The model's own counter already starts at 1 and carries on: no Start At needed.
|
||
assert_eq!((st.paras[3].para.start_at, st.paras[4].para.start_at), (None, None));
|
||
assert_eq!((st.paras[1].para.left_indent, st.paras[1].para.first_line_indent), (Some(24.0), Some(-24.0)));
|
||
}
|
||
|
||
/// 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). The
|
||
// model numbers each level on its own, so only the paragraphs after the plain one need
|
||
// Start At.
|
||
assert_eq!(start, [None, None, None, None, None, None, Some(Some(3)), Some(Some(1)), Some(Some(4))]);
|
||
let levels: Vec<Option<u32>> = st.paras.iter().map(|p| p.para.list_level).collect();
|
||
assert_eq!(levels, [Some(1), Some(2), Some(2), Some(3), Some(1), None, Some(1), Some(1), Some(1)]);
|
||
assert_eq!(label(&st, 1), "a) ");
|
||
// Parent numbers are the model's level references.
|
||
assert_eq!(st.paras[3].para.number_expression.as_deref(), Some("^1.^2.^#^t"));
|
||
assert_eq!(label(&st, 3), "1.b.I\t");
|
||
let labels: Vec<String> = (0..st.paras.len()).map(|i| label(&st, i)).collect();
|
||
assert_eq!(labels, ["1.\t", "a) ", "b) ", "1.b.I\t", "2.\t", "", "3.\t", "1.\t", "4.\t"]);
|
||
assert_eq!(st.paras[1].para.left_indent, Some(72.0));
|
||
assert_eq!(st.paras[1].para.first_line_indent, Some(-18.0));
|
||
}
|
||
|
||
/// Parent numbers the model's levels can't give are written as text: a list that starts at
|
||
/// level 1 (level 0 never numbered), and a legal-style label (decimal parents).
|
||
#[test]
|
||
fn parent_numbers_the_model_lacks_are_text() {
|
||
let numbering = format!(
|
||
r#"<w:numbering {W}>
|
||
<w:abstractNum w:abstractNumId="1">
|
||
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="upperRoman"/><w:lvlText w:val="%1."/></w:lvl>
|
||
<w:lvl w:ilvl="1"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1.%2"/></w:lvl>
|
||
<w:lvl w:ilvl="2"><w:start w:val="1"/><w:isLgl/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1.%2.%3"/></w:lvl>
|
||
</w:abstractNum>
|
||
<w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num>
|
||
</w:numbering>"#
|
||
);
|
||
let body = [list_p("1", Some("1"), "A"), list_p("1", Some("0"), "B"), list_p("1", Some("1"), "C"), list_p("1", Some("2"), "D")];
|
||
let st = lists_docx(&numbering, "", &body).story;
|
||
let labels: Vec<String> = (0..st.paras.len()).map(|i| label(&st, i)).collect();
|
||
assert_eq!(labels, ["I.1\t", "I.\t", "I.1\t", "1.1.1\t"]);
|
||
assert_eq!(st.paras[0].para.number_expression.as_deref(), Some("I.^#^t"));
|
||
assert_eq!(st.paras[2].para.number_expression.as_deref(), Some("^1.^#^t"));
|
||
}
|
||
|
||
/// A paragraph style's `w:numPr` (inherited through `w:basedOn`) makes its paragraphs a list;
|
||
/// numId 0 on a paragraph takes it out of the list.
|
||
#[test]
|
||
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), "•\t");
|
||
assert_eq!(label(&st, 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^\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);
|
||
}
|
||
|
||
#[test]
|
||
fn an_element_with_many_attributes_parses_in_linear_time() {
|
||
// RUSTSEC-2026-0194: before quick-xml 0.41 the duplicate-attribute check compared each name with every
|
||
// earlier one on the element, so a crafted .docx/.xlsx with one huge tag hung the import (O(N²)).
|
||
let attrs: String = (0..100_000).map(|i| format!(" w:a{i}=\"{i}\"")).collect();
|
||
let xml = format!("<w:document><w:body><w:p{attrs}/></w:body></w:document>");
|
||
let started = std::time::Instant::now();
|
||
let doc = parse(&xml).unwrap();
|
||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "parse took {:?}", started.elapsed());
|
||
let p = doc.child("body").and_then(|b| b.child("p")).unwrap();
|
||
assert_eq!(p.attr("a0"), Some("0"));
|
||
assert_eq!(p.attr("a99999"), Some("99999"));
|
||
}
|
||
|
||
#[test]
|
||
fn table_cells_read_text_like_body_paragraphs() {
|
||
// Regression (#721): cells took every text node, so deleted text, move sources and field
|
||
// instructions showed up in the table.
|
||
let para = r#"<w:p><w:r><w:t xml:space="preserve">kept </w:t></w:r>
|
||
<w:del w:id="1" w:author="A"><w:r><w:delText>GONE</w:delText></w:r></w:del>
|
||
<w:moveFrom w:id="2" w:author="A"><w:r><w:t>MOVED AWAY</w:t></w:r></w:moveFrom>
|
||
<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>
|
||
<w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>7</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#;
|
||
let document = format!(r#"<w:document {W}><w:body>{para}<w:tbl><w:tr><w:tc>{para}<w:p/></w:tc></w:tr></w:tbl></w:body></w:document>"#);
|
||
let i = import(&docx(&document, "", "")).unwrap();
|
||
assert!(i.story.text.starts_with("kept 7"), "{:?}", i.story.text);
|
||
let cell = &i.story.tables.values().next().unwrap().cell(0, 0).unwrap().text;
|
||
assert_eq!(cell.text, "kept 7\n");
|
||
}
|
||
|
||
#[test]
|
||
fn normal_is_its_own_style_over_the_document_defaults() {
|
||
// Regression (#722): text formatted only by Normal and the document defaults lost it.
|
||
let styles = format!(
|
||
r#"<w:styles {W}><w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="22"/></w:rPr></w:rPrDefault>
|
||
<w:pPrDefault><w:pPr><w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>
|
||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:rFonts w:ascii="Georgia"/><w:sz w:val="26"/></w:rPr></w:style>
|
||
<w:style w:type="paragraph" w:styleId="Quote"><w:name w:val="Quote"/><w:basedOn w:val="Normal"/><w:rPr><w:i/></w:rPr></w:style>
|
||
</w:styles>"#
|
||
);
|
||
let document = format!(
|
||
r#"<w:document {W}><w:body><w:p><w:r><w:t>Plain</w:t></w:r></w:p>
|
||
<w:p><w:pPr><w:pStyle w:val="Quote"/></w:pPr><w:r><w:t>Quoted</w:t></w:r></w:p></w:body></w:document>"#
|
||
);
|
||
let i = import(&docx(&document, &styles, "")).unwrap();
|
||
assert_eq!(i.story.paras[0].style, "Normal");
|
||
let normal = i.para_styles.iter().find(|s| s.name == "Normal").unwrap();
|
||
assert_eq!(normal.chars.size, Some(13.0), "Normal's size wins over the default");
|
||
assert_eq!(normal.chars.font_family.as_deref(), Some("Georgia"));
|
||
assert_eq!(normal.para.space_after, Some(8.0), "from the document defaults");
|
||
let quote = i.para_styles.iter().find(|s| s.name == "Quote").unwrap();
|
||
assert_eq!(quote.based_on.as_deref(), Some("Normal"));
|
||
assert_eq!(quote.chars.size, None, "inherited from Normal, not copied");
|
||
}
|
||
}
|