mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 18:51:26 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
14 files changed
+166
-52
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -253,24 +253,35 @@ pub fn export_flat(doc: &Document) -> Result<Vec<u8>, 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 `<draw:image …></draw:image>`).
|
||||
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!("</{el}>")).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("<draw:image") && picture {
|
||||
// `<draw:image xlink:href="…" …/>` → `<draw:image …><office:binary-data>…`.
|
||||
// `<draw:image xlink:href="…" …/>` → `<draw:image …><office:binary-data>…`, 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!("><office:binary-data>{}</office:binary-data></draw:image>", STANDARD.encode(&data)));
|
||||
rest = after.get(rest_tag_end + 2..).unwrap_or("");
|
||||
flat.push_str(&format!("><office:binary-data>{}</office:binary-data>", STANDARD.encode(&data)));
|
||||
if closed {
|
||||
flat.push_str("</draw:image>");
|
||||
}
|
||||
rest = past_tag;
|
||||
} else if tag.starts_with("<draw:object-ole") && target.starts_with("./Object ") {
|
||||
// An OLE object's file inline (§10.4.7 allows `office:binary-data` in it).
|
||||
let data = bytes_of(target.trim_start_matches("./"));
|
||||
let rest_tag_end = after.find("/>").unwrap_or(0);
|
||||
flat.push_str(before.rfind("<draw:object-ole").and_then(|i| before.get(..i)).unwrap_or(before));
|
||||
flat.push_str(&format!("<draw:object-ole><office:binary-data>{}</office:binary-data></draw:object-ole>", STANDARD.encode(&data)));
|
||||
rest = after.get(rest_tag_end + 2..).unwrap_or("");
|
||||
rest = past("draw:object-ole");
|
||||
} else if tag.starts_with("<draw:object") && target.starts_with("./Object ") {
|
||||
let dir = target.trim_start_matches("./");
|
||||
let mut text = |name: &str| String::from_utf8_lossy(&bytes_of(&format!("{dir}/{name}"))).into_owned();
|
||||
@@ -284,11 +295,10 @@ pub fn export_flat(doc: &Document) -> Result<Vec<u8>, 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("<draw:object").and_then(|i| before.get(..i)).unwrap_or(before));
|
||||
flat.push_str(&format!("<draw:object>{math}</draw:object>"));
|
||||
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(""));
|
||||
|
||||
@@ -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<str>)>) {
|
||||
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<str>)> = 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}/"));
|
||||
|
||||
@@ -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("<draw:") || head.contains(r#"style:family="graphic""#)
|
||||
xml.split('>').next().unwrap_or("").starts_with("<draw:")
|
||||
}
|
||||
|
||||
/// `styles` (a `styles.xml` WordCraft wrote) with its `office:styles` as read: every kept
|
||||
@@ -291,16 +292,8 @@ const MAX_FILES: usize = 1000;
|
||||
/// `fetch` (`entries`: the package's paths): a definition written as read needs them.
|
||||
pub(super) fn files(defs: &[(String, Arc<str>)], entries: &[String], fetch: &mut dyn FnMut(&str) -> Option<Vec<u8>>) -> Vec<(String, Vec<u8>)> {
|
||||
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<str>)], 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<String>) {
|
||||
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("..")
|
||||
|
||||
@@ -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<String>,
|
||||
taken: &dyn Fn(&str) -> bool,
|
||||
files: &mut Vec<(String, std::sync::Arc<Vec<u8>>)>,
|
||||
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<Vec<u8>, 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<Vec<u8>, 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!("<manifest:file-entry manifest:full-path=\"{}\" manifest:media-type=\"{}\"/>\n", x(path), x(mime)));
|
||||
|
||||
@@ -214,14 +214,14 @@ fn a_custom_shapes_geometry_is_written_back_as_it_was_read() {
|
||||
let cloud = r#"<draw:custom-shape text:anchor-type="paragraph" svg:width="1in" svg:height="1in"><draw:enhanced-geometry draw:type="cloud"/></draw:custom-shape>"#;
|
||||
let d = open(&format!("{hexagon}{cloud}"));
|
||||
assert_eq!(drawn(&d).iter().map(|s| s.0).collect::<Vec<_>>(), [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#"<draw:handle draw:handle-position="$0 top"/>"#), "{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#"<draw:enhanced-geometry draw:type="cloud"></draw:enhanced-geometry>"#), "{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}");
|
||||
}
|
||||
@@ -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""#));
|
||||
}
|
||||
@@ -37,9 +37,19 @@ 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();
|
||||
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##"<office:automatic-styles><style:style style:name="fr1" style:family="graphic" style:parent-style-name="Graphics"><style:graphic-properties style:wrap="parallel" style:number-wrapped-paragraphs="no-limit" style:wrap-contour="true" style:wrap-contour-mode="outside" style:vertical-pos="top" style:vertical-rel="paragraph" style:horizontal-pos="center" style:horizontal-rel="paragraph" style:mirror="none" fo:clip="rect(0in, 0.1in, 0in, 0.1in)" draw:luminance="10%" draw:contrast="5%" draw:red="0%" draw:green="0%" draw:blue="0%" draw:gamma="100%" draw:color-inversion="false" draw:image-opacity="100%" draw:color-mode="standard" wcx:look="kept"/></style:style><style:style style:name="gr1" style:family="graphic"><style:graphic-properties draw:fill-color="#729fcf" style:run-through="background" style:wrap="run-through" draw:wrap-influence-on-position="once-concurrent"/></style:style></office:automatic-styles><office:body><office:text><text:p>A picture <draw:frame draw:style-name="fr1" draw:name="Dot" text:anchor-type="paragraph" svg:width="1in" svg:height="1in" draw:z-index="1"><draw:image xlink:href="Pictures/dot.png" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad" draw:mime-type="image/png"/><svg:title>A dot</svg:title><svg:desc>One pixel of our own.</svg:desc><draw:contour-polygon svg:width="1in" svg:height="1in" svg:viewBox="0 0 1000 1000" draw:points="0,0 1000,0 1000,1000 0,1000" draw:recreate-on-edit="true"/></draw:frame>beside the text.</text:p><text:p>A shape <draw:custom-shape draw:style-name="gr1" draw:name="Badge" text:anchor-type="char" svg:x="0.5in" svg:y="0in" svg:width="1in" svg:height="0.5in" draw:z-index="2"><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="hexagon" draw:modifiers="5400" draw:enhanced-path="M ?f0 0 L ?f1 0 21600 10800 ?f1 21600 ?f0 21600 0 10800 Z N"><draw:equation draw:name="f0" draw:formula="$0 "/><draw:equation draw:name="f1" draw:formula="right-$0"/><draw:handle draw:handle-position="$0 top" draw:handle-range-x-minimum="0" draw:handle-range-x-maximum="10800"/></draw:enhanced-geometry></draw:custom-shape>and a group <draw:g draw:style-name="gr1" text:anchor-type="paragraph" draw:z-index="3"><draw:rect draw:style-name="gr1" svg:x="0in" svg:y="0in" svg:width="0.5in" svg:height="0.5in"/><draw:ellipse draw:style-name="gr1" svg:x="0.5in" svg:y="0in" svg:width="0.5in" svg:height="0.5in"/></draw:g>after.</text:p><table:table table:name="Pictured"><table:table-column/><table:table-row><table:table-cell office:value-type="string"><text:p><draw:frame draw:name="Cell dot" text:anchor-type="as-char" svg:width="0.2in" svg:height="0.2in" draw:z-index="4"><draw:image xlink:href="Pictures/dot.png" xlink:type="simple"/></draw:frame> in a cell</text:p></table:table-cell></table:table-row></table:table></office:text></office:body>"##,
|
||||
styles: r##"<office:styles><style:style style:name="Graphics" style:family="graphic"><style:graphic-properties text:anchor-type="paragraph" svg:x="0in" svg:y="0in" style:wrap="dynamic" style:vertical-pos="top" style:vertical-rel="paragraph" style:horizontal-pos="center" style:horizontal-rel="paragraph"/></style:style></office:styles>"##,
|
||||
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<String> = (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"));
|
||||
}
|
||||
@@ -108,7 +108,7 @@ fn freeforms_round_trip_as_paths_or_custom_shapes() {
|
||||
let d = open(
|
||||
r#"<draw:polygon text:anchor-type="paragraph" svg:width="2in" svg:height="1in" svg:viewBox="0 0 2000 1000" draw:points="0,0 2000,0 1000,1000"/><draw:path svg:width="1in" svg:height="1in" svg:viewBox="0 0 100 100" svg:d="M0 0 H50 v50 l-50 0 Z m60 0 L100 100"/>"#,
|
||||
);
|
||||
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("<draw:path ").count(), c.matches("<draw:custom-shape ").count()), (1, 1));
|
||||
|
||||
@@ -175,6 +175,10 @@ pub(super) struct Writer {
|
||||
/// The definitions kept paragraphs may refer to (see `super::kept`), and those the kept
|
||||
/// paragraphs written so far need: automatic, common, font faces.
|
||||
pub(super) kept_defs: std::sync::Arc<std::collections::BTreeMap<String, std::sync::Arc<str>>>,
|
||||
/// The package files the markup written as read links to (its pictures): written as read.
|
||||
pub(super) kept_files_used: std::collections::BTreeSet<String>,
|
||||
/// The file's own package paths, kept as read: names WordCraft's own pictures don't take.
|
||||
pub(super) reserved: std::sync::Arc<std::collections::BTreeSet<String>>,
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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-documentsLine truncated
|
||||
> **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 sLine 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: 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. |
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user