diff --git a/crates/engine/tests/it/odt_fields.rs b/crates/engine/tests/it/odt_fields.rs index d83ca921dc..4c1db11b10 100644 --- a/crates/engine/tests/it/odt_fields.rs +++ b/crates/engine/tests/it/odt_fields.rs @@ -55,3 +55,19 @@ fn a_reference_to_a_bookmark_updates_to_its_text() { s2.run("references.updateFields", &json!({"all": true})).unwrap(); assert_eq!(results(&s2), ["Planning ahead"]); } + +#[test] +fn captions_chapters_and_dates_update_as_words() { + // Stale results, as a file can hold them: they are recomputed. + let doc = io::open_bytes( + "fields.odt", + &odt(r#"Methods + Figure 7: a cat + Figure 9: a dog + See Figure 9 in Old."#), + ) + .unwrap(); + let mut s = Session::new(doc); + s.run("references.updateFields", &json!({"all": true})).unwrap(); + assert_eq!(results(&s), ["1", "2", "Figure 2", "Methods"], "{:?}", text(&s)); +} diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index ac92595abb..a10d189ef6 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -38,6 +38,7 @@ use wordcraft_doc::{Align, Document, RevisionKind, Rgb, SectionProps}; use crate::model::{self, Cell, FBlock, FComment, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img, mime_of}; mod body; +mod datastyle; mod fields; mod formula_size; mod frames; diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index d37c776702..fa9b778cce 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -725,9 +725,12 @@ impl Body<'_> { } if q.starts_with("text:") && self.frame.is_none() - && let Some(instr) = super::fields::instr_of(local(q), a) + && let Some(instr) = super::fields::instr_of(local(q), a, |n| self.styles.date_picture(n).map(String::from)) { - self.field = Some(OpenField { instr, result: String::new(), name: q.to_string(), depth: self.depth, fmt: self.cur_fmt() }); + let anchor = (local(q) == "sequence") + .then(|| get(a, "text:ref-name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 1024).map(String::from)) + .flatten(); + self.field = Some(OpenField { instr, result: String::new(), name: q.to_string(), depth: self.depth, fmt: self.cur_fmt(), anchor }); return true; } match local(q) { @@ -753,10 +756,11 @@ impl Body<'_> { "tab" => self.push_text("\t"), "line-break" => self.push_text("\n"), // A bookmark (§6.2.1): a point, or the start or end of the text it covers. - "bookmark" | "bookmark-start" | "bookmark-end" => { + // A reference mark (§7.6) is a bookmark too: references to it are Word's to bookmarks. + "bookmark" | "bookmark-start" | "bookmark-end" | "reference-mark" | "reference-mark-start" | "reference-mark-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) }); + self.push_inline(if local(q).ends_with("-end") { Inline::AnchorEnd(name) } else { Inline::Anchor(name) }); } } _ => return false, diff --git a/crates/formats/src/odt/body/notes.rs b/crates/formats/src/odt/body/notes.rs index 5dce58f4fe..435e06ec60 100644 --- a/crates/formats/src/odt/body/notes.rs +++ b/crates/formats/src/odt/body/notes.rs @@ -106,6 +106,9 @@ pub(in crate::odt) struct OpenField { pub(in crate::odt) depth: usize, /// The formatting it is in. pub(in crate::odt) fmt: Fmt, + /// A sequence's reference name: a bookmark over the caption's label and number, which + /// references to it show. + pub(in crate::odt) anchor: Option, } impl Body<'_> { @@ -304,13 +307,24 @@ impl Body<'_> { /// Put the field read so far into the paragraph. pub(in crate::odt) fn end_field(&mut self) { - let Some(OpenField { instr, result, mut fmt, .. }) = self.field.take() else { return }; + let Some(OpenField { instr, result, mut fmt, anchor, .. }) = self.field.take() else { return }; fmt.ins = self.open_insertion(); if self.para.is_none() { self.start_para(None, None); } if let Some(p) = &mut self.para { + if let Some(name) = &anchor { + // From the start of the paragraph when only the label is before the number (a + // caption, "Figure 1"), else from the number. + let label = crate::odt::fields::sequence_name(&instr).unwrap_or(""); + let before: String = p.inlines.iter().map(|i| if let Inline::Text(t, _) = i { t.as_str() } else { "\u{1}" }).collect(); + let at = if before.trim() == label { 0 } else { p.inlines.len() }; + p.inlines.insert(at, Inline::Anchor(name.clone())); + } p.inlines.push(Inline::Field { instr, result: result.trim().to_string(), fmt }); + if let Some(name) = anchor { + p.inlines.push(Inline::AnchorEnd(name)); + } } self.xml_space_end = false; } diff --git a/crates/formats/src/odt/datastyle.rs b/crates/formats/src/odt/datastyle.rs new file mode 100644 index 0000000000..585a56969f --- /dev/null +++ b/crates/formats/src/odt/datastyle.rs @@ -0,0 +1,184 @@ +//! Date and time styles (ODF 1.3 §16.27 `number:date-style`, `number:time-style`) and Word's +//! date-time pictures (`DATE \@ "dddd, MMMM d, yyyy"`), both ways: the parts — day, month, +//! year, weekday, hours, minutes, seconds, AM/PM — and the text between them. + +use super::writer::x; + +/// Longest picture kept, characters. +const MAX_PICTURE: usize = 200; + +/// A part of a date or time. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Part { + /// Day of the month (long: two digits). + Day(bool), + /// Month: long, and by name. + Month(bool, bool), + /// Year (long: four digits). + Year(bool), + /// Day of the week by name (long: in full). + Weekday(bool), + Hours(bool), + Minutes(bool), + Seconds(bool), + AmPm, + Text(String), +} + +/// The part element `name` (local, in the number namespace) stands for; its text, if any, +/// comes after. +pub(super) fn part(name: &str, long: bool, textual: bool) -> Option { + Some(match name { + "day" => Part::Day(long), + "month" => Part::Month(long, textual), + "year" => Part::Year(long), + "day-of-week" => Part::Weekday(long), + "hours" => Part::Hours(long), + "minutes" => Part::Minutes(long), + "seconds" => Part::Seconds(long), + "am-pm" => Part::AmPm, + _ => return None, + }) +} + +/// The Word picture for `parts`: 12-hour hours when an AM/PM shows, text in single quotes when +/// it holds letters (a quote doubled); quotes and backslashes, which would end the field's +/// argument, left out. +pub(super) fn picture(parts: &[Part]) -> String { + let ampm = parts.contains(&Part::AmPm); + let pick = |long: bool, a: &'static str, b: &'static str| if long { a } else { b }; + let mut s = String::new(); + for p in parts { + match p { + Part::Day(l) => s.push_str(pick(*l, "dd", "d")), + Part::Month(l, true) => s.push_str(pick(*l, "MMMM", "MMM")), + Part::Month(l, false) => s.push_str(pick(*l, "MM", "M")), + Part::Year(l) => s.push_str(pick(*l, "yyyy", "yy")), + Part::Weekday(l) => s.push_str(pick(*l, "dddd", "ddd")), + Part::Hours(l) if ampm => s.push_str(pick(*l, "hh", "h")), + Part::Hours(l) => s.push_str(pick(*l, "HH", "H")), + Part::Minutes(l) => s.push_str(pick(*l, "mm", "m")), + Part::Seconds(l) => s.push_str(pick(*l, "ss", "s")), + Part::AmPm => s.push_str("AM/PM"), + Part::Text(t) => { + let t: String = t.chars().filter(|c| !matches!(c, '"' | '\\')).collect(); + if t.chars().any(|c| c.is_alphabetic() || c == '\'') { + s.push('\''); + s.push_str(&t.replace('\'', "''")); + s.push('\''); + } else { + s.push_str(&t); + } + } + } + } + s.chars().take(MAX_PICTURE).collect() +} + +/// The parts of Word picture `pic` (the reverse of [`picture`]). +fn parts_of(pic: &str) -> Vec { + let chars: Vec = pic.chars().take(MAX_PICTURE).collect(); + let mut out: Vec = Vec::new(); + let mut text = String::new(); + let mut i = 0; + let flush = |text: &mut String, out: &mut Vec| { + if !text.is_empty() { + out.push(Part::Text(std::mem::take(text))); + } + }; + while let Some(&c) = chars.get(i) { + if c == '\'' { + i += 1; + while let Some(&q) = chars.get(i) { + i += 1; + if q != '\'' { + text.push(q); + } else if chars.get(i) == Some(&'\'') { + text.push('\''); + i += 1; + } else { + break; + } + } + continue; + } + if chars.get(i..i + 5).is_some_and(|w| w.iter().collect::().eq_ignore_ascii_case("am/pm")) { + flush(&mut text, &mut out); + out.push(Part::AmPm); + i += 5; + continue; + } + let mut n = 1; + while chars.get(i + n) == Some(&c) { + n += 1; + } + let p = match (c, n) { + ('y' | 'Y', 4..) => Some(Part::Year(true)), + ('y' | 'Y', _) => Some(Part::Year(false)), + ('M', 4..) => Some(Part::Month(true, true)), + ('M', 3) => Some(Part::Month(false, true)), + ('M', 2) => Some(Part::Month(true, false)), + ('M', _) => Some(Part::Month(false, false)), + ('d' | 'D', 4..) => Some(Part::Weekday(true)), + ('d' | 'D', 3) => Some(Part::Weekday(false)), + ('d' | 'D', 2) => Some(Part::Day(true)), + ('d' | 'D', _) => Some(Part::Day(false)), + ('h' | 'H', n) => Some(Part::Hours(n >= 2)), + ('m', n) => Some(Part::Minutes(n >= 2)), + ('s', n) => Some(Part::Seconds(n >= 2)), + _ => None, + }; + match p { + Some(p) => { + flush(&mut text, &mut out); + out.push(p); + } + None => { + for _ in 0..n { + text.push(c); + } + } + } + i += n; + } + flush(&mut text, &mut out); + out +} + +/// A data style named `name` for Word picture `pic`: a date style when it shows any part of a +/// date, a time style otherwise. `None` for a picture with no part. +pub(super) fn style_xml(name: &str, pic: &str) -> Option { + let parts = parts_of(pic); + if !parts.iter().any(|p| !matches!(p, Part::Text(_))) { + return None; + } + let dated = parts.iter().any(|p| matches!(p, Part::Day(_) | Part::Month(..) | Part::Year(_) | Part::Weekday(_))); + let long = |l: bool| if l { " number:style=\"long\"" } else { "" }; + let body: String = parts + .iter() + .map(|p| match p { + Part::Day(l) => format!("", long(*l)), + Part::Month(l, t) => format!("", long(*l), if *t { " number:textual=\"true\"" } else { "" }), + Part::Year(l) => format!("", long(*l)), + Part::Weekday(l) => format!("", long(*l)), + Part::Hours(l) => format!("", long(*l)), + Part::Minutes(l) => format!("", long(*l)), + Part::Seconds(l) => format!("", long(*l)), + Part::AmPm => "".to_string(), + Part::Text(t) => format!("{}", x(t)), + }) + .collect(); + let el = if dated { "number:date-style" } else { "number:time-style" }; + Some(format!("<{el} style:name=\"{name}\">{body}")) +} + +/// The `\@` picture of Word field code `instr`, if it has one. +pub(super) fn picture_of(instr: &str) -> Option { + let at = instr.find("\\@")?; + let rest = instr.get(at + 2..)?.trim_start(); + let pic = match rest.strip_prefix('"') { + Some(q) => q.split('"').next()?, + None => rest.split_whitespace().next()?, + }; + (!pic.is_empty()).then(|| pic.chars().take(MAX_PICTURE).collect()) +} diff --git a/crates/formats/src/odt/fields.rs b/crates/formats/src/odt/fields.rs index 3c0b972840..140a806fc9 100644 --- a/crates/formats/src/odt/fields.rs +++ b/crates/formats/src/odt/fields.rs @@ -1,10 +1,13 @@ //! Text fields (ODF 1.3 §7.3–7.7) and the Word field codes WordCraft keeps them as. //! -//! Read: a field element becomes a field with that code and the element's text as its result. -//! A fixed date or time (`text:fixed="true"`) is plain text, as is a field with no Word -//! equivalent (sequences, chapters, variables…), so nothing it shows is lost. Written: a field -//! whose code has an ODF element is that element around its result; any other field (or one -//! with a code ODF can't hold) is its result as text. +//! Read: a field element becomes a field with that code and the element's text as its result: +//! a date or time with its date style as a `\@` picture, a sequence numbering figures or +//! tables as `SEQ`, a chapter field as `STYLEREF` to its heading style, references to +//! bookmarks, reference marks and sequences as `REF`/`PAGEREF`. A fixed date or time +//! (`text:fixed="true"`) is plain text, as is a field with no Word equivalent (variables, a +//! reference to a caption's text…), so nothing it shows is lost. Written: a field whose code +//! has an ODF element is that element around its result; any other field (or one with a code +//! ODF can't hold) is its result as text. use super::writer::x; use super::xml::{Attrs, get}; @@ -34,9 +37,43 @@ const SIMPLE: &[(&str, &str)] = &[ /// The Word field code for the `text:` field element `local` with attributes `a`, or `None` /// when it is no field WordCraft keeps (then its text is read as text). -pub(super) fn instr_of(local: &str, a: &Attrs) -> Option { +pub(super) fn instr_of(local: &str, a: &Attrs, picture: impl Fn(&str) -> Option) -> Option { let fixed = get(a, "text:fixed").is_some_and(|v| v.trim() == "true"); + // A name a field code can hold as one word. + let name = |k: &str| { + get(a, k) + .map(str::trim) + .filter(|n| !n.is_empty() && n.len() <= MAX_REF_NAME && !n.contains(|c: char| c.is_whitespace() || c == '"' || c == '\\')) + }; match local { + // Numbering figures, tables and the like (§7.4.13): Word's `SEQ` and its number format. + "sequence" => { + let seq = name("text:name")?; + let fmt = match get(a, "style:num-format").map(str::trim) { + Some("i") => "roman", + Some("I") => "ROMAN", + Some("a") => "alphabetic", + Some("A") => "ALPHABETIC", + _ => "ARABIC", + }; + Some(format!("SEQ {seq} \\* {fmt}")) + } + // The chapter a page is in (§7.3.8): the nearest heading of its level, by name or number. + "chapter" => { + let level = get(a, "text:outline-level").and_then(|l| l.trim().parse::().ok()).unwrap_or(1).clamp(1, 9); + let number = matches!(get(a, "text:display").map(str::trim), Some("number" | "plain-number")); + Some(format!("STYLEREF \"Heading {level}\"{}", if number { " \\n" } else { "" })) + } + // References to reference marks (§7.7.5) and to sequences (§7.7.7): to the text or the + // page; a sequence's label and number is the bookmark over them. + "reference-ref" | "sequence-ref" => { + let target = name("text:ref-name")?; + match (local, get(a, "text:reference-format").map(str::trim)) { + (_, Some("page")) => Some(format!("PAGEREF {target} \\h")), + ("reference-ref", Some("text") | None) | ("sequence-ref", Some("category-and-value")) => Some(format!("REF {target} \\h")), + _ => None, + } + } // Only the current page's number: "previous" and "next" have no Word equivalent. "page-number" => (get(a, "text:select-page").is_none_or(|v| v.trim() == "current")).then(|| "PAGE".to_string()), "bookmark-ref" => { @@ -49,16 +86,45 @@ pub(super) fn instr_of(local: &str, a: &Attrs) -> Option { }) } _ if fixed => None, - _ => SIMPLE.iter().find(|(e, _)| *e == local).map(|(_, code)| code.to_string()), + _ => { + let code = SIMPLE.iter().find(|(e, _)| *e == local).map(|(_, code)| code.to_string())?; + // A date or time in its date style (§19.473 `style:data-style-name`). + let pic = get(a, "style:data-style-name").and_then(&picture).filter(|_| code.contains("DATE") || code == "TIME"); + Some(match pic { + Some(p) => format!("{code} \\@ \"{p}\""), + None => code, + }) + } } } /// The ODF field element for Word field code `instr` around `result` (already escaped), or /// `None` when ODF has none for it. -pub(super) fn element(instr: &str, result: &str) -> Option { +pub(super) fn element(instr: &str, result: &str, data_style: Option<&str>) -> Option { let mut words = instr.split_whitespace(); let code = words.next()?.to_ascii_uppercase(); match code.as_str() { + "SEQ" => { + let seq = words.next().filter(|n| !n.starts_with('\\') && n.len() <= MAX_REF_NAME)?; + let fmt = match switch_arg(instr, '*').as_deref() { + Some("roman") => "i", + Some("ROMAN") => "I", + Some("alphabetic") => "a", + Some("ALPHABETIC") => "A", + _ => "1", + }; + let seq = x(seq); + Some(format!("{result}")) + } + // `STYLEREF "Heading 2"` or `STYLEREF 2`: the chapter of that level. + "STYLEREF" => { + let target = instr.get(code.len()..)?.trim_start(); + let target = target.strip_prefix('"').and_then(|t| t.split('"').next()).unwrap_or_else(|| target.split_whitespace().next().unwrap_or("")); + let level = target.trim().strip_prefix("Heading").unwrap_or(target).trim().parse::().ok().filter(|l| (1..=10).contains(l))?; + let number = [" \\n", " \\r", " \\w"].iter().any(|s| instr.contains(s)); + let display = if number { "number" } else { "name" }; + Some(format!("{result}")) + } "PAGE" => Some(format!("{result}")), "REF" | "PAGEREF" => { let name = words.next().filter(|n| !n.starts_with('\\') && n.len() <= MAX_REF_NAME)?; @@ -74,7 +140,26 @@ pub(super) fn element(instr: &str, result: &str) -> Option { } _ => { let (el, _) = SIMPLE.iter().find(|(_, c)| *c == code)?; - Some(format!("{result}")) + let ds = data_style.map(|d| format!(" style:data-style-name=\"{}\"", x(d))).unwrap_or_default(); + Some(format!("{result}")) } } } + +/// The argument of switch `\sw` in field code `instr` (quoted or one word). +fn switch_arg(instr: &str, sw: char) -> Option { + let at = instr.find(&format!("\\{sw}"))?; + let rest = instr.get(at + 2..)?.trim_start(); + let arg = match rest.strip_prefix('"') { + Some(q) => q.split('"').next()?, + None => rest.split_whitespace().next()?, + }; + Some(arg.chars().take(64).collect()) +} + +/// The sequence (`SEQ`) field code `instr` counts, if it is one. +pub(super) fn sequence_name(instr: &str) -> Option<&str> { + let mut words = instr.split_whitespace(); + words.next().filter(|c| c.eq_ignore_ascii_case("SEQ"))?; + words.next().filter(|n| !n.starts_with('\\') && n.len() <= MAX_REF_NAME) +} diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index e134d6a6ef..a2362db6bb 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -550,8 +550,16 @@ pub fn export(doc: &Document) -> Result, String> { } else { format!("{}\n", doc.settings.track_changes, w.regions.concat()) }; + // The sequences fields count (§7.4.11), declared after any tracked changes. + let decls = if w.sequences.is_empty() { + String::new() + } else { + let d: String = + w.sequences.iter().map(|n| format!("", x(n))).collect(); + format!("{d}") + }; let content = format!( - "\n{}\n{tracked}{body}\n", + "\n{}\n{tracked}{decls}{body}\n", w.automatic_styles() ); let mut hw = Writer::with_prefix("M"); diff --git a/crates/formats/src/odt/styles.rs b/crates/formats/src/odt/styles.rs index 891d78fc58..c0034cdf06 100644 --- a/crates/formats/src/odt/styles.rs +++ b/crates/formats/src/odt/styles.rs @@ -380,6 +380,67 @@ pub(super) struct NamedStyle { pub(super) look: ParaLook, } +/// Reads date and time styles (`number:date-style`, `number:time-style`) as Word pictures. +#[derive(Default)] +struct DateStyles { + /// The style being read: its name and parts. + data: Option<(String, Vec)>, + /// In its `number:text`. + in_text: bool, +} + +impl DateStyles { + /// Event `ev` of a style file, into `out` (name → picture); false when it isn't one of a + /// date or time style. + fn event(&mut self, ev: &Ev, out: &mut HashMap) -> bool { + use super::datastyle::{Part, part, picture}; + match ev { + Ev::Start(q, a) => match local(q) { + "date-style" | "time-style" => self.data = get(a, "style:name").map(|n| (n.to_string(), Vec::new())), + "text" => self.in_text = self.data.is_some(), + name => { + let on = |k: &str, v: &str| get(a, k).is_some_and(|x| x.trim() == v); + if let (Some((_, parts)), Some(p)) = (&mut self.data, part(name, on("number:style", "long"), on("number:textual", "true"))) + && parts.len() < 64 + { + parts.push(p); + } + } + }, + Ev::End(q) => match local(q) { + "text" => self.in_text = false, + "date-style" | "time-style" => { + if let Some((name, parts)) = self.data.take() + && out.len() < MAX_DATA_STYLES + { + out.insert(name, picture(&parts)); + } + } + _ => {} + }, + Ev::Text(t) => { + if !self.in_text { + return false; + } + if let Some((_, parts)) = &mut self.data { + let n = parts.len(); + if let Some(Part::Text(s)) = parts.last_mut() + && s.len() < 200 + { + s.push_str(t); + } else if n < 64 { + parts.push(Part::Text(t.chars().take(200).collect())); + } + } + } + } + true + } +} + +/// Most date and time styles kept (hostile files). +const MAX_DATA_STYLES: usize = 10_000; + /// Longest style id made, in characters. const MAX_STYLE_ID: usize = 64; @@ -755,6 +816,8 @@ pub(super) struct Styles { default_graphic: super::frames::Graphic, /// The named dashes and gradients graphic styles use. pub(super) defs: super::strokes::Defs, + /// Date and time styles (`number:date-style`, `number:time-style`) as Word pictures. + data_styles: HashMap, } impl Styles { @@ -763,11 +826,14 @@ impl Styles { let mut cur: Option<(String, StyleInfo)> = None; let mut in_default = false; let mut in_graphic_default = false; + let mut dates = DateStyles::default(); let mut list: Option<(String, Vec)> = None; // The list or outline level being read. let mut level_at: Option = None; let mut outline_at: Option = None; events(xml, |ev| match ev { + Ev::Start(q, _) | Ev::End(q) if q.starts_with("number:") && dates.event(&ev, &mut self.data_styles) => {} + Ev::Text(_) if dates.event(&ev, &mut self.data_styles) => {} Ev::Start(q, a) => match local(&q) { "automatic-styles" => in_auto = true, "style" if q.starts_with("style:") => { @@ -1004,6 +1070,11 @@ impl Styles { name.map(|n| self.graphic(n)).unwrap_or_default().or(&self.default_graphic) } + /// The Word date-time picture of date or time style `name`. + pub(super) fn date_picture(&self, name: &str) -> Option<&str> { + self.data_styles.get(name).map(String::as_str).filter(|p| !p.is_empty()) + } + /// Vertical alignment of a table-cell style (whole chain). pub(super) fn cell_valign(&self, name: &str) -> Option { self.chain(name).iter().find_map(|(_, s)| s.cell_valign) diff --git a/crates/formats/src/odt/tests_more.rs b/crates/formats/src/odt/tests_more.rs index ec8a152c62..bfc54447c4 100644 --- a/crates/formats/src/odt/tests_more.rs +++ b/crates/formats/src/odt/tests_more.rs @@ -14,6 +14,7 @@ mod indexes; mod lists; mod named_styles; mod notes; +mod numbering_fields; mod outline; mod para_borders; mod polygons; diff --git a/crates/formats/src/odt/tests_more/fields.rs b/crates/formats/src/odt/tests_more/fields.rs index 09e5537130..0239ccceb4 100644 --- a/crates/formats/src/odt/tests_more/fields.rs +++ b/crates/formats/src/odt/tests_more/fields.rs @@ -58,7 +58,7 @@ fn fields_open_as_fields_with_their_shown_text() { #[test] fn a_fixed_date_and_unknown_fields_are_plain_text() { - let d = import(&body(r#"1 May, 4, 5"#)).unwrap(); + let d = import(&body(r#"1 May, 4, 5"#)).unwrap(); assert!(fields(&d).is_empty()); assert_eq!(d.body[0].as_para().unwrap().text, "1 May, 4, 5"); } @@ -104,7 +104,8 @@ fn fields_round_trip() { let xml = export(&d).unwrap(); let back = import(&xml).unwrap(); let got: Vec = fields(&back).into_iter().map(|(c, _)| c).collect(); - assert_eq!(got, ["PAGE", "NUMPAGES", "DATE", "TITLE", "PAGEREF intro \\h"]); + // The date keeps its picture (as a date style). + assert_eq!(got, ["PAGE", "NUMPAGES", "DATE \\@ \"d MMMM yyyy\"", "TITLE", "PAGEREF intro \\h"]); assert_eq!(back.body[0].as_para().unwrap().text.matches(wordcraft_doc::para::OBJ).count(), 5); } diff --git a/crates/formats/src/odt/tests_more/numbering_fields.rs b/crates/formats/src/odt/tests_more/numbering_fields.rs new file mode 100644 index 0000000000..ab44878885 --- /dev/null +++ b/crates/formats/src/odt/tests_more/numbering_fields.rs @@ -0,0 +1,103 @@ +//! Fields with formats and references (ODF 1.3 §7.3–7.7, §16.27 data styles): dates and times +//! in their date style (Word's `\@` picture), sequences numbering figures and tables (`SEQ`, +//! with a bookmark over the caption's label and number for references to it), chapter fields +//! (`STYLEREF`), reference marks and references to them and to sequences (`REF`, `PAGEREF`). + +use crate::odt::tests::{NS_T, content_of, package}; +use crate::odt::{export, import}; +use wordcraft_doc::Document; +use wordcraft_doc::para::{InlineObject, OBJ}; + +const NS_N: &str = r#"xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0""#; + +/// Each paragraph's fields (code, result) and bookmarks ("[name", "]name"). +fn items(d: &Document) -> Vec> { + d.body + .iter() + .filter_map(|b| b.as_para()) + .map(|p| { + p.objects + .iter() + .filter_map(|o| match o { + InlineObject::Field { instr, result, .. } => Some(format!("{instr} = {result}")), + InlineObject::BookmarkStart { name } => Some(format!("[{name}")), + InlineObject::BookmarkEnd { name } => Some(format!("]{name}")), + _ => None, + }) + .collect() + }) + .collect() +} + +/// LibreOffice's own (from a document it saved). +fn open() -> Document { + let content = format!( + r#" +, , +// +: + o'clock + +Chapter one +Sunday, November 01, 2026 01/11/26 08:45 AM 8 o'clock +In Chapter one () +Figure 1: a cat +Table I: prices +Marked here and . +See Figure 1 on page 1, here (1). +"# + ); + import(&package(&content, None)).unwrap() +} + +#[test] +fn formatted_and_referring_fields_open_as_words() { + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + assert_eq!( + items(&open()), + [ + vec![], + s(&[ + r#"DATE \@ "dddd, MMMM dd, yyyy" = Sunday, November 01, 2026"#, + r#"DATE \@ "dd/MM/yy" = 01/11/26"#, + r#"TIME \@ "hh:mm AM/PM" = 08:45 AM"#, + r#"TIME \@ "H' o''clock'" = 8 o'clock"#, + ]), + s(&[r#"STYLEREF "Heading 1" = Chapter one"#, r#"STYLEREF "Heading 2" \n = "#]), + // The bookmark over the label and number is what references to the caption show. + s(&["[refFigure0", r#"SEQ Figure \* ARABIC = 1"#, "]refFigure0"]), + s(&["[refTable0", r#"SEQ Table \* ROMAN = I"#, "]refTable0"]), + s(&["[m1", "]m1", "[m2", "]m2"]), + s(&[r#"REF refFigure0 \h = Figure 1"#, r#"PAGEREF refFigure0 \h = 1"#, r#"REF m1 \h = here"#, r#"PAGEREF m1 \h = 1"#]), + ] + ); + // The caption's bookmark starts before its label. + let p = open().body[3].as_para().unwrap().clone(); + assert!(p.text.starts_with(OBJ), "{:?}", p.text); +} + +#[test] +fn formatted_and_referring_fields_round_trip() { + let d = open(); + let bytes = export(&d).unwrap(); + let c = content_of(&bytes); + for el in ["xno name"#, + r#"1?"#, + ] { + let content = format!( + r#"{}{body}"#, + "'\"\\".repeat(500) + ); + 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 696a9b7b4d..a63c18d459 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -69,6 +69,7 @@ pub(super) const NS: &str = concat!( "xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ", "xmlns:meta=\"urn:oasis:names:tc:opendocument:xmlns:meta:1.0\" ", "xmlns:svg=\"urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0\" ", + "xmlns:number=\"urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0\" ", "xmlns:loext=\"urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0\" ", "office:version=\"1.3\"" ); @@ -87,6 +88,10 @@ pub(super) struct Writer { pub(super) ranged: std::collections::HashSet, bookmarks: std::collections::HashSet, bookmark_ends: std::collections::HashSet, + /// The date and time styles fields are shown in: (Word picture, style name, definition). + data_styles: Vec<(String, String, String)>, + /// The sequences (`SEQ`) fields count, declared at the start of the text. + pub(super) sequences: std::collections::BTreeSet, /// 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. @@ -350,7 +355,7 @@ impl Writer { // A field around content: an index's is its element (see `index_at`). Inline::FieldStart(_) | Inline::FieldEnd => {} Inline::CommentEnd(id) => out.push_str(&format!("", annotation_name(self.prefix, *id))), - Inline::Field { instr, result, fmt } => match super::fields::element(instr, &x(result)) { + Inline::Field { instr, result, fmt } => match self.field_element(instr, result) { Some(el) => { match self.text_style(&Fmt { link: None, ..fmt.clone() }) { Some(n) => out.push_str(&format!("{el}")), @@ -543,13 +548,35 @@ impl Writer { } } + /// The ODF element for field `instr` showing `result`, with the date style and sequence + /// declaration it needs; `None` when ODF has none. + fn field_element(&mut self, instr: &str, result: &str) -> Option { + let ds = self.data_style(instr); + if let Some(seq) = super::fields::sequence_name(instr) { + self.sequences.insert(seq.to_string()); + } + super::fields::element(instr, &x(result), ds.as_deref()) + } + + /// The date or time style for field code `instr`'s `\@` picture (one per picture). + fn data_style(&mut self, instr: &str) -> Option { + let pic = super::datastyle::picture_of(instr)?; + if let Some((_, name, _)) = self.data_styles.iter().find(|(p, ..)| *p == pic) { + return Some(name.clone()); + } + let name = format!("{}N{}", self.prefix, self.data_styles.len() + 1); + let xml = super::datastyle::style_xml(&name, &pic)?; + self.data_styles.push((pic, name.clone(), xml)); + Some(name) + } + pub(super) fn automatic_styles(&self) -> String { format!("{}", self.style_defs()) } /// The automatic styles made so far, without their `office:automatic-styles` element. pub(super) fn style_defs(&self) -> String { - let mut s = String::new(); + let mut s: String = self.data_styles.iter().map(|(.., xml)| xml.as_str()).collect(); for (props, n) in &self.text_styles { let (props, parent) = match props.split_once('\u{1}') { Some((p, parent)) => (p, format!(" style:parent-style-name=\"{parent}\"")), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a056ac25a2..3bc8ecf37f 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`, `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 | +| `.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; dates and times in their date style (`number:date-style`/`time-style` ↔ Word's `\@` picture, both ways), sequences numbering figures and tables (`text:sequence` ↔ `SEQ` with its number format, declared on save; a bookmark over the caption's label and number, which references to it show), chapter fields (`text:chapter` ↔ `STYLEREF "Heading n"`, by name or number), reference marks (as bookmarks) and references to them and to sequences (`text:reference-ref`, `text:sequence-ref` ↔ `REF`/`PAGEREF`); fixed dates and fields without a Word equivalent (variables, references to a caption's text) read as their text, and Word fields ODF lacks written as their result, 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: dates and times keep their format (date and time styles ↔ Word's `\@` pictures, both ways), sequences numbering figures and tables open as `SEQ` fields with a bookmark over the caption's label and number, chapter fields as `STYLEREF`, reference marks as bookmarks, and references to marks and sequences as `REF`/`PAGEREF`, instead of as plain text, so Update Field renumbers captions and refreshes references (`odt_fields.rs`); a LibreOffice document with all of them round-trips 13 px apart. Fields: a date-time picture's text in single quotes is shown as it is (`date_pictures_keep_quoted_text`) | | 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 |