From 7f6d9fbb978016920ca4aebf53aa5ed64460460e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 18:09:01 +0000 Subject: [PATCH] M-ODT gap 19: paragraphs and tables with pictures and shapes written as read A paragraph or table holding pictures and shapes without text is kept as read while unchanged, with the pictures it shows (only those linked from markup actually written, so a deleted picture stays deleted) and its frames' styles. Text boxes, shapes with text, objects and controls stay with the model. The file's Graphics and Frame styles are written as read; WordCraft's own pictures don't take the file's picture names. The flat writer inlines a picture whatever form its element has. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- ROADMAP.md | 1 + crates/formats/src/odt/body.rs | 11 +++-- crates/formats/src/odt/flat.rs | 34 +++++++++----- crates/formats/src/odt/import.rs | 27 ++++++++++- crates/formats/src/odt/kept.rs | 40 ++++++++++------ crates/formats/src/odt/package.rs | 17 +++++-- .../src/odt/tests_more/custom_geometry.rs | 6 +-- crates/formats/src/odt/tests_more/floats.rs | 14 +++--- crates/formats/src/odt/tests_more/lossless.rs | 47 ++++++++++++++++++- crates/formats/src/odt/tests_more/polygons.rs | 2 +- crates/formats/src/odt/writer.rs | 6 +++ crates/formats/src/odt/writer/drawing.rs | 8 +++- docs/file-format-parity.md | 3 +- docs/gaps.md | 2 +- 14 files changed, 166 insertions(+), 52 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 45c389577a..4c953480ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 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. - 2026-11-03: ODT sections inside text boxes are written as read (gap 19), as LibreOffice's letter and fax templates hold the sender's address; seven of those templates now round-trip pixel-identical; over the corpus 103 of 133 files do (95), the round-trip difference falls from 51k to 44k pixels, and the losses a save makes fall from 59 to 35. - 2026-11-03: ODT saves lose less (gap 19): list styles sharing a paragraph style's name are kept apart (§16.2), named sections around tables (nested too) are written as read, unchanged charts keep their replacement picture, note citations follow the document's numbering, and frames keep their stacking order. Over the 133-file corpus the losses a save makes, file by file, fall from 242 to 59 (19 kinds), with the pages unchanged. - 2026-11-03: ODT form definitions and indexes are written back as read (gap 19): a file's forms keep their settings, properties and event listeners, and an index nobody edited keeps its source settings and entry templates. Over the 133-file corpus the element kinds a save loses fall from 38 to 22 (losses per file 354 → 242), with the pages unchanged (95 pixel-identical, 130 with identical text). diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 3ebb5834b0..1447a493ea 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -1670,12 +1670,13 @@ struct KeptOpen { done: usize, } -/// Whether a paragraph holding element `q` can be written back as it was read: not when the -/// element refers to something outside the paragraph that the writer names afresh (drawings and -/// their pictures, comments, tracked changes, marks and bookmarks that span paragraphs, form -/// controls). +/// Whether markup holding element `q` can be kept as read. Pictures and shapes can (a shape's +/// text, or a text box's, is read as paragraphs, which keeps the paragraph holding it from being +/// kept); embedded objects (charts and formulas, written on their own), form controls, plugins +/// and text boxes (whose text is the document's own story) can't, nor comments, tracked changes +/// and marks that pair up with others elsewhere. fn keepable(q: &str) -> bool { - !(q.starts_with("draw:") + !(matches!(q, "draw:object" | "draw:object-ole" | "draw:control" | "draw:plugin" | "draw:applet" | "draw:floating-frame" | "draw:text-box") || q.starts_with("office:annotation") || q.starts_with("text:change") || q.starts_with("form:") diff --git a/crates/formats/src/odt/flat.rs b/crates/formats/src/odt/flat.rs index f6c69650a5..689074a2cd 100644 --- a/crates/formats/src/odt/flat.rs +++ b/crates/formats/src/odt/flat.rs @@ -253,24 +253,35 @@ pub fn export_flat(doc: &Document) -> Result, String> { let tag = before.rfind('<').and_then(|i| before.get(i..)).unwrap_or(""); // A picture, or an embedded object's replacement picture. let picture = target.starts_with("Pictures/") || target.trim_start_matches("./").starts_with("ObjectReplacements/"); + // The rest of the linking element's start tag, whether it closes itself, and what follows + // the tag (markup kept as read may have ``). + let gt = after.find('>').unwrap_or(after.len()); + let closed = after.get(..gt).is_some_and(|t| t.ends_with('/')); + let tag_rest = after.get(..gt).unwrap_or("").trim_end_matches('/'); + let past_tag = after.get(gt + 1..).unwrap_or(""); + // What follows the element: past its tag, or past its end tag. + let past = |el: &str| { + if closed { past_tag } else { past_tag.find(&format!("")).and_then(|e| past_tag.get(e + el.len() + 3..)).unwrap_or("") } + }; + // The link's other attributes go with it. + let attrs: String = tag_rest.split_whitespace().filter(|a| !a.starts_with("xlink:")).map(|a| format!(" {a}")).collect(); if tag.starts_with("` → `…`. + // `` → `…`, before + // anything else it holds. let data = bytes_of(target.trim_start_matches("./")); - let rest_tag_end = after.find("/>").unwrap_or(0); - // The link's other attributes go with it. - let attrs: String = - after.get(..rest_tag_end).unwrap_or("").split_whitespace().filter(|a| !a.starts_with("xlink:")).map(|a| format!(" {a}")).collect(); flat.push_str(before.trim_end()); flat.push_str(&attrs); - flat.push_str(&format!(">{}", STANDARD.encode(&data))); - rest = after.get(rest_tag_end + 2..).unwrap_or(""); + flat.push_str(&format!(">{}", STANDARD.encode(&data))); + if closed { + flat.push_str(""); + } + rest = past_tag; } else if tag.starts_with("").unwrap_or(0); flat.push_str(before.rfind("{}", STANDARD.encode(&data))); - rest = after.get(rest_tag_end + 2..).unwrap_or(""); + rest = past("draw:object-ole"); } else if tag.starts_with(" Result, String> { } else { content.split_once("?>").map_or(content.as_str(), |(_, m)| m).trim().to_string() }; - // A plain `draw:object` around it: the reference's other attributes go with it. - let rest_tag_end = after.find("/>").unwrap_or(0); + // A plain `draw:object` around it. flat.push_str(before.rfind("{math}")); - rest = after.get(rest_tag_end + 2..).unwrap_or(""); + rest = past("draw:object"); } else { flat.push_str(before); flat.push_str(from.get(..target_start + len + 1).unwrap_or("")); diff --git a/crates/formats/src/odt/import.rs b/crates/formats/src/odt/import.rs index bff43f4a57..e55c603f7e 100644 --- a/crates/formats/src/odt/import.rs +++ b/crates/formats/src/odt/import.rs @@ -426,6 +426,25 @@ fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> { parse_tracked(bytes, default_paper(), &OdfProfile::default()).map(|p| (p.flow, p.last)) } +/// The markup kept as read in `blocks` (paragraphs', tables', the paragraphs' in tables not +/// kept whole) that links to package files, added to `out`. +fn kept_markup(blocks: &[FBlock], depth: usize, out: &mut Vec<(String, std::sync::Arc)>) { + for b in blocks { + match b { + FBlock::Para(p) => out.extend(p.kept.iter().filter(|x| x.contains("xlink:href")).map(|x| (String::new(), x.clone()))), + FBlock::Table(t) => match &t.kept { + Some(x) => out.extend(x.contains("xlink:href").then(|| (String::new(), x.clone()))), + None if depth < crate::model::MAX_DEPTH => { + for c in t.rows.iter().flatten() { + kept_markup(&c.blocks, depth + 1, out); + } + } + None => {} + }, + } + } +} + /// What [`parse_tracked`] reads besides the flow. struct Parsed { flow: Flow, @@ -627,7 +646,13 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Resul let track = body.track; let sources = std::mem::take(&mut body.sources); let section_masters = std::mem::take(&mut body.section_masters); - let kept_files = super::kept::files(&kept_defs, &entries, &mut fetch); + // And the pictures paragraphs and tables kept as read show. + let mut linking: Vec<(String, std::sync::Arc)> = kept_defs.clone(); + kept_markup(&blocks, 0, &mut linking); + for b in parts.blocks_mut() { + kept_markup(b, 0, &mut linking); + } + let kept_files = super::kept::files(&linking, &entries, &mut fetch); // Their manifest entries, and their folders' (an object's says what it is). for (path, _) in &kept_files { let dir = path.rsplit_once('/').map(|(d, _)| format!("{d}/")); diff --git a/crates/formats/src/odt/kept.rs b/crates/formats/src/odt/kept.rs index 6ad61f7135..6e7602d658 100644 --- a/crates/formats/src/odt/kept.rs +++ b/crates/formats/src/odt/kept.rs @@ -234,12 +234,13 @@ fn with_as_read( format!("{head}{body}{tail}") } -/// Whether a definition (its markup) is a drawing's: a graphic style or default style, or a -/// dash, gradient, marker or fill picture. Drawings are always written from the model, so -/// their definitions are WordCraft's where both have one by that name. +/// Whether a definition (its markup) is a dash, gradient, marker or fill picture: WordCraft's +/// where both have one by that name, as the drawings it writes from the model name theirs. A +/// graphic style (the `Graphics` and `Frame` styles drawings derive from) is the file's: its +/// drawings written as read derive from it, and those written from the model derived from it +/// when read (what WordCraft models of it is in theirs). fn drawing_def(xml: &str) -> bool { - let head = xml.split('>').next().unwrap_or(""); - head.starts_with("').next().unwrap_or("").starts_with(")], entries: &[String], fetch: &mut dyn FnMut(&str) -> Option>) -> Vec<(String, Vec)> { let mut paths = BTreeSet::new(); - for (_, xml) in defs.iter().filter(|(_, x)| x.contains("xlink:href")) { - super::xml::events(xml.as_bytes(), |ev| { - if let Ev::Start(_, a) = ev - && let Some(h) = get(&a, "xlink:href") - && inside_package(h) - && paths.len() < MAX_FILES - { - paths.insert(h.trim_start_matches("./").to_string()); - } - }); + for (_, xml) in defs { + hrefs(xml, &mut paths); } let mut out = Vec::new(); for p in paths { @@ -317,6 +310,23 @@ pub(super) fn files(defs: &[(String, Arc)], entries: &[String], fetch: &mut out } +/// The package files `xml` links to (`xlink:href` to a path inside the package), added to +/// `paths` (at most [`MAX_FILES`]). +pub(super) fn hrefs(xml: &str, paths: &mut BTreeSet) { + if !xml.contains("xlink:href") { + return; + } + super::xml::events(xml.as_bytes(), |ev| { + if let Ev::Start(_, a) = ev + && let Some(h) = get(&a, "xlink:href") + && inside_package(h) + && paths.len() < MAX_FILES + { + paths.insert(h.trim_start_matches("./").to_string()); + } + }); +} + /// Whether link `h` names a file inside the package (a relative path, not a URL or a fragment). fn inside_package(h: &str) -> bool { !h.is_empty() && !h.contains(':') && !h.starts_with(['#', '/']) && !h.contains("..") diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index a7dd714e38..5c56124815 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -770,15 +770,23 @@ fn styles_as_read(doc: &Document, kept: Option<&wordcraft_doc::kept::KeptXml>, s super::kept::with_common_as_read(&styles, &k.defs, &k.order) } -/// The package files the file's own definitions refer to, written as read with them (added to -/// `files` and `manifest` where no file `taken` has the path). +/// The package files the file's own definitions refer to, and those the markup written as read +/// links to (`used`), written as read with them (added to `files` and `manifest` where no file +/// `taken` has the path). A picture only an edited paragraph showed isn't written. fn with_kept_files( kept: Option<&wordcraft_doc::kept::KeptXml>, + used: &std::collections::BTreeSet, taken: &dyn Fn(&str) -> bool, files: &mut Vec<(String, std::sync::Arc>)>, manifest: &mut String, ) { let Some(k) = kept else { return }; + let mut wanted = used.clone(); + for d in k.defs.values() { + super::kept::hrefs(d, &mut wanted); + } + // A file linked, or one in a folder linked (an object's). + let linked = |p: &str| wanted.contains(p) || wanted.iter().any(|w| p.strip_prefix(w.trim_end_matches('/')).is_some_and(|r| r.starts_with('/'))); let entry = |manifest: &mut String, path: &str, mime: &str| { let e = format!("manifest:full-path=\"{}\"", x(path)); if !manifest.contains(&e) { @@ -786,7 +794,7 @@ fn with_kept_files( } }; for (path, bytes) in &k.files { - if !taken(path) && !files.iter().any(|(p, _)| p == path) { + if linked(path) && !taken(path) && !files.iter().any(|(p, _)| p == path) { // As the file's manifest had it (the folder too), else by its extension. if let Some((dir, _)) = path.rsplit_once('/') && let Some(mime) = k.defs.get(&format!("manifest:{dir}/")) @@ -886,6 +894,7 @@ fn export_as(doc: &Document, mime: &str) -> Result, String> { (o.num_fmt, o.num_start) })); w.kept_defs = std::sync::Arc::new(kept.map(|k| k.defs.clone()).unwrap_or_default()); + w.reserved = std::sync::Arc::new(kept.map(|k| k.files.keys().cloned().collect()).unwrap_or_default()); // The file's master pages are written as read: what was kept may start them. w.kept_pages = kept.is_some_and(|k| k.pages_as_read(doc)); w.accents.clone_from(&accents); @@ -1071,7 +1080,7 @@ fn export_as(doc: &Document, mime: &str) -> Result, String> { w.objects.iter().map(|(d, _, _)| d).chain(w.object_dirs.iter().map(|(d, _)| d)).map(|d| format!("{}/", d.trim_end_matches('/'))).collect(); let taken = |p: &str| w.pictures.iter().any(|(n, _, _)| n == p) || dirs.iter().any(|d| p.starts_with(d.as_str())); let mut kept_files: Vec<_> = kept_files.into_iter().filter(|(p, _)| !taken(p)).collect(); - with_kept_files(kept, &taken, &mut kept_files, &mut manifest); + with_kept_files(kept, &w.kept_files_used, &taken, &mut kept_files, &mut manifest); for (path, mime) in &kept_entries { if (path.ends_with('/') && kept_files.iter().any(|(p, _)| p.starts_with(path.as_str()))) || kept_files.iter().any(|(p, _)| p == path) { manifest.push_str(&format!("\n", x(path), x(mime))); diff --git a/crates/formats/src/odt/tests_more/custom_geometry.rs b/crates/formats/src/odt/tests_more/custom_geometry.rs index afbff50aaf..c28c886dd6 100644 --- a/crates/formats/src/odt/tests_more/custom_geometry.rs +++ b/crates/formats/src/odt/tests_more/custom_geometry.rs @@ -214,14 +214,14 @@ fn a_custom_shapes_geometry_is_written_back_as_it_was_read() { let cloud = r#""#; let d = open(&format!("{hexagon}{cloud}")); assert_eq!(drawn(&d).iter().map(|s| s.0).collect::>(), [ShapeKind::Freeform, ShapeKind::Rectangle]); - let out = crate::odt::tests::content_of(&export(&d).unwrap()); + let out = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap()); assert!(out.contains(r#"draw:type="hexagon" draw:modifiers="5400""#) && out.contains(r#"draw:formula="right-$0""#), "{out}"); assert!(out.contains(r#""#), "{out}"); // Its mirroring is the shape's own, written once. assert_eq!(out.matches("draw:mirror-horizontal=\"true\"").count(), 1, "{out}"); assert!(out.contains(r#""#), "{out}"); // Read back, it is the same shape again. - let back = import(&export(&d).unwrap()).unwrap(); + let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(); assert_eq!(drawn(&back), drawn(&d)); // Made an ellipse: written as one. let mut changed = d.clone(); @@ -235,6 +235,6 @@ fn a_custom_shapes_geometry_is_written_back_as_it_was_read() { } } } - let out = crate::odt::tests::content_of(&export(&changed).unwrap()); + let out = crate::odt::tests::content_of(&crate::odt::tests::export_model(&changed).unwrap()); assert!(!out.contains("hexagon") && !out.contains("cloud"), "{out}"); } diff --git a/crates/formats/src/odt/tests_more/floats.rs b/crates/formats/src/odt/tests_more/floats.rs index efd451b601..4332c27ed3 100644 --- a/crates/formats/src/odt/tests_more/floats.rs +++ b/crates/formats/src/odt/tests_more/floats.rs @@ -96,7 +96,7 @@ fn an_inline_picture_is_saved_standing_on_the_baseline() { // LibreOffice puts an in-line frame without placement at the line's top (measured): a // picture standing on the baseline, as in Word, says so. let d = float_doc_inline(); - let out = crate::odt::tests::content_of(&export(&d).unwrap()); + let out = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap()); assert!(out.contains(r#"style:vertical-pos="top" style:vertical-rel="baseline""#), "{out}"); } @@ -134,7 +134,7 @@ fn a_picture_alone_in_its_paragraph_keeps_its_raise_and_the_paragraphs_size() { let doc = crate::odt::tests::import_as(&pkg, &wordcraft_doc::odf_profile::OdfProfile::libreoffice()); assert_eq!(look(&doc), (Some(10.8), Some(9.0), Some(9.0))); // Saved, it says so in terms every reader takes the same way. - let again = crate::odt::import(&crate::odt::export(&doc).unwrap()).unwrap(); + let again = crate::odt::import(&crate::odt::tests::export_model(&doc).unwrap()).unwrap(); assert_eq!(look(&again), (Some(10.8), Some(9.0), Some(9.0))); let doc = crate::odt::import(&pkg).unwrap(); assert_eq!(look(&doc), (None, Some(9.0), Some(9.0))); @@ -258,13 +258,13 @@ fn an_inline_frame_keeps_its_place_on_the_baseline() { let pos = p.props_at(off + wordcraft_doc::para::OBJ.len_utf8()).position.unwrap_or(0.0); assert_eq!((pos * 10.0).round() / 10.0, want, "{vpos}{y}"); // Saved: a lowered one says how far its top is from the baseline. - let saved = crate::odt::tests::content_of(&crate::odt::export(&doc).unwrap()); + let saved = crate::odt::tests::content_of(&crate::odt::tests::export_model(&doc).unwrap()); if want != 0.0 { assert!(saved.contains(r#"style:vertical-pos="from-top" style:vertical-rel="baseline""#), "{saved}"); let top = -(72.0 + want) + 0.0; assert!(saved.contains(&format!(r#"svg:y="{}pt""#, top)), "{vpos}{y}: {saved}"); } - let again = crate::odt::import(&crate::odt::export(&doc).unwrap()).unwrap(); + let again = crate::odt::import(&crate::odt::tests::export_model(&doc).unwrap()).unwrap(); let p2 = again.body.iter().find_map(|b| b.as_para()).unwrap(); let (off2, _) = p2.objects_with_offsets().next().unwrap(); assert_eq!( @@ -296,7 +296,7 @@ fn a_cropped_picture_keeps_its_crop() { .and_then(|p| p.objects.iter().find_map(|o| if let wordcraft_doc::para::InlineObject::Image { crop, .. } = o { Some(*crop) } else { None })) .unwrap(); assert_eq!(crop.map(|c| (c * 100.0).round() / 100.0), [0.25, 0.0, 0.0, 0.25]); - let c = crate::odt::tests::content_of(&crate::odt::export(&doc).unwrap()); + let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&doc).unwrap()); // 0.2602 in is 18.73 pt, 0.5209 in 37.5 pt. assert!(c.contains(r#"fo:clip="rect(0pt, 0pt, 18.73pt, 37.5pt)""#), "{c}"); // An absurd crop (nothing left) is none. @@ -325,7 +325,7 @@ fn contour_wrapping_and_its_outline_are_kept() { let f = float(&doc); assert_eq!(f.wrap, want, "{mode}"); assert_eq!(f.wrap_polygon.as_deref().map(|p| p.points.clone()), Some(vec![[10800, 0], [21600, 21600], [0, 21600]]), "{mode}"); - let saved = crate::odt::export(&doc).unwrap(); + let saved = crate::odt::tests::export_model(&doc).unwrap(); let c = crate::odt::tests::content_of(&saved); assert!(c.contains(&format!(r#"style:wrap-contour="true" style:wrap-contour-mode="{mode}""#)), "{c}"); assert!(c.contains(r#"draw:points="10800,0 21600,21600 0,21600""#), "{c}"); @@ -380,5 +380,5 @@ fn a_frame_keeps_its_place_in_the_stacking_order() { // Unknown (inserted in WordCraft): at the bottom, in document order. let d = open(r#"style:wrap="run-through""#, r#"text:anchor-type="paragraph""#); assert_eq!(float(&d).z, 0); - assert!(crate::odt::tests::entry(&export(&d).unwrap(), "content.xml").contains(r#"draw:z-index="0""#)); + assert!(crate::odt::tests::entry(&crate::odt::tests::export_model(&d).unwrap(), "content.xml").contains(r#"draw:z-index="0""#)); } diff --git a/crates/formats/src/odt/tests_more/lossless.rs b/crates/formats/src/odt/tests_more/lossless.rs index df8f9048e1..c7a2b2ad33 100644 --- a/crates/formats/src/odt/tests_more/lossless.rs +++ b/crates/formats/src/odt/tests_more/lossless.rs @@ -37,9 +37,19 @@ fn package(s: &Sample) -> Vec { 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(); z.finish().unwrap().into_inner() } +/// 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, + 0x89, 0, 0, 0, 0x0d, b'I', b'D', b'A', b'T', 0x78, 0xda, 0x63, 0xfc, 0xcf, 0xc0, 0x50, 0x0f, 0, 0x04, 0x85, 0x01, 0x80, 0x84, 0xa9, 0x8c, 0x21, + 0, 0, 0, 0, b'I', b'E', b'N', b'D', 0xae, 0x42, 0x60, 0x82, +]; + /// A length's value in points to a tenth, else the value as written. fn norm(v: &str) -> String { let v = v.trim(); @@ -219,8 +229,17 @@ const INDEXES: Sample = Sample { meta: "", }; +/// Pictures and shapes in the text: a cropped picture with its look (adjustments WordCraft +/// doesn't model), a wrapped one with a contour, a shape, a group, and a picture in a table. +const DRAWINGS: Sample = Sample { + name: "drawings", + content: r##"A picture A dotOne pixel of our own.beside the text.A shape and a group after. in a cell"##, + styles: r##""##, + meta: "", +}; + /// Every sample. -const SAMPLES: [Sample; 9] = [TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS, FORMS, INDEXES]; +const SAMPLES: [Sample; 10] = [TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS, FORMS, INDEXES, DRAWINGS]; /// Whether `xml` is well-formed: every element closed, in order, once. fn well_formed(xml: &[u8]) -> bool { @@ -336,3 +355,29 @@ fn a_section_in_a_text_box_closes_in_it_whatever_is_edited() { } } } + +#[test] +fn a_picture_kept_as_read_is_written_with_its_paragraph_only() { + // The picture a paragraph written as read shows is in the package; once that paragraph + // and the table showing it are gone, it isn't (a deleted picture stays deleted). + let d = import(&package(&DRAWINGS)).unwrap(); + let files = |bytes: &[u8]| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap(); + let names: Vec = (0..z.len()).filter_map(|i| z.by_index(i).ok().map(|f| f.name().to_string())).collect(); + let dot = z.by_name("Pictures/dot.png").ok().map(|mut f| { + let mut v = Vec::new(); + std::io::Read::read_to_end(&mut f, &mut v).unwrap(); + v + }); + (names, dot) + }; + let saved = export(&d).unwrap(); + assert_eq!(files(&saved).1.as_deref(), Some(DOT)); + assert!(crate::odt::tests::entry(&saved, "META-INF/manifest.xml").contains(r#"manifest:full-path="Pictures/dot.png""#)); + let mut e = d.clone(); + e.body.retain(|b| b.as_para().is_none_or(|p| !p.text.starts_with("A picture")) && b.as_table().is_none()); + let saved = export(&e).unwrap(); + let (names, dot) = files(&saved); + assert!(dot.is_none(), "{names:?}"); + assert!(!content_of(&saved).contains("dot.png")); +} diff --git a/crates/formats/src/odt/tests_more/polygons.rs b/crates/formats/src/odt/tests_more/polygons.rs index a40d6efe62..d761cef78d 100644 --- a/crates/formats/src/odt/tests_more/polygons.rs +++ b/crates/formats/src/odt/tests_more/polygons.rs @@ -108,7 +108,7 @@ fn freeforms_round_trip_as_paths_or_custom_shapes() { let d = open( r#""#, ); - let bytes = export(&d).unwrap(); + let bytes = crate::odt::tests::export_model(&d).unwrap(); // The second, with a closed and an open path, as a custom shape (see `custom_geometry`). let c = content_of(&bytes); assert_eq!((c.matches(">>, + /// The package files the markup written as read links to (its pictures): written as read. + pub(super) kept_files_used: std::collections::BTreeSet, + /// The file's own package paths, kept as read: names WordCraft's own pictures don't take. + pub(super) reserved: std::sync::Arc>, pub(super) kept_used: super::kept::Needed, /// The file's own master pages are written (its page setup is as read): kept markup may /// start one. @@ -592,6 +596,8 @@ impl Writer { } self.close_ins(out); out.push_str(xml); + // With the pictures it shows. + super::kept::hrefs(xml, &mut self.kept_files_used); true } diff --git a/crates/formats/src/odt/writer/drawing.rs b/crates/formats/src/odt/writer/drawing.rs index 5b498843da..a5560d306b 100644 --- a/crates/formats/src/odt/writer/drawing.rs +++ b/crates/formats/src/odt/writer/drawing.rs @@ -189,7 +189,13 @@ pub(in crate::odt) const DEFAULT_BOX_INSET: [f32; 4] = [7.2, 3.6, 7.2, 3.6]; impl Writer { pub(in crate::odt) fn picture(&mut self, img: &model::Img) -> String { let ext = if img.ext == "jpeg" { "jpg".to_string() } else { img.ext.clone() }; - let name = format!("Pictures/{}image{}.{}", self.prefix, self.pictures.len() + 1, ext); + // Not a name the file's own pictures, kept as read, have. + let mut n = self.pictures.len() + 1; + let mut name = format!("Pictures/{}image{n}.{ext}", self.prefix); + while self.reserved.contains(&name) && n < usize::MAX { + n += 1; + name = format!("Pictures/{}image{n}.{ext}", self.prefix); + } self.pictures.push((name.clone(), img.data.clone(), mime_of(&img.ext).to_string())); name } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9f7b30a147..e8a0d235aa 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** 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 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 spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** 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 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 spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -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: 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). | | 2026-11-03 | trivial | ODT: a named section inside a text box (LibreOffice's letter, fax and business-card templates put the sender's address in one) is written as read around the box's paragraphs, and always closes inside the box, whatever in it is edited. Still written from the model: a section starting or ending at a paragraph with a twin elsewhere (an empty one), and one in a table cell. | | 2026-11-03 | trivial | ODT, found by the loss check: a list style with a paragraph style's name (LibreOffice's "Numbering 1" and "List 1" are both) overwrote it among the definitions kept as read, so a file's unused list styles were dropped — names are unique only within a kind of definition (§16.2), and each kind is now kept apart. A named section that starts or ends with a table (LibreOffice's report and agenda templates), nested ones included, is written as read around it, as around paragraphs; still written from the model: one starting or ending at a paragraph with a twin elsewhere (an empty one). An unchanged chart keeps its replacement picture (`ObjectReplacements/`, what readers that can't draw charts show); an edited one has none. A note's citation is the number the document gives it (its format and first number, §6.3.3), not LibreOffice's default 1, 2, 3. Frames, pictures, charts and shapes keep their place in the stacking order (§19.231 `draw:z-index`), read and written; it was always 0. The no-edit test now counts elements, so a section of three lost is caught. | | 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. | diff --git a/docs/gaps.md b/docs/gaps.md index 06666c633e..8bf638b607 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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; paragraphs with drawings, comments or tracked changes 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; paragraphs with text boxes, objects, comments or tracked changes 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) |