mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:29:51 -06:00
A save regenerated word/settings.xml from the model, so every setting the model doesn't hold was dropped: compatibility options (lost in 18 of 18 real-world Word files), rsids, document variables, proofing state and the colour scheme mapping. The reader now keeps the original part when it holds such settings, and the writer merges into it: the original root and every unowned child stay byte for byte; owned children follow the model in CT_Settings order; in w:compat only compatibilityMode is replaced. The privacy options are the model's since #1062, so they are owned too. Kept children that point at a relationship (a mail-merge data source) are dropped. Anything unexpected falls back to the generated part. Files WordCraft wrote itself keep nothing extra. Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
przemek-arch
Claude Opus 5.5
Brandon Thomas
parent
b53fc8ca6e
commit
d2ff87856f
@@ -63,6 +63,9 @@ pub const FONT_TABLE_PART: &str = "word/fontTable.xml";
|
||||
/// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`.
|
||||
/// Their opaque bytes and related parts use the same bounded manifest as embedded objects.
|
||||
pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots";
|
||||
/// The original `word/settings.xml` when it holds settings the model doesn't (merged into on
|
||||
/// save by `write::settings_merge`). Not a package part: WordCraft's own bookkeeping.
|
||||
pub const SETTINGS_ORIG: &str = "wordcraft:settings.orig";
|
||||
/// Most parts we carry along for a file's embedded objects.
|
||||
pub const MAX_EMBEDDED_PARTS: usize = 4096;
|
||||
/// Most relationships we keep for one such part.
|
||||
|
||||
@@ -994,6 +994,9 @@ impl Reader<'_> {
|
||||
|
||||
fn read_settings(&mut self, path: &str) -> Result<(), DocxError> {
|
||||
let Some(root) = self.xml(path)? else { return Ok(()) };
|
||||
if let Some(b) = self.pkg.get(path).filter(|b| crate::write::settings_has_unowned(b)) {
|
||||
self.doc.passthrough.insert(crate::package::SETTINGS_ORIG.into(), Arc::new(b.to_vec()));
|
||||
}
|
||||
let rels = self.pkg.rels(path);
|
||||
let s = &mut self.doc.settings;
|
||||
for k in root.els() {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! [`Document`] → DOCX.
|
||||
|
||||
mod embed;
|
||||
mod settings_merge;
|
||||
pub(crate) use settings_merge::has_unowned as settings_has_unowned;
|
||||
mod math;
|
||||
mod props;
|
||||
mod sdt;
|
||||
@@ -376,7 +378,13 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
&mut entries,
|
||||
&mut overrides,
|
||||
"word/settings.xml",
|
||||
settings_xml(doc, notes_part(doc, &wr, true), notes_part(doc, &wr, false), template_rel.as_deref()),
|
||||
{
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
//! `word/settings.xml` merged into the file's original settings part.
|
||||
//!
|
||||
//! The model owns only some settings (`super::settings_xml`). Regenerating the part from the
|
||||
//! model alone drops everything else Word stores there: compatibility options, rsids, document
|
||||
//! variables, proofing state, the colour scheme mapping, "remove personal information on save"…
|
||||
//! So when a file brought settings the model doesn't hold ([`has_unowned`]; the reader keeps its
|
||||
//! bytes under `SETTINGS_ORIG`), the writer merges instead ([`merge`]): the original root start
|
||||
//! tag (namespace declarations, `mc:Ignorable`) and every child the model doesn't own are kept
|
||||
//! byte for byte; owned children come from the generated part (or go, when the model turned
|
||||
//! them off); owned children the original lacks are inserted at their CT_Settings position; in
|
||||
//! `w:compat` only the `compatibilityMode` setting is replaced. Kept children that refer to a
|
||||
//! relationship (`r:id`, e.g. a mail merge data source) are dropped: their relationships aren't
|
||||
//! carried. Anything unexpected returns `None`, and the generated part is written as before.
|
||||
|
||||
/// Children of `w:settings` the model writes (`super::settings_xml`).
|
||||
const OWNED: &[&str] = &[
|
||||
"w:removePersonalInformation",
|
||||
"w:removeDateAndTime",
|
||||
"w:displayBackgroundShape",
|
||||
"w:mirrorMargins",
|
||||
"w:attachedTemplate",
|
||||
"w:linkStyles",
|
||||
"w:trackRevisions",
|
||||
"w:documentProtection",
|
||||
"w:defaultTabStop",
|
||||
"w:autoHyphenation",
|
||||
"w:consecutiveHyphenLimit",
|
||||
"w:hyphenationZone",
|
||||
"w:doNotHyphenateCaps",
|
||||
"w:evenAndOddHeaders",
|
||||
"w:drawingGridHorizontalSpacing",
|
||||
"w:drawingGridVerticalSpacing",
|
||||
"w:characterSpacingControl",
|
||||
"w:footnotePr",
|
||||
"w:endnotePr",
|
||||
"m:mathPr",
|
||||
];
|
||||
|
||||
/// Owned children whose original, when there is one, is kept as is.
|
||||
const KEEP_ORIGINAL: &[&str] = &["m:mathPr", "w:displayBackgroundShape"];
|
||||
|
||||
/// CT_Settings' sequence (ECMA-376 §17.15.1.78), for placing inserted children.
|
||||
const ORDER: &[&str] = &[
|
||||
"w:writeProtection",
|
||||
"w:view",
|
||||
"w:zoom",
|
||||
"w:removePersonalInformation",
|
||||
"w:removeDateAndTime",
|
||||
"w:doNotDisplayPageBoundaries",
|
||||
"w:displayBackgroundShape",
|
||||
"w:printPostScriptOverText",
|
||||
"w:printFractionalCharacterWidth",
|
||||
"w:printFormsData",
|
||||
"w:embedTrueTypeFonts",
|
||||
"w:embedSystemFonts",
|
||||
"w:saveSubsetFonts",
|
||||
"w:saveFormsData",
|
||||
"w:mirrorMargins",
|
||||
"w:alignBordersAndEdges",
|
||||
"w:bordersDoNotSurroundHeader",
|
||||
"w:bordersDoNotSurroundFooter",
|
||||
"w:gutterAtTop",
|
||||
"w:hideSpellingErrors",
|
||||
"w:hideGrammaticalErrors",
|
||||
"w:activeWritingStyle",
|
||||
"w:proofState",
|
||||
"w:formsDesign",
|
||||
"w:attachedTemplate",
|
||||
"w:linkStyles",
|
||||
"w:stylePaneFormatFilter",
|
||||
"w:stylePaneSortMethod",
|
||||
"w:documentType",
|
||||
"w:mailMerge",
|
||||
"w:revisionView",
|
||||
"w:trackRevisions",
|
||||
"w:doNotTrackMoves",
|
||||
"w:doNotTrackFormatting",
|
||||
"w:documentProtection",
|
||||
"w:autoFormatOverride",
|
||||
"w:styleLockTheme",
|
||||
"w:styleLockQFSet",
|
||||
"w:defaultTabStop",
|
||||
"w:autoHyphenation",
|
||||
"w:consecutiveHyphenLimit",
|
||||
"w:hyphenationZone",
|
||||
"w:doNotHyphenateCaps",
|
||||
"w:showEnvelope",
|
||||
"w:summaryLength",
|
||||
"w:clickAndTypeStyle",
|
||||
"w:defaultTableStyle",
|
||||
"w:evenAndOddHeaders",
|
||||
"w:bookFoldRevPrinting",
|
||||
"w:bookFoldPrinting",
|
||||
"w:bookFoldPrintingSheets",
|
||||
"w:drawingGridHorizontalSpacing",
|
||||
"w:drawingGridVerticalSpacing",
|
||||
"w:displayHorizontalDrawingGridEvery",
|
||||
"w:displayVerticalDrawingGridEvery",
|
||||
"w:doNotUseMarginsForDrawingGridOrigin",
|
||||
"w:drawingGridHorizontalOrigin",
|
||||
"w:drawingGridVerticalOrigin",
|
||||
"w:doNotShadeFormData",
|
||||
"w:noPunctuationKerning",
|
||||
"w:characterSpacingControl",
|
||||
"w:printTwoOnOne",
|
||||
"w:strictFirstAndLastChars",
|
||||
"w:noLineBreaksAfter",
|
||||
"w:noLineBreaksBefore",
|
||||
"w:savePreviewPicture",
|
||||
"w:doNotValidateAgainstSchema",
|
||||
"w:saveInvalidXml",
|
||||
"w:ignoreMixedContent",
|
||||
"w:alwaysShowPlaceholderText",
|
||||
"w:doNotDemarcateInvalidXml",
|
||||
"w:saveXmlDataOnly",
|
||||
"w:useXSLTWhenSaving",
|
||||
"w:saveThroughXslt",
|
||||
"w:showXMLTags",
|
||||
"w:alwaysMergeEmptyNamespace",
|
||||
"w:updateFields",
|
||||
"w:hdrShapeDefaults",
|
||||
"w:footnotePr",
|
||||
"w:endnotePr",
|
||||
"w:compat",
|
||||
"w:docVars",
|
||||
"w:rsids",
|
||||
"m:mathPr",
|
||||
"w:attachedSchema",
|
||||
"w:themeFontLang",
|
||||
"w:clrSchemeMapping",
|
||||
"w:doNotIncludeSubdocsInStats",
|
||||
"w:doNotAutoCompressPictures",
|
||||
"w:forceUpgrade",
|
||||
"w:captions",
|
||||
"w:readModeInkLockDown",
|
||||
"w:smartTagType",
|
||||
"sl:schemaLibrary",
|
||||
"w:shapeDefaults",
|
||||
"w:doNotEmbedSmartTags",
|
||||
"w:decimalSymbol",
|
||||
"w:listSeparator",
|
||||
];
|
||||
|
||||
fn rank(name: &str) -> usize {
|
||||
ORDER.iter().position(|n| *n == name).unwrap_or(ORDER.len())
|
||||
}
|
||||
|
||||
/// A child element of the root: its qualified name and byte span.
|
||||
struct Child {
|
||||
name: String,
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
/// The root start tag's span end and the root's direct children, or `None` for anything the
|
||||
/// scanner doesn't expect (CDATA, DOCTYPE, a different root).
|
||||
fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec<Child>)> {
|
||||
let open = xml.find(&format!("<{root}"))?;
|
||||
let open_end = open + xml.get(open..)?.find('>')? + 1;
|
||||
if xml.get(open_end - 2..open_end)? == "/>" {
|
||||
return None;
|
||||
}
|
||||
let close = xml.rfind(&format!("</{root}>"))?;
|
||||
let mut out = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut i = open_end;
|
||||
let mut cur: Option<(String, usize)> = None;
|
||||
while i < close {
|
||||
let rest = xml.get(i..close)?;
|
||||
let Some(lt) = rest.find('<') else { break };
|
||||
let at = i + lt;
|
||||
let tail = xml.get(at..close)?;
|
||||
if tail.starts_with("<!--") {
|
||||
i = at + tail.find("-->")? + 3;
|
||||
continue;
|
||||
}
|
||||
if tail.starts_with("<![CDATA[") || tail.starts_with("<!") {
|
||||
return None;
|
||||
}
|
||||
if tail.starts_with("<?") {
|
||||
i = at + tail.find("?>")? + 2;
|
||||
continue;
|
||||
}
|
||||
let gt = at + tail.find('>')? + 1;
|
||||
let tag = xml.get(at..gt)?;
|
||||
if tag.starts_with("</") {
|
||||
depth = depth.checked_sub(1)?;
|
||||
if depth == 0 {
|
||||
let (name, s) = cur.take()?;
|
||||
out.push(Child { name, start: s, end: gt });
|
||||
}
|
||||
} else {
|
||||
let name: String = tag.get(1..)?.chars().take_while(|c| !c.is_whitespace() && *c != '>' && *c != '/').collect();
|
||||
let selfclose = tag.ends_with("/>");
|
||||
if depth == 0 {
|
||||
if selfclose {
|
||||
out.push(Child { name, start: at, end: gt });
|
||||
} else {
|
||||
cur = Some((name, at));
|
||||
depth = 1;
|
||||
}
|
||||
} else if !selfclose {
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
i = gt;
|
||||
}
|
||||
if depth != 0 {
|
||||
return None;
|
||||
}
|
||||
Some((open_end, close, out))
|
||||
}
|
||||
|
||||
/// The original `w:compat` with its `compatibilityMode` taken from the generated one.
|
||||
fn merge_compat(orig: &str, generated: &str) -> Option<String> {
|
||||
let mode = |s: &str| -> Option<(usize, usize)> {
|
||||
let at = s.find("w:name=\"compatibilityMode\"")?;
|
||||
let tag_start = s.get(..at)?.rfind('<')?;
|
||||
let tag_end = tag_start + s.get(tag_start..)?.find('>')? + 1;
|
||||
Some((tag_start, tag_end))
|
||||
};
|
||||
match (mode(orig), mode(generated)) {
|
||||
(Some((a, b)), Some((c, d))) => Some(format!("{}{}{}", orig.get(..a)?, generated.get(c..d)?, orig.get(b..)?)),
|
||||
(None, Some((c, d))) => {
|
||||
// No mode in the original: add the generated one before `</w:compat>`.
|
||||
let close = orig.rfind("</w:compat>")?;
|
||||
Some(format!("{}{}{}", orig.get(..close)?, generated.get(c..d)?, orig.get(close..)?))
|
||||
}
|
||||
_ => Some(orig.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `original` (a settings part) hold anything the model doesn't write? Then the reader keeps
|
||||
/// it for [`merge`]. False for parts WordCraft wrote itself, and for anything it can't scan.
|
||||
pub fn has_unowned(original: &[u8]) -> bool {
|
||||
let Some(xml) = std::str::from_utf8(original).ok() else { return false };
|
||||
let Some((_, _, kids)) = children(xml, "w:settings") else { return false };
|
||||
kids.iter().any(|k| match k.name.as_str() {
|
||||
"w:zoom" => false,
|
||||
// Anything in `w:compat` beyond its own tag and the compatibility mode.
|
||||
"w:compat" => xml.get(k.start..k.end).is_some_and(|c| {
|
||||
let mode = usize::from(c.contains("w:name=\"compatibilityMode\""));
|
||||
c.matches("<w:").count().saturating_sub(1 + mode) > 0
|
||||
}),
|
||||
n => !OWNED.contains(&n),
|
||||
})
|
||||
}
|
||||
|
||||
/// The merged settings part, or `None` to fall back to `generated`.
|
||||
pub fn merge(original: &[u8], generated: &[u8]) -> Option<Vec<u8>> {
|
||||
let orig = std::str::from_utf8(original).ok()?;
|
||||
let gen_s = std::str::from_utf8(generated).ok()?;
|
||||
let (o_open, o_close, o_kids) = children(orig, "w:settings")?;
|
||||
let (_, _, g_kids) = children(gen_s, "w:settings")?;
|
||||
// Prefixes the generated children use must be declared by the original root.
|
||||
let root_tag = orig.get(..o_open)?;
|
||||
for p in ["w", "m", "r"] {
|
||||
if g_kids.iter().any(|k| k.name.starts_with(&format!("{p}:"))) && !root_tag.contains(&format!("xmlns:{p}=")) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let g_text = |k: &Child| gen_s.get(k.start..k.end).map(str::to_string);
|
||||
let mut used = vec![false; g_kids.len()];
|
||||
let mark = |name: &str, used: &mut Vec<bool>| -> Option<usize> {
|
||||
let gi = g_kids.iter().enumerate().position(|(i, g)| g.name == name && !used.get(i).copied().unwrap_or(true))?;
|
||||
if let Some(u) = used.get_mut(gi) {
|
||||
*u = true;
|
||||
}
|
||||
Some(gi)
|
||||
};
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for k in &o_kids {
|
||||
let text = orig.get(k.start..k.end)?;
|
||||
if KEEP_ORIGINAL.contains(&k.name.as_str()) {
|
||||
// The model reads these only partly (math settings) or writes them only in one case
|
||||
// (the background shape flag, with a page colour): the original wins.
|
||||
mark(&k.name, &mut used);
|
||||
out.push((k.name.clone(), text.to_string()));
|
||||
} else if OWNED.contains(&k.name.as_str()) {
|
||||
if let Some(gi) = mark(&k.name, &mut used) {
|
||||
out.push((k.name.clone(), g_text(g_kids.get(gi)?)?));
|
||||
}
|
||||
} else if k.name == "w:compat" {
|
||||
let merged = match mark("w:compat", &mut used) {
|
||||
Some(gi) => merge_compat(text, &g_text(g_kids.get(gi)?)?)?,
|
||||
None => text.to_string(),
|
||||
};
|
||||
out.push((k.name.clone(), merged));
|
||||
} else if !text.contains("r:id=") {
|
||||
out.push((k.name.clone(), text.to_string()));
|
||||
}
|
||||
}
|
||||
for (gi, g) in g_kids.iter().enumerate() {
|
||||
let owned = OWNED.contains(&g.name.as_str()) || g.name == "w:compat";
|
||||
if used.get(gi).copied().unwrap_or(true) || !owned {
|
||||
continue;
|
||||
}
|
||||
let r = rank(&g.name);
|
||||
let at = out.iter().position(|(n, _)| rank(n) > r).unwrap_or(out.len());
|
||||
out.insert(at, (g.name.clone(), g_text(g)?));
|
||||
}
|
||||
let mut s = String::with_capacity(orig.len() + 256);
|
||||
s.push_str(root_tag);
|
||||
for (_, t) in &out {
|
||||
s.push_str(t);
|
||||
}
|
||||
s.push_str(orig.get(o_close..)?);
|
||||
Some(s.into_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GEN: &str = r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:zoom w:percent="100"/><w:defaultTabStop w:val="720"/><w:compat><w:compatSetting w:name="compatibilityMode" w:uri="u" w:val="15"/></w:compat></w:settings>"#;
|
||||
|
||||
#[test]
|
||||
fn odd_or_hostile_originals_fall_back_or_merge_without_panicking() {
|
||||
let odd = [
|
||||
"",
|
||||
"<w:settings",
|
||||
"<w:settings/>",
|
||||
"<x:other/>",
|
||||
r#"<w:settings xmlns:w="w"><w:a><w:b></w:a></w:settings>"#,
|
||||
r#"<w:settings xmlns:w="w"><![CDATA[x]]></w:settings>"#,
|
||||
r#"<w:settings xmlns:w="w"></w:x></w:settings>"#,
|
||||
"<w:settings xmlns:w=\"w\">\u{1F600}<w:rsids/></w:settings>",
|
||||
r#"<w:settings xmlns:w="w"><!-- c --><?pi x?><w:rsids/></w:settings>"#,
|
||||
];
|
||||
for o in odd {
|
||||
let _ = merge(o.as_bytes(), GEN.as_bytes());
|
||||
let _ = has_unowned(o.as_bytes());
|
||||
}
|
||||
assert!(merge(&[0xff, 0xfe], GEN.as_bytes()).is_none());
|
||||
// Missing declarations for a prefix the generated part uses: fall back.
|
||||
assert!(merge(br#"<w:settings xmlns:x="x"><w:rsids/></w:settings>"#, GEN.as_bytes()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compat_block_without_a_mode_gets_the_generated_one() {
|
||||
let orig = r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:compat><w:useFELayout/></w:compat></w:settings>"#;
|
||||
let out = String::from_utf8(merge(orig.as_bytes(), GEN.as_bytes()).unwrap()).unwrap();
|
||||
assert!(out.contains("<w:useFELayout/>") && out.contains("compatibilityMode"), "{out}");
|
||||
assert!(has_unowned(orig.as_bytes()));
|
||||
assert!(!has_unowned(GEN.as_bytes()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Settings a save used to drop: `word/settings.xml` is merged into the file's original, so
|
||||
//! settings the model doesn't hold (compatibility options, rsids, document variables, proofing
|
||||
//! state, "remove personal information"…) survive, and owned ones follow the model. Hand-written
|
||||
//! fixtures (from the spec), no files produced by Word.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
|
||||
fn zip(entries: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut out = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut b = Vec::new();
|
||||
f.read_to_end(&mut b).unwrap();
|
||||
assert!(out.insert(f.name().to_string(), b).is_none(), "duplicate part {}", f.name());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn text(files: &BTreeMap<String, Vec<u8>>, name: &str) -> String {
|
||||
String::from_utf8(files.get(name).unwrap_or_else(|| panic!("no {name} in {:?}", files.keys())).clone()).unwrap()
|
||||
}
|
||||
|
||||
fn rels(list: &[(&str, &str, &str)]) -> Vec<u8> {
|
||||
let mut s =
|
||||
String::from(r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
for (id, ty, target) in list {
|
||||
s.push_str(&format!(r#"<Relationship Id="{id}" Type="{ty}" Target="{target}"/>"#));
|
||||
}
|
||||
s.push_str("</Relationships>");
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
/// A package: `body`, document relationships, root relationships after the main document's,
|
||||
/// and extra parts.
|
||||
fn package(body: &str, doc_rels: &[(&str, &str, &str)], root_rels: &[(&str, &str, &str)], extra: &[(&str, &str)]) -> Vec<u8> {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}" xmlns:r="{R_NS}" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut root = vec![("rIdMain", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "word/document.xml")];
|
||||
root.extend_from_slice(root_rels);
|
||||
let mut entries: Vec<(&str, Vec<u8>)> =
|
||||
vec![("_rels/.rels", rels(&root)), ("word/document.xml", doc.into_bytes()), ("word/_rels/document.xml.rels", rels(doc_rels))];
|
||||
for (p, c) in extra {
|
||||
entries.push((p, c.as_bytes().to_vec()));
|
||||
}
|
||||
zip(&entries)
|
||||
}
|
||||
|
||||
fn save(doc: &wordcraft_doc::Document) -> BTreeMap<String, Vec<u8>> {
|
||||
unzip(&wordcraft_docx::write(doc).expect("write"))
|
||||
}
|
||||
|
||||
const SETTINGS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" mc:Ignorable="w14 w15"><w:zoom w:percent="120"/><w:removePersonalInformation/><w:removeDateAndTime/><w:proofState w:spelling="clean" w:grammar="clean"/><w:mailMerge><w:mainDocumentType w:val="formLetters"/><w:dataSource r:id="rId9"/></w:mailMerge><w:trackRevisions/><w:defaultTabStop w:val="708"/><w:hyphenationZone w:val="425"/><w:characterSpacingControl w:val="doNotCompress"/><w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/><w:compatSetting w:name="overrideTableStyleFontSizeAndJustification" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/><w:compatSetting w:name="enableOpenTypeFeatures" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/></w:compat><w:docVars><w:docVar w:name="ClauseSet" w:val="B"/></w:docVars><w:rsids><w:rsidRoot w:val="00A1B2C3"/><w:rsid w:val="00A1B2C3"/></w:rsids><m:mathPr><m:mathFont m:val="Cambria Math"/><m:smallFrac m:val="0"/></m:mathPr><w:themeFontLang w:val="pl-PL"/><w:decimalSymbol w:val=","/><w:listSeparator w:val=";"/><w14:docId w14:val="1A2B3C4D"/></w:settings>"#;
|
||||
|
||||
fn with_settings(settings: &str) -> Vec<u8> {
|
||||
package("<w:p><w:r><w:t>Text</w:t></w:r></w:p>", &[("rId1", &format!("{REL}/settings"), "settings.xml")], &[], &[("word/settings.xml", settings)])
|
||||
}
|
||||
|
||||
fn pos(s: &str, needle: &str) -> usize {
|
||||
s.find(needle).unwrap_or_else(|| panic!("{needle} missing from {s}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmodelled_settings_survive_a_save_and_owned_ones_follow_the_model() {
|
||||
let mut doc = wordcraft_docx::read(&with_settings(SETTINGS)).unwrap();
|
||||
assert!(doc.settings.track_changes);
|
||||
doc.settings.track_changes = false;
|
||||
let files = save(&doc);
|
||||
let s = text(&files, "word/settings.xml");
|
||||
// Kept byte for byte: everything the model doesn't hold, the original root and zoom.
|
||||
for kept in [
|
||||
r#"<w:zoom w:percent="120"/>"#,
|
||||
r#"<w:proofState w:spelling="clean" w:grammar="clean"/>"#,
|
||||
r#"w:name="overrideTableStyleFontSizeAndJustification""#,
|
||||
r#"w:name="enableOpenTypeFeatures""#,
|
||||
r#"<w:docVar w:name="ClauseSet" w:val="B"/>"#,
|
||||
r#"<w:rsidRoot w:val="00A1B2C3"/>"#,
|
||||
r#"<m:mathFont m:val="Cambria Math"/>"#,
|
||||
r#"<w:themeFontLang w:val="pl-PL"/>"#,
|
||||
r#"<w:decimalSymbol w:val=","/>"#,
|
||||
r#"<w:listSeparator w:val=";"/>"#,
|
||||
r#"<w14:docId w14:val="1A2B3C4D"/>"#,
|
||||
r#"mc:Ignorable="w14 w15""#,
|
||||
] {
|
||||
assert!(s.contains(kept), "{kept} lost: {s}");
|
||||
}
|
||||
// Owned: Track Changes was turned off; one compatibility mode; the mail merge source (a
|
||||
// relationship that isn't carried) is dropped rather than left dangling.
|
||||
assert!(!s.contains("trackRevisions"), "{s}");
|
||||
assert_eq!(s.matches("compatibilityMode").count(), 1, "{s}");
|
||||
assert!(!s.contains("mailMerge") && !s.contains("rId9"), "{s}");
|
||||
// The privacy options are the model's (#1062): written once each, and gone when turned off.
|
||||
assert_eq!(s.matches("removePersonalInformation").count(), 1, "{s}");
|
||||
assert_eq!(s.matches("removeDateAndTime").count(), 1, "{s}");
|
||||
// CT_Settings order holds for kept and owned children.
|
||||
assert!(pos(&s, "removePersonalInformation") < pos(&s, "defaultTabStop"));
|
||||
assert!(pos(&s, "defaultTabStop") < pos(&s, "<w:compat>"));
|
||||
assert!(pos(&s, "<w:compat>") < pos(&s, "<w:rsids>"));
|
||||
// Turning Track Changes back on inserts it at its place.
|
||||
doc.settings.track_changes = true;
|
||||
let s = text(&save(&doc), "word/settings.xml");
|
||||
assert!(pos(&s, "proofState") < pos(&s, "<w:trackRevisions/>") && pos(&s, "<w:trackRevisions/>") < pos(&s, "defaultTabStop"), "{s}");
|
||||
let mut private = doc.clone();
|
||||
private.settings.remove_personal_info = false;
|
||||
private.settings.remove_date_time = false;
|
||||
let p = text(&save(&private), "word/settings.xml");
|
||||
assert!(!p.contains("removePersonalInformation") && !p.contains("removeDateAndTime"), "{p}");
|
||||
// A second generation is identical.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
assert_eq!(text(&save(&again), "word/settings.xml"), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_written_by_wordcraft_keep_no_original() {
|
||||
let doc = wordcraft_doc::Document::new();
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
assert!(!back.passthrough.contains_key("wordcraft:settings.orig"), "{:?}", back.passthrough.keys());
|
||||
assert_eq!(text(&save(&back), "word/settings.xml"), text(&save(&doc), "word/settings.xml"));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: ODT tracked changes read and written, gap 19; previously trivial: RTF tracked insertions and deletions read and written, gap 19; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: DOCX save keeps unmodelled settings, #1034; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
@@ -72,7 +72,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. No native stroke rendering, eraser, conversion or replay; ink nested in groups/canvases remains unsupported |
|
||||
| 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
|
||||
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
|
||||
| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not used for rendering, but the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles |
|
||||
| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not used for rendering, but the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles. Settings the model doesn't hold (other `w:compatSetting`s, rsids, document variables, proofing state, colour scheme mapping…) survive a save: the writer merges into the file's own `settings.xml` ([below](#settings-preservation)) |
|
||||
| Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom XML stores reached from the package/main document preserve opaque data, properties and relationships; binding evaluation remains absent. Custom properties round-trip; bibliography model I/O remains separate (#532 open) |
|
||||
| Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) |
|
||||
| Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language |
|
||||
@@ -160,6 +160,17 @@ Preservation is separate from field evaluation. Native Word reopening and
|
||||
refresh, full schema validation and company documents remain untested. Synthetic checks do not
|
||||
raise readiness estimates. No Word-derived files are fixtures.
|
||||
|
||||
## Settings preservation
|
||||
|
||||
A save used to regenerate `settings.xml` from the model alone, so every setting the model doesn't
|
||||
hold was dropped without a warning (on 18 real-world Word documents: compatibility settings in 18
|
||||
of 18; the privacy options are modelled since #1062). Now the writer merges into the
|
||||
file's original (`crates/docx/src/write/settings_merge.rs`): the original root and every child
|
||||
the model doesn't own stay byte for byte; owned children follow the model, in CT_Settings order;
|
||||
in `w:compat` only `compatibilityMode` is replaced. Kept children that point at a relationship
|
||||
(a mail-merge data source) are dropped. Files WordCraft wrote itself keep nothing extra. Tests:
|
||||
`crates/docx/tests/settings_merge.rs`.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
@@ -179,6 +190,7 @@ raise readiness estimates. No Word-derived files are fixtures.
|
||||
| 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) |
|
||||
| 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) |
|
||||
| 2026-10-14 | trivial | `.doc` tables: cell shading read from every SHD record (`sprmTDefTableShd`/`ShdRaw` and their 2nd/3rd ranges for cells 22-62; Word 97 `Shd80` clear fills now show their background colour; exact colours win over the palette fallback), the row-end mark no longer becomes an extra empty column, and a table no longer inherits the previous table's grid, borders, alignment or indent (#1380). Horizontally merged rows (a row of fewer, wider cells) come with #1123 |
|
||||
| 2026-10-14 | minor | DOCX save merges `settings.xml` into the file's original: unmodelled settings (compatibility options, rsids, document variables, proofing state…) survive (#1034) |
|
||||
| 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) |
|
||||
| 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) |
|
||||
| 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text |
|
||||
|
||||
Reference in New Issue
Block a user