Files
wordcraft/crates/docx/src/wordml2003_write.rs
T
09b8b436cc Save as Word 2003 XML Document (#1081) (#1088)
Write WordprocessingML 2003 by rewriting the DOCX writer's package into the 2003 vocabulary; file.save/saveAs take format wordml2003, File > Export lists the format, CLI convert --format wordml2003, a plain save over a 2003 file keeps it.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 01:52:42 -04:00

922 lines
35 KiB
Rust

//! Word 2003 XML Document writer (WordprocessingML 2003, root `w:wordDocument`, from the public
//! Office 2003 XML Reference Schemas).
//!
//! The mirror image of the reader in `wordml2003`: the DOCX writer produces the package, and the
//! package is rewritten into the 2003 vocabulary. ECMA-376 kept most of the 2003 element names
//! (`w:p`, `w:r`, `w:rPr`, `w:tbl`, `w:sectPr`…), so the body is mostly renamed (`w:szCs` →
//! `w:sz-cs`, `w:eastAsia` → `w:fareast`, `w:vMerge` → `w:vmerge`), and what 2003 keeps inline is
//! moved back inline: hyperlink targets (`w:hlink`), footnotes and endnotes (inside their
//! reference run), headers and footers (inside `w:sectPr`), comments, bookmarks and tracked
//! changes (`aml:annotation`), lists (`w:listPr` and a `w:lists` block), pictures (`w:pict` with
//! `w:binData` in base64) and the core properties (`o:DocumentProperties`).
//!
//! What the 2003 format cannot carry is dropped or degraded, never an error: drawings other than
//! pictures (shapes, text boxes, groups, charts, SmartArt, ink), picture crop, rotation and
//! floating position (pictures become inline), content controls (their content stays), math,
//! revisions of formatting, theme colours and fonts, custom XML, macros, embedded objects and
//! `docDefaults` other than fonts (folded into the Normal style). Elements outside the 2003
//! vocabulary are not written, so Word has nothing to reject. Bounded: nesting is capped by the
//! parser's depth limit, pictures and stories are capped in number.
use std::collections::{BTreeMap, HashMap};
use base64::Engine as _;
use wordcraft_doc::Document;
use crate::flat::W2003_NS;
use crate::package::{Package, Rels, rt};
use crate::xml::{El, MAX_DEPTH, Node};
use crate::{DocxError, Flavor};
const AUX_NS: &str = "http://schemas.microsoft.com/office/word/2003/auxHint";
const AML_NS: &str = "http://schemas.microsoft.com/aml/2001/core";
const VML_NS: &str = "urn:schemas-microsoft-com:vml";
const OFFICE_NS: &str = "urn:schemas-microsoft-com:office:office";
const W10_NS: &str = "urn:schemas-microsoft-com:office:word";
/// Most pictures written (the reader takes as many).
const MAX_MEDIA: usize = 4096;
/// Most header, footer, footnote, endnote and comment stories written.
const MAX_STORIES: usize = 50_000;
/// A picture's longest side, points (a corrupt extent must not give a huge frame).
const MAX_PICT_PT: f64 = 31_680.0;
/// Write `doc` as a Word 2003 XML Document.
pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
let parts: BTreeMap<String, Vec<u8>> = crate::write::write_parts(doc, Flavor::Document)?.into_iter().collect();
let pkg = Package::from_parts(parts);
let doc_part = pkg
.rels("_rels/.rels")
.by_type(rt::OFFICE_DOC)
.map(|r| r.target.clone())
.filter(|t| pkg.get(t).is_some())
.unwrap_or_else(|| "word/document.xml".to_string());
let mut c = Conv::new(&pkg, &doc_part);
let body = match pkg.xml(&doc_part)? {
Some(root) => {
let rels = c.doc_rels.clone();
match root.child("w:body") {
Some(b) => c.kids(b, &rels, "w:body", 1),
None => Vec::new(),
}
}
None => Vec::new(),
};
let mut kids: Vec<Node> = vec![Node::El(core_props(doc))];
if let Some(f) = c.default_fonts() {
kids.push(Node::El(f));
}
if let Some(l) = c.lists() {
kids.push(Node::El(l));
}
kids.push(Node::El(c.styles()));
kids.push(Node::El(plain(
"w:docPr",
vec![Node::El(attr_el("w:view", &[("w:val", "print")])), Node::El(attr_el("w:zoom", &[("w:percent", "100")]))],
)));
kids.push(Node::El(plain("w:body", vec![Node::El(plain("wx:sect", body))])));
let attrs = [
("xmlns:w", W2003_NS),
("xmlns:wx", AUX_NS),
("xmlns:o", OFFICE_NS),
("xmlns:v", VML_NS),
("xmlns:w10", W10_NS),
("xmlns:aml", AML_NS),
("w:macrosPresent", "no"),
("w:embeddedObjPresent", "no"),
("w:ocxPresent", "no"),
("xml:space", "preserve"),
];
let root = El { name: "w:wordDocument".into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids };
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?mso-application progid=\"Word.Document\"?>\n");
s.push_str(&root.to_xml());
Ok(s.into_bytes())
}
fn plain(name: &str, kids: Vec<Node>) -> El {
El { name: name.into(), attrs: Vec::new(), kids }
}
fn attr_el(name: &str, attrs: &[(&str, &str)]) -> El {
El { name: name.into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids: Vec::new() }
}
fn text_el(name: &str, text: &str) -> El {
El { name: name.into(), attrs: Vec::new(), kids: vec![Node::Text(text.to_string())] }
}
/// `o:DocumentProperties` from the core properties.
fn core_props(doc: &Document) -> El {
let c = &doc.core;
let mut kids = Vec::new();
let revision = c.revision.to_string();
for (name, v) in [
("o:Title", c.title.as_str()),
("o:Subject", c.subject.as_str()),
("o:Author", c.creator.as_str()),
("o:Keywords", c.keywords.as_str()),
("o:Description", c.description.as_str()),
("o:LastAuthor", c.last_modified_by.as_str()),
("o:Revision", if c.revision > 0 { revision.as_str() } else { "" }),
("o:Created", c.created.as_str()),
("o:LastSaved", c.modified.as_str()),
("o:Category", c.category.as_str()),
] {
if !v.trim().is_empty() {
kids.push(Node::El(text_el(name, v.trim())));
}
}
plain("o:DocumentProperties", kids)
}
struct Conv<'a> {
pkg: &'a Package,
doc_rels: Rels,
footnotes: HashMap<String, El>,
endnotes: HashMap<String, El>,
comments: HashMap<String, El>,
foot_rels: Rels,
end_rels: Rels,
comment_rels: Rels,
styles_part: Option<El>,
numbering_part: Option<El>,
/// Picture part path → `wordml://` name, for the pictures already given a `w:binData`.
media: HashMap<String, String>,
/// `w:binData` elements waiting for the `w:pict` being written.
bins: Vec<El>,
/// Annotation ids: (kind, original id) → the id written (one number space, as in 2003).
ann_ids: HashMap<(char, String), i64>,
next_ann: i64,
next_shape: i64,
stories: usize,
in_note: bool,
}
impl<'a> Conv<'a> {
fn new(pkg: &'a Package, doc_part: &str) -> Conv<'a> {
let doc_rels = pkg.rels(doc_part);
let part_of = |kind: &str| doc_rels.by_type(kind).map(|r| r.target.clone());
let notes = |kind: &str, local: &str| -> (HashMap<String, El>, Rels) {
let Some(path) = part_of(kind) else { return (HashMap::new(), Rels::default()) };
let map = pkg
.xml(&path)
.ok()
.flatten()
.map(|root| {
root.els()
.filter(|e| e.name == local)
.filter(|e| e.attr("w:type").is_none())
.filter_map(|e| e.attr("w:id").map(|id| (id.to_string(), e.clone())))
.collect()
})
.unwrap_or_default();
(map, pkg.rels(&path))
};
let (footnotes, foot_rels) = notes(rt::FOOTNOTES, "w:footnote");
let (endnotes, end_rels) = notes(rt::ENDNOTES, "w:endnote");
let (comments, comment_rels) = notes(rt::COMMENTS, "w:comment");
let load = |kind: &str| part_of(kind).and_then(|p| pkg.xml(&p).ok().flatten());
Conv {
pkg,
styles_part: load(rt::STYLES),
numbering_part: load(rt::NUMBERING),
doc_rels,
footnotes,
endnotes,
comments,
foot_rels,
end_rels,
comment_rels,
media: HashMap::new(),
bins: Vec::new(),
ann_ids: HashMap::new(),
next_ann: 0,
next_shape: 1025,
stories: 0,
in_note: false,
}
}
/// The annotation id for `(kind, id)`: the same each time it is asked.
fn ann_id(&mut self, kind: char, id: &str) -> i64 {
if let Some(n) = self.ann_ids.get(&(kind, id.to_string())) {
return *n;
}
let n = self.next_ann;
self.next_ann = self.next_ann.saturating_add(1);
self.ann_ids.insert((kind, id.to_string()), n);
n
}
fn kids(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
let mut out = Vec::with_capacity(e.kids.len());
for n in &e.kids {
match n {
Node::Text(t) => out.push(Node::Text(t.clone())),
Node::El(k) => out.extend(self.node(k, rels, parent, depth + 1)),
}
}
out
}
/// `e` rewritten for 2003: nothing (dropped), its content (unwrapped) or one element.
fn node(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
if depth > MAX_DEPTH {
return Vec::new();
}
let name = e.name.as_str();
match name {
// Content controls: the content stays, the control goes.
"w:sdt" => return e.child("w:sdtContent").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
"w:drawing" => return self.picture(e, rels),
// A VML fallback is kept for what the modern drawing could not be.
"mc:AlternateContent" => return e.child("mc:Fallback").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
"w:object" | "w:txbxContent" => return Vec::new(),
"w:footnoteReference" | "w:endnoteReference" => return self.note(e, depth),
"w:commentReference" => return self.comment(e, depth),
"w:commentRangeStart" | "w:commentRangeEnd" | "w:bookmarkStart" | "w:bookmarkEnd" => return self.range_marker(e),
"w:ins" | "w:del" | "w:moveFrom" | "w:moveTo" => {
// Paragraph-mark and row revisions sit in property blocks: no 2003 form.
if matches!(parent, "w:rPr" | "w:trPr") {
return Vec::new();
}
return self.revision(e, rels, parent, depth);
}
"w:hyperlink" => return vec![Node::El(self.hyperlink(e, rels, depth))],
"w:numPr" if parent == "w:pPr" => return vec![Node::El(list_pr(e))],
"w:numFmt" if parent == "w:lvl" => return vec![Node::El(nfc(e))],
"w:sectPr" => return vec![Node::El(self.section(e, rels, depth))],
"w:pict" => return vec![Node::El(self.vml_pict(e, rels, depth))],
"w:tblLook" => {
return match e.attr("w:val") {
Some(v) => vec![Node::El(attr_el("w:tblLook", &[("w:val", v)]))],
None => Vec::new(),
};
}
_ => {}
}
if !allowed(name) {
return Vec::new();
}
let mut el = El { name: rename(name), attrs: attrs(e), kids: Vec::new() };
if name == "v:imagedata" {
let id = e.attr("r:id").or_else(|| e.attr("r:pict")).map(str::to_string);
if let Some(src) = id.and_then(|id| rels.by_id(&id).map(|r| r.target.clone())).and_then(|t| self.bin_for(&t)) {
el.attrs.retain(|(k, _)| k != "src");
el.attrs.push(("src".into(), src));
} else {
return Vec::new();
}
}
el.kids = self.kids(e, rels, name, depth);
vec![Node::El(el)]
}
/// A `wordml://` name for the picture at package path `target`, queueing its `w:binData` the
/// first time. `None` for a format 2003 XML pictures don't hold, or past the cap.
fn bin_for(&mut self, target: &str) -> Option<String> {
if let Some(n) = self.media.get(target) {
return Some(n.clone());
}
let ext = target.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?;
if !matches!(ext.as_str(), "png" | "jpeg" | "jpg" | "gif" | "bmp" | "emf" | "wmf" | "tif" | "tiff") || self.media.len() >= MAX_MEDIA {
return None;
}
let bytes = self.pkg.get(target)?;
let name = format!("wordml://0300{:04}.{ext}", self.media.len() + 1);
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let bin = El {
name: "w:binData".into(),
attrs: vec![("w:name".into(), name.clone()), ("xml:space".into(), "preserve".into())],
kids: vec![Node::Text(b64)],
};
self.bins.push(bin);
self.media.insert(target.to_string(), name.clone());
Some(name)
}
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
let Some(src) = self.bin_for(&target) else { return Vec::new() };
let pt = |k: &str| {
let emu = drawing.find("wp:extent").and_then(|x| x.attr(k)).and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(0.0);
(if emu.is_finite() { emu / 12700.0 } else { 0.0 }).clamp(0.0, MAX_PICT_PT)
};
let style = format!("width:{}pt;height:{}pt", trim_num(pt("cx")), trim_num(pt("cy")));
let alt = drawing.find("wp:docPr").and_then(|d| d.attr("descr")).unwrap_or("").to_string();
let shape_id = format!("_x0000_i{}", self.next_shape);
self.next_shape = self.next_shape.saturating_add(1);
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
vec![Node::El(plain("w:pict", kids))]
}
/// The `v:shape` holding a picture's `v:imagedata`.
fn shape_frame(&self, id: &str, style: &str, src: &str, alt: &str) -> El {
let mut shape = attr_el(
"v:shape",
&[
("id", id),
("style", style),
("coordsize", "21600,21600"),
("o:spt", "75"),
("path", "m0,0l0,21600,21600,21600,21600,0xe"),
("filled", "f"),
("stroked", "f"),
],
);
if !alt.is_empty() {
shape.attrs.push(("alt".into(), alt.to_string()));
}
let mut data = attr_el("v:imagedata", &[("src", src)]);
data.attrs.push(("o:title".into(), alt.to_string()));
shape.kids.push(Node::El(data));
shape
}
/// A legacy `w:pict` (VML) kept as it is, its pictures given `w:binData`.
fn vml_pict(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let kids = self.kids(e, rels, "w:pict", depth);
let mut all: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
all.extend(kids);
plain("w:pict", all)
}
fn hyperlink(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let mut a = Vec::new();
if let Some(r) = e.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| r.external) {
a.push(("w:dest".to_string(), r.target.clone()));
}
for (from, to) in [("w:anchor", "w:bookmark"), ("w:tooltip", "w:screenTip"), ("w:tgtFrame", "w:target")] {
if let Some(v) = e.attr(from) {
a.push((to.to_string(), v.to_string()));
}
}
El { name: "w:hlink".into(), attrs: a, kids: self.kids(e, rels, "w:hlink", depth) }
}
/// A footnote or endnote reference as the note itself, inline in its run.
fn note(&mut self, e: &El, depth: usize) -> Vec<Node> {
let foot = e.name == "w:footnoteReference";
let Some(id) = e.attr("w:id") else { return Vec::new() };
if self.in_note || self.stories >= MAX_STORIES {
return Vec::new();
}
let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return Vec::new() };
self.stories += 1;
let rels = if foot { self.foot_rels.clone() } else { self.end_rels.clone() };
self.in_note = true;
let name = if foot { "w:footnote" } else { "w:endnote" };
let kids = self.kids(&note, &rels, name, depth);
self.in_note = false;
vec![Node::El(plain(name, kids))]
}
fn annotation(&self, kind: &str, id: i64, mut extra: Vec<(String, String)>, content: Option<Vec<Node>>) -> El {
let mut a = vec![("aml:id".to_string(), id.to_string())];
a.append(&mut extra);
a.push(("w:type".into(), kind.into()));
El { name: "aml:annotation".into(), attrs: a, kids: content.map(|c| vec![Node::El(plain("aml:content", c))]).unwrap_or_default() }
}
fn comment(&mut self, e: &El, depth: usize) -> Vec<Node> {
let Some(id) = e.attr("w:id") else { return Vec::new() };
if self.in_note || self.stories >= MAX_STORIES {
return Vec::new();
}
let Some(c) = self.comments.get(id).cloned() else { return Vec::new() };
self.stories += 1;
let rels = self.comment_rels.clone();
let n = self.ann_id('c', id);
self.in_note = true;
let content = self.kids(&c, &rels, "w:comment", depth);
self.in_note = false;
let mut extra = Vec::new();
if let Some(v) = c.attr("w:author") {
extra.push(("aml:author".to_string(), v.to_string()));
}
if let Some(v) = c.attr("w:date") {
extra.push(("aml:createdate".to_string(), v.to_string()));
}
if let Some(v) = c.attr("w:initials") {
extra.push(("w:initials".to_string(), v.to_string()));
}
vec![Node::El(self.annotation("Word.Comment", n, extra, Some(content)))]
}
fn range_marker(&mut self, e: &El) -> Vec<Node> {
let Some(id) = e.attr("w:id") else { return Vec::new() };
let (kind, ty) = match e.name.as_str() {
"w:commentRangeStart" => ('c', "Word.Comment.Start"),
"w:commentRangeEnd" => ('c', "Word.Comment.End"),
"w:bookmarkStart" => ('b', "Word.Bookmark.Start"),
_ => ('b', "Word.Bookmark.End"),
};
let n = self.ann_id(kind, id);
let extra = if e.name == "w:bookmarkStart" { vec![("w:name".to_string(), e.attr("w:name").unwrap_or("").to_string())] } else { Vec::new() };
vec![Node::El(self.annotation(ty, n, extra, None))]
}
/// Tracked insertions and deletions (moves become a deletion and an insertion).
fn revision(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
let ty = if matches!(e.name.as_str(), "w:ins" | "w:moveTo") { "Word.Insertion" } else { "Word.Deletion" };
let n = self.ann_id('r', e.attr("w:id").unwrap_or(""));
let mut extra = Vec::new();
if let Some(v) = e.attr("w:author") {
extra.push(("aml:author".to_string(), v.to_string()));
}
if let Some(v) = e.attr("w:date") {
extra.push(("aml:createdate".to_string(), v.to_string()));
}
let content = self.kids(e, rels, parent, depth);
vec![Node::El(self.annotation(ty, n, extra, Some(content)))]
}
/// `w:sectPr` with its headers and footers inline (`w:hdr`, `w:ftr`).
fn section(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let mut stories = Vec::new();
let mut rest = Vec::new();
for k in e.els() {
if k.name == "w:headerReference" || k.name == "w:footerReference" {
let footer = k.name == "w:footerReference";
if self.stories >= MAX_STORIES {
continue;
}
let Some(path) = k.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| !r.external).map(|r| r.target.clone()) else { continue };
let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue };
self.stories += 1;
let story_rels = self.pkg.rels(&path);
let ty = match k.attr("w:type") {
Some("even") => "even",
Some("first") => "first",
_ => "odd",
};
let name = if footer { "w:ftr" } else { "w:hdr" };
let was = std::mem::replace(&mut self.in_note, true);
let kids = self.kids(&root, &story_rels, name, depth + 1);
self.in_note = was;
let mut story = plain(name, kids);
story.attrs.push(("w:type".into(), ty.into()));
stories.push(Node::El(story));
} else {
rest.extend(self.node(k, rels, "w:sectPr", depth + 1));
}
}
stories.extend(rest);
El { name: "w:sectPr".into(), attrs: Vec::new(), kids: stories }
}
/// `w:fonts/w:defaultFonts` from the document defaults.
fn default_fonts(&self) -> Option<El> {
let f = self.styles_part.as_ref()?.child("w:docDefaults")?.child("w:rPrDefault")?.child("w:rPr")?.child("w:rFonts")?;
let a = attrs(f);
(!a.is_empty()).then(|| plain("w:fonts", vec![Node::El(El { name: "w:defaultFonts".into(), attrs: a, kids: Vec::new() })]))
}
/// `w:styles`: the styles, with the document defaults (other than fonts) folded into the
/// default paragraph style, as 2003 has no `docDefaults`.
fn styles(&mut self) -> El {
let Some(root) = self.styles_part.clone() else { return plain("w:styles", Vec::new()) };
let defaults = root.child("w:docDefaults");
let default_of = |part: &str, block: &str| -> Vec<El> {
defaults
.and_then(|d| d.child(part))
.and_then(|p| p.child(block))
.map(|b| b.els().filter(|e| e.name != "w:rFonts").cloned().collect())
.unwrap_or_default()
};
let (def_r, def_p) = (default_of("w:rPrDefault", "w:rPr"), default_of("w:pPrDefault", "w:pPr"));
let rels = self.doc_rels.clone();
let mut kids = Vec::new();
for st in root.children("w:style") {
let mut st = st.clone();
if st.attr("w:type") == Some("paragraph") && matches!(st.attr("w:default"), Some("1" | "true" | "on")) {
fold(&mut st, "w:pPr", &def_p);
fold(&mut st, "w:rPr", &def_r);
}
kids.extend(self.node(&st, &rels, "w:styles", 2));
}
plain("w:styles", kids)
}
/// `w:lists`: `w:abstractNum` → `w:listDef`, `w:num` → `w:list`.
fn lists(&mut self) -> Option<El> {
let root = self.numbering_part.clone()?;
let rels = self.doc_rels.clone();
let mut kids = Vec::new();
for abs in root.children("w:abstractNum") {
let Some(id) = abs.attr("w:abstractNumId") else { continue };
let mut def = El { name: "w:listDef".into(), attrs: vec![("w:listDefId".into(), id.to_string())], kids: Vec::new() };
for k in abs.els() {
match k.name.as_str() {
"w:nsid" => def.kids.push(Node::El(attr_el("w:lsid", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
"w:multiLevelType" => {
let v = match k.attr("w:val").unwrap_or("") {
"singleLevel" => "SingleLevel",
"hybridMultilevel" => "HybridMultilevel",
_ => "Multilevel",
};
def.kids.push(Node::El(attr_el("w:plt", &[("w:val", v)])));
}
"w:numStyleLink" => def.kids.push(Node::El(attr_el("w:listStyleLink", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
"w:lvl" => def.kids.extend(self.node(k, &rels, "w:abstractNum", 3)),
_ => {}
}
}
kids.push(Node::El(def));
}
for num in root.children("w:num") {
let (Some(id), Some(abs)) = (num.attr("w:numId"), num.child_val("w:abstractNumId")) else { continue };
let mut list = El { name: "w:list".into(), attrs: vec![("w:ilfo".into(), id.to_string())], kids: Vec::new() };
list.kids.push(Node::El(attr_el("w:ilst", &[("w:val", abs)])));
for o in num.children("w:lvlOverride") {
list.kids.extend(self.node(o, &rels, "w:num", 3));
}
kids.push(Node::El(list));
}
(!kids.is_empty()).then(|| plain("w:lists", kids))
}
}
/// Add the properties of `defaults` that block `name` of `style` doesn't set (at its start).
fn fold(style: &mut El, name: &str, defaults: &[El]) {
if defaults.is_empty() {
return;
}
let pos = style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name == name));
let idx = match pos {
Some(i) => i,
None => {
// Schema order within a style: name, basedOn, next, link, … then pPr, rPr.
let at = if name == "w:pPr" {
style
.kids
.iter()
.position(|n| matches!(n, Node::El(e) if e.name == "w:rPr" || e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
} else {
style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
}
.unwrap_or(style.kids.len());
style.kids.insert(at, Node::El(plain(name, Vec::new())));
at
}
};
if let Some(Node::El(block)) = style.kids.get_mut(idx) {
let have: Vec<String> = block.els().map(|e| e.name.clone()).collect();
let mut add: Vec<Node> = defaults.iter().filter(|d| !have.contains(&d.name)).cloned().map(Node::El).collect();
add.append(&mut block.kids);
block.kids = add;
}
}
/// `w:numPr` (`w:ilvl`, `w:numId`) → `w:listPr` (`w:ilvl`, `w:ilfo`).
fn list_pr(e: &El) -> El {
let mut kids = Vec::new();
if let Some(v) = e.child_val("w:ilvl") {
kids.push(Node::El(attr_el("w:ilvl", &[("w:val", v)])));
}
if let Some(v) = e.child_val("w:numId") {
kids.push(Node::El(attr_el("w:ilfo", &[("w:val", v)])));
}
plain("w:listPr", kids)
}
/// `w:numFmt` → `w:nfc` (a number format code).
fn nfc(e: &El) -> El {
let code = match e.attr("w:val").unwrap_or("decimal") {
"upperRoman" => "1",
"lowerRoman" => "2",
"upperLetter" => "3",
"lowerLetter" => "4",
"ordinal" => "5",
"cardinalText" => "6",
"ordinalText" => "7",
"chicago" => "9",
"decimalFullWidth" => "14",
"decimalHalfWidth" => "15",
"decimalEnclosedCircle" => "18",
"decimalZero" => "22",
"bullet" => "23",
"ganada" => "24",
"chosung" => "25",
"decimalEnclosedFullstop" => "26",
"decimalEnclosedParen" => "27",
"none" => "255",
_ => "0",
};
attr_el("w:nfc", &[("w:val", code)])
}
/// A number without a pointless fraction (`36`, `10.5`).
fn trim_num(v: f64) -> String {
let s = format!("{v:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// Is this element written? WordprocessingML elements of the 2003 vocabulary, and the VML a
/// picture needs; everything else (DrawingML, `w14`, math, markup compatibility…) is not.
fn allowed(name: &str) -> bool {
match name.split_once(':') {
Some(("w", local)) => allowed_w(local),
Some(("v" | "o" | "w10", _)) => true,
_ => false,
}
}
fn allowed_w(local: &str) -> bool {
matches!(
local,
"p" | "pPr"
| "r"
| "rPr"
| "t"
| "tab"
| "br"
| "cr"
| "noBreakHyphen"
| "softHyphen"
| "sym"
| "pgNum"
| "delText"
| "instrText"
| "fldChar"
| "fldSimple"
| "footnoteRef"
| "endnoteRef"
| "annotationRef"
| "separator"
| "continuationSeparator"
| "tbl"
| "tblPr"
| "tblGrid"
| "gridCol"
| "tr"
| "trPr"
| "tc"
| "tcPr"
| "sectPr"
| "hdr"
| "ftr"
| "pStyle"
| "keepNext"
| "keepLines"
| "pageBreakBefore"
| "framePr"
| "widowControl"
| "suppressLineNumbers"
| "pBdr"
| "shd"
| "tabs"
| "suppressAutoHyphens"
| "kinsoku"
| "wordWrap"
| "overflowPunct"
| "topLinePunct"
| "autoSpaceDE"
| "autoSpaceDN"
| "bidi"
| "adjustRightInd"
| "snapToGrid"
| "spacing"
| "ind"
| "contextualSpacing"
| "mirrorIndents"
| "textAlignment"
| "textDirection"
| "textboxTightWrap"
| "outlineLvl"
| "jc"
| "divId"
| "top"
| "left"
| "bottom"
| "right"
| "between"
| "bar"
| "insideH"
| "insideV"
| "tl2br"
| "tr2bl"
| "rStyle"
| "rFonts"
| "b"
| "bCs"
| "i"
| "iCs"
| "caps"
| "smallCaps"
| "strike"
| "dstrike"
| "outline"
| "shadow"
| "emboss"
| "imprint"
| "noProof"
| "vanish"
| "webHidden"
| "color"
| "w"
| "kern"
| "position"
| "sz"
| "szCs"
| "highlight"
| "u"
| "effect"
| "bdr"
| "fitText"
| "vertAlign"
| "rtl"
| "cs"
| "em"
| "lang"
| "eastAsianLayout"
| "specVanish"
| "tblStyle"
| "tblpPr"
| "tblOverlap"
| "bidiVisual"
| "tblStyleRowBandSize"
| "tblStyleColBandSize"
| "tblW"
| "tblCellSpacing"
| "tblInd"
| "tblBorders"
| "tblLayout"
| "tblCellMar"
| "cantSplit"
| "trHeight"
| "tblHeader"
| "gridBefore"
| "gridAfter"
| "wBefore"
| "wAfter"
| "hidden"
| "tcW"
| "gridSpan"
| "hMerge"
| "vMerge"
| "tcBorders"
| "noWrap"
| "tcMar"
| "tcFitText"
| "vAlign"
| "pgSz"
| "pgMar"
| "paperSrc"
| "pgBorders"
| "lnNumType"
| "pgNumType"
| "cols"
| "col"
| "formProt"
| "noEndnote"
| "titlePg"
| "rtlGutter"
| "docGrid"
| "type"
| "style"
| "name"
| "aliases"
| "basedOn"
| "next"
| "link"
| "autoRedefine"
| "semiHidden"
| "qFormat"
| "locked"
| "tblStylePr"
| "lvl"
| "start"
| "lvlRestart"
| "isLgl"
| "suff"
| "lvlText"
| "legacy"
| "lvlJc"
| "lvlOverride"
| "startOverride"
| "ilvl"
| "binData"
)
}
/// The 2003 spelling of an ECMA-376 name: `w:szCs` → `w:sz-cs`, `w:eastAsia` → `w:fareast`,
/// `w:vMerge` → `w:vmerge`. Names outside `w:` are kept.
fn rename(name: &str) -> String {
let Some(local) = name.strip_prefix("w:") else { return name.to_string() };
let new = match local {
"eastAsia" => "fareast",
"vMerge" => "vmerge",
"hMerge" => "hmerge",
"bCs" => "b-cs",
"iCs" => "i-cs",
"szCs" => "sz-cs",
"hAnsi" => "h-ansi",
"firstLine" => "first-line",
"lineRule" => "line-rule",
"hRule" => "h-rule",
"linePitch" => "line-pitch",
"firstLineChars" => "first-line-chars",
"hangingChars" => "hanging-chars",
"leftChars" => "left-chars",
"rightChars" => "right-chars",
other => other,
};
format!("w:{new}")
}
/// Elements whose `w:val` is an on/off switch (2003 writes `on` / `off`).
fn is_toggle(local: &str) -> bool {
matches!(
local,
"b" | "bCs"
| "i"
| "iCs"
| "caps"
| "smallCaps"
| "strike"
| "dstrike"
| "outline"
| "shadow"
| "emboss"
| "imprint"
| "noProof"
| "snapToGrid"
| "vanish"
| "webHidden"
| "keepNext"
| "keepLines"
| "pageBreakBefore"
| "widowControl"
| "suppressLineNumbers"
| "suppressAutoHyphens"
| "wordWrap"
| "contextualSpacing"
| "mirrorIndents"
| "rtl"
| "cs"
| "specVanish"
| "tblHeader"
| "cantSplit"
| "noWrap"
| "hidden"
| "semiHidden"
| "qFormat"
| "locked"
| "tcFitText"
| "isLgl"
| "bidi"
| "overflowPunct"
| "kinsoku"
| "autoSpaceDE"
| "autoSpaceDN"
| "adjustRightInd"
| "topLinePunct"
| "titlePg"
)
}
/// The attributes of `e` that 2003 has, renamed: theme references, revision ids and attributes of
/// other vocabularies (`w14:`, `r:`…) are dropped.
fn attrs(e: &El) -> Vec<(String, String)> {
let local_el = e.name.strip_prefix("w:").unwrap_or("");
let vml = e.name.starts_with("v:") || e.name.starts_with("o:") || e.name.starts_with("w10:");
let mut out = Vec::new();
for (k, v) in &e.attrs {
let key = match k.split_once(':') {
Some(("w", l)) => {
let lower = l.to_ascii_lowercase();
if lower.starts_with("rsid") || lower.contains("theme") {
continue;
}
rename(k)
}
Some(("xml", "space")) => k.clone(),
Some(("v" | "o" | "w10", _)) if vml => k.clone(),
None if vml => k.clone(),
_ => continue,
};
let mut val = v.clone();
if (key == "w:val" && is_toggle(local_el)) || ((key == "w:default" || key == "w:customStyle") && e.name == "w:style") {
val = switch(&val);
}
out.push((key, val));
}
out
}
/// An on/off value in 2003's spelling.
fn switch(v: &str) -> String {
match v {
"1" | "true" | "on" => "on".into(),
"0" | "false" | "off" => "off".into(),
other => other.into(),
}
}