mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-14 03:29:04 +00:00
SCRUM-26: preserve custom XML stores through saves (#612)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
1 parent
c08d9f8598
commit
b0f3d52d80
9 files changed
+266
-10
No files matched your search
@@ -177,6 +177,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Insert › Icons (#309): a library of 61 original icons drawn in code, a searchable picker and Icon Color |
|
||||
| 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field |
|
||||
| 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip |
|
||||
| 2026-10-12 | Custom XML backing stores preserved as opaque data and related properties through edited DOCX saves; content-control markup is handled by the existing reader/writer and #532 bibliography I/O remains separate; binding evaluation and readiness unchanged |
|
||||
| 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) |
|
||||
| 2026-10-11 | Customize Keyboard: assign, remove and reset keyboard shortcuts; custom keys override built-ins and are kept between runs (#368) |
|
||||
| 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. |
|
||||
@@ -228,6 +229,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) |
|
||||
| 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) |
|
||||
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) |
|
||||
| 2026-10-12 | minor | Progress log: opaque custom XML store preservation, with synthetic package regressions and explicit limits |
|
||||
| 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) |
|
||||
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
|
||||
| 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) |
|
||||
|
||||
@@ -57,6 +57,9 @@ pub const MAX_VBA_RELATED: usize = 64;
|
||||
/// types and own relationships, as [`EmbeddedManifest`] lines. Each part's bytes are kept under its
|
||||
/// part name. Not a package part: WordCraft's own bookkeeping.
|
||||
pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts";
|
||||
/// 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";
|
||||
/// 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.
|
||||
|
||||
@@ -13,13 +13,37 @@ use std::sync::Arc;
|
||||
use wordcraft_doc::graphic::{Embedded, EmbeddedRef};
|
||||
|
||||
use super::Reader;
|
||||
use crate::package::{ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok};
|
||||
use crate::package::{
|
||||
CUSTOM_XML_ROOTS, ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok, rel_is, rt,
|
||||
};
|
||||
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc};
|
||||
|
||||
/// Most relationship ids one object's markup may use.
|
||||
const MAX_IDS: usize = 64;
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Keep custom XML stores reached from the main document or package, without evaluating
|
||||
/// bindings. A bibliography already loaded into the source model belongs to its writer.
|
||||
pub(super) fn keep_custom_xml(&mut self, package: &Rels, document: &Rels) {
|
||||
let mut roots = String::new();
|
||||
for (owner, rels) in [("P", package), ("D", document)] {
|
||||
for rel in rels.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) {
|
||||
if ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) {
|
||||
continue;
|
||||
}
|
||||
if !self.doc.sources.is_empty() && self.xml(&rel.target).ok().flatten().is_some_and(|r| r.local() == "Sources") {
|
||||
continue;
|
||||
}
|
||||
if self.keep_part_tree(&rel.target) {
|
||||
roots.push_str(&format!("{owner}\t{}\t{}\n", rel.kind, rel.target));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !roots.is_empty() {
|
||||
self.doc.passthrough.insert(CUSTOM_XML_ROOTS.into(), Arc::new(roots.into_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `el` (an object's `a:graphic` or `w:object`, read with `rels`) and the parts it refers
|
||||
/// to, so the object can be written back. `extra` names relationships the object's parts use
|
||||
/// by id although its markup doesn't (a SmartArt drawing, named in the diagram's data part).
|
||||
|
||||
@@ -252,6 +252,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), Arc::new(b.to_vec()));
|
||||
r.read_vba_related(&v);
|
||||
}
|
||||
r.keep_custom_xml(&root_rels, &rels);
|
||||
if !r.embedded.parts.is_empty() {
|
||||
let manifest = r.embedded.to_bytes();
|
||||
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
|
||||
|
||||
@@ -13,7 +13,9 @@ use wordcraft_doc::graphic::Embedded;
|
||||
use wordcraft_doc::para::Float;
|
||||
|
||||
use super::PartRels;
|
||||
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, VBA_PROJECT_PART, VBA_RELATED, relative};
|
||||
use crate::package::{
|
||||
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt,
|
||||
};
|
||||
use crate::units::emu;
|
||||
use crate::xml::{self, El, MAX_DEPTH};
|
||||
|
||||
@@ -64,6 +66,10 @@ impl<'d> EmbedWriter<'d> {
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
taken.insert(VBA_PROJECT_PART.to_ascii_lowercase());
|
||||
// The bibliography writer uses these names when sources are present (#532).
|
||||
if !doc.sources.is_empty() {
|
||||
taken.extend(["customxml/item1.xml".into(), "customxml/itemprops1.xml".into()]);
|
||||
}
|
||||
// The macro project's parts keep their names (see `write_as`).
|
||||
if let Some(m) = doc.passthrough.get(VBA_RELATED) {
|
||||
for line in String::from_utf8_lossy(m).lines() {
|
||||
@@ -101,6 +107,9 @@ impl<'d> EmbedWriter<'d> {
|
||||
|
||||
/// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`.
|
||||
fn alloc(&mut self, orig: &str) -> String {
|
||||
// Suffixes cannot escape reserved directories or a .rels extension. Such a kept
|
||||
// part needs a different base name, otherwise every candidate would be reserved.
|
||||
let orig = if Self::reserved(&orig.to_ascii_lowercase()) { "word/embeddings/part.xml" } else { orig };
|
||||
let lower = orig.to_ascii_lowercase();
|
||||
if !Self::reserved(&lower) && self.taken.insert(lower) {
|
||||
return orig.to_string();
|
||||
@@ -157,6 +166,25 @@ impl<'d> EmbedWriter<'d> {
|
||||
self.manifest.parts.contains_key(name) && self.passthrough.contains_key(name)
|
||||
}
|
||||
|
||||
/// Reattach implicit custom XML roots, sharing their kept parts across both owners.
|
||||
pub fn custom_xml_rels(&mut self, owner: &str, rels: &mut PartRels) {
|
||||
let Some(roots) = self.passthrough.get(CUSTOM_XML_ROOTS).cloned() else { return };
|
||||
let mut memo = std::mem::take(&mut self.first);
|
||||
let mut written = Vec::new();
|
||||
for line in String::from_utf8_lossy(&roots).lines().take(MAX_EMBEDDED_RELS * 2) {
|
||||
let fields: Vec<&str> = line.split('\t').collect();
|
||||
let [source, kind, target] = fields.as_slice() else { continue };
|
||||
if *source != owner || !rel_is(kind, rt::CUSTOM_XML) {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = self.copy_tree(target, &mut memo, &mut written) {
|
||||
let source_part = if owner == "P" { "" } else { STORY_PART };
|
||||
rels.add(kind, &relative(source_part, &name), false);
|
||||
}
|
||||
}
|
||||
self.first = memo;
|
||||
}
|
||||
|
||||
/// What copying the parts reached from `roots` costs against the copy budget.
|
||||
fn tree_cost(&self, roots: &[&str]) -> u64 {
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
|
||||
@@ -170,6 +170,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
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();
|
||||
@@ -380,7 +381,9 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
}
|
||||
}
|
||||
|
||||
// Parts of charts, diagrams and OLE objects kept from a file, as the objects written need them.
|
||||
wr.embeds.custom_xml_rels("D", &mut rels);
|
||||
wr.embeds.custom_xml_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);
|
||||
}
|
||||
@@ -427,7 +430,6 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
entries.insert(1, ("word/_rels/document.xml.rels".into(), rels.xml()));
|
||||
|
||||
// Package-level parts.
|
||||
let mut root = PartRels::default();
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Original synthetic custom XML stores, written from the public OOXML package contract.
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use wordcraft_doc::{Path, StoryRef};
|
||||
|
||||
const REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
|
||||
const DATA: &str =
|
||||
"<?xml version=\"1.0\"?><c:customer xmlns:c=\"urn:original:customer\"><c:name>Atlas Studios</c:name><c:ref>CASE-17</c:ref></c:customer>";
|
||||
const PROPS: &str = r#"<ds:datastoreItem xmlns:ds="http://schemas.openxmlformats.org/officeDocument/2006/customXml" ds:itemID="{22222222-2222-2222-2222-222222222222}"><ds:schemaRefs><ds:schemaRef ds:uri="urn:original:customer"/></ds:schemaRefs></ds:datastoreItem>"#;
|
||||
|
||||
fn rels(rows: &[(&str, &str, &str, bool)]) -> String {
|
||||
let mut out = format!(r#"<Relationships xmlns="{REL_NS}">"#);
|
||||
for (id, kind, target, external) in rows {
|
||||
let mode = if *external { r#" TargetMode="External""# } else { "" };
|
||||
out.push_str(&format!(r#"<Relationship Id="{id}" Type="{RT}{kind}" Target="{target}"{mode}/>"#));
|
||||
}
|
||||
out + "</Relationships>"
|
||||
}
|
||||
|
||||
fn fixture() -> BTreeMap<String, Vec<u8>> {
|
||||
let doc = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Independent edit.</w:t></w:r></w:p><w:p><w:sdt><w:sdtPr><w:id w:val="42"/><w:dataBinding w:xpath="/c:customer/c:name" w:prefixMappings="xmlns:c='urn:original:customer'" w:storeItemID="{22222222-2222-2222-2222-222222222222}"/><w:text/></w:sdtPr><w:sdtContent><w:r><w:t>Atlas Studios</w:t></w:r></w:sdtContent></w:sdt></w:p></w:body></w:document>"#;
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/customXml/itemProps1.xml" ContentType="application/vnd.openxmlformats-officedocument.customXmlProperties+xml"/></Types>"#;
|
||||
[
|
||||
("[Content_Types].xml", types.to_string()),
|
||||
("_rels/.rels", rels(&[("office", "officeDocument", "word/document.xml", false)])),
|
||||
("word/document.xml", doc.to_string()),
|
||||
("word/_rels/document.xml.rels", rels(&[("data", "customXml", "../customXml/item1.xml", false)])),
|
||||
("customXml/item1.xml", DATA.to_string()),
|
||||
("customXml/itemProps1.xml", PROPS.to_string()),
|
||||
("customXml/_rels/item1.xml.rels", rels(&[("properties", "customXmlProps", "itemProps1.xml", false)])),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(p, s)| (p.into(), s.into_bytes()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn zip(parts: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, bytes) in parts {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(bytes).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut parts = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut bytes = Vec::new();
|
||||
f.read_to_end(&mut bytes).unwrap();
|
||||
assert!(parts.insert(name, bytes).is_none(), "duplicate ZIP entry");
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn text(parts: &BTreeMap<String, Vec<u8>>, name: &str) -> String {
|
||||
String::from_utf8(parts[name].clone()).unwrap()
|
||||
}
|
||||
|
||||
fn edited_cycles(input: &BTreeMap<String, Vec<u8>>, check: impl Fn(&BTreeMap<String, Vec<u8>>)) {
|
||||
let mut doc = wordcraft_docx::read(&zip(input)).unwrap();
|
||||
for _ in 0..2 {
|
||||
doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_text(0, "Unrelated edit. ", &Default::default()).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
check(&unzip(&bytes));
|
||||
doc = wordcraft_docx::read(&bytes).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_store_bytes_properties_and_relationships_survive_two_edited_saves() {
|
||||
edited_cycles(&fixture(), |p| {
|
||||
let document = text(p, "word/document.xml");
|
||||
assert!(document.contains("<w:dataBinding"), "the control keeps its store binding");
|
||||
assert!(document.contains(r#"w:storeItemID="{22222222-2222-2222-2222-222222222222}""#));
|
||||
assert!(document.contains(r#"w:xpath="/c:customer/c:name""#));
|
||||
assert_eq!(p.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
assert_eq!(p.get("customXml/itemProps1.xml").map(Vec::as_slice), Some(PROPS.as_bytes()));
|
||||
assert!(text(p, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/item1.xml""#));
|
||||
assert!(text(p, "customXml/_rels/item1.xml.rels").contains(r#"Id="properties""#));
|
||||
assert!(text(p, "customXml/_rels/item1.xml.rels").contains(r#"Target="itemProps1.xml""#));
|
||||
assert!(text(p, "[Content_Types].xml").contains("application/vnd.openxmlformats-officedocument.customXmlProperties+xml"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_and_document_roots_share_parts_and_keep_multiple_stores() {
|
||||
let mut p = fixture();
|
||||
p.insert(
|
||||
"_rels/.rels".into(),
|
||||
rels(&[("office", "officeDocument", "word/document.xml", false), ("data", "customXml", "customXml/item1.xml", false)]).into_bytes(),
|
||||
);
|
||||
p.insert(
|
||||
"word/_rels/document.xml.rels".into(),
|
||||
rels(&[("one", "customXml", "../customXml/item1.xml", false), ("two", "customXml", "../customXml/other.xml", false)]).into_bytes(),
|
||||
);
|
||||
p.insert("customXml/other.xml".into(), b"<other>Independent store</other>".to_vec());
|
||||
p.insert("customXml/_rels/other.xml.rels".into(), rels(&[("shared", "customXmlProps", "itemProps1.xml", false)]).into_bytes());
|
||||
// A cycle must not cause repeated parts, and an external schema stays external.
|
||||
p.insert(
|
||||
"customXml/_rels/itemProps1.xml.rels".into(),
|
||||
rels(&[("cycle", "customXml", "item1.xml", false), ("schema", "schema", "https://example.invalid/schema", true)]).into_bytes(),
|
||||
);
|
||||
edited_cycles(&p, |out| {
|
||||
assert_eq!(out.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
assert_eq!(out.get("customXml/other.xml"), p.get("customXml/other.xml"));
|
||||
assert!(text(out, "_rels/.rels").contains(r#"Target="customXml/item1.xml""#));
|
||||
assert!(text(out, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/other.xml""#));
|
||||
assert!(text(out, "customXml/_rels/itemProps1.xml.rels").contains(r#"TargetMode="External""#));
|
||||
assert!(text(out, "customXml/_rels/other.xml.rels").contains(r#"Target="itemProps1.xml""#));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_external_stores_are_not_fetched_or_written() {
|
||||
let mut p = fixture();
|
||||
p.insert(
|
||||
"word/_rels/document.xml.rels".into(),
|
||||
rels(&[
|
||||
("missing", "customXml", "../customXml/missing.xml", false),
|
||||
("external", "customXml", "https://example.invalid/store.xml", true),
|
||||
("valid", "customXml", "../customXml/item1.xml", false),
|
||||
])
|
||||
.into_bytes(),
|
||||
);
|
||||
p.insert("customXml/_rels/item1.xml.rels".into(), rels(&[("missing", "customXmlProps", "missing.xml", false)]).into_bytes());
|
||||
edited_cycles(&p, |out| {
|
||||
assert_eq!(out.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
let roots = text(out, "word/_rels/document.xml.rels");
|
||||
assert!(!roots.contains("missing.xml"));
|
||||
assert!(!roots.contains("example.invalid"));
|
||||
assert!(!out.contains_key("customXml/_rels/item1.xml.rels"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_stores_do_not_shadow_reserved_bibliography_names() {
|
||||
let mut doc = wordcraft_docx::read(&zip(&fixture())).unwrap();
|
||||
doc.sources.push(wordcraft_doc::Source { tag: "OriginalSource".into(), title: "Original synthetic reference".into(), ..Default::default() });
|
||||
let out = unzip(&wordcraft_docx::write(&doc).unwrap());
|
||||
assert_eq!(out.get("customXml/item1_2.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
assert_eq!(out.get("customXml/itemProps1_2.xml").map(Vec::as_slice), Some(PROPS.as_bytes()));
|
||||
assert!(text(&out, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/item1_2.xml""#));
|
||||
assert!(text(&out, "customXml/_rels/item1_2.xml.rels").contains(r#"Target="itemProps1_2.xml""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_non_utf8_data_and_strict_relationship_types_are_preserved() {
|
||||
let mut p = fixture();
|
||||
let data: Vec<u8> =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-16\"?><customer>Atlas Studios</customer>".encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let mut bytes = vec![0xff, 0xfe];
|
||||
bytes.extend(data);
|
||||
p.insert("customXml/item1.xml".into(), bytes.clone());
|
||||
let strict =
|
||||
rels(&[("strict", "customXml", "../customXml/item1.xml", false)]).replace(RT, "http://purl.oclc.org/ooxml/officeDocument/relationships/");
|
||||
p.insert("word/_rels/document.xml.rels".into(), strict.into_bytes());
|
||||
edited_cycles(&p, |out| {
|
||||
assert_eq!(out.get("customXml/item1.xml"), Some(&bytes));
|
||||
assert!(text(out, "word/_rels/document.xml.rels").contains("http://purl.oclc.org/ooxml/officeDocument/relationships/customXml"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_part_targets_are_renamed_instead_of_shadowing_or_looping() {
|
||||
let mut p = fixture();
|
||||
p.insert("word/_rels/document.xml.rels".into(), rels(&[("hostile", "customXml", "../docProps/foreign.xml", false)]).into_bytes());
|
||||
p.insert("docProps/foreign.xml".into(), DATA.as_bytes().to_vec());
|
||||
edited_cycles(&p, |out| {
|
||||
assert!(out.values().any(|b| b == DATA.as_bytes()));
|
||||
assert!(!out.contains_key("docProps/foreign.xml"));
|
||||
assert!(text(out, "word/_rels/document.xml.rels").contains(r#"Target="embeddings/part.xml""#));
|
||||
assert!(text(out, "docProps/core.xml").contains("coreProperties"));
|
||||
});
|
||||
}
|
||||
@@ -70,7 +70,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Word ink (`w14:contentPart`, InkML), 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 read; attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles |
|
||||
| Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom properties round-trip; bibliography sources pending (#169) |
|
||||
| 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 |
|
||||
| Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), paragraph `w:snapToGrid` | ✅ | ✅ | #391; run-level `w:snapToGrid` not read |
|
||||
@@ -99,14 +99,29 @@ The [public OOXML reference](https://learn.microsoft.com/en-us/dotnet/api/docume
|
||||
describes omission as leaving paragraph defaults application-defined. Real-world corpus
|
||||
coverage and WordCraft layout matching remain separate gaps.
|
||||
|
||||
## Custom XML store preservation
|
||||
|
||||
A reduced original bound-control DOCX loses its backing data, item properties and relationship
|
||||
on main, both without editing and after unrelated edits. Main's content-control support (#381)
|
||||
preserves control metadata, including binding attributes, while this custom XML path keeps the
|
||||
backing store through the bounded opaque part graph: payload bytes, content types and
|
||||
related-part IDs survive two edited save/reopen cycles.
|
||||
|
||||
The public [dataBinding contract](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.databinding?view=openxml-3.0.1)
|
||||
maps a control to a custom XML data part; its [item properties](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.customxmldataproperties.datastoreitem?view=openxml-3.0.1)
|
||||
identify the store. Keeping displayed text alone does not preserve that mapping. This change
|
||||
preserves stores without evaluating XPath or synchronizing control edits into XML.
|
||||
|
||||
Focused original tests cover package/main roots, multiple/shared stores, cycles, external schema
|
||||
references, missing parts, UTF-16 payloads, strict relationship types and reserved filenames.
|
||||
The bibliography names used by #532 are reserved; bibliography already loaded into the source
|
||||
model belongs to its writer. Word-derived files are not fixtures. No readiness estimate rises
|
||||
from these synthetic package checks, and native Word rendering/binding behavior remains untested.
|
||||
|
||||
## Native save-path guarantees
|
||||
|
||||
Native `io::save_path_with` serializes (and, for password-protected DOCX, encrypts) before creating an exclusively owned sibling temporary file. It writes and synchronizes that file, closes it, then replaces the destination. Failed preparation or replacement keeps the original bytes; cleanup removes only the save's temporary file, on a best-effort basis. Existing `<name>.tmp` files, directories and links are untouched. A new document gets the default mode (`0666` less the umask). When replacing an existing regular file, the Unix temporary file starts with mode `0600` and then takes the existing file's basic permissions. Serialization and format support are unchanged.
|
||||
|
||||
Synthetic CLI cases and `engine/tests/safe_save.rs` cover occupied names, links, blocked replacement, missing directories, Save As session state, password-protected saves and original-file protection on macOS. Unit tests inject preparation errors and interleave two prepared saves. Independent saves use separate files; the last successful replacement wins. This is not document locking or conflict detection.
|
||||
|
||||
Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full conditions, power loss and network filesystems are not verified by these tests. The containing directory is not explicitly synchronized. Cleanup can fail when storage or permissions change, and process termination can leave a temporary file. No Word observation was needed for this filesystem change; it makes no new format-fidelity claim.
|
||||
|
||||
## What decides beta for formats
|
||||
|
||||
1. A **real-world DOCX corpus** opened, rendered and round-tripped, compared page by page with
|
||||
@@ -142,6 +157,7 @@ Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full condi
|
||||
| 2026-10-10 | trivial | Right-to-left tables (`w:bidiVisual`) read and written (#362) |
|
||||
| 2026-10-10 | trivial | Drawing canvas (`wpc:wpc`) read and written (#344); group shapes row brought up to date (#267) |
|
||||
| 2026-10-11 | trivial | Spreadsheets (.xlsx/.xlsm/.ods) read as mail-merge recipient lists (#334) |
|
||||
| 2026-10-12 | minor | Preserve opaque custom XML stores and related properties; bindings and control support remain separate |
|
||||
| 2026-10-11 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
|
||||
| 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` |
|
||||
| 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) |
|
||||
|
||||
+2
-1
@@ -17,7 +17,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
|
||||
| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); File › Print has a preview, page range, odd/even, copies and collate (#641); printer choice, duplex and pages per sheet are still the system's; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
| 7 | **Dialog depth**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
|
||||
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
|
||||
@@ -89,6 +89,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
| 2026-10-11 | trivial | #4: columns balance before a continuous section break (#350) |
|
||||
| 2026-10-11 | trivial | #21: Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets as recipient lists (#334); hours 6–10 → 4–8 |
|
||||
| 2026-10-11 | trivial | Insert › Icons landed (#309); removed from the objects gap |
|
||||
| 2026-10-12 | minor | Preserve opaque custom XML data/properties through saves; gap #5 and readiness remain unchanged |
|
||||
| 2026-10-11 | trivial | #24 narrowed: Customize Ribbon and Quick Access Toolbar customization landed (#379); the default shortcuts remain |
|
||||
| 2026-10-11 | trivial | #24 narrowed: Customize Keyboard landed (#368); Customize Ribbon and the Quick Access Toolbar remain |
|
||||
| 2026-10-11 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
|
||||
|
||||
Reference in new issue
Block a user