Gap 19: ODT fixes found by round-tripping LibreOffice's own templates

LibreOffice ships 78 text templates (letters, faxes, agendas, reports,
CVs). Round-tripping them through WordCraft exposed these problems:
- Placeholders (`text:placeholder`, "<Recipient's name>") were
  printed text. They now open as plain-text content controls showing
  them, so a click selects one and typing replaces it. Their
  description becomes the control's title. They are written back as
  placeholders of their type while they still show; what was typed is
  written as text.
- A fixed date with neither a value nor text was lost. It is now a date
  field.
- `text:author-name` opens as USERNAME, which the engine left empty.
  USERNAME and USERINITIALS now show the user's name and initials
  from File › Options; a quoted text after the name is shown instead.
- A "First Page" master followed by another (`style:next-style-name`)
  put the running header on the first page. It is now a section with a
  different first page: its own (empty) header on page 1, the next
  master's after that.
- A page layout without a size was Letter. It is now the default
  page's, the user's region's, as LibreOffice uses its default paper.
  ODT import takes the regional page like the other importers.
- Style-only templates (no `content.xml`) failed to open. They now
  open empty with their styles.
- Table columns are now written with their share of the table
  (`style:rel-column-width`) too. A table filling the margins of a
  narrower page keeps its proportions in LibreOffice.

All 78 templates now open and round-trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude committed 2026-11-02 06:36:36 +00:00
1 parent 7848757954
commit ab02c85701
18 files changed
+369 -20

No files matched your search

