M-ODT gap 19: form definitions and indexes written back as read

The file's office:forms keeps its own head and each form:form with what
WordCraft doesn't model; controls are merged with the file's own. An
index whose generated entries nobody edited is written as read, with
its source settings and entry templates; an edited one is written from
the model. The no-edit gate saves nine samples of our own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude committed 2026-11-03 14:57:28 +00:00
1 parent eb8d195188
commit 67b3bbfdb0
11 files changed
+360 -20

No files matched your search

+41 -1
View File
@@ -37,6 +37,12 @@ pub struct KeptXml {
pub wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// While reading: the same by paragraph `rev`, until [`Kept::seal`].
pub pending_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// Indexes kept as read: by their first paragraph's fingerprint, how many top-level
/// paragraphs they take, those paragraphs' fingerprint together ([`run_fingerprint`]),
/// and the index's markup. Written as read while those paragraphs are all as read.
pub indexes: HashMap<u64, (usize, u64, Arc<str>)>,
/// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup.
pub pending_indexes: HashMap<u64, (usize, Arc<str>)>,
/// Named definitions the kept markup refers to (styles, font faces…), by a key the format
/// chooses (`"auto:P12"`, `"style:Heading_20_1"`), as read.
pub defs: BTreeMap<String, Arc<str>>,
@@ -122,6 +128,21 @@ impl KeptXml {
}
self.wraps.get(&fingerprint(p))
}
/// The index kept as read starting at paragraph `p`: how many paragraphs it takes, their
/// [`run_fingerprint`] when read, its markup.
pub fn index(&self, p: &Paragraph) -> Option<&(usize, u64, Arc<str>)> {
if self.indexes.is_empty() {
return None;
}
self.indexes.get(&fingerprint(p))
}
/// Keep an index's markup, starting at the paragraph whose `rev` is `rev` and taking `n`.
pub fn keep_pending_index(&mut self, rev: u64, n: usize, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending_indexes.insert(rev, (n, xml));
}
}
/// Keep wrapper markup for the paragraph whose `rev` is `rev`, within the budget.
pub fn keep_pending_wrap(&mut self, rev: u64, wrap: (Vec<Arc<str>>, u32)) {
let n: usize = wrap.0.iter().map(|t| t.len()).sum();
@@ -203,6 +224,15 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
h.finish()
}
/// What a run of paragraphs is in the model, together.
pub fn run_fingerprint<'a>(paras: impl Iterator<Item = &'a Paragraph>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in paras {
fingerprint(p).hash(&mut h);
}
h.finish()
}
/// What table `t` is in the model.
pub fn table_fingerprint(t: &crate::Table) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -248,8 +278,18 @@ impl crate::Document {
/// After reading: the markup kept for paragraphs still as the reader made them is filed
/// under their fingerprints (see [`Kept::seal`]); the rest is dropped.
pub fn seal_kept(&mut self) {
// The body's tables kept, by their place among its tables.
// The body's tables kept, by their place among its tables; its indexes, by their run of
// top-level paragraphs.
if let Some(k) = self.kept.get_mut() {
let pending = std::mem::take(&mut k.pending_indexes);
for (i, b) in self.body.iter().enumerate() {
let Some(p) = b.as_para() else { continue };
let Some((n, xml)) = pending.get(&p.rev) else { continue };
let run: Option<Vec<&Paragraph>> = self.body.get(i..i + n).map(|r| r.iter().filter_map(|b| b.as_para()).collect());
if let Some(run) = run.filter(|r| r.len() == *n) {
k.indexes.entry(fingerprint(p)).or_insert((*n, run_fingerprint(run.into_iter()), xml.clone()));
}
}
let pending = std::mem::take(&mut k.pending_tables);
let tables: Vec<&crate::Table> = self.body.iter().filter_map(|b| b.as_table()).collect();
for (n, xml) in pending {
+37 -1
View File
@@ -463,6 +463,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
mark: None,
kept: None,
wrap: None,
index: None,
};
if opts.paras {
out.extra = para_extra(doc, &p.props);
@@ -503,6 +504,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
mark: None,
kept: None,
wrap: None,
index: None,
};
let prev = std::mem::replace(&mut out, next);
if prev.inlines.is_empty() {
@@ -839,9 +841,43 @@ fn plain_control(kind: &wordcraft_doc::control::ControlKind) -> bool {
matches!(kind, K::Text { .. } | K::CheckBox { .. } | K::DropDown { .. } | K::ComboBox { .. } | K::Date { .. })
}
/// The index kept as read starting at block `i` of `bl`, while the paragraphs it takes are all
/// as read: how many blocks, and its markup.
fn kept_index(doc: &Document, bl: &Blocks, i: usize) -> Option<(usize, std::sync::Arc<str>)> {
let k = doc.kept.of("odt")?;
let p = bl.get(i)?.as_para()?;
let (n, run, xml) = k.index(p)?;
let paras: Vec<&Paragraph> = bl.get(i..i + n)?.iter().filter_map(|b| b.as_para()).collect();
(paras.len() == *n && wordcraft_doc::kept::run_fingerprint(paras.into_iter()) == *run).then(|| (*n, xml.clone()))
}
fn flow_blocks(doc: &Document, bl: &Blocks, depth: usize, tracked: FlowOpts) -> Vec<FBlock> {
let mut out = Vec::new();
for b in bl {
let mut skip = 0usize;
for (i, b) in bl.iter().enumerate() {
if skip > 0 {
skip -= 1;
continue;
}
// An index kept as read, while every paragraph it takes still is what was read: its
// flow paragraphs, the first carrying its markup and how many there are.
if tracked.paras
&& depth == 0
&& let Some(ix) = kept_index(doc, bl, i)
{
let from = out.len();
for b in bl.get(i..i + ix.0).unwrap_or(&[]) {
if let Block::Para(p) = &**b {
out.extend(flow_paras_in(doc, p, tracked).into_iter().map(FBlock::Para));
}
}
let n = out.len() - from;
if let Some(FBlock::Para(first)) = out.get_mut(from) {
first.index = Some(Box::new((ix.1, n)));
}
skip = ix.0.saturating_sub(1);
continue;
}
match &**b {
Block::Para(p) => out.extend(flow_paras_in(doc, p, tracked).into_iter().map(FBlock::Para)),
Block::Table(t) if depth < MAX_DEPTH => out.push(FBlock::Table(flow_table(doc, t, depth, tracked))),
+23 -13
View File
@@ -470,6 +470,28 @@ impl<'a> Builder<'a> {
}
}
/// Keeps a paragraph's markup read from the file (its own, its frame wrap, the index it
/// starts) pending until the document is sealed.
fn keep_pending(&mut self, p: &Para, rev: u64) {
if p.kept.is_none() && p.wrap.is_none() && p.index.is_none() {
return;
}
if self.doc.kept.get().is_none() {
self.doc.kept = wordcraft_doc::kept::Kept::new(Default::default());
}
if let Some(k) = self.doc.kept.get_mut() {
if let Some(xml) = &p.kept {
k.keep_pending(rev, xml.clone());
}
if let Some(w) = &p.wrap {
k.keep_pending_wrap(rev, (**w).clone());
}
if let Some(ix) = &p.index {
k.keep_pending_index(rev, ix.1, ix.0.clone());
}
}
}
fn para(&mut self, p: &Para, list_num: Option<u32>) -> Paragraph {
let mut out = Paragraph::new();
out.props = para_props(p, list_num);
@@ -625,19 +647,7 @@ impl<'a> Builder<'a> {
out.touch();
// Its markup kept for the writer, as long as nothing changes the paragraph from here on
// (see `wordcraft_doc::kept`: the reader seals it once the document is done).
if p.kept.is_some() || p.wrap.is_some() {
if self.doc.kept.get().is_none() {
self.doc.kept = wordcraft_doc::kept::Kept::new(Default::default());
}
if let Some(k) = self.doc.kept.get_mut() {
if let Some(xml) = &p.kept {
k.keep_pending(out.rev, xml.clone());
}
if let Some(w) = &p.wrap {
k.keep_pending_wrap(out.rev, (**w).clone());
}
}
}
self.keep_pending(p, out.rev);
out
}
+3
View File
@@ -673,6 +673,9 @@ pub struct Para {
/// Sections kept as read around paragraphs ([`wordcraft_doc::kept`]): the start tags of
/// those opening before this paragraph (outermost first), and how many close after it.
pub wrap: Option<Box<(Vec<std::sync::Arc<str>>, u32)>>,
/// An index (a table of contents…) kept as read that starts with this paragraph
/// ([`wordcraft_doc::kept`]): its markup, and how many blocks of the flow it takes.
pub index: Option<Box<(std::sync::Arc<str>, usize)>>,
}
impl Para {
+44
View File
@@ -92,6 +92,8 @@ pub(super) struct Body<'a> {
kept_open: Option<KeptOpen>,
/// The body table whose markup is being kept (its `done`: the container's length before it).
kept_table: Option<KeptOpen>,
/// The body index (a table of contents…) whose markup is being kept, likewise.
kept_index: Option<KeptOpen>,
/// Body sections whose wrapper is kept as read: start tag, depth, the body's length before
/// it; `None` for one read as a columned section (kept as a section break).
kept_sections: Vec<Option<(std::sync::Arc<str>, usize, usize)>>,
@@ -220,6 +222,7 @@ impl<'a> Body<'a> {
raw: &[],
kept_open: None,
kept_table: None,
kept_index: None,
kept_sections: Vec::new(),
section_masters: vec![None],
pending_master: None,
@@ -300,6 +303,47 @@ impl<'a> Body<'a> {
/// whole and refers to nothing outside itself keeps its markup (`Para::kept`), so a save
/// that doesn't change it writes it as it was (see `wordcraft_doc::kept`).
pub(super) fn feed_at(&mut self, ev: Ev, at: (usize, usize)) {
// A body index, kept whole: its settings and templates, which WordCraft writes its own.
match &ev {
Ev::Start(q, _) => match &mut self.kept_index {
Some(k) => k.clean &= !(q.starts_with("draw:") || q.starts_with("office:annotation") || q.starts_with("text:change")),
None if q.starts_with("text:")
&& is_index(local(q))
&& !self.raw.is_empty()
&& self.in_text
&& self.skip == 0
&& self.containers.len() == 1
&& self.tables.is_empty()
&& self.frame.is_none()
&& self.note.is_none()
&& self.del.is_none() =>
{
self.end_para();
self.kept_index = Some(KeptOpen { from: at.0, depth: self.depth + 1, clean: true, done: self.container().len() });
}
None => {}
},
Ev::End(q) if q.starts_with("text:") && is_index(local(q)) && self.kept_index.as_ref().is_some_and(|k| k.depth == self.depth) => {
let k = self.kept_index.take();
self.feed(ev);
if let Some(k) = k
&& k.clean
&& self.containers.len() == 1
&& let Some(xml) = self.raw.get(k.from..at.1).and_then(|b| std::str::from_utf8(b).ok())
{
let blocks = self.container();
let n = blocks.len().saturating_sub(k.done);
if n > 0
&& blocks.get(k.done..).is_some_and(|r| r.iter().all(|b| matches!(b, FBlock::Para(_))))
&& let Some(FBlock::Para(p)) = blocks.get_mut(k.done)
{
p.index = Some(Box::new((std::sync::Arc::from(xml), n)));
}
}
return;
}
_ => {}
}
// A body section: its start tag kept, put around its paragraphs at its end.
match &ev {
Ev::Start(q, _) if q == "text:section" && self.kept_sections.len() < 64 => {
+154
View File
@@ -55,6 +55,7 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc<str>)> {
(3, "office:font-face-decls") => child_key("font", &q, &a),
(3, "office:master-styles") => child_key("master", &q, &a),
(4, "office:text") if content && q.starts_with("text:") && q.ends_with("-decls") => Some(format!("decls:{q}")),
(4, "office:text") if content && q == "office:forms" => Some("forms".to_string()),
_ => None,
};
if let Some(key) = key {
@@ -345,6 +346,159 @@ pub(super) fn with_kept_meta(meta: String, defs: &BTreeMap<String, Arc<str>>, or
}
}
/// Whether a child of a `form:form` is a form control (else the form's own properties or
/// event listeners).
fn is_control(q: &str) -> bool {
q.starts_with("form:") && !matches!(q, "form:properties" | "form:form")
}
/// The children `depth` deep in `xml` (the root is 1): each one's element, `form:name` and
/// markup.
fn children_at(xml: &str, at_depth: usize) -> Vec<(String, Option<String>, &str)> {
let mut out = Vec::new();
let mut depth = 0usize;
let mut open: Option<(String, Option<String>, usize)> = None;
events_at(xml.as_bytes(), |ev, at| match ev {
Ev::Start(q, a) => {
depth += 1;
if depth == at_depth && open.is_none() {
open = Some((q, get(&a, "form:name").map(str::to_string), at.0));
}
}
Ev::End(_) => {
if depth == at_depth
&& let Some((q, name, from)) = open.take()
&& let Some(x) = xml.get(from..at.1)
{
out.push((q, name, x));
}
depth = depth.saturating_sub(1);
}
Ev::Text(_) => {}
});
out
}
/// Control `ours` (as WordCraft writes it) with what the file's control `theirs` had that
/// WordCraft doesn't model and so can't have changed: its other attributes (not its ids) and its
/// properties and event listeners.
fn with_unmodelled(ours: &str, theirs: &str) -> String {
let attrs_of = |xml: &str| {
let mut out: Vec<(String, String)> = Vec::new();
let mut first = true;
super::xml::events(xml.as_bytes(), |ev| {
if let Ev::Start(_, a) = ev
&& std::mem::take(&mut first)
{
out = a;
}
});
out
};
let have: BTreeSet<String> = attrs_of(ours).into_iter().map(|(k, _)| k).collect();
let extra: String = attrs_of(theirs)
.into_iter()
.filter(|(k, _)| !have.contains(k) && !matches!(k.as_str(), "form:id" | "xml:id" | "form:name") && !k.starts_with("xmlns"))
.map(|(k, v)| format!(" {k}=\"{}\"", super::writer::x(&v)))
.collect();
let kids: String = children_at(theirs, 2)
.into_iter()
.filter(|(q, ..)| matches!(q.as_str(), "form:properties" | "office:event-listeners"))
.filter(|(q, ..)| !ours.contains(&format!("<{q}")))
.map(|(.., x)| x)
.collect();
// Into its start tag, and before its end (an empty one opened for them).
let Some(end) = ours.find('>') else { return ours.to_string() };
let (head, rest) = ours.split_at(end);
let empty = head.ends_with('/');
let head = head.trim_end_matches('/');
let name = head.trim_start_matches('<').split(char::is_whitespace).next().unwrap_or("");
if empty {
if kids.is_empty() { format!("{head}{extra}/>") } else { format!("{head}{extra}>{kids}</{name}>") }
} else {
let close = format!("</{name}>");
let body = rest.get(1..).unwrap_or("");
match body.rfind(&close) {
Some(at) => format!("{head}{extra}>{}{kids}{}", body.get(..at).unwrap_or(""), body.get(at..).unwrap_or("")),
None => format!("{head}{extra}>{body}"),
}
}
}
/// The document's forms (`office:forms`, §13.2) as the file had them (`orig`), with the
/// controls WordCraft writes (`ours`, its own `office:forms`): the file's forms element and each
/// form's own start tag, properties and event listeners as read; the controls the document has
/// in the first form (the file's controls are the document's, read into it and written back);
/// `ours` alone when the file had none, its own form when the file had none.
pub(super) fn merge_forms(orig: Option<&str>, ours: String) -> String {
let Some(orig) = orig else { return ours };
// `ours`: the controls (children of its one form).
let ours_controls = children_at(&ours, 3);
let orig_controls: Vec<(String, Option<String>, &str)> = children_at(orig, 3).into_iter().filter(|(q, ..)| is_control(q)).collect();
// Each of ours with what the file's control by that name (else in that place) had and
// WordCraft doesn't model.
let mut used = vec![false; orig_controls.len()];
let mut controls = String::new();
for (i, (q, name, xml)) in ours_controls.iter().enumerate() {
let same = |(k, (oq, oname, _)): &(usize, &(String, Option<String>, &str))| !used[*k] && oq == q && oname == name;
let found = orig_controls.iter().enumerate().find(|c| same(c)).map(|(k, _)| k).or_else(|| {
let nth = ours_controls.iter().take(i).filter(|(oq, ..)| oq == q).count();
orig_controls.iter().enumerate().filter(|(k, (oq, ..))| !used[*k] && oq == q).nth(nth).map(|(k, _)| k)
});
match found.and_then(|k| orig_controls.get(k).map(|c| (k, c.2))) {
Some((k, theirs)) => {
used[k] = true;
controls.push_str(&with_unmodelled(xml, theirs));
}
None => controls.push_str(xml),
}
}
// `orig`: its forms element, each form's start tag and non-control children.
let mut head: Option<String> = None;
let mut forms: Vec<(String, String)> = Vec::new();
let mut child: Option<(usize, bool)> = None;
let mut depth = 0usize;
events_at(orig.as_bytes(), |ev, at| match ev {
Ev::Start(q, _) => {
depth += 1;
let tag = orig.get(at.0..at.1).unwrap_or("");
let opened = || tag.strip_suffix("/>").map_or_else(|| tag.to_string(), |t| format!("{}>", t.trim_end()));
match depth {
1 => head = Some(opened()),
2 if q == "form:form" => forms.push((opened(), String::new())),
3 if child.is_none() => child = Some((at.0, is_control(&q))),
_ => {}
}
}
Ev::End(_) => {
if depth == 3
&& let Some((from, control)) = child.take()
&& !control
&& let (Some(f), Some(x)) = (forms.last_mut(), orig.get(from..at.1))
{
f.1.push_str(x);
}
depth = depth.saturating_sub(1);
}
Ev::Text(_) => {}
});
let Some(head) = head else { return ours };
let mut out = head;
if forms.is_empty() && !controls.is_empty() {
out.push_str(&format!("<form:form form:name=\"Form\">{controls}</form:form>"));
}
for (i, (start, kids)) in forms.iter().enumerate() {
out.push_str(start);
out.push_str(kids);
if i == 0 {
out.push_str(&controls);
}
out.push_str("</form:form>");
}
out.push_str("</office:forms>");
out
}
/// A list of declarations as the file had it (`orig`, a `text:variable-decls` or the like),
/// with those of WordCraft's (`ours`, the same list) it doesn't have by name; `ours` alone when
/// the file had none.
+2 -1
View File
@@ -997,7 +997,8 @@ fn export_as(doc: &Document, mime: &str) -> Result<Vec<u8>, String> {
let content = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-content {NS}>{scripts}{}<office:body><office:text>\n{}{tracked}{var_decls}{user_decls}{decls}{other_decls}{body}</office:text></office:body></office:document-content>\n",
w.automatic_styles(),
super::forms::forms_xml(&w.forms, &hidden_controls(doc))
// The file's forms as read, with the document's controls.
super::kept::merge_forms(kept.and_then(|k| k.defs.get("forms")).map(|d| &**d), super::forms::forms_xml(&w.forms, &hidden_controls(doc)))
);
let mut hw = Writer::with_prefix(if kept.is_some() { "wcM" } else { "M" });
hw.accents = accents;
+1 -1
View File
@@ -194,7 +194,7 @@ fn hidden_controls_are_kept() {
r#"<office:document-content {NS_T} {NS_F}><office:body><office:text><office:forms><form:form>{hidden}</form:form></office:forms><text:p>x</text:p></office:text></office:body></office:document-content>"#
);
let c = entry(&export(&import(&package(&only, None)).unwrap()).unwrap(), "content.xml");
assert!(c.contains("<office:forms ") && c.contains("<form:hidden "), "{c}");
assert!(c.contains("<office:forms") && c.contains("<form:hidden "), "{c}");
}
#[test]
+41 -2
View File
@@ -14,7 +14,7 @@ use crate::odt::{export, import};
/// What a save may lose, and why. Matched against the start of a loss (see [`inventory`]).
const ALLOWED: &[(&str, &str)] = &[];
const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" xmlns:wcx="urn:example:wordcraft-test" office:version="1.3""#;
const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" xmlns:wcx="urn:example:wordcraft-test" xmlns:form="urn:oasis:names:tc:opendocument:xmlns:form:1.0" office:version="1.3""#;
/// A sample package: `content.xml`, `styles.xml` and `meta.xml` bodies (inside their roots).
struct Sample {
@@ -201,8 +201,24 @@ const SECTIONS: Sample = Sample {
meta: "",
};
/// A form with its own properties and event listeners, and a field placed in the text.
const FORMS: Sample = Sample {
name: "forms",
content: r##"<office:body><office:text><office:forms form:automatic-focus="false" form:apply-design-mode="false"><form:form form:name="Standard" form:apply-filter="true" form:command-type="table" office:target-frame="" wcx:origin="sample"><form:properties><form:property form:property-name="GroupBy" office:value-type="string" office:string-value=""/></form:properties><office:event-listeners><wcx:listener wcx:on="submit"/></office:event-listeners><form:text form:name="Reader" form:id="control1" xml:id="control1" form:control-implementation="ooo:com.sun.star.form.component.TextField" form:current-value="Ann"/></form:form></office:forms><text:p>Name: <draw:control draw:control="control1" svg:width="1.5in" svg:height="0.25in" text:anchor-type="as-char"/></text:p></office:text></office:body>"##,
styles: "",
meta: "",
};
/// A table of contents and an alphabetical index with their settings and templates.
const INDEXES: Sample = Sample {
name: "indexes",
content: r##"<office:automatic-styles><style:style style:name="Sect9" style:family="section"><style:section-properties style:editable="false"><style:columns fo:column-count="1" fo:column-gap="0in"/></style:section-properties></style:style></office:automatic-styles><office:body><office:text><text:table-of-content text:style-name="Sect9" text:protected="true" text:name="Contents" wcx:kind="main"><text:table-of-content-source text:outline-level="2" text:use-index-marks="false"><text:index-title-template text:style-name="Contents_20_Heading">Contents</text:index-title-template><text:table-of-content-entry-template text:outline-level="1" text:style-name="Contents_20_1"><text:index-entry-chapter/><text:index-entry-text/><text:index-entry-tab-stop style:type="right" style:leader-char="."/><text:index-entry-page-number/></text:table-of-content-entry-template></text:table-of-content-source><text:index-body><text:index-title text:style-name="Sect9" text:name="Contents_Head"><text:p text:style-name="Contents_20_Heading">Contents</text:p></text:index-title><text:p text:style-name="Contents_20_1">Orchards<text:tab/>1</text:p></text:index-body></text:table-of-content><text:h text:outline-level="1">Orchards</text:h><text:p>Pears and <text:alphabetical-index-mark text:string-value="apples"/>apples.</text:p><text:alphabetical-index text:name="Words" text:protected="true"><text:alphabetical-index-source text:ignore-case="true" text:combine-entries="true" text:combine-entries-with-pp="false" fo:language="en" fo:country="GB"><text:index-title-template>Words</text:index-title-template><text:alphabetical-index-entry-template text:outline-level="1" text:style-name="Index_20_1"><text:index-entry-text/><text:index-entry-tab-stop style:type="right" style:leader-char="."/><text:index-entry-page-number/></text:alphabetical-index-entry-template></text:alphabetical-index-source><text:index-body><text:index-title text:name="Words_Head"><text:p>Words</text:p></text:index-title><text:p text:style-name="Index_20_1">apples<text:tab/>1</text:p></text:index-body></text:alphabetical-index></office:text></office:body>"##,
styles: "",
meta: "",
};
/// Every sample.
const SAMPLES: [Sample; 7] = [TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS];
const SAMPLES: [Sample; 9] = [TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS, FORMS, INDEXES];
/// Whether `xml` is well-formed: every element closed, in order, once.
fn well_formed(xml: &[u8]) -> bool {
@@ -273,3 +289,26 @@ fn edits_leave_kept_markup_well_formed() {
}
}
}
#[test]
fn an_edited_index_is_written_from_the_model() {
// Unedited, the index keeps its templates; once an entry changes, the edit is what's saved.
use wordcraft_doc::Pos;
let d = import(&package(&INDEXES)).unwrap();
let c = content_of(&export(&d).unwrap());
assert!(c.contains("text:combine-entries-with-pp=\"false\""), "{c}");
let i = d.body.iter().position(|b| b.as_para().is_some_and(|p| p.plain_text().starts_with("Orchards\t"))).unwrap();
let mut e = d.clone();
e.insert_text(&Pos::body(i, 0), "Edited ", &Default::default()).unwrap();
let c = content_of(&export(&e).unwrap());
assert!(c.contains("Edited Orchards"), "{c}");
// The other index, unedited, is still as read.
assert!(c.contains("text:combine-entries-with-pp=\"false\""), "{c}");
}
fn content_of(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap();
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut s).unwrap();
s
}
+12
View File
@@ -681,6 +681,18 @@ impl Writer {
// Only the first top-level block of a section starts its page.
if depth == 0 {
self.master = self.pending_start.take();
// An index kept as read (its paragraphs all as read): as it was.
if let Some(FBlock::Para(p)) = blocks.get(i)
&& let Some(ix) = p.index.as_deref()
&& chains.is_none()
&& (self.master.is_none() || self.kept_pages)
&& self.kept_markup(&ix.0, out)
{
self.master = None;
out.push('\n');
i += ix.1.max(1);
continue;
}
// A table of contents or another index, with its entries.
if let Some((n, kind, titled)) = index_at(blocks, i) {
let joined = chains.is_some_and(|c| (i..i + n).any(|k| c.contains_key(&k)));
+2 -1
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least Line truncated
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no lonLine truncated
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;
@@ -285,6 +285,7 @@ Custom document properties retain their bookmark link targets through DOCX saves
| 2026-11-02 | trivial | ODT: a chart lost the look WordCraft doesn't model (its fonts and sizes, axis and grid lines) on save, written anew from WordCraft's chart. The chart object as read is kept (through drawing it again when it opens or is resized), and written back whole while the chart reads the same; an edited chart is written from WordCraft's chart as before. The chart documents round-trip pixel-identical (`charts::an_unchanged_chart_is_written_back_as_it_was_and_an_edited_one_anew`, `engine::tests::redrawing_a_chart_keeps_the_chart_as_read`). Found with LibreOffice's serif business letter: a text frame outlined by its drawn stroke (`draw:stroke`, no `fo:border`) lost its outline; it's the frame's line. A header or footer whose style states no height (or that has none) was 0 tall; it's LibreOffice's 0.5 cm (measured), so the text no longer moves up. The letter round-trips to 116 differing pixels (from 4,223) (`text_boxes::a_frames_drawn_stroke_is_its_line_when_it_has_no_border`, `headers::a_header_without_a_stated_height_is_half_a_centimetre`) |
| 2026-11-02 | trivial | ODT, found with LibreOffice's agendas: saving wrote every table's first row as a header row (`table:table-header-rows`), repeated at the top of each page, because a table style's header row (on by default in Word's table look) was taken for Repeat as header row; a table crossing a page showed its first row twice. Only rows set to repeat are written so. The agendas round-trip to about 145 differing pixels (from 5,269 and 5,086), their text the same (`tables::only_rows_set_to_repeat_are_written_as_header_rows`) |
| 2026-11-02 | trivial | ODT, found with LibreOffice's report templates and one-page résumé. A paragraph's padding on a side without a border (measured: LibreOffice gives it room) was dropped; it's kept, laid out (see layout-parity) and written back. Unequal columns (`style:column` `style:rel-width` and indents, on pages and sections) were made equal; they keep their widths, written back as shares, without the gap LibreOffice ignores them beside. Columns with no stated gap have none (it had been Word's 0.5 in). A paragraph starting a column (`fo:break-before="column"`) didn't; it starts with Word's column break, written back on the paragraph. The report templates round-trip to 0–11 differing pixels (from 9,600), the résumé to 24 (from 6,286) (`para_borders::padding_without_a_border_keeps_its_room`, `odt::tests::unequal_columns_keep_their_widths_and_columns_without_a_gap_have_none`, `odt::tests::a_paragraph_starting_a_column_starts_with_a_column_break`) |
| 2026-11-03 | trivial | ODT: form definitions and indexes are written back as read. The file's `office:forms` keeps its own head and each `form:form` with its settings, properties, event listeners and anything WordCraft doesn't model; each control WordCraft keeps is merged with the file's own (its unmodelled attributes, `form:properties` and `office:event-listeners`). A table of contents, alphabetical index, bibliography or other index whose generated entries nobody edited is written as read, with its source settings and entry templates; once its entries change it is written from the model. The no-edit test now saves nine samples of our own. |
| 2026-11-03 | trivial | ODT: a gradient's unitless angle in an ODF 1.3 file is read in degrees, as the standard says (§18.3.1; it was read in tenths of a degree in every file). In files older than ODF 1.3 it is still read in tenths, as LibreOffice and OpenOffice wrote and read them: a deviation from the standard, out of necessity, documented in `docs/odf-interpretation.md`. The ODF 1.2 → 1.3 changes were checked; none other changes what kept markup means. |
| 2026-11-03 | minor | ODT: more is written back as read when unedited — tables, the document's properties WordCraft doesn't model (template, statistics, editing time, printing), the declarations opening the text and the document's scripts, page layouts and master pages while the page setup is unchanged (with the master page each section started with), and named sections around unedited paragraphs (start tag and section style as read). A file's package files and folders that kept markup names (a style's fill picture, a header's embedded object) are written with it, with their manifest entries. Files are saved as ODF 1.3; markup kept from an older file is brought to 1.3's meaning (a gradient's or hatch's unitless angle was tenths of a degree). WordCraft's own drawings state `style:flow-with-text`, which the standard leaves open. A test now saves seven samples of our own without edits and finds nothing lost (`odt::tests_more::lossless`). Over the 133-file corpus: round-trip difference 191k → 51k pixels, pixel-identical files 62 → 95, lost element kinds 65 → 38. |
| 2026-11-03 | minor | ODT: what isn't edited is written back as it was read. A paragraph nothing changed (by a fingerprint of how it was modelled) is written with its own markup, unknown elements and attributes included, with the automatic styles, common styles, font faces, namespaces and package files it names; while no style or list definition changed, `styles.xml`'s styles are the file's own (unused list styles, note and line numbering configuration, attributes WordCraft doesn't read). Paragraphs with drawings, comments, tracked changes, ranged marks, or that start a page are written from the model; the default graphic style and drawing definitions are WordCraft's, as drawings are written from the model. Kept markup stays in memory only (`wordcraft_doc::kept`, 64 MB at most). Round-trip differences over the 133-file corpus 528k → 191k pixels, pixel-identical files 46 → 62. |