mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 13:04:28 +00:00
Gap 19: ODT date formats, sequences, chapters and references
Fields that were read as plain text now open as Word's, so Update Field
recomputes them, and are written back as ODF's:
- dates and times in their date or time style (`number:date-style`,
`number:time-style`) ↔ `DATE`/`TIME \@ "picture"`: day, month (by
number or name), year, weekday, hours (12-hour with AM/PM), minutes,
seconds and the text between, both ways; data styles are written as
automatic styles and the `number` namespace declared;
- sequences numbering figures and tables (`text:sequence`) ↔ `SEQ` with
its number format; on save the sequences are declared
(`text:sequence-decls`); on open a bookmark named by the sequence's
reference name covers the caption's label and number ("Figure 1"),
what references to it show;
- chapter fields (`text:chapter`) ↔ `STYLEREF "Heading n"`, by name or
number (`\n`);
- reference marks as bookmarks, and references to them and to sequences
(`text:reference-ref`, `text:sequence-ref`) as `REF`/`PAGEREF`.
Captions renumber, references to them and chapter fields refresh after
Update Field (engine test `odt_fields.rs`). A LibreOffice document with
all of them round-trips 13 px apart with the same text; the written
content and styles validate against the ODF 1.3 schema.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
13 files changed
+536
-20
No files matched your search
@@ -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#"<text:h text:outline-level="1">Methods</text:h>
|
||||
<text:p>Figure <text:sequence text:ref-name="refFigure0" text:name="Figure" text:formula="ooow:Figure+1" style:num-format="1">7</text:sequence>: a cat</text:p>
|
||||
<text:p>Figure <text:sequence text:ref-name="refFigure1" text:name="Figure" text:formula="ooow:Figure+1" style:num-format="1">9</text:sequence>: a dog</text:p>
|
||||
<text:p>See <text:sequence-ref text:reference-format="category-and-value" text:ref-name="refFigure1">Figure 9</text:sequence-ref> in <text:chapter text:display="name" text:outline-level="1">Old</text:chapter>.</text:p>"#),
|
||||
)
|
||||
.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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<Part> {
|
||||
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<Part> {
|
||||
let chars: Vec<char> = pic.chars().take(MAX_PICTURE).collect();
|
||||
let mut out: Vec<Part> = Vec::new();
|
||||
let mut text = String::new();
|
||||
let mut i = 0;
|
||||
let flush = |text: &mut String, out: &mut Vec<Part>| {
|
||||
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::<String>().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<String> {
|
||||
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!("<number:day{}/>", long(*l)),
|
||||
Part::Month(l, t) => format!("<number:month{}{}/>", long(*l), if *t { " number:textual=\"true\"" } else { "" }),
|
||||
Part::Year(l) => format!("<number:year{}/>", long(*l)),
|
||||
Part::Weekday(l) => format!("<number:day-of-week{}/>", long(*l)),
|
||||
Part::Hours(l) => format!("<number:hours{}/>", long(*l)),
|
||||
Part::Minutes(l) => format!("<number:minutes{}/>", long(*l)),
|
||||
Part::Seconds(l) => format!("<number:seconds{}/>", long(*l)),
|
||||
Part::AmPm => "<number:am-pm/>".to_string(),
|
||||
Part::Text(t) => format!("<number:text>{}</number:text>", x(t)),
|
||||
})
|
||||
.collect();
|
||||
let el = if dated { "number:date-style" } else { "number:time-style" };
|
||||
Some(format!("<{el} style:name=\"{name}\">{body}</{el}>"))
|
||||
}
|
||||
|
||||
/// The `\@` picture of Word field code `instr`, if it has one.
|
||||
pub(super) fn picture_of(instr: &str) -> Option<String> {
|
||||
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())
|
||||
}
|
||||
@@ -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<String> {
|
||||
pub(super) fn instr_of(local: &str, a: &Attrs, picture: impl Fn(&str) -> Option<String>) -> Option<String> {
|
||||
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::<u32>().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<String> {
|
||||
})
|
||||
}
|
||||
_ 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<String> {
|
||||
pub(super) fn element(instr: &str, result: &str, data_style: Option<&str>) -> Option<String> {
|
||||
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!("<text:sequence text:name=\"{seq}\" text:formula=\"ooow:{seq}+1\" style:num-format=\"{fmt}\">{result}</text:sequence>"))
|
||||
}
|
||||
// `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::<u32>().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!("<text:chapter text:display=\"{display}\" text:outline-level=\"{level}\">{result}</text:chapter>"))
|
||||
}
|
||||
"PAGE" => Some(format!("<text:page-number text:select-page=\"current\">{result}</text:page-number>")),
|
||||
"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<String> {
|
||||
}
|
||||
_ => {
|
||||
let (el, _) = SIMPLE.iter().find(|(_, c)| *c == code)?;
|
||||
Some(format!("<text:{el}>{result}</text:{el}>"))
|
||||
let ds = data_style.map(|d| format!(" style:data-style-name=\"{}\"", x(d))).unwrap_or_default();
|
||||
Some(format!("<text:{el}{ds}>{result}</text:{el}>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The argument of switch `\sw` in field code `instr` (quoted or one word).
|
||||
fn switch_arg(instr: &str, sw: char) -> Option<String> {
|
||||
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)
|
||||
}
|
||||
@@ -550,8 +550,16 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
} else {
|
||||
format!("<text:tracked-changes text:track-changes=\"{}\">{}</text:tracked-changes>\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!("<text:sequence-decl text:display-outline-level=\"0\" text:name=\"{}\"/>", x(n))).collect();
|
||||
format!("<text:sequence-decls>{d}</text:sequence-decls>")
|
||||
};
|
||||
let content = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-content {NS}>{}<office:body><office:text>\n{tracked}{body}</office:text></office:body></office:document-content>\n",
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-content {NS}>{}<office:body><office:text>\n{tracked}{decls}{body}</office:text></office:body></office:document-content>\n",
|
||||
w.automatic_styles()
|
||||
);
|
||||
let mut hw = Writer::with_prefix("M");
|
||||
|
||||
@@ -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<super::datastyle::Part>)>,
|
||||
/// 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<String, String>) -> 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<String, String>,
|
||||
}
|
||||
|
||||
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<ListLevel>)> = None;
|
||||
// The list or outline level being read.
|
||||
let mut level_at: Option<usize> = None;
|
||||
let mut outline_at: Option<usize> = 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<VAlign> {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.cell_valign)
|
||||
|
||||
@@ -14,6 +14,7 @@ mod indexes;
|
||||
mod lists;
|
||||
mod named_styles;
|
||||
mod notes;
|
||||
mod numbering_fields;
|
||||
mod outline;
|
||||
mod para_borders;
|
||||
mod polygons;
|
||||
|
||||
@@ -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#"<text:p><text:date text:fixed="true">1 May</text:date>, <text:sequence text:name="Figure">4</text:sequence>, <text:page-number text:select-page="next">5</text:page-number></text:p>"#)).unwrap();
|
||||
let d = import(&body(r#"<text:p><text:date text:fixed="true">1 May</text:date>, <text:variable-get text:name="v">4</text:variable-get>, <text:page-number text:select-page="next">5</text:page-number></text:p>"#)).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<String> = 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Vec<String>> {
|
||||
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#"<office:document-content {NS_T} {NS_N}><office:automatic-styles>
|
||||
<number:date-style style:name="N1"><number:day-of-week number:style="long"/><number:text>, </number:text><number:month number:style="long" number:textual="true"/><number:text> </number:text><number:day number:style="long"/><number:text>, </number:text><number:year number:style="long"/></number:date-style>
|
||||
<number:date-style style:name="N2"><number:day number:style="long"/><number:text>/</number:text><number:month number:style="long"/><number:text>/</number:text><number:year/></number:date-style>
|
||||
<number:time-style style:name="N3"><number:hours number:style="long"/><number:text>:</number:text><number:minutes number:style="long"/><number:text> </number:text><number:am-pm/></number:time-style>
|
||||
<number:time-style style:name="N4"><number:hours/><number:text> o'clock</number:text></number:time-style>
|
||||
</office:automatic-styles><office:body><office:text>
|
||||
<text:h text:outline-level="1">Chapter one</text:h>
|
||||
<text:p><text:date style:data-style-name="N1">Sunday, November 01, 2026</text:date> <text:date style:data-style-name="N2">01/11/26</text:date> <text:time style:data-style-name="N3">08:45 AM</text:time> <text:time style:data-style-name="N4">8 o'clock</text:time></text:p>
|
||||
<text:p>In <text:chapter text:display="name" text:outline-level="1">Chapter one</text:chapter> (<text:chapter text:display="number" text:outline-level="2"/>)</text:p>
|
||||
<text:p>Figure <text:sequence text:ref-name="refFigure0" text:name="Figure" text:formula="ooow:Figure+1" style:num-format="1">1</text:sequence>: a cat</text:p>
|
||||
<text:p>Table <text:sequence text:ref-name="refTable0" text:name="Table" text:formula="ooow:Table+1" style:num-format="I">I</text:sequence>: prices</text:p>
|
||||
<text:p>Marked <text:reference-mark-start text:name="m1"/>here<text:reference-mark-end text:name="m1"/> and <text:reference-mark text:name="m2"/>.</text:p>
|
||||
<text:p>See <text:sequence-ref text:reference-format="category-and-value" text:ref-name="refFigure0">Figure 1</text:sequence-ref> on page <text:sequence-ref text:reference-format="page" text:ref-name="refFigure0">1</text:sequence-ref>, <text:reference-ref text:reference-format="text" text:ref-name="m1">here</text:reference-ref> (<text:reference-ref text:reference-format="page" text:ref-name="m1">1</text:reference-ref>).</text:p>
|
||||
</office:text></office:body></office:document-content>"#
|
||||
);
|
||||
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::<Vec<_>>();
|
||||
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 ["<number:date-style ", "<text:sequence ", "<text:sequence-decl ", "<text:chapter ", "style:data-style-name="] {
|
||||
assert!(c.contains(el), "{el} in {c}");
|
||||
}
|
||||
assert_eq!(items(&import(&bytes).unwrap()), items(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_formats_and_references_do_not_panic() {
|
||||
for body in [
|
||||
r#"<text:p><text:date style:data-style-name="missing">x</text:date><text:sequence>no name</text:sequence><text:chapter text:outline-level="99999"/></text:p>"#,
|
||||
r#"<text:p><text:sequence text:name="A B" text:ref-name="">1</text:sequence><text:sequence-ref text:ref-name="">?</text:sequence-ref><text:reference-mark-end text:name="never"/></text:p>"#,
|
||||
] {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_N}><office:automatic-styles><number:date-style style:name="N1"><number:text>{}</number:text></number:date-style></office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#,
|
||||
"'\"\\".repeat(500)
|
||||
);
|
||||
let d = import(&package(&content, None)).unwrap();
|
||||
let _ = export(&d).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
bookmarks: std::collections::HashSet<String>,
|
||||
bookmark_ends: std::collections::HashSet<String>,
|
||||
/// 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<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.
|
||||
@@ -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!("<office:annotation-end office:name=\"{}\"/>", 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!("<text:span text:style-name=\"{n}\">{el}</text:span>")),
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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!("<office:automatic-styles>{}</office:automatic-styles>", 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}\"")),
|
||||
|
||||
@@ -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 Line 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; dates and times in their date style (`number:date-style`/`time-style` ↔ Word's `\@` picture, both ways), sequences numbering fLine 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: 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 |
|
||||
|
||||
Reference in new issue
Block a user