@@ -234,6 +234,16 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
updates.push((k, text_result(instr, summary_property(&core, &name).to_string())));
}
"LASTSAVEDBY" => updates.push((k, text_result(instr, core.last_modified_by.clone()))),
// The user's name and initials (File › Options › General); a quoted text after
// the name is shown instead.
"USERNAME" | "USERINITIALS" => {
let v = match new_property_value(instr) {
Some(v) => v.to_string(),
None if name == "USERNAME" => s.author.clone(),
None => s.user_initials(),
};
updates.push((k, text_result(instr, v)));
}
"NUMWORDS" => updates.push((k, count_text(instr, s.doc.word_count()))),
"NUMCHARS" => {
let chars = s.doc.plain_text(StoryRef::Body).chars().filter(|c| !c.is_whitespace()).count();
+14
View File
@@ -327,6 +327,20 @@ fn document_information_fields_show_the_properties() {
assert_eq!(got, ["Budget", "plan; costs", "Draft for review", "Grace", "7", "VII", "11", "iii"]);
}
/// USERNAME and USERINITIALS show the user's name and initials from File › Options (an ODT's
/// `text:author-name` opens as USERNAME); a quoted text after the name is shown instead.
#[test]
fn user_name_fields_show_the_users_name() {
let doc = wordcraft_doc::Document::from_text("one");
let codes = ["USERNAME", "USERINITIALS", r"USERNAME \* Upper", r#"USERNAME "Someone Else""#];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.author = "Ada Lovelace".into();
s.initials = String::new();
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Ada Lovelace", "AL", "ADA LOVELACE", "Someone Else"]);
}
/// A quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces the property
/// (at most 255 characters); the field and later ones, DOCPROPERTY too, show the new value.
#[test]
+1
View File
@@ -50,6 +50,7 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option<Result<Document, String>> {
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
let mut doc = match ext.as_str() {
"odt" | "ott" => return Some(odt::import_on(bytes, page)),
"txt" | "text" => txt::import_on(bytes, page),
"md" | "markdown" => markdown::import_on(&txt::decode(bytes), page),
"html" | "htm" | "xhtml" => html::import_on(&html::decode(bytes), &|_| None, page),
+1 -1
View File
@@ -66,7 +66,7 @@ pub use flat::{export_flat, import_flat};
use import::Pages;
#[cfg(test)]
use import::{Pictures, apply_page_layout, read_entry, read_meta};
pub use import::{import, parse};
pub use import::{import, import_on, parse};
#[cfg(test)]
use limits::MAX_XML_DEPTH;
pub use package::{TEMPLATE_MIME, TEXT_MIME, export, export_template};
+30 -1
View File
@@ -51,6 +51,8 @@ pub(super) struct Body<'a> {
/// A ruby being read: its base text, ruby text, which of them the text goes to (`true`:
/// the ruby), and the base's formatting.
ruby: Option<(String, String, Option<bool>, Fmt)>,
/// A placeholder being read (§7.7.2 `text:placeholder`): its text, its run's formatting.
placeholder: Option<(String, Fmt, &'static str, String)>,
/// The form controls of `office:forms`, placed where a `draw:control` names them.
forms: super::forms::FormsReader,
pub(super) manifest: Option<&'a HashMap<String, String>>,
@@ -161,6 +163,7 @@ impl<'a> Body<'a> {
break_next: false,
sources: Vec::new(),
ruby: None,
placeholder: None,
index_marks: Vec::new(),
conn_ids: HashMap::new(),
shape_places: HashMap::new(),
@@ -282,6 +285,31 @@ impl<'a> Body<'a> {
}
}
}
// A placeholder (§7.7.2): a plain text content control showing its text until
// something is typed in its place.
Ev::Start(q, a) if q == "text:placeholder" && self.para.is_some() && self.field.is_none() && self.placeholder.is_none() => {
// Its description (§19.800 `text:description`): the control's title.
let about = get(&a, "text:description").map(|d| d.chars().take(MAX_FIELD_RESULT).collect()).unwrap_or_default();
self.placeholder = Some((String::new(), self.cur_fmt(), super::forms::placeholder_type(get(&a, "text:placeholder-type")), about));
}
Ev::Text(t) if self.placeholder.is_some() => {
if let Some((s, ..)) = &mut self.placeholder {
let room = MAX_FIELD_RESULT.saturating_sub(s.len());
s.extend(t.chars().take(room));
}
}
Ev::End(q) if q == "text:placeholder" && self.placeholder.is_some() => {
if let Some((text, fmt, kind, about)) = self.placeholder.take() {
let mut control = wordcraft_doc::control::ContentControl::new(wordcraft_doc::control::ControlKind::Text { multi_line: false });
control.placeholder_text.clone_from(&text);
control.showing_placeholder = true;
// Its element, so it is written back a placeholder of its type.
control.form_xml = super::forms::placeholder_start(kind);
control.title = about;
let shown = control.placeholder_shown().to_string();
self.push_inline(Inline::Control { control: Box::new(control), text: shown, fmt });
}
}
Ev::Text(t) if self.ruby.is_some() => {
if let Some((base, ruby, Some(in_ruby), _)) = &mut self.ruby {
let s = if *in_ruby { ruby } else { base };
@@ -963,7 +991,8 @@ impl Body<'_> {
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 });
let fixed = get(a, "text:fixed").is_some_and(|v| v.trim() == "true");
self.field = Some(OpenField { instr, result: String::new(), name: q.to_string(), depth: self.depth, fmt: self.cur_fmt(), anchor, fixed });
return true;
}
match local(q) {
+8 -2
View File
@@ -109,6 +109,8 @@ pub(in crate::odt) struct OpenField {
/// 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>,
/// Fixed without a value to be fixed at: a field while it shows nothing, else its text.
pub(in crate::odt) fixed: bool,
}
impl Body<'_> {
@@ -312,7 +314,7 @@ 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, anchor, .. }) = self.field.take() else { return };
let Some(OpenField { instr, result, mut fmt, anchor, fixed, .. }) = self.field.take() else { return };
fmt.ins = self.open_insertion();
if self.para.is_none() {
self.start_para(None, None);
@@ -326,7 +328,11 @@ impl Body<'_> {
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 fixed && !result.trim().is_empty() {
p.push_text(&result, &fmt);
} else {
p.inlines.push(Inline::Field { instr, result: result.trim().to_string(), fmt });
}
if let Some(name) = anchor {
p.inlines.push(Inline::AnchorEnd(name));
}
+4 -1
View File
@@ -38,7 +38,10 @@ 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, picture: impl Fn(&str) -> Option<String>) -> Option<String> {
let fixed = get(a, "text:fixed").is_some_and(|v| v.trim() == "true");
// Fixed, unless it holds no value to be fixed at (LibreOffice's templates: a date that shows
// the day the document is made from them).
let fixed =
get(a, "text:fixed").is_some_and(|v| v.trim() == "true") && (get(a, "text:date-value").is_some() || get(a, "text:time-value").is_some());
// A name a field code can hold as one word.
let name = |k: &str| {
get(a, k)
+23 -1
View File
@@ -32,6 +32,28 @@ const PREFIXES: [&str; 19] = [
"loext", "xml",
];
/// The `text:placeholder-type` (§19.851) of a placeholder: what it stands for.
pub(in crate::odt) fn placeholder_type(t: Option<&str>) -> &'static str {
match t.map(str::trim) {
Some("table") => "table",
Some("text-box") => "text-box",
Some("image") => "image",
Some("object") => "object",
_ => "text",
}
}
/// A placeholder's start tag, which a content control read from one keeps (in
/// [`ContentControl::form_xml`]) to be written back as one.
pub(in crate::odt) fn placeholder_start(kind: &str) -> String {
format!("<text:placeholder text:placeholder-type=\"{kind}\">")
}
/// Whether control `c` was read from a placeholder.
pub(in crate::odt) fn is_placeholder(c: &ContentControl) -> bool {
c.form_xml.starts_with("<text:placeholder ")
}
/// A control read: its content control and the text it holds.
pub(in crate::odt) type Read = (ContentControl, String);
@@ -261,7 +283,7 @@ pub(in crate::odt) fn form_xml(c: &ContentControl, text: &str, id: &str, n: usiz
};
// A control kept verbatim, with its new id, while it is still the control it was read as
// (made another kind, it is written as that).
if !c.form_xml.is_empty() && c.kind == (ControlKind::Text { multi_line: false }) {
if !c.form_xml.is_empty() && c.kind == (ControlKind::Text { multi_line: false }) && !is_placeholder(c) {
let end = c.form_xml.find(|ch: char| ch.is_whitespace() || ch == '>')?;
let (head, rest) = (c.form_xml.get(..end)?, c.form_xml.get(end..)?);
return Some(size((format!("{head} xml:id=\"{id}\" form:id=\"{id}\"{rest}"), 72.0, 20.0)));
+61 -11
View File
@@ -363,7 +363,7 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
/// [`parse`], with the last section's properties (the others are on the paragraphs that end them).
fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> {
parse_tracked(bytes).map(|p| (p.flow, p.last))
parse_tracked(bytes, default_paper()).map(|p| (p.flow, p.last))
}
/// What [`parse_tracked`] reads besides the flow.
@@ -432,8 +432,18 @@ fn hyphenation(blocks: &mut [FBlock]) -> bool {
any
}
/// [`parse_full`], plus what the document holds outside the flow.
fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
/// The content of a package that has none: an empty text.
const EMPTY_CONTENT: &str = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text><text:p/></office:text></office:body></office:document-content>"#;
/// The page size of a page layout that states none: WordCraft's default page's.
fn default_paper() -> (f32, f32) {
let d = SectionProps::default();
(d.page_w, d.page_h)
}
/// [`parse_full`], plus what the document holds outside the flow. A page layout that states no
/// size (§20.215 `fo:page-width` is optional) is `paper` (width, height) in size.
fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("ODT: not a valid package ({e})"))?;
if let Some(mt) = read_entry(&mut z, "mimetype") {
let mt = String::from_utf8_lossy(&mt);
@@ -441,13 +451,18 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
return Err(format!("ODT: not a text document ({})", mt.trim()));
}
}
let content = read_entry(&mut z, "content.xml").ok_or("ODT: content.xml is missing")?;
let styles_xml = read_entry(&mut z, "styles.xml");
// A template of styles only (LibreOffice's style templates) has no content: an empty text.
let content = match read_entry(&mut z, "content.xml") {
Some(c) => c,
None if styles_xml.is_some() => EMPTY_CONTENT.as_bytes().to_vec(),
None => return Err("ODT: content.xml is missing".into()),
};
let mut styles = Styles::default();
let mut pages = Pages::default();
let styles_xml = read_entry(&mut z, "styles.xml");
if let Some(s) = &styles_xml {
styles.read(s, false);
pages = read_pages(s);
pages = read_pages(s, paper);
}
styles.read(&content, true);
let settings = read_entry(&mut z, "settings.xml");
@@ -471,6 +486,7 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
};
let objects = (entries.as_slice(), &manifest);
let mut parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch, objects)).unwrap_or_default();
first_pages(&mut pages);
let mut body = Body::new(&styles, &pages, &mut fetch);
(body.entries, body.manifest) = (objects.0, Some(objects.1));
events(&content, |ev| body.feed(ev));
@@ -533,6 +549,12 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
/// Parse an `.odt` package into a document.
pub fn import(bytes: &[u8]) -> Result<Document, String> {
import_on(bytes, &SectionProps::default())
}
/// [`import`], with `page` the default page (the user's region's): a page layout that states no
/// size is `page`'s size, as LibreOffice makes it its default paper's.
pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String> {
model::begin_import();
let Parsed {
flow,
@@ -558,7 +580,7 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
sources,
kept,
hidden_controls,
} = parse_tracked(bytes)?;
} = parse_tracked(bytes, (page.page_w, page.page_h))?;
let mut doc = model::to_doc(&flow);
kept.store(&mut doc);
// Elements can't hold a NUL: it parts them.
@@ -612,6 +634,9 @@ pub(super) struct Pages {
pub(super) masters: HashMap<String, SectionProps>,
pub(super) first: Option<SectionProps>,
pub(super) first_name: Option<String>,
/// Each master page's next one (§19.511 `style:next-style-name`): the page style the pages
/// after its first take.
pub(super) next: HashMap<String, String>,
/// What the document's first page style and its note and line numbering settings say for
/// the whole document.
pub(super) doc: PageDoc,
@@ -680,7 +705,7 @@ struct Layout {
footer: Band,
}
fn read_pages(xml: &[u8]) -> Pages {
fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
let mut layouts: HashMap<String, Layout> = HashMap::new();
let mut cur: Option<String> = None;
// Inside a layout's footer style (else its header style).
@@ -688,6 +713,7 @@ fn read_pages(xml: &[u8]) -> Pages {
// Master pages: name, layout, whether a header and a footer show.
let mut masters: Vec<(String, String, bool, bool)> = Vec::new();
let mut in_master = false;
let mut nexts: HashMap<String, String> = HashMap::new();
let mut doc = PageDoc::default();
let mut lines = None;
events(xml, |ev| match ev {
@@ -732,6 +758,9 @@ fn read_pages(xml: &[u8]) -> Pages {
if let (Some(n), Some(l)) = (get(&a, "style:name"), get(&a, "style:page-layout-name")) {
masters.push((n.to_string(), l.to_string(), false, false));
in_master = true;
if let Some(next) = get(&a, "style:next-style-name").map(str::trim).filter(|x| !x.is_empty() && *x != n) {
nexts.insert(n.to_string(), next.to_string());
}
}
}
"header" | "footer" if in_master && q.starts_with("style:") && get(&a, "style:display") != Some("false") => {
@@ -752,10 +781,10 @@ fn read_pages(xml: &[u8]) -> Pages {
},
Ev::Text(_) => {}
});
let mut pages = Pages { doc, ..Default::default() };
let mut pages = Pages { doc, next: nexts, ..Default::default() };
for (name, layout, header, footer) in masters {
let Some(Layout { attrs: a, columns: cols, header: hb, footer: fb }) = layouts.get(&layout) else { continue };
let mut sec = SectionProps::default();
let mut sec = SectionProps { page_w: paper.0, page_h: paper.1, ..SectionProps::default() };
apply_layout(a, &mut sec);
sec.line_numbers.clone_from(&lines);
if pages.first.is_none() {
@@ -787,10 +816,31 @@ fn read_pages(xml: &[u8]) -> Pages {
pages
}
/// A master page followed by another (`style:next-style-name`, LibreOffice's "First Page"
/// before "Default") is a section with a different first page: its own headers and footers on
/// the first page (none there is an empty one), the next master's on the pages after it. Its
/// page size and margins are its own (the first page's).
fn first_pages(pages: &mut Pages) {
let names: Vec<(String, String)> = pages.next.iter().map(|(a, b)| (a.clone(), b.clone())).collect();
for (name, next) in names {
let Some(after) = pages.masters.get(&next).cloned() else { continue };
let Some(sec) = pages.masters.get_mut(&name) else { continue };
let (own_header, own_footer) = (sec.headers.default, sec.footers.default);
sec.headers = after.headers;
sec.footers = after.footers;
sec.headers.first = own_header;
sec.footers.first = own_footer;
sec.title_page = true;
if pages.first_name.as_deref() == Some(name.as_str()) {
pages.first = Some(sec.clone());
}
}
}
/// Replace `sec` with the page setup of the first master page in `styles.xml`, if any.
#[cfg(test)]
pub(super) fn apply_page_layout(xml: &[u8], sec: &mut SectionProps) {
if let Some(first) = read_pages(xml).first {
if let Some(first) = read_pages(xml, default_paper()).first {
*sec = first;
}
}
+1
View File
@@ -30,6 +30,7 @@ mod outline;
mod page_setup;
mod para_borders;
mod parts;
mod placeholders;
mod polygons;
mod rotation;
mod rtl;
@@ -61,6 +61,10 @@ 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: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");
// A fixed date with neither a value nor text (LibreOffice's templates) shows the day: a
// date field.
let d = import(&body(r#"<text:p><text:date text:fixed="true"/>.</text:p>"#)).unwrap();
assert_eq!(fields(&d).len(), 1);
}
fn field_para(parts: &[(&str, Option<&str>)]) -> std::sync::Arc<wordcraft_doc::Block> {
+24
View File
@@ -143,3 +143,27 @@ fn a_template_is_saved_and_opened_as_one() {
// A document is still a document.
assert_eq!(crate::odt::tests::entry(&crate::export("odt", &d).unwrap().unwrap(), "mimetype"), crate::odt::TEXT_MIME);
}
#[test]
fn a_template_of_styles_only_opens_empty_with_its_styles() {
// LibreOffice's style templates hold `styles.xml` and no `content.xml`.
use std::io::Write;
let styles = format!(
r#"<office:document-styles {}><office:styles><style:style style:name="Standard" style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:style></office:styles></office:document-styles>"#,
crate::odt::tests::NS_T
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", crate::odt::TEMPLATE_MIME), ("styles.xml", styles.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
}
let d = crate::import("ott", &z.finish().unwrap().into_inner()).unwrap().unwrap();
assert_eq!(d.body.len(), 1);
assert_eq!(d.styles.default_chr.font.as_deref(), Some("DejaVu Serif"));
// Without styles either, it isn't a document.
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
z.start_file("mimetype", o).unwrap();
z.write_all(crate::odt::TEXT_MIME.as_bytes()).unwrap();
assert!(crate::import("odt", &z.finish().unwrap().into_inner()).unwrap().is_err());
}
@@ -88,3 +88,16 @@ fn a_tables_alignment_width_and_row_heights_are_kept() {
assert_eq!((pt.props.align, pt.props.width_pct), (None, None));
assert!(content_of(&export(&plain).unwrap()).contains(r#"table:align="margins""#));
}
#[test]
fn columns_are_written_with_their_share_of_the_table() {
// A table filling the margins of a page narrower than it was made for keeps its columns'
// proportions only when they are given as shares too (LibreOffice scales by them).
let styles = r#"<style:style style:name="A" style:family="table-column"><style:table-column-properties style:column-width="1in"/></style:style><style:style style:name="B" style:family="table-column"><style:table-column-properties style:column-width="3in"/></style:style>"#;
let d = doc(
styles,
r#"<table:table><table:table-column table:style-name="A"/><table:table-column table:style-name="B"/><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#,
);
let c = content_of(&export(&d).unwrap());
assert!(c.contains(r#"style:column-width="72pt" style:rel-column-width="7200*""#) && c.contains(r#"style:rel-column-width="21600*""#), "{c}");
}
@@ -153,3 +153,35 @@ fn header_and_footer_distances_round_trip() {
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
assert!(near(s.header, 30.0) && near(s.margin_top, 80.0) && near(s.footer, 20.0) && near(s.margin_bottom, 70.0), "{s:?}");
}
#[test]
fn a_first_page_master_followed_by_another_is_a_different_first_page() {
// LibreOffice's "First Page" style (no header) passes to "Standard" (a header) after the
// first page: one section, the first page's header empty, the others' Standard's.
let ns = crate::odt::tests::NS_T;
let styles = format!(
r#"<office:document-styles {ns}><office:styles><style:style style:name="Opening" style:family="paragraph" style:master-page-name="First_20_Page"/></office:styles><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="1in"/></style:page-layout><style:page-layout style:name="pm2"><style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="2in"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:header><text:p>Running head</text:p></style:header></style:master-page><style:master-page style:name="First_20_Page" style:page-layout-name="pm2" style:next-style-name="Standard"/></office:master-styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><text:p text:style-name="Opening">Letter.</text:p><text:p>More.</text:p></office:text></office:body></office:document-content>"#
);
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
let sec = &d.last_section;
assert!(sec.title_page, "a different first page");
let first_text: String = sec
.headers
.first
.and_then(|id| d.parts.get(&id))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect())
.unwrap_or_default();
assert_eq!(first_text, "", "{:?}", sec.headers);
let head = sec.headers.default.and_then(|id| d.parts.get(&id)).expect("the running head");
assert_eq!(head.blocks[0].as_para().map(|p| p.text.as_str()), Some("Running head"));
// The first page's own size and margins.
assert_eq!(sec.margin_top, 144.0);
// Written back: an empty first-page header.
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
let first = s.split("<style:header-first>").nth(1).and_then(|r| r.split("</style:header-first>").next()).unwrap_or("missing");
assert!(first.trim().starts_with("<text:p") && !first.contains("Running head") && s.contains("Running head"), "{s}");
}
@@ -118,3 +118,26 @@ fn line_numbering_is_on_unless_it_says_otherwise() {
assert_eq!(d.last_section.line_numbers.is_some(), on, "{attr}");
}
}
#[test]
fn a_page_layout_without_a_size_is_the_default_pages_size() {
// LibreOffice's templates often state margins but no page size: it is the default paper's
// (the user's region's in WordCraft, as LibreOffice uses its own default).
let styles = format!(
r#"<office:document-styles {}><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1.25in" fo:margin-right="1.25in"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"/></office:master-styles></office:document-styles>"#,
crate::odt::tests::NS_T
);
let content = format!(
"<office:document-content {}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>",
crate::odt::tests::NS_T
);
let bytes = crate::odt::tests::package(&content, Some(&styles));
let a4 = wordcraft_doc::SectionProps { page_w: 595.3, page_h: 841.9, ..Default::default() };
let d = crate::import_on("odt", &bytes, &a4).unwrap().unwrap();
assert_eq!((d.last_section.page_w, d.last_section.page_h), (595.3, 841.9));
assert_eq!((d.last_section.margin_top, d.last_section.margin_left), (72.0, 90.0));
// A stated size is the file's.
let sized = styles.replace(r#"<style:page-layout-properties "#, r#"<style:page-layout-properties fo:page-width="5in" fo:page-height="7in" "#);
let d = crate::import_on("odt", &crate::odt::tests::package(&content, Some(&sized)), &a4).unwrap().unwrap();
assert_eq!((d.last_section.page_w, d.last_section.page_h), (360.0, 504.0));
}
@@ -0,0 +1,92 @@
//! Placeholders (ODF 1.3 §7.7.2 `text:placeholder`: "click here and type" text, as LibreOffice's
//! templates use): opened as plain text content controls showing their placeholder, so a click
//! selects one and typing replaces it, and written back as placeholders while they still show it.
use crate::model::Inline;
use crate::odt::tests::{NS_T, content_of, package, paras};
use crate::odt::{export, import, parse};
fn doc(body: &str) -> Vec<u8> {
package(
&format!(r#"<office:document-content {NS_T}><office:body><office:text>{body}</office:text></office:body></office:document-content>"#),
None,
)
}
/// Each control: (its placeholder text, showing it, the text shown).
fn controls(bytes: &[u8]) -> Vec<(String, bool, String)> {
paras(&parse(bytes).unwrap())
.into_iter()
.flat_map(|p| &p.inlines)
.filter_map(|i| match i {
Inline::Control { control, text, .. } => Some((control.placeholder_text.clone(), control.showing_placeholder, text.clone())),
_ => None,
})
.collect()
}
#[test]
fn placeholders_open_as_controls_showing_them() {
let bytes = doc(
r#"<text:p>Dear <text:placeholder text:placeholder-type="text">&lt;Title&gt;</text:placeholder><text:placeholder text:placeholder-type="text">&lt;Name&gt;</text:placeholder>,</text:p>"#,
);
assert_eq!(controls(&bytes), [("<Title>".into(), true, "<Title>".into()), ("<Name>".into(), true, "<Name>".into())]);
// The paragraph's own text around them.
let d = import(&bytes).unwrap();
let text = d.body[0].as_para().unwrap().text.replace(wordcraft_doc::para::OBJ, "");
assert_eq!(text, "Dear <Title><Name>,");
}
#[test]
fn placeholders_are_written_back_while_they_show() {
let bytes = doc(r#"<text:p>Dear <text:placeholder text:placeholder-type="text">&lt;Name&gt;</text:placeholder>,</text:p>"#);
let saved = export(&import(&bytes).unwrap()).unwrap();
let c = content_of(&saved);
assert!(c.contains(r#"<text:placeholder text:placeholder-type="text">&lt;Name&gt;</text:placeholder>"#), "{c}");
assert!(!c.contains("<form:"), "{c}");
assert_eq!(controls(&saved), controls(&bytes));
}
#[test]
fn an_empty_or_hostile_placeholder_never_panics() {
let long = "x".repeat(200_000);
for body in [
r#"<text:p><text:placeholder/></text:p>"#.to_string(),
r#"<text:placeholder>outside a paragraph</text:placeholder><text:p>p</text:p>"#.to_string(),
format!(r#"<text:p><text:placeholder>{long}</text:placeholder></text:p>"#),
r#"<text:p><text:placeholder><text:placeholder>nested</text:placeholder></text:placeholder></text:p>"#.to_string(),
r#"<text:p><text:placeholder>unclosed</text:p>"#.to_string(),
] {
if let Ok(d) = import(&doc(&body)) {
let _ = export(&d).unwrap();
}
}
}
#[test]
fn a_placeholders_type_is_kept_and_typed_text_is_text() {
let bytes = doc(
r#"<text:p><text:placeholder text:placeholder-type="image" text:description="Your logo &amp; mark">&lt;Logo&gt;</text:placeholder> and <text:placeholder text:placeholder-type="nonsense">&lt;X&gt;</text:placeholder></text:p>"#,
);
let c = content_of(&export(&import(&bytes).unwrap()).unwrap());
// Its description is the control's title, written back.
assert!(
c.contains(r#"<text:placeholder text:placeholder-type="image" text:description="Your logo &amp; mark">&lt;Logo&gt;</text:placeholder>"#),
"{c}"
);
assert!(c.contains(r#"<text:placeholder text:placeholder-type="text">&lt;X&gt;</text:placeholder>"#), "{c}");
// Something typed in its place is text.
let mut flow = parse(&bytes).unwrap();
for b in &mut flow.blocks {
if let crate::model::FBlock::Para(p) = b {
for i in &mut p.inlines {
if let Inline::Control { control, text, .. } = i {
control.showing_placeholder = false;
*text = "Acme Ltd".into();
}
}
}
}
let c = content_of(&export(&crate::model::to_doc(&flow)).unwrap());
assert!(!c.contains("<text:placeholder") && c.contains(r#"Acme <text:s text:c="1"/>Ltd"#), "{c}");
}
+25 -1
View File
@@ -1,4 +1,5 @@
use super::{Align, FBlock, FTable, Fmt, HashMap, Inline, Kind, Para, RevMark, Rgb, VAlign, mime_of, model};
use wordcraft_doc::control::ControlKind;
use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing};
mod charts;
@@ -463,6 +464,26 @@ impl Writer {
}
// A form control (in the body: forms belong to the text, ODF 1.3 §13.2), placed
// here; elsewhere, or of a kind forms don't have, its text.
// A control read from a placeholder (§7.7.2), in the body or anywhere else: a
// placeholder while it shows it, else what was typed in its place.
Inline::Control { control, text, fmt } if super::forms::is_placeholder(control) => {
if control.showing_placeholder && control.kind == (ControlKind::Text { multi_line: false }) {
// Its description: the control's title.
let start = match control.form_xml.strip_suffix('>') {
Some(head) if !control.title.is_empty() => format!("{head} text:description=\"{}\">", x(&control.title)),
_ => control.form_xml.clone(),
};
let el = format!("{start}{}</text:placeholder>", x(control.placeholder_shown()));
match self.text_style(&Fmt { link: None, ..fmt.clone() }) {
Some(n) => out.push_str(&format!("<text:span text:style-name=\"{n}\">{el}</text:span>")),
None => out.push_str(&el),
}
prev_space = false;
} else {
self.plain_inlines(&[Inline::Text(text.clone(), fmt.clone())], out);
prev_space = text.ends_with(' ');
}
}
Inline::Control { control, text, fmt } => {
let id = format!("control{}", self.forms.len() + 1);
match super::forms::form_xml(control, text, &id, self.forms.len() + 1).filter(|_| self.forms_allowed) {
@@ -779,8 +800,11 @@ impl Writer {
pt(total)
));
for (c, w) in widths.iter().enumerate() {
// Its share of the table too (§20.334 `style:rel-column-width`), so a table filling
// the margins of a narrower page keeps its columns' proportions.
let rel = if w.is_finite() { (w.clamp(0.0, 1584.0) * 100.0).round().max(1.0) as u32 } else { 1 };
s.push_str(&format!(
"<style:style style:name=\"Table{tn}.C{}\" style:family=\"table-column\"><style:table-column-properties style:column-width=\"{}\"/></style:style>",
"<style:style style:name=\"Table{tn}.C{}\" style:family=\"table-column\"><style:table-column-properties style:column-width=\"{}\" style:rel-column-width=\"{rel}*\"/></style:style>",
c + 1,
pt(*w)
));
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments <!--> and <!---> at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corpoLine truncated
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments <!--> and <!---> at once, #5040); previously trivial Line truncated
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -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 (and flat `.fodt`, templates `.ott`) | 2% | R/W | 🟡 | 🟡 | ~90% | 20–35 | `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`, `odt_charts.rs`, `odt_objects.rs`, `odt_password.rs`, `odt_forms.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | Ruby (`text:ruby`, §6.4: a base text with ruby text over it) is read as WordCraft's Phonetic Guide and written back as ODF ruby above its base, centred (one inside a tracked change is written as its base text, which the change marks); its own position and alignment aren't kept. A border around text (LibreOffice's `loext:border` or its sides, with `loext:padding`: ODF 1.3 has no attribute for it) is Word's text border, one line all round, and is written back so (a document with one is then extended ODF, not strict). Emphasis marks (`style:text-emphasize`) and rotated text (`style:text-rotation-angle`) aren't read. How text flows: a page break after a paragraph (`fo:break-after`, a page break before the next paragraph as Word has it), lines kept together (`fo:keep-together`), widow and orphan control (`fo:widows`/`fo:orphans`: on with any line count, off with none; the counts themselves aren't kept) and hyphenation (`fo:hyphenate`, a paragraph's in ODF and the document's in WordCraft: a document with a hyphenated paragraph hyphenates, its other paragraphs opting out) are read and written; a table's alignment and width (`table:align`, `style:rel-width`, or `style:width` for an aligned table) and its rows' heights (`style:row-height` exact, `style:min-row-height` at least) are read and written. Form controls (`office:forms` controls placed by `draw:control`, ODF 1.3 §13) open as content controls — text fields and text areas as plain text controls, check boxes as check boxes, radio buttons as check boxes with round symbols, list boxes as drop-down lists, combo boxes as combo boxes, date fields as date controls, each with its value, entries and selection, its name as the control's tag and a label as its title — and content controls of those kinds in the body are written back as form controls LibreOffice shows with their values (in headers and footers, as their text); a control keeps its size (`draw:control` `svg:width`/`svg:height`); buttons, image buttons, labels and the other controls WordCraft has no kind for show their label, locked, and are kept verbatim and written back as they were (one naming a namespace WordCraft doesn't declare is left out); hidden controls (`form:hidden`) are kept and written back with their form; a control's look (colours, font) isn't kept. OpenDocument text templates (`.ott`) open as documents and save as templates (the template's media type in `mimetype` and the manifest), with a password too. Turned text (`style:writing-mode`, §20.404; LibreOffice's own `loext:writing-mode="bt-lr"` for text running upwards) in table cells and text boxes opens as WordCraft's cell and text box text direction, down or up, and is written back the same way; turned characters (`style:text-rotation-angle`) open upright, as WordCraft has no turned characters. Package parts WordCraft doesn't read — LibreOffice Basic macros and dialogs (`Basic/`, `Dialogs/`), `manifest.rdf`, other applications' files — are kept as they are and written back with their manifest entries, so LibreOffice still finds a document's macros (WordCraft never runs them); the old thumbnail and digital signatures (`META-INF/*signatures*.xml`, which sign the package as it was) are dropped on save, and paths that climb out of the package aren't written. Password-protected ODT (ODF 1.3 Part 2 §3.4) opens with its password — AES-256-CBC with a SHA-256 or SHA-1 start key and PBKDF2, as LibreOffice writes it; the old Blowfish encryption is refused with a message saying so — and a document opened with a password, or saved with one (Save with password), is written encrypted the way LibreOffice encrypts (every file but the `mimetype` and manifest; each with its own salt and initialisation vector; SHA-256 checksums), which LibreOffice opens with the password. Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the 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 ContentsLine truncated
| `.odt` OpenDocument Text (and flat `.fodt`, templates `.ott`) | 2% | R/W | 🟡 | 🟡 | ~90% | 20–35 | `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`, `odt_charts.rs`, `odt_objects.rs`, `odt_password.rs`, `odt_forms.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | Ruby (`text:ruby`, §6.4: a base text with ruby text over it) is read as WordCraft's Phonetic Guide and written back as ODF ruby above its base, centred (one inside a tracked change is written as its base text, which the change marks); its own position and alignment aren't kept. A border around text (LibreOffice's `loext:border` or its sides, with `loext:padding`: ODF 1.3 has no attribute for it) is Word's text border, one line all round, and is written back so (a document with one is then extended ODF, not strict). Emphasis marks (`style:text-emphasize`) and rotated text (`style:text-rotation-angle`) aren't read. How text flows: a page break after a paragraph (`fo:break-after`, a page break before the next paragraph as Word has it), lines kept together (`fo:keep-together`), widow and orphan control (`fo:widows`/`fo:orphans`: on with any line count, off with none; the counts themselves aren't kept) and hyphenation (`fo:hyphenate`, a paragraph's in ODF and the document's in WordCraft: a document with a hyphenated paragraph hyphenates, its other paragraphs opting out) are read and written; a table's alignment and width (`table:align`, `style:rel-width`, or `style:width` for an aligned table) and its rows' heights (`style:row-height` exact, `style:min-row-height` at least) are read and written. Form controls (`office:forms` controls placed by `draw:control`, ODF 1.3 §13) open as content controls — text fields and text areas as plain text controls, check boxes as check boxes, radio buttons as check boxes with round symbols, list boxes as drop-down lists, combo boxes as combo boxes, date fields as date controls, each with its value, entries and selection, its name as the control's tag and a label as its title — and content controls of those kinds in the body are written back as form controls LibreOffice shows with their values (in headers and footers, as their text); a control keeps its size (`draw:control` `svg:width`/`svg:height`); buttons, image buttons, labels and the other controls WordCraft has no kind for show their label, locked, and are kept verbatim and written back as they were (one naming a namespace WordCraft doesn't declare is left out); hidden controls (`form:hidden`) are kept and written back with their form; a control's look (colours, font) isn't kept. Placeholders (`text:placeholder`, §7.7.2, as LibreOffice's templates use: "<Recipient's name>") open as plain text content controls showing them, so a click selects one and typing replaces it, and are written back as placeholders of their type (their description as the control's title) while they show it. A master page followed by another (`style:next-style-name`: LibreOffice's "First Page" before "Default") is a section with a different first page, its own headers and footers on the first page and the next one's after it. Table columns are written with their share of the table (`style:rel-column-width`) too, so a table filling the margins of a narrower page keeps its proportions in LibreOffice. A fixed date or time with neither a value nor text (LibreOffice's templates) is a date field showing the day; `text:author-name` is `USERNAME`, which WordCraft now fills with the user's name. A page layout that states no size is the default page's (the user's region's, as LibreOffice uses its default paper), and a template of styles only (no `content.xml`) opens empty with its styles. OpenDocument text templates (`.ott`) open as documents and save as templates (the template's media type in `mimetype` and the manifest), with a password too. Turned text (`style:writing-mode`, §20.404; LibreOffice's own `loext:writing-mode="bt-lr"` for text running upwards) in table cells and text boxes opens as WordCraft's cell and text box text direction, down or up, and is written back the same way; turned characters (`style:text-rotation-angle`) open upright, as WordCraft has no turned characters. Package parts WordCraft doesn't read — LibreOffice Basic macros and dialogs (`Basic/`, `Dialogs/`), `manifest.rdf`, other applications' files — are kept as they are and written back with their manifest entries, so LibreOffice still finds a document's macros (WordCraft never runs them); the old thumbnail and digital signatures (`META-INF/*signatures*.xml`, which sign the package as it was) are dropped on save, and paths that climb out of the package aren't written. Password-protected ODT (ODF 1.3 Part 2 §3.4) opens with its password — AES-256-CBC with a SHA-256 or SHA-1 start key and PBKLine 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-02 | trivial | ODT, from round-tripping LibreOffice's 78 bundled templates: a "First Page" master followed by another put the running header on the first page; it is a different first page. Columns lost their proportions on a narrower page; their shares are written. Placeholders were printed text; they open as content controls showing them and are written back as placeholders. A value-less fixed date was lost; it is a date field. `USERNAME`/`USERINITIALS` fields show the user's name and initials. A page layout without a size was Letter; it is the default page's (A4 where that is the default). Style-only templates failed to open; they open empty with their styles (`odt::tests_more::placeholders`, `fields`, `page_setup::a_page_layout_without_a_size_is_the_default_pages_size`, `flat::a_template_of_styles_only_opens_empty_with_its_styles`, `references::tests::user_name_fields_show_the_users_name`) |
| 2026-11-02 | trivial | ODT: hidden form controls were dropped; they are kept and written back. OpenDocument text templates (`.ott`) weren't opened; they open as documents and save as templates (password too), and LibreOffice's template round-trips with its text the same (`odt::tests_more::forms::hidden_controls_are_kept`, `flat::a_template_is_saved_and_opened_as_one`, `odt_password.rs::an_odt_template_opens_and_saves_as_a_template_with_a_password_too`) |
| 2026-11-01 | trivial | ODT re-estimated at ~90% (20–35 h left) after custom shapes, cell formulas, kept package parts, turned text and control sizes and buttons; a kept form control made another kind is saved as that kind |
| 2026-11-01 | trivial | ODT: form controls were sized to their text on save, and buttons and other controls WordCraft has no kind for were dropped; controls keep their size, and those controls show their label and are kept verbatim, so LibreOffice's button round-trips as a button (`odt::tests_more::forms::controls_keep_their_size`, `buttons_are_kept`, `hostile_kept_controls_are_bounded`) |