diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 56d0c6d198..64350459d1 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,6 +1,7 @@ mod comment_ids; mod date_fields; mod macro_packages; +mod odt_fields; #[cfg(not(target_arch = "wasm32"))] mod odt_sections; mod odt_toc; diff --git a/crates/engine/tests/it/odt_fields.rs b/crates/engine/tests/it/odt_fields.rs new file mode 100644 index 0000000000..d83ca921dc --- /dev/null +++ b/crates/engine/tests/it/odt_fields.rs @@ -0,0 +1,57 @@ +//! Fields in an ODT file update in WordCraft as they do in Word: a reference to a bookmark shows +//! the bookmarked text (a bookmark over text, `text:bookmark-start`…`-end`, not a point). The +//! ODT is original and synthetic, shaped like LibreOffice's. + +use std::io::Write; + +use serde_json::json; +use wordcraft_engine::{Session, io}; + +const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#; + +fn odt(body: &str) -> Vec { + let content = format!(r#"{body}"#); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] { + z.start_file(name, o).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +fn text(s: &Session) -> Vec { + s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.replace(wordcraft_doc::para::OBJ, "")).collect() +} + +/// The displayed results of the fields in the body. +fn results(s: &Session) -> Vec { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .filter_map(|o| match o { + wordcraft_doc::para::InlineObject::Field { result, .. } => Some(result.clone()), + _ => None, + }) + .collect() +} + +#[test] +fn a_reference_to_a_bookmark_updates_to_its_text() { + let doc = io::open_bytes( + "refs.odt", + &odt(r#"Planning aheadSee Planning."#), + ) + .unwrap(); + let mut s = Session::new(doc); + assert_eq!(results(&s), ["Planning"]); + s.run("references.updateFields", &json!({"all": true})).unwrap(); + assert_eq!(results(&s), ["Planning ahead"], "{:?}", text(&s)); + // And after a save in ODT and opening again. + let again = io::open_bytes("refs.odt", &io::save_bytes("refs.odt", &s.doc).unwrap()).unwrap(); + let mut s2 = Session::new(again); + s2.run("references.updateFields", &json!({"all": true})).unwrap(); + assert_eq!(results(&s2), ["Planning ahead"]); +} diff --git a/crates/formats/src/html/export.rs b/crates/formats/src/html/export.rs index 76ba3561a3..7ddf181101 100644 --- a/crates/formats/src/html/export.rs +++ b/crates/formats/src/html/export.rs @@ -143,7 +143,8 @@ pub fn inlines_html(inlines: &[Inline]) -> String { | Inline::Shape { .. } | Inline::Group { .. } | Inline::FieldStart(_) - | Inline::FieldEnd => {} + | Inline::FieldEnd + | Inline::AnchorEnd(_) => {} Inline::Field { result, fmt, .. } => { let (open, close) = fmt_tags(fmt); let at_start = o.is_empty(); diff --git a/crates/formats/src/latex/export.rs b/crates/formats/src/latex/export.rs index 676490625b..6c9e979f5b 100644 --- a/crates/formats/src/latex/export.rs +++ b/crates/formats/src/latex/export.rs @@ -247,7 +247,8 @@ impl Writer { | Inline::Shape { .. } | Inline::Group { .. } | Inline::FieldStart(_) - | Inline::FieldEnd => {} + | Inline::FieldEnd + | Inline::AnchorEnd(_) => {} Inline::Field { result, fmt, .. } => { let s = self.span(result, fmt); push_apart(&mut o, &s); diff --git a/crates/formats/src/markdown/export.rs b/crates/formats/src/markdown/export.rs index 5e7ce35b0d..8e8b811baa 100644 --- a/crates/formats/src/markdown/export.rs +++ b/crates/formats/src/markdown/export.rs @@ -141,7 +141,8 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { | Inline::Shape { .. } | Inline::Group { .. } | Inline::FieldStart(_) - | Inline::FieldEnd => {} + | Inline::FieldEnd + | Inline::AnchorEnd(_) => {} // Read as its base text by `ruby_as_text` above. Inline::Ruby { .. } => {} } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 0b5621156a..224aba2ddc 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -28,7 +28,7 @@ pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in}; pub use types::{ Affix, BoxLook, CODE_STYLE, Cell, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, Label, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, Para, RUBY_BASE_SIZE, RevMark, Spacing, clean_text, equations_as_text, - flatten_tables, kept_font, kept_link, ruby_as_text, ruby_object, + flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object, }; pub(crate) use types::{Dropped, begin_import, dropped}; diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index 75c8e3c7d1..7e0cd4f6f5 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -272,6 +272,8 @@ fn rich_inlines(doc: &Document, o: &InlineObject, f: &Fmt, opts: FlowOpts) -> Op InlineObject::Shape { .. } | InlineObject::Group { .. } if opts.boxes => vec![drawing_inline(doc, o, opts, 0, (1.0, 1.0))?], InlineObject::FieldStart { instr, .. } if opts.fields => vec![Inline::FieldStart(instr.clone())], InlineObject::FieldEnd if opts.fields => vec![Inline::FieldEnd], + // Where a bookmark ends, so it covers its text (Word's `_GoBack` is the last edit's spot). + InlineObject::BookmarkEnd { name } if opts.fields && name != "_GoBack" => vec![Inline::AnchorEnd(name.clone())], InlineObject::Field { instr, result, .. } if opts.fields => { vec![Inline::Field { instr: instr.clone(), result: result.clone(), fmt: f.clone() }] } diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index 4f59a1e991..b1caf3c896 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -184,6 +184,11 @@ struct Builder<'a> { comments: std::collections::HashMap, /// The document's list instance for each flow list number ([`ListInfo::list`]). lists: std::collections::HashMap, + /// Bookmarks that cover text ([`super::types::ranged_anchors`]), those started, and those + /// ended: each started once and ended once, after its start. + ranged: std::collections::HashSet, + started: std::collections::HashSet, + ended: std::collections::HashSet, } fn rev_key(kind: RevisionKind) -> u8 { @@ -195,7 +200,8 @@ fn rev_key(kind: RevisionKind) -> u8 { } impl<'a> Builder<'a> { - fn new(doc: &'a mut Document) -> Builder<'a> { + /// A builder for `blocks` into `doc`. + fn new(doc: &'a mut Document, blocks: &[FBlock]) -> Builder<'a> { let mut revs = std::collections::HashMap::new(); for (i, r) in doc.revisions.iter().enumerate() { if r.move_name.is_none() { @@ -203,7 +209,16 @@ impl<'a> Builder<'a> { revs.entry((rev_key(r.kind), mark)).or_insert(i as u32); } } - Builder { doc, revs, run_strings: 0, comments: Default::default(), lists: Default::default() } + Builder { + doc, + revs, + run_strings: 0, + comments: Default::default(), + lists: Default::default(), + ranged: super::types::ranged_anchors(blocks), + started: Default::default(), + ended: Default::default(), + } } /// The document revision for a tracked change, added the first time it is seen. @@ -436,9 +451,18 @@ impl<'a> Builder<'a> { append(&mut out, "", &CharProps::default(), Some(obj)); } } - Inline::Anchor(name) => { + // A bookmark once (the first of a name): a point, or the start of the text it covers. + Inline::Anchor(name) if self.started.insert(name.clone()) => { append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkStart { name: name.clone() })); - append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkEnd { name: name.clone() })); + if !self.ranged.contains(name) { + append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkEnd { name: name.clone() })); + } + } + Inline::Anchor(_) => {} + Inline::AnchorEnd(name) => { + if self.ranged.contains(name) && self.started.contains(name) && self.ended.insert(name.clone()) { + append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkEnd { name: name.clone() })); + } } Inline::Figure(_) => {} Inline::Comment { id, comment } => { @@ -715,7 +739,7 @@ pub fn to_doc_in(flow: &Flow, section: wordcraft_doc::SectionProps) -> Document if uses_code { doc.styles.upsert(code_style()); } - let body = Builder::new(&mut doc).blocks(&flow.blocks, 0); + let body = Builder::new(&mut doc, &flow.blocks).blocks(&flow.blocks, 0); doc.body = body; let m = &flow.meta; doc.core.title = m.title.clone(); @@ -731,7 +755,7 @@ pub fn to_doc_in(flow: &Flow, section: wordcraft_doc::SectionProps) -> Document /// `blocks` as document blocks of `doc` (their pictures go into its media), for a header, /// footer or other part an importer adds itself. pub fn part_blocks(doc: &mut Document, blocks: &[FBlock]) -> Blocks { - let mut out = Builder::new(doc).blocks(blocks, 0); + let mut out = Builder::new(doc, blocks).blocks(blocks, 0); if out.is_empty() { out.push(para_block(Paragraph::new())); } diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 9d6b739501..a1c4640706 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -158,6 +158,10 @@ pub enum Inline { Image(Img), /// A bookmark (link target). Anchor(String), + /// Where bookmark `name`, started by an [`Inline::Anchor`] before it, ends: the bookmark + /// covers the text between. Only flows asked for fields ([`FlowOpts::fields`]) hold them; + /// an anchor without one is a point. + AnchorEnd(String), /// A chart or diagram known here only by its alt text: written where alt text has a place /// (an HTML or Markdown image), left out of editable formats so it never turns into body text. Figure(String), @@ -273,6 +277,44 @@ impl Default for BoxLook { } } +/// The bookmarks in `blocks` whose end ([`Inline::AnchorEnd`]) follows their start +/// ([`Inline::Anchor`]): ranges, not points. Tables' cells, notes, comments and shapes' text +/// are looked through, in document order. +pub fn ranged_anchors(blocks: &[FBlock]) -> std::collections::HashSet { + fn walk(blocks: &[FBlock], depth: usize, started: &mut std::collections::HashSet, ranged: &mut std::collections::HashSet) { + if depth > MAX_DEPTH { + return; + } + for b in blocks { + match b { + FBlock::Para(p) => { + for i in &p.inlines { + match i { + Inline::Anchor(n) => { + started.insert(n.clone()); + } + Inline::AnchorEnd(n) if started.contains(n) => { + ranged.insert(n.clone()); + } + Inline::Note { blocks, .. } | Inline::Shape { blocks, .. } => walk(blocks, depth + 1, started, ranged), + Inline::Comment { comment, .. } => walk(&comment.blocks, depth + 1, started, ranged), + _ => {} + } + } + } + FBlock::Table(t) => { + for c in t.rows.iter().flatten() { + walk(&c.blocks, depth + 1, started, ranged); + } + } + } + } + } + let (mut started, mut ranged) = Default::default(); + walk(blocks, 0, &mut started, &mut ranged); + ranged +} + /// `blocks` with each table replaced by the blocks of its cells, row by row (nested tables too), /// for places that hold paragraphs only. pub fn flatten_tables(blocks: &[FBlock]) -> Vec { @@ -579,7 +621,8 @@ impl Para { | Inline::Shape { .. } | Inline::Group { .. } | Inline::FieldStart(_) - | Inline::FieldEnd => {} + | Inline::FieldEnd + | Inline::AnchorEnd(_) => {} } } s @@ -604,7 +647,8 @@ impl Para { | Inline::Shape { .. } | Inline::Group { .. } | Inline::FieldStart(_) - | Inline::FieldEnd => {} + | Inline::FieldEnd + | Inline::AnchorEnd(_) => {} } } s diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 39932e70ee..d37c776702 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -752,14 +752,11 @@ impl Body<'_> { } "tab" => self.push_text("\t"), "line-break" => self.push_text("\n"), - "bookmark" | "bookmark-start" => { - if let Some(name) = get(a, "text:name") { - if self.para.is_none() { - self.start_para(None, None); - } - if let Some(p) = &mut self.para { - p.inlines.push(Inline::Anchor(name.to_string())); - } + // A bookmark (§6.2.1): a point, or the start or end of the text it covers. + "bookmark" | "bookmark-start" | "bookmark-end" => { + if let Some(name) = get(a, "text:name").filter(|n| !n.is_empty()) { + let name = name.to_string(); + self.push_inline(if local(q) == "bookmark-end" { Inline::AnchorEnd(name) } else { Inline::Anchor(name) }); } } _ => return false, diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index c1dce8de92..e134d6a6ef 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -500,6 +500,7 @@ pub fn export(doc: &Document) -> Result, String> { model::FlowOpts { tracked: true, fields: true, notes: true, comments: true, lists: true, chars: true, paras: true, boxes: true }, ); let mut w = Writer::default(); + w.ranged = model::ranged_anchors(&flow.blocks); let mut body = String::new(); let sections = flow_sections(&flow.blocks, &doc.last_section); // One master page per section that starts a page; the first one is "Standard". diff --git a/crates/formats/src/odt/tests_more.rs b/crates/formats/src/odt/tests_more.rs index da01c96de7..ec8a152c62 100644 --- a/crates/formats/src/odt/tests_more.rs +++ b/crates/formats/src/odt/tests_more.rs @@ -1,5 +1,6 @@ //! Tests for the ODT features added after the first reader and writer: one module per feature. +mod bookmarks; mod char_styles; mod chars; mod comments; diff --git a/crates/formats/src/odt/tests_more/bookmarks.rs b/crates/formats/src/odt/tests_more/bookmarks.rs new file mode 100644 index 0000000000..74e63446bd --- /dev/null +++ b/crates/formats/src/odt/tests_more/bookmarks.rs @@ -0,0 +1,82 @@ +//! Bookmarks (ODF 1.3 §6.2.1 `text:bookmark`, `text:bookmark-start`/`-end`): a point or the text +//! between its start and end, in one paragraph or across several, kept as a WordCraft bookmark +//! over that text (what a reference to it shows) and written back the same way. + +use crate::odt::tests::{NS_T, content_of, package}; +use crate::odt::{export, import}; +use wordcraft_doc::Document; +use wordcraft_doc::para::{InlineObject, OBJ}; + +/// Each bookmark: (name, the text it covers, paragraphs joined by '|'). +fn bookmarks(d: &Document) -> Vec<(String, String)> { + let mut open: Vec<(String, String)> = Vec::new(); + let mut done = Vec::new(); + for (i, b) in d.body.iter().enumerate() { + let Some(p) = b.as_para() else { continue }; + if i > 0 { + for o in &mut open { + o.1.push('|'); + } + } + let mut k = 0; + for ch in p.text.chars() { + if ch == OBJ { + match p.objects.get(k) { + Some(InlineObject::BookmarkStart { name }) => open.push((name.clone(), String::new())), + Some(InlineObject::BookmarkEnd { name }) => { + if let Some(at) = open.iter().position(|(n, _)| n == name) { + done.push(open.remove(at)); + } + } + _ => {} + } + k += 1; + } else { + for o in &mut open { + o.1.push(ch); + } + } + } + } + done.sort(); + done +} + +fn open() -> Document { + let content = format!( + r#"Before important words after.From hereto there."# + ); + import(&package(&content, None)).unwrap() +} + +#[test] +fn a_bookmark_covers_its_text() { + assert_eq!( + bookmarks(&open()), + [("span".to_string(), "here|to there".to_string()), ("spot".into(), String::new()), ("words".into(), "important words".into())] + ); +} + +#[test] +fn bookmarks_round_trip() { + let d = open(); + let bytes = export(&d).unwrap(); + let c = content_of(&bytes); + assert!(c.contains(r#""#) && c.contains(r#""#), "{c}"); + assert_eq!(bookmarks(&import(&bytes).unwrap()), bookmarks(&d)); +} + +#[test] +fn hostile_bookmarks_do_not_panic() { + for body in [ + r#"x"#, + r#"no end"#, + r#"twice"#, + r#"nameless"#, + ] { + let content = + format!(r#"{body}"#); + let d = import(&package(&content, None)).unwrap(); + let _ = export(&d).unwrap(); + } +} diff --git a/crates/formats/src/odt/writer.rs b/crates/formats/src/odt/writer.rs index c8518faeac..696a9b7b4d 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -83,6 +83,10 @@ type SecStart = (String, Option); #[derive(Default)] pub(super) struct Writer { + /// Bookmarks that cover text (written as a start and an end), and those started and ended. + pub(super) ranged: std::collections::HashSet, + bookmarks: std::collections::HashSet, + bookmark_ends: std::collections::HashSet, /// Indexes written so far (their names). pub(super) indexes: usize, /// An index's entries are being written: their links are in LibreOffice's Index Link style. @@ -315,7 +319,22 @@ impl Writer { self.group((*w, *h), float.as_deref(), members, out); prev_space = false; } - Inline::Anchor(a) => out.push_str(&format!("", x(a))), + // A bookmark (§6.2.1): over the text up to its end, or a point; each name once. + Inline::Anchor(a) if self.bookmarks.insert(a.clone()) => { + // One ending right where it starts is a point. + let empty = matches!(inl.get(idx + 1), Some(Inline::AnchorEnd(e)) if e == a); + if empty { + self.bookmark_ends.insert(a.clone()); + } + let el = if self.ranged.contains(a) && !empty { "bookmark-start" } else { "bookmark" }; + out.push_str(&format!("", x(a))); + } + Inline::Anchor(_) => {} + Inline::AnchorEnd(a) => { + if self.ranged.contains(a) && self.bookmarks.contains(a) && self.bookmark_ends.insert(a.clone()) { + out.push_str(&format!("", x(a))); + } + } Inline::Note { endnote, custom, blocks } => { self.note(*endnote, custom, blocks, out); prev_space = false; diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index 7d9238621a..f91da71ef1 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -408,7 +408,8 @@ impl Writer { | Inline::Shape { .. } | Inline::Group { .. } | Inline::FieldStart(_) - | Inline::FieldEnd => {} + | Inline::FieldEnd + | Inline::AnchorEnd(_) => {} } } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ddaedf549d..a056ac25a2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -57,7 +57,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | -| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~72% | 30–50 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks (`text:alphabetical-index-mark`, Word's `XE`) are not read. Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), floating pictures (a frame anchored to a paragraph, character or page: wrap — square on both sides, left, right or the larger side, top and bottom, behind or in front of text —, horizontal and vertical alignment or offset and what it is relative to, distances from the text, read into the picture's placement and written back in a graphic style), tables (cell borders — width, style, colour, an edge LibreOffice gives one cell shared by both — and cell padding read and written, each shared edge written once so lines don't double, a table without lines written without), pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as a proportional approximation) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`) read as fields and written back as ODF fields; fixed dates and fields without a Word equivalent (sequences, chapters, variables) read as their text, and Word fields ODF lacks written as their result, date formats not kept, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099); export writes each equation as a formula object of its own (MathML, with its linear text as an annotation), its frame sized from the equation's structure and centred on the line, so LibreOffice shows and edits it, page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). footnotes and endnotes (`text:note`) read into note stories as Word's (Footnote Text style, the note's own mark first, a custom mark kept) and written back with their citations; notes inside notes and in deleted text left out; comments (`office:annotation` with `office:annotation-end` ranges, author, date, initials, LibreOffice's resolved state and reply threads, `loext:resolved`/`loext:parent-name`) read and written, a comment whose end never comes covering nothing; comments in notes and in deleted text left out; text boxes (`draw:text-box` in a frame) open as WordCraft text boxes with their text, in line or floating, and are written back, keeping the frame's line (colour and width), fill, padding (through the frame's graphic style and its parents, LibreOffice's Frame style included) and a frame without a height growing to its text (`fo:min-height`), which WordCraft lays out floating as well as in line. Right-to-left paragraphs (`style:writing-mode` `rl-tb`) read and written. Drawing shapes (`draw:rect`, `draw:ellipse`, `draw:line`, and `draw:custom-shape` of a predefined geometry by its `draw:type`: rounded rectangle, triangles, diamond, the four block arrows, star, heart, flowchart or; LibreOffice's names and the `ooxml-` preset names) open as WordCraft shapes with their size, placement (in line or floating, as frames are), fill, line (colour and width; LibreOffice's default blue line and fill where the styles say nothing), text and a line's direction, and are written back, a custom shape by its type alone, which LibreOffice rebuilds the geometry from; a geometry WordCraft has no shape for opens as a rectangle; shapes and pictures turned by `draw:transform` (rotate, translate and scale; anticlockwise about the corner in the file, clockwise about the centre in WordCraft) and custom shapes mirrored (`draw:mirror-horizontal`/`-vertical`) keep their turn and mirroring both ways, in line or floating, a turned line by its turned ends; groups (`draw:g`) of shapes, text boxes and pictures open as one WordCraft group anchored and wrapped as the group is, its members placed (and turned) as they are in it, groups in it flattened into it, and are written back as a group; a frame, shape or group whose styles state no wrap has text on both sides, as LibreOffice wraps it (§20.400 states no default), and `biggest` wraps on the larger side; arrowheads (`draw:marker-start`/`-end`, written with WordCraft's own triangle marker), dashes (`draw:stroke-dash`: up to two kinds of dash and a space, in line widths or lengths, matched to WordCraft's named dashes; a pattern with more kinds is written as its first two) and gradient fills (`draw:gradient`: linear and axial along their angle, radial, ellipsoid, square and rectangular out from their centre, border and intensity; more than two colours written as the first and last) are read and written, a line's direction (and so its arrowheads' ends) kept from its two ends; polygons, polylines, paths (SVG path data: moves, lines, cubic and quadratic curves, smooth ones and elliptical arcs, absolute and relative, curves followed in short straight steps) and regular polygons (stars too) open as WordCraft freeforms in their view box, circles as ellipses, and freeforms (ink too) are written as `draw:path`; connectors, captions and measures are left out, a custom shape whose type WordCraft has no shape for opens as a rectangle; an in-line shape is written sitting on the baseline, as Word places it | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~72% | 30–50 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks (`text:alphabetical-index-mark`, Word's `XE`) are not read. Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), floating pictures (a frame anchored to a paragraph, character or page: wrap — square on both sides, left, right or the larger side, top and bottom, behind or in front of text —, horizontal and vertical alignment or offset and what it is relative to, distances from the text, read into the picture's placement and written back in a graphic style), tables (cell borders — width, style, colour, an edge LibreOffice gives one cell shared by both — and cell padding read and written, each shared edge written once so lines don't double, a table without lines written without), pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as a proportional approximation) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`, to bookmarks kept over the text they mark — `text:bookmark-start`…`-end`, in a paragraph or across several — so a reference updates to that text) read as fields and written back as ODF fields; fixed dates and fields without a Word equivalent (sequences, chapters, variables) read as their text, and Word fields ODF lacks written as their result, date formats not kept, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099); export writes each equation as a formula object of its own (MathML, with its linear text as an annotation), its frame sized from the equation's structure and centred on the line, so LibreOffice shows and edits it, page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). footnotes and endnotes (`text:note`) read into note stories as Word's (Footnote Text style, the note's own mark first, a custom mark kept) and written back with their citations; notes inside notes and in deleted text left out; comments (`office:annotation` with `office:annotation-end` ranges, author, date, initials, LibreOffice's resolved state and reply threads, `loext:resolved`/`loext:parent-name`) read and written, a comment whose end never comes covering nothing; comments in notes and in deleted text left out; text boxes (`draw:text-box` in a frame) open as WordCraft text boxes with their text, in line or floating, and are written back, keeping the frame's line (colour and width), fill, padding (through the frame's graphic style and its parents, LibreOffice's Frame style included) and a frame without a height growing to its text (`fo:min-height`), which WordCraft lays out floating as well as in line. Right-to-left paragraphs (`style:writing-mode` `rl-tb`) read and written. Drawing shapes (`draw:rect`, `draw:ellipse`, `draw:line`, and `draw:custom-shape` of a predefined geometry by its `draw:type`: rounded rectangle, triangles, diamond, the four block arrows, star, heart, flowchart or; LibreOffice's names and the `ooxml-` preset names) open as WordCraft shapes with their size, placement (in line or floating, as frames are), fill, line (colour and width; LibreOffice's default blue line and fill where the styles say nothing), text and a line's direction, and are written back, a custom shape by its type alone, which LibreOffice rebuilds the geometry from; a geometry WordCraft has no shape for opens as a rectangle; shapes and pictures turned by `draw:transform` (rotate, translate and scale; anticlockwise about the corner in the file, clockwise about the centre in WordCraft) and custom shapes mirrored (`draw:mirror-horizontal`/`-vertical`) keep their turn and mirroring both ways, in line or floating, a turned line by its turned ends; groups (`draw:g`) of shapes, text boxes and pictures open as one WordCraft group anchored and wrapped as the group is, its members placed (and turned) as they are in it, groups in it flattened into it, and are written back as a group; a frame, shape or group whose styles state no wrap has text on both sides, as LibreOffice wraps it (§20.400 states no default), and `biggest` wraps on the larger side; arrowheads (`draw:marker-start`/`-end`, written with WordCraft's own triangle marker), dashes (`draw:stroke-dash`: up to two kinds of dash and a space, in line widths or lengths, matched to WordCraft's named dashes; a pattern with more kinds is written as its first two) and gradient fills (`draw:gradient`: linear and axial along their angle, radial, ellipsoid, square and rectangular out from their centre, border and intensity; more than two colours written as the first and last) are read and written, a line's direction (and so its arrowheads' ends) kept from its two ends; polygons, polylines, paths (SVG path data: moves, lines, cubic and quadratic curves, smooth ones and elliptical arcs, absolute and relative, curves followed in short straight steps) and regular polygons (stars too) open as WordCraft freeforms in their view box, circles as ellipses, and freeforms (ink too) are written as `draw:path`; connectors, captions and measures are left out, a custom shape whose type WordCraft has no shape for opens as a rectangle; an in-line shape is written sitting on the baseline, as Word places it | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154); the empty comments `` and `` end at once instead of swallowing the rest of the page (#5040). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | @@ -279,6 +279,7 @@ Custom document properties retain their bookmark link targets through DOCX saves | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | ODT: a bookmark over text (`text:bookmark-start`…`-end`, in one paragraph or across several) keeps the text it covers, both ways (it was read as a point at its start, and a Word bookmark over text was written as a point), so a reference to it — LibreOffice's references to headings included — updates to that text instead of to nothing (`odt_fields.rs`) | | 2026-11-01 | trivial | ODT: tables of contents, tables of figures, alphabetical indexes and bibliographies open as WordCraft's (`TOC`, `INDEX`, `BIBLIOGRAPHY` fields over their entries; LibreOffice's Contents styles as the TOC styles), so Update Table works on them, and are saved as ODF indexes LibreOffice updates (a LibreOffice document with a table of contents, a table of figures and an index round-trips pixel for pixel). Found on the way: a style based on LibreOffice's generic Heading style lost that parent on save (its font and spacing), and a paragraph's tab stops weren't measured from an indent its style gives (`tabs_are_measured_from_the_indent_a_style_gives`, `a_style_based_on_libreoffices_heading_keeps_it`). ODT fidelity re-estimated at ~72% (weighted by how often documents use each area; the gaps: fields' formats and references, page-level settings, embedded objects other than equations, flat and encrypted files) | | 2026-11-01 | trivial | ODT: polygons, polylines, paths (`svg:d`: moves, lines, cubic and quadratic curves, smooth curves and elliptical arcs, absolute and relative; curves followed in 16 straight steps) and regular polygons and stars open as WordCraft freeforms in their view box, and circles (`draw:circle`, by size or centre and radius) as ellipses, instead of being skipped; WordCraft's freeforms (ink included) are written as `draw:path` in a view box of hundredths of a point. A LibreOffice document with a polygon, a polyline and a closed Bézier curve round-trips 333 px apart, and WordCraft draws it 529 px from LibreOffice | | 2026-11-01 | trivial | ODT: arrowheads (`draw:marker-start`/`-end`), line dashes (`draw:stroke-dash`) and gradient fills (`draw:gradient`) of shapes and frames are read into WordCraft's arrowheads, dash patterns (named ones when they match) and gradients, and written back as definitions in `styles.xml` (WordCraft's own triangle arrowhead); a line keeps the direction it is drawn in, so its arrowheads stay at their ends. A LibreOffice document with arrowheads, dashes and gradients round-trips 215 px apart, and WordCraft draws it 518 px from LibreOffice. Layout: a sloped line's arrowheads were drawn at the other diagonal's corners (the arrowhead code assumed a line from bottom left to top right, the renderers draw top left to bottom right); they now sit at the line's ends (`tests::a_lines_arrowheads_sit_at_its_ends`) |