Files
wordcraft/crates/docx/src/write/mod.rs
T
fe9d560b49 Layout: paragraph spacing adds up when HTML auto spacing is off (#1612)
space after and the second's space before, as Word does by default. Word
adds them instead when the document's compatibility options include
w:doNotUseHTMLParagraphAutoSpacing. Measured in Word with five paragraphs
of 12 pt before and after: 24 pt between them with the option, 12 pt
without, the same in compatibility modes 14 and 15. A 60-page report with
the option lost 6 pt per paragraph, about 60 pt on its cover.

The option is read, laid out in the body and in boxes, and saved back.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:29:43 -04:00

1490 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! [`Document`] → DOCX.
mod embed;
mod settings_merge;
pub(crate) use settings_merge::has_unowned as settings_has_unowned;
mod math;
mod props;
mod sdt;
mod story;
mod watermark;
mod wordart;
use std::collections::{BTreeMap, HashMap};
use wordcraft_doc::numbering::{Level, LevelSuffix};
use wordcraft_doc::styles::{Style, StyleKind};
use wordcraft_doc::{Blocks, Document, PartKind};
use crate::package::{MAX_VBA_RELATED, VBA_PROJECT_PART, VBA_RELATED, rt, zip_entries};
use crate::xml::{self, W};
use crate::{DocxError, Flavor};
/// Relationships of one part.
#[derive(Default)]
pub struct PartRels {
list: Vec<(String, String, String, bool)>,
index: HashMap<(String, String, bool), String>,
}
impl PartRels {
/// Add (or reuse) a relationship; returns its id.
pub fn add(&mut self, kind: &str, target: &str, external: bool) -> String {
let key = (kind.to_string(), target.to_string(), external);
if let Some(id) = self.index.get(&key) {
return id.clone();
}
let id = format!("rId{}", self.list.len() + 1);
self.list.push((id.clone(), kind.to_string(), target.to_string(), external));
self.index.insert(key, id.clone());
id
}
/// Add a relationship with a given id (the id a kept part's markup uses); a repeated id is
/// ignored. Not to be mixed with [`PartRels::add`] in one part.
pub fn add_with_id(&mut self, id: &str, kind: &str, target: &str, external: bool) {
if self.list.iter().any(|(i, ..)| i == id) {
return;
}
self.list.push((id.to_string(), kind.to_string(), target.to_string(), external));
}
pub(crate) fn is_empty(&self) -> bool {
self.list.is_empty()
}
pub(crate) fn xml(&self) -> Vec<u8> {
let mut w = W::new();
w.open("Relationships", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/relationships")]);
for (id, kind, target, external) in &self.list {
if *external {
w.empty("Relationship", &[("Id", id), ("Type", kind), ("Target", target), ("TargetMode", "External")]);
} else {
w.empty("Relationship", &[("Id", id), ("Type", kind), ("Target", target)]);
}
}
w.close("Relationships");
w.into_bytes()
}
}
pub(crate) struct Writer<'d> {
doc: &'d Document,
/// Numbered display equations written so far (automatic numbers are written as text).
pub(crate) eq_number: u32,
/// Media key → file name under `word/media/`.
media_files: BTreeMap<String, String>,
bookmarks: HashMap<String, u32>,
next_rev_id: u32,
/// Move ranges open in the output: (revision, range `w:id`, destination?).
open_moves: Vec<(u32, String, bool)>,
docpr: u32,
/// Shapes' own drawing ids written so far (connectors refer to them).
shape_ids: std::collections::HashSet<u32>,
z: u32,
/// Note part id → file note id, in first-reference order.
footnotes: Vec<u32>,
endnotes: Vec<u32>,
/// Header/footer part id → (file name, is footer).
hf: Vec<(u32, String, bool)>,
/// The headers each slot has so far in body order, with sections' empty slots linked to the
/// ones before (as layout shows them): where a watermark needs a header of its own.
linked_headers: wordcraft_doc::section::HeaderSet,
/// The part id standing for a header written only to hold the watermark (no such part).
wm_header: u32,
/// The watermark shape to put at the start of the next top-level paragraph (a header's first).
pending_watermark: Option<String>,
/// Paragraphs that need a `w14:paraId` (last paragraph of each comment), by address.
para_ids: HashMap<usize, String>,
/// A note reference mark to put at the start of the next paragraph.
pending_mark: Option<&'static str>,
/// The note being written (is footnote, part id): its reference to itself is the mark above.
current_note: Option<(bool, u32)>,
/// Writing a TOC heading: its TOC field is held back so the entries become the field's result.
toc_hold_end: bool,
/// The held TOC field (instruction, locked, props), opened in the first entry as Word does.
toc_field: Option<(String, bool, wordcraft_doc::props::CharProps)>,
/// Open the held TOC field at the start of the paragraph being written.
toc_begin_here: bool,
/// Close the open TOC field at the end of the paragraph being written.
toc_end_here: bool,
/// Byte offsets of the content control markers the next paragraph writes inline.
inline_controls: Vec<usize>,
/// Media keys actually referenced by a written drawing.
used_media: std::collections::BTreeSet<String>,
/// Bounds writing text boxes inside text boxes (as layout shows them).
boxes: wordcraft_doc::BoxBudget,
/// Charts, diagrams and OLE objects kept from a file: the parts they need.
embeds: embed::EmbedWriter<'d>,
/// Linked text boxes: part id → (`wps:txbx`/`wps:linkedTxbx` id, seq; 0 for the box holding
/// the text).
txbx_links: HashMap<u32, (u32, u32)>,
}
const CT_WML: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.";
/// Write a `.docx` package.
pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
write_as(doc, Flavor::Document)
}
/// Write a package of the given flavour (`.docx`, `.docm`, `.dotx`, `.dotm`).
pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
zip_entries(&write_parts(doc, flavor)?)
}
/// The parts of the package [`write_as`] zips, in order, `[Content_Types].xml` first: (part name
/// without the leading `/`, bytes).
pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String, Vec<u8>)>, DocxError> {
// A range field must be whole in the file: drop markers that lost their partner in editing.
let balanced;
let doc = if doc.has_unbalanced_field_ranges() {
let mut d = doc.clone();
d.balance_field_ranges();
balanced = d;
&balanced
} else {
doc
};
let mut wr = Writer {
eq_number: 0,
doc,
media_files: BTreeMap::new(),
bookmarks: HashMap::new(),
next_rev_id: 0,
open_moves: Vec::new(),
// Fresh drawing ids start past the shapes' own (connectors refer to those).
docpr: doc.next_shape_id().saturating_sub(1),
shape_ids: std::collections::HashSet::new(),
z: 251_658_240,
footnotes: Vec::new(),
endnotes: Vec::new(),
hf: Vec::new(),
linked_headers: Default::default(),
wm_header: (0..=u32::MAX).rev().find(|id| !doc.parts.contains_key(id)).unwrap_or(u32::MAX),
pending_watermark: None,
para_ids: HashMap::new(),
pending_mark: None,
current_note: None,
toc_hold_end: false,
toc_field: None,
toc_begin_here: false,
toc_end_here: false,
inline_controls: Vec::new(),
used_media: Default::default(),
boxes: wordcraft_doc::BoxBudget::default(),
embeds: embed::EmbedWriter::new(doc),
txbx_links: doc
.text_box_chains()
.iter()
.enumerate()
.flat_map(|(n, c)| c.iter().enumerate().map(move |(seq, id)| (*id, (n as u32 + 1, seq as u32))))
.collect(),
};
wr.assign_media();
wr.embeds.reserve_media(wr.media_files.values());
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
let mut overrides: Vec<(String, String)> = Vec::new();
let mut rels = PartRels::default();
let mut root = PartRels::default();
// Comments get paragraph ids before any story is written.
let mut comment_paras: Vec<(u32, String)> = Vec::new();
for (i, (cid, c)) in doc.comments.iter().enumerate() {
if let Some(part) = doc.parts.get(&c.part)
&& let Some(last) = part.blocks.iter().rev().find_map(|b| b.as_para())
{
let pid = format!("{:08X}", 0x1000_0000u32 + i as u32 + 1);
wr.para_ids.insert(last as *const _ as usize, pid.clone());
comment_paras.push((*cid, pid));
}
}
// Body.
let mut w = W::new();
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
w.open("w:document", &ns_refs);
let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media));
if pic.is_some() || doc.settings.page_color.is_some() {
let color = doc.settings.page_color.map(|c| c.hex());
let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect();
w.open("w:background", &attrs);
if let Some(p) = pic
&& let Some(file) = wr.media_files.get(&p.media)
{
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
wr.used_media.insert(p.media.clone());
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
if !p.fill_type.is_empty() {
fa.push(("type", &p.fill_type));
}
if !p.title.is_empty() {
fa.push(("o:title", &p.title));
}
w.empty("v:fill", &fa);
w.close("v:background");
}
w.close("w:background");
}
w.open("w:body", &[]);
wr.blocks(&mut w, &doc.body, &mut rels, true, 0);
let last = doc.last_section.clone();
wr.sectpr(&mut w, &last, &mut rels);
w.close("w:body");
w.close("w:document");
let body = w.into_bytes();
// Headers and footers (discovered while writing sections).
let mut done = 0;
while let Some((id, file, footer)) = wr.hf.get(done).cloned() {
done += 1;
let mut prels = PartRels::default();
let mut w = W::new();
let tag = if footer { "w:ftr" } else { "w:hdr" };
w.open(tag, &ns_refs);
let blocks: Blocks = doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
// The watermark goes in every header, at the start of its first paragraph.
wr.pending_watermark = match &doc.settings.watermark {
Some(wm) if !footer => watermark::run(wm, done, &doc.last_section),
_ => None,
};
wr.blocks(&mut w, &blocks, &mut prels, false, 0);
// Always taken: the story ends with a top-level paragraph.
wr.pending_watermark = None;
w.close(tag);
push_part(
&mut entries,
&mut overrides,
&format!("word/{file}"),
w.into_bytes(),
&format!("{CT_WML}{}+xml", if footer { "footer" } else { "header" }),
prels,
);
}
// Notes (references were collected from the body and headers).
for foot in [true, false] {
let seps = if foot { &doc.footnote_separators } else { &doc.endnote_separators };
if !notes_part(doc, &wr, foot) {
continue;
}
let (root, tag, file) = if foot { ("w:footnotes", "w:footnote", "footnotes.xml") } else { ("w:endnotes", "w:endnote", "endnotes.xml") };
let mut prels = PartRels::default();
let mut w = W::new();
w.open(root, &ns_refs);
// The separators and continuation notice: the document's own, else the defaults (no
// notice). Word numbers them -1, 0 and 1.
for (id, kind, own) in [
("-1", "separator", &seps.separator),
("0", "continuationSeparator", &seps.continuation_separator),
("1", "continuationNotice", &seps.continuation_notice),
] {
match own {
Some(blocks) => {
w.open(tag, &[("w:type", kind), ("w:id", id)]);
if blocks.is_empty() {
w.empty("w:p", &[]);
} else {
wr.blocks(&mut w, blocks, &mut prels, false, 0);
}
w.close(tag);
}
None if kind != "continuationNotice" => {
w.open(tag, &[("w:type", kind), ("w:id", id)]);
w.open("w:p", &[]);
w.open("w:pPr", &[]);
w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]);
w.close("w:pPr");
w.open("w:r", &[]);
w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]);
w.close("w:r");
w.close("w:p");
w.close(tag);
}
None => {}
}
}
let base = note_id_base(doc, foot);
let mut k = 0;
while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() {
let nid = (k + base).to_string();
k += 1;
w.open(tag, &[("w:id", &nid)]);
let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default();
wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid);
w.close(tag);
}
w.close(root);
let kind = if foot { rt::FOOTNOTES } else { rt::ENDNOTES };
rels.add(kind, file, false);
push_part(
&mut entries,
&mut overrides,
&format!("word/{file}"),
w.into_bytes(),
&format!("{CT_WML}{}+xml", if foot { "footnotes" } else { "endnotes" }),
prels,
);
}
// Comments.
if !doc.comments.is_empty() {
let mut prels = PartRels::default();
let mut w = W::new();
w.open("w:comments", &ns_refs);
for (cid, c) in &doc.comments {
let id = cid.to_string();
let author = annotation_author(doc, &c.author);
let mut a = vec![("w:id", id.as_str()), ("w:author", author)];
if !c.date.is_empty() && !doc.settings.remove_date_time {
a.push(("w:date", c.date.as_str()));
}
if doc.settings.remove_personal_info {
a.push(("w:initials", ANONYMOUS_INITIALS));
} else if !c.initials.is_empty() {
a.push(("w:initials", c.initials.as_str()));
}
w.open("w:comment", &a);
let blocks: Blocks = doc.parts.get(&c.part).map(|p| p.blocks.clone()).unwrap_or_default();
wr.blocks(&mut w, &blocks, &mut prels, false, 0);
w.close("w:comment");
}
w.close("w:comments");
rels.add(rt::COMMENTS, "comments.xml", false);
push_part(&mut entries, &mut overrides, "word/comments.xml", w.into_bytes(), &format!("{CT_WML}comments+xml"), prels);
let mut w = W::new();
w.open(
"w15:commentsEx",
&[
("xmlns:w15", "http://schemas.microsoft.com/office/word/2012/wordml"),
("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
],
);
let pid_of = |cid: u32| comment_paras.iter().find(|(c, _)| *c == cid).map(|(_, p)| p.clone());
for (cid, c) in &doc.comments {
let Some(pid) = pid_of(*cid) else { continue };
let done = if c.resolved { "1" } else { "0" };
match c.parent.and_then(pid_of) {
Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:paraIdParent", &parent), ("w15:done", done)]),
None => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:done", done)]),
}
}
w.close("w15:commentsEx");
rels.add(rt::COMMENTS_EX, "commentsExtended.xml", false);
push_part(
&mut entries,
&mut overrides,
"word/commentsExtended.xml",
w.into_bytes(),
&format!("{CT_WML}commentsExtended+xml"),
PartRels::default(),
);
write_comment_ids(doc, &comment_paras, &mut entries, &mut overrides, &mut rels);
}
rels.add(rt::STYLES, "styles.xml", false);
push_part(&mut entries, &mut overrides, "word/styles.xml", styles_xml(doc), &format!("{CT_WML}styles+xml"), PartRels::default());
if !doc.numbering.nums.is_empty() || !doc.numbering.abstracts.is_empty() {
rels.add(rt::NUMBERING, "numbering.xml", false);
push_part(&mut entries, &mut overrides, "word/numbering.xml", numbering_xml(doc), &format!("{CT_WML}numbering+xml"), PartRels::default());
}
rels.add(rt::SETTINGS, "settings.xml", false);
// The attached template is an external relationship of the settings part (§17.15.1.6).
let mut settings_rels = PartRels::default();
let template = doc.settings.attached_template.as_deref().map(str::trim).filter(|t| !t.is_empty());
let template_rel = template.map(|t| settings_rels.add(rt::ATTACHED_TEMPLATE, t, true));
push_part(
&mut entries,
&mut overrides,
"word/settings.xml",
{
let generated = settings_xml(doc, notes_part(doc, &wr, true), notes_part(doc, &wr, false), template_rel.as_deref());
match doc.passthrough.get(crate::package::SETTINGS_ORIG) {
Some(orig) => settings_merge::merge(orig, &generated).unwrap_or(generated),
None => generated,
}
},
&format!("{CT_WML}settings+xml"),
settings_rels,
);
rels.add(rt::THEME, "theme/theme1.xml", false);
push_part(
&mut entries,
&mut overrides,
"word/theme/theme1.xml",
theme_xml(doc),
"application/vnd.openxmlformats-officedocument.theme+xml",
PartRels::default(),
);
wr.embeds.font_table(&mut rels);
// Media referenced by some drawing (orphan parts would make the package suspect).
let mut media_types: BTreeMap<String, &str> = BTreeMap::new();
for (key, file) in &wr.media_files {
if !wr.used_media.contains(key) {
continue;
}
if let Some(bytes) = doc.media.get(key) {
let ext = file.rsplit('.').next().unwrap_or("bin").to_ascii_lowercase();
media_types.insert(ext, content_type_for(file));
entries.push((format!("word/media/{file}"), bytes.to_vec()));
}
}
wr.embeds.custom_xml_rels("D", &mut rels);
wr.embeds.custom_xml_rels("P", &mut root);
wr.embeds.unmodelled_rels("D", &mut rels);
wr.embeds.unmodelled_rels("P", &mut root);
// Parts of embedded objects and custom XML stores kept from the file.
for (name, bytes, ct, prels) in std::mem::take(&mut wr.embeds.parts) {
push_part(&mut entries, &mut overrides, &name, bytes, &ct, prels);
}
// The macro project and the parts it relates to (VBA data, signatures…), verbatim. A
// macro-free package can't hold them (Word drops them too).
if let Some(vba) = doc.passthrough.get(VBA_PROJECT_PART).filter(|b| !b.is_empty()) {
if flavor.macros() {
let mut vba_rels = PartRels::default();
let manifest = doc.passthrough.get(VBA_RELATED).map(|m| String::from_utf8_lossy(m).into_owned()).unwrap_or_default();
let mut written: Vec<String> = Vec::new();
for line in manifest.lines().take(MAX_VBA_RELATED) {
let mut f = line.split('\t');
let (Some(kind), Some(path), Some(ct), None) = (f.next(), f.next(), f.next(), f.next()) else { continue };
let Some(target) = path.strip_prefix("word/").filter(|t| !t.is_empty()) else { continue };
let Some(bytes) = doc.passthrough.get(path) else { continue };
let ours = written.iter().any(|w| w.eq_ignore_ascii_case(path));
// Never shadow a part this writer produces (a hostile relationship may point at one).
let clash = path.eq_ignore_ascii_case("word/document.xml")
|| path.eq_ignore_ascii_case(VBA_PROJECT_PART)
|| path.to_ascii_lowercase().ends_with(".rels")
|| entries.iter().any(|(n, _)| n.eq_ignore_ascii_case(path));
if clash && !ours {
continue;
}
// A relative reference whose first segment has a colon would read as a URI scheme.
let target = if target.split('/').next().is_some_and(|seg| seg.contains(':')) { format!("./{target}") } else { target.to_string() };
vba_rels.add(kind, &target, false);
if !ours {
push_part(&mut entries, &mut overrides, path, bytes.to_vec(), ct, PartRels::default());
written.push(path.to_string());
}
}
rels.add(rt::VBA_PROJECT, "vbaProject.bin", false);
push_part(&mut entries, &mut overrides, VBA_PROJECT_PART, vba.to_vec(), "application/vnd.ms-office.vbaProject", vba_rels);
} else {
log::warn!("docx: {flavor:?} can't hold macros; the VBA project is left out");
}
}
// Main part goes first in the zip after content types.
entries.insert(0, ("word/document.xml".into(), body));
overrides.insert(0, ("/word/document.xml".into(), flavor.main_content_type().into()));
entries.insert(1, ("word/_rels/document.xml.rels".into(), rels.xml()));
// Package-level parts.
root.add(rt::OFFICE_DOC, "word/document.xml", false);
root.add(rt::CORE, "docProps/core.xml", false);
root.add(rt::APP, "docProps/app.xml", false);
entries.push(("docProps/core.xml".into(), core_xml(doc)));
overrides.push(("/docProps/core.xml".into(), "application/vnd.openxmlformats-package.core-properties+xml".into()));
entries.push(("docProps/app.xml".into(), app_xml(doc)));
overrides.push(("/docProps/app.xml".into(), "application/vnd.openxmlformats-officedocument.extended-properties+xml".into()));
if !doc.custom_props.is_empty() {
root.add(rt::CUSTOM, "docProps/custom.xml", false);
entries.push(("docProps/custom.xml".into(), crate::custom::write(&doc.custom_props)));
overrides.push(("/docProps/custom.xml".into(), "application/vnd.openxmlformats-officedocument.custom-properties+xml".into()));
}
if !doc.sources.is_empty() {
root.add(rt::CUSTOM_XML, "customXml/item1.xml", false);
let mut item_rels = PartRels::default();
item_rels.add(rt::CUSTOM_XML_PROPS, "itemProps1.xml", false);
push_part(
&mut entries,
&mut overrides,
"customXml/item1.xml",
bibliography_xml(doc),
"application/vnd.openxmlformats-officedocument.customXml+xml",
item_rels,
);
push_part(
&mut entries,
&mut overrides,
"customXml/itemProps1.xml",
bibliography_props_xml(),
"application/vnd.openxmlformats-officedocument.customXmlProperties+xml",
PartRels::default(),
);
}
entries.insert(0, ("_rels/.rels".into(), root.xml()));
// Content types.
let mut w = W::new();
w.open("Types", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/content-types")]);
w.empty("Default", &[("Extension", "rels"), ("ContentType", "application/vnd.openxmlformats-package.relationships+xml")]);
w.empty("Default", &[("Extension", "xml"), ("ContentType", "application/xml")]);
for (ext, ct) in &media_types {
if ext != "xml" && ext != "rels" {
w.empty("Default", &[("Extension", ext), ("ContentType", ct)]);
}
}
for (part, ct) in &overrides {
w.empty("Override", &[("PartName", part), ("ContentType", ct)]);
}
w.close("Types");
entries.insert(0, ("[Content_Types].xml".into(), w.into_bytes()));
Ok(entries)
}
fn push_part(entries: &mut Vec<(String, Vec<u8>)>, overrides: &mut Vec<(String, String)>, path: &str, bytes: Vec<u8>, ct: &str, rels: PartRels) {
if !rels.is_empty() {
let (dir, file) = path.rsplit_once('/').unwrap_or(("", path));
entries.push((format!("{dir}/_rels/{file}.rels"), rels.xml()));
}
entries.push((path.to_string(), bytes));
overrides.push((format!("/{path}"), ct.to_string()));
}
/// Content type by file extension (or sniffed name).
fn content_type_for(file: &str) -> &'static str {
match file.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" | "jpe" => "image/jpeg",
"gif" => "image/gif",
"bmp" => "image/bmp",
"webp" => "image/webp",
"tif" | "tiff" => "image/tiff",
"svg" => "image/svg+xml",
"emf" => "image/x-emf",
"wmf" => "image/x-wmf",
"ico" => "image/x-icon",
_ => "application/octet-stream",
}
}
/// Guess an image extension from magic bytes.
fn sniff(bytes: &[u8]) -> &'static str {
if bytes.starts_with(b"\x89PNG") {
"png"
} else if bytes.starts_with(&[0xFF, 0xD8]) {
"jpeg"
} else if bytes.starts_with(b"GIF8") {
"gif"
} else if bytes.starts_with(b"BM") {
"bmp"
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
"webp"
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
"tiff"
} else if bytes.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) {
"wmf"
} else if bytes.get(40..44) == Some(b" EMF") {
"emf"
} else if bytes.starts_with(b"<svg") || bytes.starts_with(b"<?xml") {
"svg"
} else {
"bin"
}
}
impl Writer<'_> {
fn assign_media(&mut self) {
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
for (key, bytes) in &self.doc.media {
let clean: String = key.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).take(80).collect();
let has_known_ext = clean.rsplit_once('.').is_some_and(|(s, _)| !s.is_empty()) && content_type_for(&clean) != "application/octet-stream";
let mut base = if has_known_ext && !clean.starts_with('.') {
clean
} else {
let stem = clean.trim_matches('.');
let stem = if stem.is_empty() { "image" } else { stem };
format!("{stem}.{}", sniff(bytes))
};
let (stem, ext) = base.rsplit_once('.').map(|(s, e)| (s.to_string(), e.to_string())).unwrap_or((base.clone(), "bin".into()));
let mut i = 1;
while used.contains(&base.to_ascii_lowercase()) {
i += 1;
base = format!("{stem}_{i}.{ext}");
}
used.insert(base.to_ascii_lowercase());
self.media_files.insert(key.clone(), base);
}
}
fn next_docpr(&mut self) -> String {
self.docpr = self.docpr.saturating_add(1);
self.docpr.to_string()
}
/// The drawing id of a shape: its own (once; a copy gets a fresh one), else a fresh one.
fn shape_docpr(&mut self, own: u32) -> String {
if own != 0 && self.shape_ids.insert(own) { own.to_string() } else { self.next_docpr() }
}
fn bookmark_id(&mut self, name: &str) -> String {
let n = self.bookmarks.len() as u32;
self.bookmarks.entry(name.to_string()).or_insert(n).to_string()
}
fn note_id(&mut self, part: u32, foot: bool) -> String {
let base = note_id_base(self.doc, foot);
let list = if foot { &mut self.footnotes } else { &mut self.endnotes };
let idx = match list.iter().position(|p| *p == part) {
Some(i) => i,
None => {
list.push(part);
list.len() - 1
}
};
(idx + base).to_string()
}
fn hf_rel(&mut self, part: u32, footer: bool, rels: &mut PartRels) -> Option<String> {
let kind = if part == self.wm_header && !footer { PartKind::Header } else { self.doc.parts.get(&part)?.kind };
if !matches!(kind, PartKind::Header | PartKind::Footer) {
return None;
}
let file = match self.hf.iter().find(|(p, _, f)| *p == part && *f == footer) {
Some((_, f, _)) => f.clone(),
None => {
let n = self.hf.iter().filter(|(_, _, f)| *f == footer).count() + 1;
let f = format!("{}{n}.xml", if footer { "footer" } else { "header" });
self.hf.push((part, f.clone(), footer));
f
}
};
Some(rels.add(if footer { rt::FOOTER } else { rt::HEADER }, &file, false))
}
fn take_para_id(&self, p: &wordcraft_doc::Paragraph) -> Option<String> {
self.para_ids.get(&(p as *const _ as usize)).cloned()
}
/// Whether `p` holds a reference to the note being written.
fn holds_own_ref(&self, p: &wordcraft_doc::Paragraph) -> bool {
let Some((foot, id)) = self.current_note else { return false };
p.objects.iter().any(|o| match o {
wordcraft_doc::InlineObject::NoteRef { kind, id: nid, .. } => *nid == id && (*kind == wordcraft_doc::para::NoteKind::Footnote) == foot,
_ => false,
})
}
/// Note stories: the first paragraph starts with the note's own reference mark.
fn note_blocks(&mut self, w: &mut W, blocks: &Blocks, rels: &mut PartRels, foot: bool, part: u32) {
self.pending_mark = Some(if foot { "w:footnoteRef" } else { "w:endnoteRef" });
self.current_note = Some((foot, part));
self.blocks(w, blocks, rels, false, 0);
self.current_note = None;
self.pending_mark = None;
}
}
/// Built-in names Word keeps in lower case in styles.xml.
fn file_style_name(name: &str) -> String {
if let Some((f, _)) = crate::read::LOWER_NAMES.iter().find(|(_, ui)| ui.eq_ignore_ascii_case(name)) {
return (*f).to_string();
}
for (pre, f) in [("Heading ", "heading "), ("TOC ", "toc "), ("Index ", "index ")] {
if let Some(d) = name.strip_prefix(pre)
&& d.len() == 1
&& d.chars().all(|c| c.is_ascii_digit())
{
return format!("{f}{d}");
}
}
name.to_string()
}
fn styles_xml(doc: &Document) -> Vec<u8> {
let s = &doc.styles;
let mut w = W::new();
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
w.open("w:styles", &ns_refs);
w.open("w:docDefaults", &[]);
w.open("w:rPrDefault", &[]);
w.open("w:rPr", &[]);
props::rpr_inner(&mut w, &s.default_chr);
w.close("w:rPr");
w.close("w:rPrDefault");
if s.default_para_present || s.default_para != wordcraft_doc::props::ParaProps::default() {
w.open("w:pPrDefault", &[]);
w.open("w:pPr", &[]);
props::ppr_inner(&mut w, &s.default_para, false, None);
w.close("w:pPr");
w.close("w:pPrDefault");
}
w.close("w:docDefaults");
if let Some(latent) = s.latent_styles.as_deref() {
w.raw(latent);
}
// Kinds whose default style the file named (`w:default`); the English ids stand in otherwise.
let flagged: Vec<StyleKind> = s.styles.iter().filter(|st| st.default).map(|st| st.kind).collect();
for st in &s.styles {
style_xml(&mut w, st, &flagged);
}
w.close("w:styles");
w.into_bytes()
}
fn style_xml(w: &mut W, st: &Style, flagged: &[StyleKind]) {
let ty = match st.kind {
StyleKind::Paragraph => "paragraph",
StyleKind::Character => "character",
StyleKind::Table => "table",
StyleKind::Numbering => "numbering",
};
let english_default = matches!(
(st.kind, st.id.as_str()),
(StyleKind::Paragraph, "Normal")
| (StyleKind::Character, "DefaultParagraphFont")
| (StyleKind::Table, "TableNormal")
| (StyleKind::Numbering, "NoList")
);
let is_default = st.default || (english_default && !flagged.contains(&st.kind));
let mut a = vec![("w:type", ty), ("w:styleId", st.id.as_str())];
if is_default {
a.push(("w:default", "1"));
}
if !st.builtin {
a.push(("w:customStyle", "1"));
}
w.open("w:style", &a);
w.val("w:name", &file_style_name(if st.name.is_empty() { &st.id } else { &st.name }));
if let Some(b) = &st.based_on {
w.val("w:basedOn", b);
}
if let Some(b) = &st.next {
w.val("w:next", b);
}
if let Some(b) = &st.linked {
w.val("w:link", b);
}
if let Some(p) = st.priority {
w.val("w:uiPriority", &p.to_string());
}
if st.hidden {
w.empty("w:semiHidden", &[]);
w.empty("w:unhideWhenUsed", &[]);
}
if st.quick {
w.empty("w:qFormat", &[]);
}
if st.locked {
w.empty("w:locked", &[]);
}
if !st.para.is_empty() && st.kind != StyleKind::Character {
w.open("w:pPr", &[]);
props::ppr_inner(w, &st.para, false, None);
w.close("w:pPr");
}
if props::has_rpr(&st.chr) && st.kind != StyleKind::Numbering {
w.open("w:rPr", &[]);
props::rpr_inner(w, &st.chr);
w.close("w:rPr");
}
if let Some(t) = &st.table {
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() {
w.open("w:tblPr", &[]);
if let Some(n) = t.band_size {
w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string());
}
if let Some(b) = &t.borders {
props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]);
}
if let Some(m) = &t.cell_margins {
props::margins(w, "w:tblCellMar", m);
}
w.close("w:tblPr");
}
// One conditional formatting region (ECMA-376 §17.7.6): run properties, then cell borders
// and shading.
let cond = |w: &mut W,
ty: &str,
chr: &wordcraft_doc::CharProps,
fill: Option<wordcraft_doc::Rgb>,
borders: Option<wordcraft_doc::props::Borders>| {
if !props::has_rpr(chr) && fill.is_none() && borders.is_none() {
return;
}
w.open("w:tblStylePr", &[("w:type", ty)]);
if props::has_rpr(chr) {
w.open("w:rPr", &[]);
props::rpr_inner(w, chr);
w.close("w:rPr");
}
if fill.is_some() || borders.is_some() {
w.open("w:tcPr", &[]);
if let Some(bs) = &borders {
props::borders(w, "w:tcBorders", bs, Some("w:insideH"), &[]);
}
if let Some(f) = fill {
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]);
}
w.close("w:tcPr");
}
w.close("w:tblStylePr");
};
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders);
// The total row's borders, its top edge falling back to the built-in top rule.
let total_borders = match (t.total_borders, t.total_border_top) {
(Some(mut b), top) => {
b.top = b.top.or(top);
Some(b)
}
(None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }),
};
cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders);
cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders);
cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders);
cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders);
cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders);
}
w.close("w:style");
}
fn numbering_xml(doc: &Document) -> Vec<u8> {
let mut w = W::new();
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
w.open("w:numbering", &ns_refs);
let mut nsids: std::collections::HashSet<&str> = std::collections::HashSet::new();
for a in &doc.numbering.abstracts {
w.open("w:abstractNum", &[("w:abstractNumId", &a.id.to_string())]);
// CT_AbstractNum order: nsid, multiLevelType, tmpl, name… A copied definition repeats
// its source's nsid; only the first keeps it, so Word doesn't take them for one list.
if let Some(n) = a.nsid.as_deref().filter(|n| nsids.insert(n)) {
w.val("w:nsid", n);
}
let derived = if a.levels.len() > 1 { "multilevel" } else { "singleLevel" };
w.val("w:multiLevelType", a.multi_level_type.as_deref().unwrap_or(derived));
if let Some(t) = &a.tmpl {
w.val("w:tmpl", t);
}
if let Some(n) = &a.name {
w.val("w:name", n);
}
for (i, l) in a.levels.iter().take(9).enumerate() {
write_level(&mut w, i, l);
}
w.close("w:abstractNum");
}
for n in &doc.numbering.nums {
w.open("w:num", &[("w:numId", &n.id.to_string())]);
w.val("w:abstractNumId", &n.abstract_id.to_string());
for lvl in 0..9u8 {
let start = n.start_overrides.iter().find(|(l, _)| (*l).min(8) == lvl).map(|(_, s)| *s);
let level = n.level_overrides.iter().find(|(l, _)| (*l).min(8) == lvl).map(|(_, l)| l);
if start.is_none() && level.is_none() {
continue;
}
w.open("w:lvlOverride", &[("w:ilvl", &lvl.to_string())]);
if let Some(start) = start {
w.val("w:startOverride", &start.to_string());
}
if let Some(level) = level {
write_level(&mut w, lvl as usize, level);
}
w.close("w:lvlOverride");
}
w.close("w:num");
}
w.close("w:numbering");
w.into_bytes()
}
/// One `w:lvl` (list level `i`).
fn write_level(w: &mut W, i: usize, l: &Level) {
w.open("w:lvl", &[("w:ilvl", &i.to_string())]);
w.val("w:start", &l.start.to_string());
w.val("w:numFmt", l.format.ooxml());
if !l.restart {
w.val("w:lvlRestart", "0");
} else if let Some(k) = l.restart_after.filter(|k| (1..=9).contains(k)) {
w.val("w:lvlRestart", &k.to_string());
}
if let Some(s) = &l.style {
w.val("w:pStyle", s);
}
if l.legal {
w.empty("w:isLgl", &[]);
}
match l.suffix {
LevelSuffix::Tab => {}
LevelSuffix::Space => w.val("w:suff", "space"),
LevelSuffix::Nothing => w.val("w:suff", "nothing"),
}
w.val("w:lvlText", &l.text);
w.val("w:lvlJc", props::align_val(l.align));
w.open("w:pPr", &[]);
if let Some(t) = l.tab.filter(|t| t.is_finite()) {
w.open("w:tabs", &[]);
w.empty("w:tab", &[("w:val", "num"), ("w:pos", &crate::units::twips(t.clamp(-1584.0, 1584.0)))]);
w.close("w:tabs");
}
let ind = crate::units::twips(l.indent);
if l.hanging >= 0.0 {
w.empty("w:ind", &[("w:left", &ind), ("w:hanging", &crate::units::twips(l.hanging))]);
} else {
w.empty("w:ind", &[("w:left", &ind), ("w:firstLine", &crate::units::twips(-l.hanging))]);
}
w.close("w:pPr");
props::rpr(w, &l.chr);
w.close("w:lvl");
}
/// The file id of the first footnote or endnote: after the separators (-1, 0) and the
/// continuation notice (1), when the document has one.
fn note_id_base(doc: &Document, foot: bool) -> usize {
let seps = if foot { &doc.footnote_separators } else { &doc.endnote_separators };
if seps.continuation_notice.is_some() { 2 } else { 1 }
}
/// Whether the footnotes (or endnotes) part is written: there are notes, or separators of the
/// document's own to keep.
fn notes_part(doc: &Document, wr: &Writer<'_>, foot: bool) -> bool {
let (ids, seps) = if foot { (&wr.footnotes, &doc.footnote_separators) } else { (&wr.endnotes, &doc.endnote_separators) };
!ids.is_empty() || !seps.is_empty()
}
/// `w:settings`, its children in the schema's order (CT_Settings is a sequence). `template_rel`
/// is the id of the attached template's relationship, when there is one.
fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: Option<&str>) -> Vec<u8> {
let s = &doc.settings;
let s_notes = (&doc.footnote_separators, &doc.endnote_separators);
let mut w = W::new();
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
w.open("w:settings", &ns_refs);
let zoom = s.zoom.unwrap_or(wordcraft_doc::Zoom { percent: 100, fit: None });
let percent = zoom.percent.clamp(10, 500).to_string();
match zoom.fit {
Some(fit) => {
let val = match fit {
wordcraft_doc::ZoomFit::OnePage => "fullPage",
wordcraft_doc::ZoomFit::PageWidth => "bestFit",
wordcraft_doc::ZoomFit::TextWidth => "textFit",
};
w.empty("w:zoom", &[("w:val", val), ("w:percent", &percent)]);
}
None => w.empty("w:zoom", &[("w:percent", &percent)]),
}
// §17.15.1.70 / §17.15.1.69, right after `w:zoom` in CT_Settings.
if s.remove_personal_info {
w.empty("w:removePersonalInformation", &[]);
}
if s.remove_date_time {
w.empty("w:removeDateAndTime", &[]);
}
if s.page_color.is_some() || s.page_picture.is_some() {
w.empty("w:displayBackgroundShape", &[]);
}
if s.mirror_margins {
w.empty("w:mirrorMargins", &[]);
}
if let Some(id) = template_rel {
w.empty("w:attachedTemplate", &[("r:id", id)]);
}
if s.link_styles {
w.empty("w:linkStyles", &[]);
}
if s.track_changes {
w.empty("w:trackRevisions", &[]);
}
if let Some(p) = &s.protection {
let mut a: Vec<(&str, &str)> = Vec::new();
if p != "none" {
a.push(("w:edit", p));
}
if s.protect_formatting {
a.push(("w:formatting", "1"));
}
a.push(("w:enforcement", "1"));
// Only the hash attributes the reader keeps, each once.
for name in crate::read::PROTECTION_HASH_ATTRS {
if let Some((_, v)) = s.protect_hash.iter().find(|(k, _)| k == name) {
a.push((name, v));
}
}
w.empty("w:documentProtection", &a);
}
w.val("w:defaultTabStop", &crate::units::twips(s.default_tab.clamp(1.0, 1584.0)));
if s.auto_hyphenation {
w.empty("w:autoHyphenation", &[]);
}
// Schema order: consecutiveHyphenLimit, hyphenationZone, doNotHyphenateCaps.
if s.consecutive_hyphen_limit > 0 {
w.val("w:consecutiveHyphenLimit", &s.consecutive_hyphen_limit.min(32_767).to_string());
}
if s.hyphenation_zone.is_finite() && (s.hyphenation_zone - wordcraft_doc::DEFAULT_HYPHENATION_ZONE).abs() > 0.01 {
w.val("w:hyphenationZone", &crate::units::twips(s.hyphenation_zone.clamp(0.0, 1584.0)));
}
if !s.hyphenate_caps {
w.empty("w:doNotHyphenateCaps", &[]);
}
if s.even_odd_headers {
w.empty("w:evenAndOddHeaders", &[]);
}
for (tag, v) in [("w:drawingGridHorizontalSpacing", s.grid_h), ("w:drawingGridVerticalSpacing", s.grid_v)] {
if v.is_finite() && (v - wordcraft_doc::DEFAULT_GRID).abs() > 0.01 {
w.val(tag, &crate::units::twips(v.clamp(0.5, 1584.0)));
}
}
w.val("w:characterSpacingControl", "doNotCompress");
for (tag, fmt, pr, used, el) in [
("w:footnotePr", s.footnote_format, &s.footnote_pr, footnotes, "w:footnote"),
("w:endnotePr", s.endnote_format, &s.endnote_pr, endnotes, "w:endnote"),
] {
w.open(tag, &[]);
story::note_pr_inner(&mut w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr });
if used {
w.empty(el, &[("w:id", "-1")]);
w.empty(el, &[("w:id", "0")]);
let seps = if el == "w:footnote" { &s_notes.0 } else { &s_notes.1 };
if seps.continuation_notice.is_some() {
w.empty(el, &[("w:id", "1")]);
}
}
w.close(tag);
}
w.open("w:compat", &[]);
// CT_Compat: the older switches come before the `w:compatSetting`s.
if doc.settings.sum_paragraph_spacing {
w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]);
}
if s.allow_space_of_same_style_in_table {
w.empty("w:allowSpaceOfSameStyleInTable", &[]);
}
let mode = doc.settings.compat_mode.clamp(11, 15).to_string();
w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", &mode)]);
w.close("w:compat");
if let Some(m) = &s.math {
math::math_pr(&mut w, m);
}
w.close("w:settings");
w.into_bytes()
}
/// Is `d` a usable `w16cid:durableId`: 1–8 hex digits, below 0x7FFFFFFF, not 0?
pub(crate) fn durable_id_ok(d: &str) -> bool {
d.len() <= 8 && u32::from_str_radix(d, 16).is_ok_and(|v| v != 0 && v < 0x7FFF_FFFF)
}
/// `commentsIds.xml` (paragraph id → durable id) and `commentsExtensible.xml` (durable id → UTC
/// date) for the comments that got paragraph ids. Durable ids read from the file are kept (unique
/// ones); comments without one get the next free id. Word uses them to keep tracking a comment
/// across saves and for its UTC time.
fn write_comment_ids(
doc: &Document,
comment_paras: &[(u32, String)],
entries: &mut Vec<(String, Vec<u8>)>,
overrides: &mut Vec<(String, String)>,
rels: &mut PartRels,
) {
if comment_paras.is_empty() {
return;
}
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut next = 0x1000_0000u32;
let mut rows: Vec<(String, String, Option<String>)> = Vec::new();
for (cid, pid) in comment_paras {
let Some(c) = doc.comments.get(cid) else { continue };
let kept = c.durable_id.as_deref().filter(|d| durable_id_ok(d)).map(|d| format!("{:08X}", u32::from_str_radix(d, 16).unwrap_or(0)));
let durable = match kept.filter(|d| !used.contains(d)) {
Some(d) => d,
None => loop {
next = next.saturating_add(1);
let d = format!("{next:08X}");
if !used.contains(&d) && !doc.comments.values().any(|o| o.durable_id.as_deref() == Some(d.as_str())) {
break d;
}
},
};
used.insert(durable.clone());
// A local `w:date` with a zone designator is UTC already; otherwise only the file's own.
// Remove date and time on save (#1062): no comment date here either.
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone().or_else(|| c.date.ends_with('Z').then(|| c.date.clone())) };
rows.push((pid.clone(), durable, utc));
}
let mc = "http://schemas.openxmlformats.org/markup-compatibility/2006";
let mut w = W::new();
w.open(
"w16cid:commentsIds",
&[("xmlns:mc", mc), ("xmlns:w16cid", "http://schemas.microsoft.com/office/word/2016/wordml/cid"), ("mc:Ignorable", "w16cid")],
);
for (pid, durable, _) in &rows {
w.empty("w16cid:commentId", &[("w16cid:paraId", pid), ("w16cid:durableId", durable)]);
}
w.close("w16cid:commentsIds");
rels.add(rt::COMMENTS_IDS, "commentsIds.xml", false);
push_part(entries, overrides, "word/commentsIds.xml", w.into_bytes(), &format!("{CT_WML}commentsIds+xml"), PartRels::default());
let mut w = W::new();
w.open(
"w16cex:commentsExtensible",
&[("xmlns:mc", mc), ("xmlns:w16cex", "http://schemas.microsoft.com/office/word/2018/wordml/cex"), ("mc:Ignorable", "w16cex")],
);
for (_, durable, utc) in &rows {
match utc {
Some(d) => w.empty("w16cex:commentExtensible", &[("w16cex:durableId", durable), ("w16cex:dateUtc", d)]),
None => w.empty("w16cex:commentExtensible", &[("w16cex:durableId", durable)]),
}
}
w.close("w16cex:commentsExtensible");
rels.add(rt::COMMENTS_EXTENSIBLE, "commentsExtensible.xml", false);
push_part(entries, overrides, "word/commentsExtensible.xml", w.into_bytes(), &format!("{CT_WML}commentsExtensible+xml"), PartRels::default());
}
fn theme_xml(doc: &Document) -> Vec<u8> {
let s = &doc.settings;
let names = ["dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink"];
let mut w = W::new();
let name = if s.theme_name.is_empty() { "Craft" } else { s.theme_name.as_str() };
w.open("a:theme", &[("xmlns:a", "http://schemas.openxmlformats.org/drawingml/2006/main"), ("name", name)]);
w.open("a:themeElements", &[]);
w.open("a:clrScheme", &[("name", name)]);
for (i, n) in names.iter().enumerate() {
let c = s.theme_colors.get(i).or_else(|| wordcraft_doc::THEME_COLORS.get(i)).copied().unwrap_or_default();
w.open(&format!("a:{n}"), &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close(&format!("a:{n}"));
}
w.close("a:clrScheme");
w.open("a:fontScheme", &[("name", name)]);
for (tag, face) in [("a:majorFont", &s.major_font), ("a:minorFont", &s.minor_font)] {
w.open(tag, &[]);
w.empty("a:latin", &[("typeface", face)]);
w.empty("a:ea", &[("typeface", "")]);
w.empty("a:cs", &[("typeface", "")]);
w.close(tag);
}
w.close("a:fontScheme");
// A minimal format scheme (three entries per list, as the schema requires).
w.open("a:fmtScheme", &[("name", name)]);
w.open("a:fillStyleLst", &[]);
for _ in 0..3 {
w.raw("<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>");
}
w.close("a:fillStyleLst");
w.open("a:lnStyleLst", &[]);
for width in ["6350", "12700", "19050"] {
w.raw(&format!(
"<a:ln w=\"{width}\" cap=\"flat\" cmpd=\"sng\" algn=\"ctr\"><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:prstDash val=\"solid\"/><a:miter lim=\"800000\"/></a:ln>"
));
}
w.close("a:lnStyleLst");
w.open("a:effectStyleLst", &[]);
for _ in 0..3 {
w.raw("<a:effectStyle><a:effectLst/></a:effectStyle>");
}
w.close("a:effectStyleLst");
w.open("a:bgFillStyleLst", &[]);
for _ in 0..3 {
w.raw("<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>");
}
w.close("a:bgFillStyleLst");
w.close("a:fmtScheme");
w.close("a:themeElements");
w.empty("a:objectDefaults", &[]);
w.empty("a:extraClrSchemeLst", &[]);
w.close("a:theme");
w.into_bytes()
}
/// The author the saved file gives a comment or tracked change when the document removes personal
/// information on save (`w:removePersonalInformation`), as Word does.
pub(crate) const ANONYMOUS_AUTHOR: &str = "Author";
const ANONYMOUS_INITIALS: &str = "A";
/// The `w:author` written for an annotation by `author`: "Author" when the document removes
/// personal information on save, "Unknown" when it has none.
pub(crate) fn annotation_author<'a>(doc: &Document, author: &'a str) -> &'a str {
if doc.settings.remove_personal_info {
ANONYMOUS_AUTHOR
} else if author.is_empty() {
"Unknown"
} else {
author
}
}
fn core_xml(doc: &Document) -> Vec<u8> {
let c = &doc.core;
// Remove personal information (§17.15.1.70): no creator or last-modified-by in the file.
let personal = |v: &String| if doc.settings.remove_personal_info { String::new() } else { v.clone() };
let (creator, last_modified_by) = (personal(&c.creator), personal(&c.last_modified_by));
let mut w = W::new();
w.open(
"cp:coreProperties",
&[
("xmlns:cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"),
("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
("xmlns:dcterms", "http://purl.org/dc/terms/"),
("xmlns:dcmitype", "http://purl.org/dc/dcmitype/"),
("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"),
],
);
for (tag, v) in [
("dc:title", &c.title),
("dc:subject", &c.subject),
("dc:creator", &creator),
("cp:keywords", &c.keywords),
("dc:description", &c.description),
("cp:lastModifiedBy", &last_modified_by),
("cp:category", &c.category),
] {
if !v.is_empty() {
w.leaf(tag, &[], v);
}
}
if c.revision > 0 {
w.leaf("cp:revision", &[], &c.revision.to_string());
}
for (tag, v) in [("dcterms:created", &c.created), ("dcterms:modified", &c.modified)] {
if !v.is_empty() {
w.leaf(tag, &[("xsi:type", "dcterms:W3CDTF")], v);
}
}
w.close("cp:coreProperties");
w.into_bytes()
}
fn app_xml(doc: &Document) -> Vec<u8> {
let mut w = W::new();
w.open(
"Properties",
&[
("xmlns", "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"),
("xmlns:vt", "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"),
],
);
w.leaf("Application", &[], "WordCraft");
w.leaf("Words", &[], &doc.word_count().to_string());
w.leaf("Paragraphs", &[], &doc.paragraph_count().to_string());
w.leaf("DocSecurity", &[], "0");
// Kept properties, except the personal ones when the file asks Word to remove personal
// information on save.
let private = doc.settings.remove_personal_info;
if let Some(kept) = doc.passthrough.get(crate::package::APP_KEPT) {
for line in String::from_utf8_lossy(kept).lines().take(crate::package::APP_KEPT_PROPS.len()) {
let Some((name, value)) = line.split_once('\t') else { continue };
let Some(name) = crate::package::APP_KEPT_PROPS.iter().find(|n| **n == name) else { continue };
if private && matches!(*name, "Company" | "Manager") {
continue;
}
w.leaf(name, &[], value);
}
}
w.close("Properties");
w.into_bytes()
}
fn to_source_type(kind: &str) -> &str {
match kind.to_ascii_lowercase().as_str() {
"book" => "Book",
"booksection" | "chapter" => "BookSection",
"article" | "journal" | "journalarticle" => "JournalArticle",
"periodical" | "articleinaperiodical" => "ArticleInAPeriodical",
"website" | "site" | "internetsite" => "InternetSite",
"documentfrominternetsite" => "DocumentFromInternetSite",
"electronicsource" => "ElectronicSource",
"report" => "Report",
"conference" | "proceedings" | "conferenceproceedings" => "ConferenceProceedings",
"case" => "Case",
"art" => "Art",
"film" => "Film",
"interview" => "Interview",
"patent" => "Patent",
"performance" => "Performance",
"soundrecording" => "SoundRecording",
"standard" => "Standard",
"thesis" => "Thesis",
"other" | "misc" => "Misc",
_ => "Book",
}
}
pub(crate) fn from_source_type(st: &str) -> String {
match st.trim() {
"Book" | "book" => "book".into(),
"BookSection" | "booksection" => "bookSection".into(),
"JournalArticle" | "journalarticle" | "article" => "article".into(),
"ArticleInAPeriodical" | "articleinaperiodical" => "periodical".into(),
"InternetSite" | "internetsite" | "website" => "website".into(),
"DocumentFromInternetSite" | "documentfrominternetsite" => "website".into(),
"ElectronicSource" | "electronicsource" => "electronicsource".into(),
"Report" | "report" => "report".into(),
"ConferenceProceedings" | "conferenceproceedings" => "conference".into(),
"Case" | "case" => "case".into(),
"SoundRecording" | "soundrecording" => "soundrecording".into(),
"Film" | "film" => "film".into(),
"Interview" | "interview" => "interview".into(),
"Patent" | "patent" => "patent".into(),
"Performance" | "performance" => "performance".into(),
"Standard" | "standard" => "standard".into(),
"Thesis" | "thesis" => "thesis".into(),
"Misc" | "misc" => "other".into(),
"" => "book".into(),
other => other.to_ascii_lowercase(),
}
}
/// The document's citation style, APA when it has none, and a style sheet file name made from
/// it (`\APA.XSL`, `\MLA.XSL`).
fn bib_style(doc: &Document) -> (&str, String) {
let name = doc.settings.bib_style.as_deref().map(str::trim).filter(|n| !n.is_empty()).unwrap_or("APA");
let file: String = name.chars().filter(char::is_ascii_alphanumeric).collect();
(name, format!("\\{}.XSL", if file.is_empty() { "APA" } else { &file }))
}
fn write_name_list(w: &mut W, names: &str) {
w.open("b:NameList", &[]);
let persons: Vec<_> = names.split(';').map(str::trim).filter(|s| !s.is_empty()).collect();
for a in persons {
w.open("b:Person", &[]);
// Parse "Last, First" or "Last, First Middle"
let (last, first_middle) = match a.split_once(',') {
Some((l, f)) => (l.trim(), f.trim()),
None => (a, ""),
};
w.leaf("b:Last", &[], last);
if !first_middle.is_empty() {
let parts: Vec<&str> = first_middle.split_whitespace().collect();
if let Some(first) = parts.first() {
w.leaf("b:First", &[], first);
}
if let Some(slice) = parts.get(1..).filter(|s| !s.is_empty()) {
let mid = slice.join(" ");
w.leaf("b:Middle", &[], &mid);
}
}
w.close("b:Person");
}
w.close("b:NameList");
}
fn split_accessed_date(s: &str) -> (Option<&str>, Option<&str>, Option<&str>) {
let s = s.trim();
if s.is_empty() {
return (None, None, None);
}
let parts: Vec<&str> = if s.contains('-') {
s.split('-').collect()
} else if s.contains('/') {
s.split('/').collect()
} else {
Vec::new()
};
if let [y, m, d] = parts.as_slice() {
(Some(y.trim()), Some(m.trim()), Some(d.trim()))
} else if let [y, m] = parts.as_slice() {
(Some(y.trim()), Some(m.trim()), None)
} else {
(Some(s), None, None)
}
}
fn bibliography_xml(doc: &Document) -> Vec<u8> {
let (style_name, selected) = bib_style(doc);
let mut w = W::new();
w.open(
"b:Sources",
&[
("SelectedStyle", selected.as_str()),
("StyleName", style_name),
("xmlns:b", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
("xmlns", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
],
);
for src in &doc.sources {
w.open("b:Source", &[]);
w.leaf("b:Tag", &[], &src.tag);
w.leaf("b:SourceType", &[], to_source_type(&src.kind));
// Author & Editor
if !src.author.is_empty() || !src.editor.is_empty() {
w.open("b:Author", &[]);
if !src.author.is_empty() {
w.open("b:Author", &[]);
write_name_list(&mut w, &src.author);
w.close("b:Author");
}
if !src.editor.is_empty() {
w.open("b:Editor", &[]);
write_name_list(&mut w, &src.editor);
w.close("b:Editor");
}
w.close("b:Author");
}
if !src.title.is_empty() {
w.leaf("b:Title", &[], &src.title);
}
if !src.year.is_empty() {
w.leaf("b:Year", &[], &src.year);
}
if !src.month.is_empty() {
w.leaf("b:Month", &[], &src.month);
}
if !src.day.is_empty() {
w.leaf("b:Day", &[], &src.day);
}
if !src.city.is_empty() {
w.leaf("b:City", &[], &src.city);
}
if !src.publisher.is_empty() {
w.leaf("b:Publisher", &[], &src.publisher);
}
if !src.journal.is_empty() {
w.leaf("b:JournalName", &[], &src.journal);
}
if !src.edition.is_empty() {
w.leaf("b:Edition", &[], &src.edition);
}
if !src.volume.is_empty() {
w.leaf("b:Volume", &[], &src.volume);
}
if !src.issue.is_empty() {
w.leaf("b:Issue", &[], &src.issue);
}
if !src.pages.is_empty() {
w.leaf("b:Pages", &[], &src.pages);
}
if !src.url.is_empty() {
w.leaf("b:URL", &[], &src.url);
}
if !src.accessed.is_empty() {
let (y, m, d) = split_accessed_date(&src.accessed);
if let Some(y) = y.filter(|s| !s.is_empty()) {
w.leaf("b:YearAccessed", &[], y);
}
if let Some(m) = m.filter(|s| !s.is_empty()) {
w.leaf("b:MonthAccessed", &[], m);
}
if let Some(d) = d.filter(|s| !s.is_empty()) {
w.leaf("b:DayAccessed", &[], d);
}
w.leaf("b:Accessed", &[], &src.accessed);
}
if !src.doi.is_empty() {
w.leaf("b:DOI", &[], &src.doi);
w.leaf("b:StandardNumber", &[], &src.doi);
}
w.close("b:Source");
}
w.close("b:Sources");
w.into_bytes()
}
fn bibliography_props_xml() -> Vec<u8> {
let mut w = W::new();
w.open(
"ds:datastoreItem",
&[("xmlns:ds", "http://schemas.openxmlformats.org/officeDocument/2006/customXml"), ("ds:itemID", "{3315B9A8-2323-4554-B8B2-1234567890AB}")],
);
w.open("ds:schemaRefs", &[]);
w.empty("ds:schemaRef", &[("ds:uri", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography")]);
w.close("ds:schemaRefs");
w.close("ds:datastoreItem");
w.into_bytes()
}