Gap 19: ODT bookmarks keep the text they cover

A bookmark over text (`text:bookmark-start`…`text:bookmark-end`, in one
paragraph or across several) was read as a point at its start, and a
Word bookmark over text was written as a point: a reference to it then
updated to nothing. The flow model gets the end of a bookmark
(`Inline::AnchorEnd`, in flows asked for fields, as ODT's is), the
document gets the bookmark's start and end where they are, and the
writer writes a start and an end (a point, `text:bookmark`, when it
ends where it starts). A bookmark started twice, ended twice or ended
before it starts is kept once, as a point when it has no end after it.

References to bookmarks, LibreOffice's references to headings included,
now update to the bookmarked text (engine test `odt_fields.rs`, through
a save and reopen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude committed 2026-11-01 08:53:42 +00:00
1 parent aea3b4c7d7
commit bb5dc4bcaa
16 files changed
+256 -23

No files matched your search

+1
View File
@@ -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;
+57
View File
@@ -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<u8> {
let content = format!(r#"<office:document-content {NS}><office:body><office:text>{body}</office:text></office:body></office:document-content>"#);
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<String> {
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<String> {
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#"<text:h text:outline-level="1"><text:bookmark-start text:name="__RefHeading___Toc1"/>Planning ahead<text:bookmark-end text:name="__RefHeading___Toc1"/></text:h><text:p>See <text:bookmark-ref text:reference-format="text" text:ref-name="__RefHeading___Toc1">Planning</text:bookmark-ref>.</text:p>"#),
)
.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"]);
}
+2 -1
View File
@@ -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();
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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 { .. } => {}
}
+1 -1
View File
@@ -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};
+2
View File
@@ -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() }]
}
+30 -6
View File
@@ -184,6 +184,11 @@ struct Builder<'a> {
comments: std::collections::HashMap<u32, u32>,
/// The document's list instance for each flow list number ([`ListInfo::list`]).
lists: std::collections::HashMap<u32, u32>,
/// 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<String>,
started: std::collections::HashSet<String>,
ended: std::collections::HashSet<String>,
}
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()));
}
+46 -2
View File
@@ -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<String> {
fn walk(blocks: &[FBlock], depth: usize, started: &mut std::collections::HashSet<String>, ranged: &mut std::collections::HashSet<String>) {
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<FBlock> {
@@ -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
+5 -8
View File
@@ -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,
+1
View File
@@ -500,6 +500,7 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, 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".
+1
View File
@@ -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;
@@ -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#"<office:document-content {NS_T}><office:body><office:text><text:p>Before <text:bookmark-start text:name="words"/>important words<text:bookmark-end text:name="words"/> after<text:bookmark text:name="spot"/>.</text:p><text:p>From <text:bookmark-start text:name="span"/>here</text:p><text:p>to there<text:bookmark-end text:name="span"/>.</text:p></office:text></office:body></office:document-content>"#
);
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#"<text:bookmark-start text:name="words"/>"#) && c.contains(r#"<text:bookmark text:name="spot"/>"#), "{c}");
assert_eq!(bookmarks(&import(&bytes).unwrap()), bookmarks(&d));
}
#[test]
fn hostile_bookmarks_do_not_panic() {
for body in [
r#"<text:p><text:bookmark-end text:name="never"/>x</text:p>"#,
r#"<text:p><text:bookmark-start text:name="open"/>no end</text:p>"#,
r#"<text:p><text:bookmark-start text:name="a"/><text:bookmark-start text:name="a"/>twice<text:bookmark-end text:name="a"/></text:p>"#,
r#"<text:p><text:bookmark-start/>nameless<text:bookmark-end/></text:p>"#,
] {
let content =
format!(r#"<office:document-content {NS_T}><office:body><office:text>{body}</office:text></office:body></office:document-content>"#);
let d = import(&package(&content, None)).unwrap();
let _ = export(&d).unwrap();
}
}
+20 -1
View File
@@ -83,6 +83,10 @@ type SecStart = (String, Option<u32>);
#[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<String>,
bookmarks: std::collections::HashSet<String>,
bookmark_ends: std::collections::HashSet<String>,
/// 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!("<text:bookmark text:name=\"{}\"/>", 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!("<text:{el} text:name=\"{}\"/>", 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!("<text:bookmark-end text:name=\"{}\"/>", x(a)));
}
}
Inline::Note { endnote, custom, blocks } => {
self.note(*endnote, custom, blocks, out);
prev_space = false;
+2 -1
View File
@@ -408,7 +408,8 @@ impl Writer {
| Inline::Shape { .. }
| Inline::Group { .. }
| Inline::FieldStart(_)
| Inline::FieldEnd => {}
| Inline::FieldEnd
| Inline::AnchorEnd(_) => {}
}
}
}
+2 -1
View File
@@ -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 HLine truncated
| `.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 Line truncated
| `.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 (`<html>`, `<!DOCTYPE html>`) 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 `</head>` tolerated (#154); the empty comments `<!-->` and `<!--->` end at once instead of swallowing the rest of the page (#5040). Header cells (`<th>`) 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 `<blockquote>` (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 `<ruby>base<rt>reading</rt></ruby>` in the base text's formatting and read back from `<ruby>`/`<rb>`/`<rt>` (`<rp>` 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`) |