M-ODT gap 19: bookmarks, notes, objects and page-anchored frames written as read

- Paragraphs and tables holding bookmarks are kept; the writer takes
  the names kept markup begins and ends, so either end may be as read.
- Footnotes and endnotes: each note's text fingerprinted when read;
  kept while it and the note numbering are as read.
- Formulas, charts and other objects in kept markup, with their files
  and preview pictures (only while that markup is written).
- Page-anchored frames LibreOffice puts in the text before a paragraph
  are kept before its markup.
- Styles of different families sharing a name are kept apart.
- The text keeps text:use-soft-page-breaks while kept markup holds them.

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 21:09:10 +00:00
1 parent 54988ddfda
commit 69b457f155
12 files changed
+277 -44

No files matched your search

+1
View File
@@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
- 2026-11-03: ODT paragraphs holding bookmarks, notes, formulas and objects, and the page-anchored frames before a paragraph, are written as read while unchanged (gap 19); styles of different families sharing a name are kept apart. Over the corpus 123 of 133 files round-trip pixel-identical (118), 132 with the same text, and the attributes and values a save loses fall from 539 to under 315.
- 2026-11-03: ODT paragraphs showing text boxes and shapes holding text are written as read while that text is unchanged (gap 19). Over the corpus 111 of 133 files now round-trip pixel-identical (103), the round-trip difference falls from 44k to 39k pixels, and the element kinds a save loses fall from 16 to 8.
- 2026-11-03: ODT paragraphs holding comments or tracked changes are written as read while those are unchanged (gap 19): a comment edited, resolved or answered, or a change accepted, sends its paragraph to the model; the file's change regions are kept beside WordCraft's own. Over the corpus the words a save loses fall from 29 to 4.
- 2026-11-03: ODT paragraphs and tables holding pictures and shapes are written as read while unchanged (gap 19), with their pictures and frame styles; over the corpus the attributes and values a save loses halve (1,551 → 789), the pages unchanged.
+22 -6
View File
@@ -48,8 +48,8 @@ pub struct KeptXml {
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>)>,
/// Each text box's or shape's story's [`story_fingerprint`] when read: a paragraph showing
/// one is written as read only while it is as read.
/// Each text box's, shape's or note's story's [`story_fingerprint`] when read: a paragraph
/// showing one is written as read only while it is as read.
pub story_fps: HashMap<u32, u64>,
/// Each top-level comment's [`comments_fingerprint`] when read (it and its replies): a
/// paragraph holding comments is written as read only while they are as read.
@@ -149,9 +149,21 @@ impl KeptXml {
}
count == in_markup
}
/// Whether the stories paragraph `p` shows (its text boxes' and shapes' text) are as read.
/// Whether the stories paragraph `p` shows (its text boxes' and shapes' text, its notes') are
/// as read.
pub fn stories_as_read(&self, doc: &crate::Document, p: &Paragraph) -> bool {
p.objects.iter().flat_map(|o| o.text_boxes()).all(|id| self.story_fps.get(&id).is_some_and(|fp| *fp == story_fingerprint(doc, id)))
// A note's citation is its number in the document's numbering, which is the page setup's.
let notes = p.objects.iter().any(|o| matches!(o, crate::para::InlineObject::NoteRef { .. }));
if notes && !self.pages_as_read(doc) {
return false;
}
p.objects
.iter()
.flat_map(|o| match o {
crate::para::InlineObject::NoteRef { id, .. } => vec![*id],
o => o.text_boxes(),
})
.all(|id| self.story_fps.get(&id).is_some_and(|fp| *fp == story_fingerprint(doc, id)))
}
/// The wrappers kept around table `t`, while it still is what was read.
pub fn table_wrap(&self, t: &crate::Table) -> Option<&(Vec<Arc<str>>, u32)> {
@@ -442,8 +454,12 @@ impl crate::Document {
}
// The text boxes' and shapes' stories as read, for paragraphs showing them.
if self.kept.get().is_some_and(|k| !k.pending.is_empty()) {
let fps: HashMap<u32, u64> =
self.parts.iter().filter(|(_, p)| p.kind == crate::PartKind::TextBox).map(|(id, _)| (*id, story_fingerprint(self, *id))).collect();
let fps: HashMap<u32, u64> = self
.parts
.iter()
.filter(|(_, p)| matches!(p.kind, crate::PartKind::TextBox | crate::PartKind::Footnote | crate::PartKind::Endnote))
.map(|(id, _)| (*id, story_fingerprint(self, *id)))
.collect();
if let Some(k) = self.kept.get_mut() {
k.story_fps = fps;
}
+67 -13
View File
@@ -72,6 +72,13 @@ pub(super) struct Body<'a> {
/// frames before the first one): the next paragraph's, so they don't make a paragraph of
/// their own (which would start the document on another page style).
loose: Vec<Inline>,
/// The markup of each drawing in `loose`, as read, while each one's is (`loose_whole`): the
/// paragraph they join keeps it before its own (see `feed_at`).
loose_raw: Vec<std::sync::Arc<str>>,
loose_whole: bool,
/// The drawing outside a paragraph being read: where it starts, its depth, and how many
/// drawings were waiting before it.
loose_open: Option<(usize, usize, usize)>,
fmt: Vec<Fmt>,
para: Option<Para>,
/// The text read last ended in a space collapsed from XML whitespace (not a `text:s`).
@@ -211,6 +218,9 @@ impl<'a> Body<'a> {
manifest: None,
containers: vec![Vec::new()],
loose: Vec::new(),
loose_raw: Vec::new(),
loose_whole: true,
loose_open: None,
fmt: Vec::new(),
para: None,
xml_space_end: false,
@@ -304,6 +314,39 @@ impl<'a> Body<'a> {
self.item_start = None;
}
/// [`Body::feed_at`] for a drawing outside a paragraph (a page-anchored frame in the text):
/// its markup kept, for the paragraph it joins. `None` when it took the event.
fn loose_drawing(&mut self, ev: Ev, at: (usize, usize)) -> Option<Ev> {
match &ev {
Ev::Start(q, _)
if self.loose_open.is_none()
&& q.starts_with("draw:")
&& is_drawing(q)
&& self.para.is_none()
&& self.kept_open.is_none()
&& !self.raw.is_empty()
&& self.in_text
&& self.skip == 0
&& self.at_body_level() =>
{
self.loose_open = Some((at.0, self.depth + 1, self.loose.len()));
}
Ev::End(_) if self.loose_open.is_some_and(|(_, d, _)| d == self.depth) => {
self.feed(ev);
if let Some((from, _, before)) = self.loose_open.take() {
match self.raw.get(from..at.1).and_then(|b| std::str::from_utf8(b).ok()) {
Some(xml) if self.loose.len() == before + 1 => self.loose_raw.push(std::sync::Arc::from(xml)),
_ if self.loose.len() == before => {}
_ => self.loose_whole = false,
}
}
return None;
}
_ => {}
}
Some(ev)
}
/// [`Body::feed`], `at` the event's markup in [`Body::raw`]: a body paragraph that comes out
/// 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`).
@@ -330,6 +373,7 @@ impl<'a> Body<'a> {
clean: true,
done: self.container().len(),
comments: KeptComments::default(),
prefix: String::new(),
});
}
None => {}
@@ -424,6 +468,7 @@ impl<'a> Body<'a> {
clean: true,
done: self.container().len(),
comments: KeptComments::default(),
prefix: String::new(),
});
}
None => {}
@@ -443,6 +488,7 @@ impl<'a> Body<'a> {
}
_ => {}
}
let Some(ev) = self.loose_drawing(ev, at) else { return };
match &ev {
Ev::Start(q, a) => match &mut self.kept_open {
Some(k) => {
@@ -461,10 +507,12 @@ impl<'a> Body<'a> {
&& self.field.is_none()
&& !self.in_desc =>
{
// Drawings read before it that join it aren't in its markup.
let clean = self.loose.is_empty();
// Drawings read before it that join it: their markup before its own, when
// each one's was kept.
let clean = self.loose.is_empty() || (self.loose_whole && self.loose_raw.len() == self.loose.len());
let prefix = if self.loose.is_empty() { String::new() } else { self.loose_raw.concat() };
self.kept_open =
Some(KeptOpen { from: at.0, depth: self.depth + 1, clean, done: self.paras_done, comments: KeptComments::default() });
Some(KeptOpen { from: at.0, depth: self.depth + 1, clean, done: self.paras_done, comments: KeptComments::default(), prefix });
}
None => {}
},
@@ -485,7 +533,7 @@ impl<'a> Body<'a> {
&& let Some(p) = &mut self.para
&& let Some(xml) = self.raw.get(k.from..at.1).and_then(|b| std::str::from_utf8(b).ok())
{
p.kept = Some(std::sync::Arc::from(xml));
p.kept = Some(std::sync::Arc::from(format!("{}{xml}", k.prefix)));
}
}
_ => {}
@@ -804,6 +852,8 @@ impl Body<'_> {
let mut loose = std::mem::take(&mut self.loose);
loose.append(&mut p.inlines);
p.inlines = loose;
self.loose_raw.clear();
self.loose_whole = true;
}
self.para = Some(p);
}
@@ -1701,6 +1751,8 @@ struct KeptOpen {
done: usize,
/// The comments in a paragraph being kept (see [`KeptComments`]).
comments: KeptComments,
/// The markup of drawings read before it that join it, kept before its own.
prefix: String,
}
/// The comments (§14.1 `office:annotation`) and tracked changes (§5.5.7) in a paragraph being
@@ -1756,8 +1808,9 @@ impl KeptComments {
"text:change" => get(a, "text:change-id").is_some(),
// A text box continuing in another box, or continued from one: their names tie them.
"draw:text-box" if get(a, "draw:chain-next-name").is_some() => false,
// A drawing: the paragraphs of its text (a text box's, a shape's) are its story's.
q if self.open.is_none() && q.starts_with("draw:") && is_drawing(q) => {
// A drawing or a note: the paragraphs of its text (a text box's, a shape's, the
// note's) are its story's.
q if self.open.is_none() && (q == "text:note" || q.starts_with("draw:") && is_drawing(q)) => {
self.open = Some((depth, done));
true
}
@@ -1785,17 +1838,18 @@ impl KeptComments {
}
/// Whether markup holding element `q` can be kept as read. Pictures, shapes and text boxes can
/// (their text is a story of its own, kept while it is as read: see `KeptComments`); embedded
/// objects (charts and formulas, written on their own), form controls and plugins can't, nor
/// marks that pair up with others elsewhere (comments and tracked changes are paired apart).
/// (their text is a story of its own, kept while it is as read: see `KeptComments`), and
/// embedded objects (a formula's text and a chart's data are the paragraph's, an object's files
/// are kept with it); form controls (named afresh in the forms) and plugins can't, nor
/// marks that pair up with others elsewhere by ids the writer gives (comments and tracked
/// changes are paired apart). Bookmarks can: their names are kept, so either end may be
/// written as read and the other from the model.
fn keepable(q: &str) -> bool {
!(matches!(q, "draw:object" | "draw:object-ole" | "draw:control" | "draw:plugin" | "draw:applet" | "draw:floating-frame")
!(matches!(q, "draw:control" | "draw:plugin" | "draw:applet" | "draw:floating-frame")
|| q.starts_with("office:annotation")
|| q.starts_with("text:change")
|| q.starts_with("form:")
|| q.ends_with("-start")
|| q.ends_with("-end")
|| q == "text:bookmark")
|| (q.ends_with("-start") || q.ends_with("-end")) && !q.starts_with("text:bookmark-"))
}
/// Whether `q` is a drawing that can hold text: a frame, a group or a shape.
+49 -7
View File
@@ -50,6 +50,16 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc<str>)> {
let parent = path.get(depth.wrapping_sub(2)).map(String::as_str).unwrap_or("");
let key = match (depth, parent) {
(2, _) if content && q == "office:scripts" => Some("top:office:scripts".to_string()),
// Whether the text holds soft page breaks (§19.857), which markup kept may.
(3, "office:body")
if content
&& q == "office:text"
&& get(&a, "text:use-soft-page-breaks").is_some_and(|v| v.trim() == "true")
&& out.len() < MAX_DEFS =>
{
out.push(("soft-page-breaks".to_string(), Arc::from("true")));
None
}
(3, "office:automatic-styles") => child_key(if content { "auto" } else { "sauto" }, &q, &a),
(3, "office:styles") => child_key("common", &q, &a),
(3, "office:font-face-decls") => child_key("font", &q, &a),
@@ -134,7 +144,7 @@ fn to_1_3(xml: &str) -> String {
fn child_key(g: &str, q: &str, a: &super::xml::Attrs) -> Option<String> {
// Drawing definitions (dashes, gradients, arrowheads…) are named by `draw:name`.
match get(a, "style:name").or_else(|| get(a, "draw:name")).filter(|n| !n.is_empty()) {
Some(name) => Some(format!("{g}:{name}{}", space(q))),
Some(name) => Some(format!("{g}:{name}{}", space(q, get(a, "style:family").unwrap_or("")))),
None => {
let which = get(a, "style:family").or_else(|| get(a, "text:note-class")).unwrap_or("");
Some(format!("{g}~{q}~{which}"))
@@ -142,13 +152,17 @@ fn child_key(g: &str, q: &str, a: &super::xml::Attrs) -> Option<String> {
}
}
/// The name space a definition (element `q`) belongs to, as a key's suffix: names are unique
/// only within one (§16.2: a style's within its family; a list style, a data style, a gradient
/// may share a paragraph style's name). Styles (`style:style`), font faces and master pages
/// have none; a list style is `|list`, a data style `|data`, another definition `|<element>`.
fn space(q: &str) -> String {
/// The name space a definition (element `q`, of style family `family`) belongs to, as a key's
/// suffix: names are unique only within one (§16.2: a style's within its family; a list style,
/// a data style, a gradient may share a paragraph style's name, as a graphic style may:
/// LibreOffice's "Marginalia" is both). Paragraph styles, font faces and master pages have none;
/// another family's style is `|<family>`, a list style `|list`, a data style `|data`, another
/// definition `|<element>`.
fn space(q: &str, family: &str) -> String {
match q {
"style:style" | "style:font-face" | "style:master-page" => String::new(),
"style:style" if family.trim() == "paragraph" || family.is_empty() => String::new(),
"style:style" => format!("|{}", family.trim()),
"style:font-face" | "style:master-page" => String::new(),
"text:list-style" => "|list".to_string(),
q if q.starts_with("number:") => "|data".to_string(),
q => format!("|{}", q.split_once(':').map_or(q, |(_, l)| l)),
@@ -610,6 +624,34 @@ pub(super) fn changes(xml: &str) -> BTreeSet<String> {
out
}
/// The names of the bookmarks (§6.2.1) `xml` begins (at a point too) and ends.
pub(super) fn bookmarks(xml: &str) -> (BTreeSet<String>, BTreeSet<String>) {
let (mut starts, mut ends) = (BTreeSet::new(), BTreeSet::new());
if xml.contains("<text:bookmark") {
super::xml::events(xml.as_bytes(), |ev| {
if let Ev::Start(q, a) = ev
&& let Some(n) = get(&a, "text:name")
&& starts.len() + ends.len() < MAX_DEFS
{
match q.as_str() {
"text:bookmark" => {
starts.insert(n.to_string());
ends.insert(n.to_string());
}
"text:bookmark-start" => {
starts.insert(n.to_string());
}
"text:bookmark-end" => {
ends.insert(n.to_string());
}
_ => {}
}
}
});
}
(starts, ends)
}
/// The sequences (§7.4.13 `text:sequence`) `xml` counts, which the document declares.
pub(super) fn sequences(xml: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
+15 -2
View File
@@ -809,6 +809,17 @@ fn with_kept_files(
}
}
/// `office:text`'s statement that the text holds the file's soft page breaks (§19.857
/// `text:use-soft-page-breaks`), when the file said so and markup kept as read in `body` holds
/// them.
fn soft_page_breaks(kept: Option<&wordcraft_doc::kept::KeptXml>, body: &str) -> &'static str {
if kept.is_some_and(|k| k.defs.contains_key("soft-page-breaks")) && body.contains("<text:soft-page-break") {
" text:use-soft-page-breaks=\"true\""
} else {
""
}
}
/// The prefix of WordCraft's own automatic style names in a document with paragraphs kept as
/// read (theirs keep the names they were read with).
const KEPT_PREFIX: &str = "wc";
@@ -1006,8 +1017,9 @@ fn export_as(doc: &Document, mime: &str) -> Result<Vec<u8>, String> {
})
.unwrap_or_default();
let scripts = kept.and_then(|k| k.defs.get("top:office:scripts")).map(|d| d.to_string()).unwrap_or_default();
let soft = soft_page_breaks(kept, &body);
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",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-content {NS}>{scripts}{}<office:body><office:text{soft}>\n{}{tracked}{var_decls}{user_decls}{decls}{other_decls}{body}</office:text></office:body></office:document-content>\n",
w.automatic_styles(),
// 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)))
@@ -1018,7 +1030,8 @@ fn export_as(doc: &Document, mime: &str) -> Result<Vec<u8>, String> {
let parts: Vec<PageParts> = pages.iter().map(|(_, sec)| page_parts(doc, sec, &mut hw)).collect();
// A custom style kept paragraphs name is written from the model (as edited), not as read.
for st in &doc.styles.styles {
if w.kept_used.1.contains_key(&super::writer::odf_name(&st.id)) {
let name = super::writer::odf_name(&st.id);
if w.kept_used.1.contains_key(&name) || w.kept_used.1.contains_key(&format!("{name}|text")) {
if model::is_custom_style(doc, &st.id) {
w.named.insert(st.id.clone());
} else if model::is_custom_char_style(doc, &st.id) {
+99 -7
View File
@@ -37,12 +37,25 @@ fn package(s: &Sample) -> Vec<u8> {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
}
// The picture a sample may show.
z.start_file("Pictures/dot.png", o).unwrap();
std::io::Write::write_all(&mut z, DOT).unwrap();
// The picture a sample may show, and a formula object with its preview.
for (name, data) in [
("META-INF/manifest.xml", MANIFEST.as_bytes()),
("Pictures/dot.png", DOT),
("Object 9/content.xml", FORMULA.as_bytes()),
("ObjectReplacements/Object 9", DOT),
] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data).unwrap();
}
z.finish().unwrap().into_inner()
}
/// The samples' manifest (ODF 1.3 Part 2 §4): the formula object is one.
const MANIFEST: &str = r#"<?xml version="1.0" encoding="UTF-8"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3"><manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/><manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/><manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/><manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/><manifest:file-entry manifest:full-path="Pictures/dot.png" manifest:media-type="image/png"/><manifest:file-entry manifest:full-path="Object 9/" manifest:media-type="application/vnd.oasis.opendocument.formula"/><manifest:file-entry manifest:full-path="Object 9/content.xml" manifest:media-type="text/xml"/><manifest:file-entry manifest:full-path="ObjectReplacements/Object 9" manifest:media-type="image/png"/></manifest:manifest>"#;
/// A formula object's content (MathML, ODF 1.3 §10.4.3): x².
const FORMULA: &str = r#"<?xml version="1.0" encoding="UTF-8"?><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><msup><mi>x</mi><mn>2</mn></msup><annotation encoding="StarMath 5.0">x^2</annotation></semantics></math>"#;
/// A one-pixel picture of our own.
const DOT: &[u8] = &[
0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, b'I', b'H', b'D', b'R', 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1f, 0x15, 0xc4,
@@ -137,7 +150,7 @@ fn check(s: &Sample) {
/// Paragraphs and text WordCraft partly models: unknown elements, attributes and namespaces.
const TEXT: Sample = Sample {
name: "text",
content: r##"<office:automatic-styles><style:style style:name="P1" style:family="paragraph" style:parent-style-name="Standard"><style:paragraph-properties fo:margin-left="0.4in" wcx:spring="tight"/><style:text-properties fo:color="#336699" wcx:hue="warm"/></style:style><style:style style:name="T1" style:family="text"><style:text-properties fo:font-weight="bold" style:text-blinking="true"/></style:style></office:automatic-styles><office:body><office:text><text:h text:outline-level="1">Orchard notes</text:h><text:p text:style-name="P1" wcx:mood="calm">Pears ripen <text:span text:style-name="T1">late</text:span> in the year<text:hidden-text text:condition="ooow:1" text:string-value="quiet"/>, and <wcx:marker wcx:kind="star">apples</wcx:marker> early.</text:p><text:p>Second paragraph with a <text:note text:id="n1" text:note-class="footnote"><text:note-citation>1</text:note-citation><text:note-body><text:p>Footnote words.</text:p></text:note-body></text:note> note.</text:p></office:text></office:body>"##,
content: r##"<office:automatic-styles><style:style style:name="P1" style:family="paragraph" style:parent-style-name="Standard"><style:paragraph-properties fo:margin-left="0.4in" wcx:spring="tight"/><style:text-properties fo:color="#336699" wcx:hue="warm"/></style:style><style:style style:name="T1" style:family="text"><style:text-properties fo:font-weight="bold" style:text-blinking="true"/></style:style></office:automatic-styles><office:body><office:text text:use-soft-page-breaks="true"><text:h text:outline-level="1">Orchard notes</text:h><text:p text:style-name="P1" wcx:mood="calm"><text:soft-page-break/>Pears ripen <text:span text:style-name="T1">late</text:span> in the year<text:hidden-text text:condition="ooow:1" text:string-value="quiet"/>, and <wcx:marker wcx:kind="star">apples</wcx:marker> early.</text:p><text:p>Second paragraph with a <text:note text:id="n1" text:note-class="footnote"><text:note-citation>1</text:note-citation><text:note-body><text:p>Footnote words.</text:p></text:note-body></text:note> note.</text:p></office:text></office:body>"##,
styles: r##"<office:styles><style:style style:name="Standard" style:family="paragraph" style:class="text"/></office:styles>"##,
meta: "",
};
@@ -147,7 +160,7 @@ const TEXT: Sample = Sample {
const STYLES: Sample = Sample {
name: "styles",
content: r##"<office:body><office:text><text:p text:style-name="Standard">Only text.</text:p></office:text></office:body>"##,
styles: r##"<office:styles><style:default-style style:family="paragraph"><style:paragraph-properties style:tab-stop-distance="0.5in" wcx:grid="on"/><style:text-properties fo:font-size="11pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text" wcx:origin="sample"><style:paragraph-properties fo:text-align="justify"/></style:style><style:style style:name="Marginal" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-style="italic"/></style:style><text:list-style style:name="Marginal"><text:list-level-style-number text:level="1" style:num-format="i" style:num-suffix=")"/></text:list-style><text:list-style style:name="Ticks"><text:list-level-style-bullet text:level="1" text:bullet-char="✓"><style:list-level-properties text:list-level-position-and-space-mode="label-alignment"><style:list-level-label-alignment text:label-followed-by="listtab" fo:margin-left="0.25in"/></style:list-level-properties></text:list-level-style-bullet></text:list-style><text:notes-configuration text:note-class="footnote" style:num-format="a" text:start-value="0" text:footnotes-position="page" text:start-numbering-at="document"/><text:linenumbering-configuration text:number-lines="false" text:offset="0.2in" style:num-format="1" text:number-position="left" text:increment="5"/></office:styles>"##,
styles: r##"<office:styles><style:default-style style:family="paragraph"><style:paragraph-properties style:tab-stop-distance="0.5in" wcx:grid="on"/><style:text-properties fo:font-size="11pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text" wcx:origin="sample"><style:paragraph-properties fo:text-align="justify"/></style:style><style:style style:name="Marginal" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-style="italic"/></style:style><style:style style:name="Marginal" style:family="graphic"><style:graphic-properties svg:width="1.4in" fo:min-height="0.2in" style:horizontal-pos="left"/></style:style><text:list-style style:name="Marginal"><text:list-level-style-number text:level="1" style:num-format="i" style:num-suffix=")"/></text:list-style><text:list-style style:name="Ticks"><text:list-level-style-bullet text:level="1" text:bullet-char="✓"><style:list-level-properties text:list-level-position-and-space-mode="label-alignment"><style:list-level-label-alignment text:label-followed-by="listtab" fo:margin-left="0.25in"/></style:list-level-properties></text:list-level-style-bullet></text:list-style><text:notes-configuration text:note-class="footnote" style:num-format="a" text:start-value="0" text:footnotes-position="page" text:start-numbering-at="document"/><text:linenumbering-configuration text:number-lines="false" text:offset="0.2in" style:num-format="1" text:number-position="left" text:increment="5"/></office:styles>"##,
meta: "",
};
@@ -261,13 +274,31 @@ const CHANGES: Sample = Sample {
/// WordCraft doesn't model.
const TEXT_BOXES: Sample = Sample {
name: "text boxes",
content: r##"<office:automatic-styles><style:style style:name="fr1" style:family="graphic" style:parent-style-name="Frame"><style:graphic-properties style:wrap="parallel" fo:padding="0.05in" fo:border="0.5pt solid #000000" style:shadow="none" draw:shadow-opacity="100%" wcx:box="kept"/></style:style><style:style style:name="P1" style:family="paragraph"><style:paragraph-properties fo:text-align="center" wcx:para="kept"/></style:style><style:style style:name="gr1" style:family="graphic"><style:graphic-properties draw:fill-color="#ffcc00" draw:textarea-vertical-align="middle"/></style:style></office:automatic-styles><office:body><office:text><text:p>A box <draw:frame draw:style-name="fr1" draw:name="Aside" text:anchor-type="paragraph" svg:width="2in" draw:z-index="0"><draw:text-box fo:min-height="0.5in"><text:p text:style-name="P1">Inside the box.</text:p><text:p>A second line.</text:p></draw:text-box></draw:frame>beside the text.</text:p><text:p>A shape <draw:custom-shape draw:style-name="gr1" draw:name="Sign" text:anchor-type="paragraph" svg:width="1in" svg:height="1in" draw:z-index="1"><text:p>Stop</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="octagon" draw:modifiers="5000" draw:enhanced-path="M ?f7 0 X 0 ?f8 L 0 ?f9 Y ?f7 21600 L ?f10 21600 X 21600 ?f9 L 21600 ?f8 Y ?f10 0 Z N"/></draw:custom-shape>with words.</text:p></office:text></office:body>"##,
content: r##"<office:automatic-styles><style:style style:name="fr1" style:family="graphic" style:parent-style-name="Frame"><style:graphic-properties style:wrap="parallel" fo:padding="0.05in" fo:border="0.5pt solid #000000" style:shadow="none" draw:shadow-opacity="100%" wcx:box="kept"/></style:style><style:style style:name="P1" style:family="paragraph"><style:paragraph-properties fo:text-align="center" wcx:para="kept"/></style:style><style:style style:name="gr1" style:family="graphic"><style:graphic-properties draw:fill-color="#ffcc00" draw:textarea-vertical-align="middle"/></style:style></office:automatic-styles><office:body><office:text><draw:frame draw:style-name="fr1" draw:name="Sender" text:anchor-type="page" text:anchor-page-number="1" svg:x="1in" svg:y="1in" svg:width="3in" draw:z-index="2"><draw:text-box fo:min-height="0.5in"><text:p>Our address</text:p></draw:text-box></draw:frame><text:p>A box <draw:frame draw:style-name="fr1" draw:name="Aside" text:anchor-type="paragraph" svg:width="2in" draw:z-index="0"><draw:text-box fo:min-height="0.5in"><text:p text:style-name="P1">Inside the box.</text:p><text:p>A second line.</text:p></draw:text-box></draw:frame>beside the text.</text:p><text:p>A shape <draw:custom-shape draw:style-name="gr1" draw:name="Sign" text:anchor-type="paragraph" svg:width="1in" svg:height="1in" draw:z-index="1"><text:p>Stop</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="octagon" draw:modifiers="5000" draw:enhanced-path="M ?f7 0 X 0 ?f8 L 0 ?f9 Y ?f7 21600 L ?f10 21600 X 21600 ?f9 L 21600 ?f8 Y ?f10 0 Z N"/></draw:custom-shape>with words.</text:p></office:text></office:body>"##,
styles: r##"<office:styles><style:style style:name="Frame" style:family="graphic"><style:graphic-properties text:anchor-type="paragraph" svg:x="0in" svg:y="0in" style:wrap="parallel" style:number-wrapped-paragraphs="no-limit" style:vertical-pos="top" style:vertical-rel="paragraph-content" style:horizontal-pos="center" style:horizontal-rel="paragraph-content"/></style:style></office:styles>"##,
meta: "",
};
/// Bookmarks: at a point in a table's cell (with LibreOffice's relative column widths), and over
/// text from one paragraph into the next.
const BOOKMARKS: Sample = Sample {
name: "bookmarks",
content: r##"<office:automatic-styles><style:style style:name="Tab2" style:family="table"><style:table-properties style:width="6in" table:align="margins"/></style:style><style:style style:name="Tab2.A" style:family="table-column"><style:table-column-properties style:column-width="2in" style:rel-column-width="21845*"/></style:style><style:style style:name="Tab2.B" style:family="table-column"><style:table-column-properties style:column-width="4in" style:rel-column-width="43690*"/></style:style></office:automatic-styles><office:body><office:text><table:table table:name="Agenda" table:style-name="Tab2"><table:table-column table:style-name="Tab2.A"/><table:table-column table:style-name="Tab2.B"/><table:table-row><table:table-cell office:value-type="string"><text:p><text:bookmark text:name="Item"/>Item</text:p></table:table-cell><table:table-cell office:value-type="string"><text:p>Who</text:p></table:table-cell></table:table-row></table:table><text:p>From <text:bookmark-start text:name="Span"/>here</text:p><text:p>to there<text:bookmark-end text:name="Span"/>.</text:p></office:text></office:body>"##,
styles: "",
meta: "",
};
/// A footnote with styled text, and a formula object with its preview picture.
const NOTES_AND_FORMULAS: Sample = Sample {
name: "notes and formulas",
content: r##"<office:automatic-styles><style:style style:name="T9" style:family="text"><style:text-properties fo:font-style="italic" wcx:note="kept"/></style:style><style:style style:name="fr9" style:family="graphic"><style:graphic-properties style:vertical-pos="middle" style:vertical-rel="text" draw:ole-draw-aspect="1"/></style:style></office:automatic-styles><office:body><office:text><text:p>A claim<text:note text:id="ftn0" text:note-class="footnote"><text:note-citation>1</text:note-citation><text:note-body><text:p>A <text:span text:style-name="T9">source</text:span> for it.</text:p></text:note-body></text:note> and a formula <draw:frame draw:style-name="fr9" draw:name="Formula 9" text:anchor-type="as-char" svg:width="0.3in" svg:height="0.2in" draw:z-index="0"><draw:object xlink:href="./Object 9" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/><draw:image xlink:href="./ObjectReplacements/Object 9" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame> in it.</text:p></office:text></office:body>"##,
styles: "",
meta: "",
};
/// Every sample.
const SAMPLES: [Sample; 13] = [TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS, FORMS, INDEXES, DRAWINGS, COMMENTS, CHANGES, TEXT_BOXES];
const SAMPLES: [Sample; 15] =
[TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS, FORMS, INDEXES, DRAWINGS, COMMENTS, CHANGES, TEXT_BOXES, BOOKMARKS, NOTES_AND_FORMULAS];
/// Whether `xml` is well-formed: every element closed, in order, once.
fn well_formed(xml: &[u8]) -> bool {
@@ -534,3 +565,64 @@ fn linked_text_boxes_keep_their_link() {
let next = c.split("draw:chain-next-name=\"").nth(1).and_then(|r| r.split('"').next()).unwrap_or_default().to_string();
assert!(!next.is_empty() && c.contains(&format!("draw:name=\"{next}\"")), "{c}");
}
#[test]
fn a_bookmark_kept_at_one_end_is_written_once() {
// Either paragraph a bookmark spans edited: one start and one end, each once.
let d = import(&package(&BOOKMARKS)).unwrap();
for start in ["From", "to there"] {
let mut e = d.clone();
let i = e.body.iter().position(|b| b.as_para().is_some_and(|p| p.text.starts_with(start))).unwrap();
if let Some(wordcraft_doc::Block::Para(p)) = e.body.get_mut(i).map(std::sync::Arc::make_mut) {
p.props.style = Some("Edited".into());
p.touch();
}
let c = content_of(&export(&e).unwrap());
assert_eq!(c.matches(r#"<text:bookmark-start text:name="Span"/>"#).count(), 1, "{c}");
assert_eq!(c.matches(r#"<text:bookmark-end text:name="Span"/>"#).count(), 1, "{c}");
assert!(c.find("bookmark-start").unwrap_or(0) < c.find("bookmark-end").unwrap_or(0), "{c}");
}
}
#[test]
fn a_note_or_formula_edited_is_written_from_the_model() {
let d = import(&package(&NOTES_AND_FORMULAS)).unwrap();
let saved = export(&d).unwrap();
assert!(content_of(&saved).contains(r#"wcx:note="kept""#));
let names = |b: &[u8]| zip::ZipArchive::new(std::io::Cursor::new(b.to_vec())).unwrap().file_names().map(String::from).collect::<Vec<_>>();
assert!(names(&saved).iter().any(|n| n == "Object 9/content.xml"), "{:?}", names(&saved));
let manifest = crate::odt::tests::entry(&saved, "META-INF/manifest.xml");
assert!(
manifest.contains(r#"manifest:full-path="Object 9/" manifest:media-type="application/vnd.oasis.opendocument.formula""#)
&& manifest.contains(r#"manifest:full-path="ObjectReplacements/Object 9""#),
"{manifest}"
);
// The note's text edited.
let mut e = d.clone();
let id = e.parts.iter().find(|(_, p)| p.kind == wordcraft_doc::PartKind::Footnote).map(|(id, _)| *id).unwrap();
let mut blocks = e.parts.get(&id).unwrap().blocks.clone();
if let Some(wordcraft_doc::Block::Para(p)) = blocks.first_mut().map(std::sync::Arc::make_mut) {
p.props.style = Some("Edited".into());
p.touch();
}
if let Some(part) = e.parts.get_mut(&id) {
part.blocks = blocks;
}
let c = content_of(&export(&e).unwrap());
assert!(!c.contains(r#"wcx:note="kept""#) && c.contains("for it."), "{c}");
// The formula edited: written anew, and the old one's files aren't.
let mut e = d.clone();
for b in e.body.iter_mut() {
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) {
for o in &mut p.objects {
if let wordcraft_doc::para::InlineObject::Equation { linear, .. } = o {
*linear = "y^3".into();
}
}
p.touch();
}
}
let saved = export(&e).unwrap();
assert!(!names(&saved).iter().any(|n| n.starts_with("Object 9")), "{:?}", names(&saved));
assert!(parts_well_formed(&saved).is_ok());
}
+8 -4
View File
@@ -177,13 +177,17 @@ fn citations_are_numbered_as_the_document_numbers_its_notes() {
// §6.3.3: the citation is the text shown. Footnotes in A, B, C from the third (ODF counts the
// first number from 0), endnotes as configured; without a configuration 1, 2, 3 and i, ii.
let mut d = import(&body(r#"<text:p>One<text:note text:note-class="footnote"><text:note-citation>x</text:note-citation><text:note-body><text:p>a</text:p></text:note-body></text:note> two<text:note text:note-class="footnote"><text:note-citation>x</text:note-citation><text:note-body><text:p>b</text:p></text:note-body></text:note> three<text:note text:note-class="endnote"><text:note-citation>x</text:note-citation><text:note-body><text:p>c</text:p></text:note-body></text:note></text:p>"#)).unwrap();
let citations = |d: &Document| {
let c = crate::odt::tests::entry(&export(d).unwrap(), "content.xml");
let citations = |d: &Document, model: bool| {
let saved = if model { crate::odt::tests::export_model(d) } else { export(d) };
let c = crate::odt::tests::entry(&saved.unwrap(), "content.xml");
c.split("<text:note-citation>").skip(1).filter_map(|s| s.split('<').next()).map(str::to_string).collect::<Vec<_>>()
};
assert_eq!(citations(&d), ["1", "2", "i"]);
assert_eq!(citations(&d, true), ["1", "2", "i"]);
// Unedited, the file's own (as read).
assert_eq!(citations(&d, false), ["x", "x", "x"]);
d.settings.footnote_format = wordcraft_doc::section::NumFormat::UpperLetter;
d.settings.footnote_pr.num_start = Some(3);
d.settings.endnote_format = wordcraft_doc::section::NumFormat::Decimal;
assert_eq!(citations(&d), ["C", "D", "1"]);
// The numbering changed: its notes' paragraph written from the model, numbered anew.
assert_eq!(citations(&d, false), ["C", "D", "1"]);
}
+6 -1
View File
@@ -128,9 +128,14 @@ fn a_flat_document_holds_its_objects_and_their_pictures_inline() {
let saved = export(&again).unwrap();
let z = zip::ZipArchive::new(std::io::Cursor::new(&saved)).unwrap();
let names: Vec<&str> = z.file_names().collect();
assert!(names.iter().filter(|n| n.starts_with("ObjectReplacements/")).count() == 2, "{names:?}");
let ole = crate::odt::tests::entry(&saved, "content.xml");
assert!(ole.contains("<draw:object-ole"), "{ole}");
// Both objects with their pictures, each link to a file (or folder) the package has.
assert_eq!(ole.matches("<draw:image ").count(), 2, "{ole}");
for link in ole.split("xlink:href=\"").skip(1).filter_map(|r| r.split('"').next()) {
let path = link.trim_start_matches("./");
assert!(names.iter().any(|n| *n == path || n.starts_with(&format!("{path}/"))), "{link}: {names:?}");
}
}
#[test]
@@ -219,9 +219,11 @@ fn a_picture_in_a_section_in_a_page_anchored_text_box_is_kept() {
.count();
assert_eq!(pictures, 1, "the logo in its box");
assert_eq!(boxes_of(&d), 2);
// And saved in it.
// And saved in it: as read, and from the model.
let c = crate::odt::tests::content_of(&export(&d).unwrap());
assert_eq!(c.matches("<draw:image ").count(), 1, "{c}");
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert_eq!(c.matches("<draw:image ").count(), 1, "{c}");
// Its frame's style derives from Graphics: LibreOffice shows a floating picture in a text box
// only so.
assert!(c.contains(r#"style:family="graphic" style:parent-style-name="Graphics""#), "{c}");
+4 -1
View File
@@ -599,8 +599,11 @@ impl Writer {
}
self.close_ins(out);
out.push_str(xml);
// With the pictures it shows.
// With the pictures it shows. Its bookmarks' names are taken: the model's don't repeat them.
super::kept::hrefs(xml, &mut self.kept_files_used);
let (starts, ends) = super::kept::bookmarks(xml);
self.bookmarks.extend(starts);
self.bookmark_ends.extend(ends);
true
}
+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 paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously 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 andLine truncated
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously 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 (OLine 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: more paragraphs written back as read while unchanged: those holding bookmarks (their names kept, so a bookmark may start in markup as read and end in a paragraph written from the model; each name written once), footnotes and endnotes (each note's text fingerprinted when read; while the note numbering is as read, as the citation is its number), formulas, charts and other embedded objects (with their files and preview pictures, only while the paragraph showing them is written as read), and those page-anchored frames LibreOffice puts in the text before a paragraph join (their markup kept before the paragraph's). Styles of different families with one name (LibreOffice's paragraph and graphic "Marginalia") are kept apart, as list styles were. The text keeps `text:use-soft-page-breaks` while markup kept as read holds soft page breaks. Still written from the model: form controls (named afresh in the forms), and a chart's paragraph (WordCraft draws the chart after reading it). |
| 2026-11-03 | trivial | ODT: a paragraph showing text boxes or shapes holding text is written back as read while their text is as read (each story fingerprinted when read, with the boxes and notes it holds; an edit to it sends the paragraph to the model). Linked boxes (`draw:chain-next-name`), a comment in a box's text, charts and formulas are still written from the model. |
| 2026-11-03 | trivial | ODT: a paragraph holding comments or tracked changes is written back as read while they are as read: comments whose range, replies and end are in it (a comment's text, author, state and replies fingerprinted when read: one edited, resolved, answered or deleted sends the paragraph to the model), and changes whose start and end are in it, with their regions as read (a deletion's content, the changes inside it first, and the change's own remark); a region without the date ODF requires is written from the model, dated. An older file's region gets the `xml:id` ODF 1.3 names it by. WordCraft's own regions take the writer's prefix (`wcct1`) beside the file's. Over the corpus the words a save loses fall from 29 to 4. |
| 2026-11-03 | trivial | ODT: a paragraph or table holding pictures and drawing shapes (no text in them) is written back as read while nothing in it changed, with the pictures it shows (only those: a picture an edited or deleted paragraph showed isn't written) and its frames' styles: wrapping, contour, picture adjustments and the rest of what WordCraft doesn't model are kept. Text boxes, shapes holding text, charts, formulas and form controls are still written from the model. The file's own `Graphics` and `Frame` styles are written as read. A flat document's pictures are put inline whatever form their element has (one holding a title lost what followed it). Over the corpus the attributes and values a save loses halve (1,551 → 789). |
+1 -1
View File
@@ -81,7 +81,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc
| 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) |
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |
| 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~90%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: turned characters (no Word equivalent), conditional text as fields (variables done, as SET/REF), control looks, drawing embedded spreadsheets' contents, dates in locales other than US English (`number:automatic-order`). Emphasis marks, fixed-width tables, tables starting a page, page background pictures, the default font size and styles turning things off are done. Checked against LibreOffice's 78 bundled templates as well as synthetic documents: all open and round-trip; placeholders, first-page styles, unsized pages, WMF/EMF logos and `.ott` templates done; what ODF leaves to the application follows a setting (WordCraft's by default, LibreOffice's or custom; [odf-interpretation.md](odf-interpretation.md)); WordCraft's own documents (templates, themes, every feature) saved as ODT open in LibreOffice with the same pages, valid against the ODF 1.3 schema, with table styles, SmartArt, watermarks and cell pictures kept; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259); an ODT saved without edits keeps what WordCraft doesn't model: unedited paragraphs, tables, named sections, indexes, forms, style definitions, page layouts and document properties are written back as read, checked by a no-edit loss test and a loss check over the corpus (left: sections bounded by a paragraph with a twin, or in a table cell; linked text boxes, charts and formulas, comments and changes spanning paragraphs are written from the model) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) |
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~90%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: turned characters (no Word equivalent), conditional text as fields (variables done, as SET/REF), control looks, drawing embedded spreadsheets' contents, dates in locales other than US English (`number:automatic-order`). Emphasis marks, fixed-width tables, tables starting a page, page background pictures, the default font size and styles turning things off are done. Checked against LibreOffice's 78 bundled templates as well as synthetic documents: all open and round-trip; placeholders, first-page styles, unsized pages, WMF/EMF logos and `.ott` templates done; what ODF leaves to the application follows a setting (WordCraft's by default, LibreOffice's or custom; [odf-interpretation.md](odf-interpretation.md)); WordCraft's own documents (templates, themes, every feature) saved as ODT open in LibreOffice with the same pages, valid against the ODF 1.3 schema, with table styles, SmartArt, watermarks and cell pictures kept; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259); an ODT saved without edits keeps what WordCraft doesn't model: unedited paragraphs, tables, named sections, indexes, forms, style definitions, page layouts and document properties are written back as read, checked by a no-edit loss test and a loss check over the corpus (left: sections bounded by a paragraph with a twin, or in a table cell; linked text boxes, form controls, comments and changes spanning paragraphs are written from the model) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) |
| 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |