mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 18:51:26 +00:00
Gap 19: flat OpenDocument text (.fodt) opened and saved
A flat ODT holds the whole document in one `office:document` element (ODF 1.3 Part 3 §2.1.2), its pictures inline as base64 `office:binary-data` and its formulas inline in their `draw:object`. It is opened by taking it apart into a package's files (content, styles, meta, settings; pictures and formulas as entries, each part's root keeping the document's namespaces) and reading that package, and saved by putting a package's files together: one set of automatic styles, every part's namespaces declared once on the root, pictures and formulas inline without the link attributes. `.fodt` opens and saves from Open, Save As, the CLI and the web app. LibreOffice's own flat documents round-trip as their packages do, and the written files validate against the ODF 1.3 schema. The parity doc's ODT fidelity is re-estimated at ~80%, and its status line, which hadn't been updated since the Asian fonts change (a text replacement that matched nothing), now lists every change since. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
16 files changed
+421
-8
No files matched your search
Generated
+1
@@ -6802,6 +6802,7 @@ dependencies = [
|
||||
name = "wordcraft-formats"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"cfb",
|
||||
"chardetng",
|
||||
"chrono",
|
||||
|
||||
@@ -9,7 +9,7 @@ use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "fodt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
|
||||
mod font_store;
|
||||
@@ -654,6 +654,7 @@ fn mime_for(name: &str) -> &'static str {
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
Some("odt") => "application/vnd.oasis.opendocument.text",
|
||||
Some("fodt") => "application/vnd.oasis.opendocument.text-flat-xml",
|
||||
Some("rtf") => "application/rtf",
|
||||
Some("html") => "text/html",
|
||||
Some("md" | "txt") => "text/plain",
|
||||
|
||||
@@ -239,6 +239,7 @@ fn format_name(ext: &str) -> String {
|
||||
"html" => "Web Page".into(),
|
||||
"rtf" => "Rich Text Format".into(),
|
||||
"odt" => "OpenDocument Text".into(),
|
||||
"fodt" => "Flat OpenDocument Text".into(),
|
||||
"tex" => "LaTeX".into(),
|
||||
"png" => "PNG Image".into(),
|
||||
"json" => "JSON".into(),
|
||||
|
||||
@@ -380,7 +380,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
|
||||
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
|
||||
let own_file = s.path.as_deref() == Some(path.as_path());
|
||||
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = false;
|
||||
s.relayout();
|
||||
|
||||
@@ -20,6 +20,7 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"htm",
|
||||
"rtf",
|
||||
"odt",
|
||||
"fodt",
|
||||
"tex",
|
||||
"latex",
|
||||
"ltx",
|
||||
@@ -28,7 +29,7 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"json",
|
||||
];
|
||||
/// Formats WordCraft saves (Save As).
|
||||
pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json", "xml"];
|
||||
pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "fodt", "tex", "png", "json", "xml"];
|
||||
|
||||
/// Magic number for ZIP package signatures (used by .docx/.docm/.dotx/.dotm).
|
||||
const ZIP_SIG: &[u8] = b"PK\x03\x04";
|
||||
|
||||
@@ -71,3 +71,14 @@ fn captions_chapters_and_dates_update_as_words() {
|
||||
s.run("references.updateFields", &json!({"all": true})).unwrap();
|
||||
assert_eq!(results(&s), ["1", "2", "Figure 2", "Methods"], "{:?}", text(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_document_opens_and_saves_by_its_extension() {
|
||||
let fodt = r#"<?xml version="1.0" encoding="UTF-8"?><office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text"><office:body><office:text><text:p>Flat words.</text:p></office:text></office:body></office:document>"#;
|
||||
let doc = io::open_bytes("plain.fodt", fodt.as_bytes()).unwrap();
|
||||
let s = Session::new(doc);
|
||||
assert_eq!(text(&s), ["Flat words."]);
|
||||
let saved = io::save_bytes("plain.fodt", &s.doc).unwrap();
|
||||
assert!(saved.starts_with(b"<?xml"));
|
||||
assert_eq!(text(&Session::new(io::open_bytes("plain.fodt", &saved).unwrap())), ["Flat words."]);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-metafile = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -23,7 +23,7 @@ pub mod txt;
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
/// Extensions this crate handles.
|
||||
pub const EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "html", "htm", "xhtml", "rtf", "odt", "tex", "latex", "ltx"];
|
||||
pub const EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "html", "htm", "xhtml", "rtf", "odt", "fodt", "tex", "latex", "ltx"];
|
||||
|
||||
/// Import `bytes` in the format named by `ext` (no dot, any case). `None` = not our format.
|
||||
pub fn import(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
|
||||
@@ -34,6 +34,7 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
|
||||
"html" | "htm" | "xhtml" => Ok(html::import(&html::decode(bytes))),
|
||||
"rtf" => rtf::import(bytes),
|
||||
"odt" => odt::import(bytes),
|
||||
"fodt" => odt::import_flat(bytes),
|
||||
"tex" | "latex" | "ltx" => Ok(latex::import(bytes)),
|
||||
_ => return None,
|
||||
};
|
||||
@@ -67,6 +68,7 @@ pub fn export(ext: &str, doc: &Document) -> Option<Result<Vec<u8>, String>> {
|
||||
"html" | "htm" | "xhtml" => Ok(html::export(doc).into_bytes()),
|
||||
"rtf" => Ok(rtf::export(doc).into_bytes()),
|
||||
"odt" => odt::export(doc),
|
||||
"fodt" => odt::export_flat(doc),
|
||||
"tex" | "latex" | "ltx" => Ok(latex::export(doc).into_bytes()),
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@ use crate::model::{self, Cell, FBlock, FComment, FTable, Flow, Fmt, Inline, Kind
|
||||
mod body;
|
||||
mod datastyle;
|
||||
mod fields;
|
||||
mod flat;
|
||||
mod formula_size;
|
||||
mod frames;
|
||||
mod import;
|
||||
@@ -53,6 +54,7 @@ mod writer;
|
||||
mod xml;
|
||||
|
||||
use body::Body;
|
||||
pub use flat::{export_flat, import_flat};
|
||||
use import::Pages;
|
||||
#[cfg(test)]
|
||||
use import::{Pictures, apply_page_layout, read_entry, read_meta};
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Flat OpenDocument text (`.fodt`, ODF 1.3 Part 3 §2.1.2): the whole document in one
|
||||
//! `office:document` element, its pictures inline (`office:binary-data`, base64) and its
|
||||
//! formulas inline (`math:math` inside `draw:object`). Read by taking it apart into a package's
|
||||
//! files and opening that; written by putting a package's files together.
|
||||
|
||||
use super::limits::MAX_ENTRY;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
/// The top-level parts of a flat document, in the order a package's files take them.
|
||||
const META: &str = "office:meta";
|
||||
const SETTINGS: &str = "office:settings";
|
||||
const FONTS: &str = "office:font-face-decls";
|
||||
const STYLES: &str = "office:styles";
|
||||
const AUTOMATIC: &str = "office:automatic-styles";
|
||||
const MASTERS: &str = "office:master-styles";
|
||||
const BODY: &str = "office:body";
|
||||
|
||||
/// Most pictures and formulas taken out of a flat document (hostile files).
|
||||
const MAX_INLINE: usize = 10_000;
|
||||
|
||||
/// Element `name` in `xml` (its first one, start to end, or self-closed); "" when there is none.
|
||||
fn element<'a>(xml: &'a str, name: &str) -> &'a str {
|
||||
let open = format!("<{name}");
|
||||
let Some(start) = xml
|
||||
.match_indices(&open)
|
||||
.map(|(i, _)| i)
|
||||
.find(|&i| xml.get(i + open.len()..).and_then(|r| r.chars().next()).is_some_and(|c| c == '>' || c == '/' || c.is_whitespace()))
|
||||
else {
|
||||
return "";
|
||||
};
|
||||
let Some(tag_end) = xml.get(start..).and_then(|r| r.find('>')).map(|e| start + e) else { return "" };
|
||||
if xml.get(..tag_end).is_some_and(|t| t.ends_with('/')) {
|
||||
return xml.get(start..=tag_end).unwrap_or("");
|
||||
}
|
||||
let close = format!("</{name}>");
|
||||
match xml.get(tag_end..).and_then(|r| r.find(&close)) {
|
||||
Some(e) => xml.get(start..tag_end + e + close.len()).unwrap_or(""),
|
||||
// Unclosed (malformed): to the end.
|
||||
None => xml.get(start..).unwrap_or(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// What is inside element `el` (an element string as [`element`] gives).
|
||||
fn inner(el: &str) -> &str {
|
||||
let Some(tag_end) = el.find('>') else { return "" };
|
||||
if el.get(..tag_end).is_some_and(|t| t.ends_with('/')) {
|
||||
return "";
|
||||
}
|
||||
let end = el.rfind("</").unwrap_or(el.len());
|
||||
el.get(tag_end + 1..end.max(tag_end + 1)).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The extension of picture `data`, by its first bytes.
|
||||
fn picture_ext(data: &[u8]) -> &'static str {
|
||||
if data.starts_with(b"\x89PNG") {
|
||||
"png"
|
||||
} else if data.starts_with(b"\xff\xd8") {
|
||||
"jpg"
|
||||
} else if data.starts_with(b"GIF8") {
|
||||
"gif"
|
||||
} else if data.starts_with(b"<svg") || data.starts_with(b"<?xml") {
|
||||
"svg"
|
||||
} else {
|
||||
"bin"
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the first `draw:object` holding its object inline (no `xlink:href`, not self-closed)
|
||||
/// starts in `xml`.
|
||||
fn inline_object(xml: &str) -> Option<usize> {
|
||||
let mut from = 0;
|
||||
while let Some(i) = xml.get(from..).and_then(|r| r.find("<draw:object")).map(|i| from + i) {
|
||||
let tag = xml.get(i..).and_then(|r| r.find('>').and_then(|e| r.get(..e))).unwrap_or("");
|
||||
let next = tag.get("<draw:object".len()..).and_then(|r| r.chars().next());
|
||||
if next.is_none_or(|c| c.is_whitespace()) && !tag.contains("xlink:href") && !tag.ends_with('/') {
|
||||
return Some(i);
|
||||
}
|
||||
from = i + "<draw:object".len();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `xml` with its inline pictures and formulas moved into `files` (named as a package names
|
||||
/// them) and pointed at there, as a package's content holds them.
|
||||
fn take_out(xml: &str, files: &mut Vec<(String, Vec<u8>)>) -> String {
|
||||
let mut out = String::with_capacity(xml.len());
|
||||
let mut rest = xml;
|
||||
loop {
|
||||
let pic = rest.find("<office:binary-data>");
|
||||
let obj = inline_object(rest);
|
||||
let (at, picture) = match (pic, obj) {
|
||||
(Some(p), Some(o)) if o < p => (o, false),
|
||||
(Some(p), _) => (p, true),
|
||||
(None, Some(o)) => (o, false),
|
||||
(None, None) => break,
|
||||
};
|
||||
let (before, from) = rest.split_at(at);
|
||||
let close = if picture { "</office:binary-data>" } else { "</draw:object>" };
|
||||
let Some(end) = from.find(close) else {
|
||||
out.push_str(before);
|
||||
rest = from;
|
||||
break;
|
||||
};
|
||||
let open_len = if picture { "<office:binary-data>".len() } else { from.find('>').map_or(0, |e| e + 1) };
|
||||
let body = from.get(open_len..end).unwrap_or("");
|
||||
let after = from.get(end + close.len()..).unwrap_or("");
|
||||
let n = files.len() + 1;
|
||||
if files.len() >= MAX_INLINE {
|
||||
out.push_str(before);
|
||||
rest = after;
|
||||
continue;
|
||||
}
|
||||
if picture {
|
||||
// The picture's `draw:image` start tag (just before) gets the package's reference.
|
||||
let b64: String = body.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
let data = STANDARD.decode(b64.as_bytes()).ok().filter(|d| d.len() <= MAX_ENTRY as usize).unwrap_or_default();
|
||||
let name = format!("Pictures/flat{n}.{}", picture_ext(&data));
|
||||
match before.rfind("<draw:image") {
|
||||
Some(img) if !data.is_empty() => {
|
||||
let (head, tag) = before.split_at(img);
|
||||
out.push_str(head);
|
||||
out.push_str(&format!("<draw:image xlink:href=\"{name}\""));
|
||||
out.push_str(tag.get("<draw:image".len()..).unwrap_or(""));
|
||||
files.push((name, data));
|
||||
}
|
||||
_ => out.push_str(before),
|
||||
}
|
||||
} else {
|
||||
let dir = format!("Object flat{n}");
|
||||
out.push_str(before);
|
||||
out.push_str(&format!("<draw:object xlink:href=\"./{dir}\"/>"));
|
||||
files.push((format!("{dir}/content.xml"), body.as_bytes().to_vec()));
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Open a flat OpenDocument text document.
|
||||
pub fn import_flat(bytes: &[u8]) -> Result<Document, String> {
|
||||
let xml = std::str::from_utf8(bytes).map_err(|_| "FODT: not UTF-8 text".to_string())?;
|
||||
let root = element(xml, "office:document");
|
||||
let attrs =
|
||||
root.find('>').and_then(|e| root.get("<office:document".len()..e)).map(|a| a.trim_end_matches('/')).ok_or("FODT: no office:document")?;
|
||||
let doc = |name: &str, parts: &[&str]| -> String {
|
||||
let body: String = parts.iter().map(|p| element(root, p)).collect();
|
||||
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:{name}{attrs}>{body}</office:{name}>")
|
||||
};
|
||||
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
let content = take_out(&doc("document-content", &[FONTS, AUTOMATIC, BODY]), &mut files);
|
||||
let styles = take_out(&doc("document-styles", &[FONTS, STYLES, AUTOMATIC, MASTERS]), &mut files);
|
||||
let entries: Vec<(String, Vec<u8>)> = [
|
||||
("mimetype".to_string(), b"application/vnd.oasis.opendocument.text".to_vec()),
|
||||
("content.xml".to_string(), content.into_bytes()),
|
||||
("styles.xml".to_string(), styles.into_bytes()),
|
||||
("meta.xml".to_string(), doc("document-meta", &[META]).into_bytes()),
|
||||
("settings.xml".to_string(), doc("document-settings", &[SETTINGS]).into_bytes()),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(files)
|
||||
.collect();
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in &entries {
|
||||
z.start_file(name.as_str(), stored).map_err(|e| format!("FODT: {e}"))?;
|
||||
z.write_all(data).map_err(|e| format!("FODT: {e}"))?;
|
||||
}
|
||||
let package = z.finish().map_err(|e| format!("FODT: {e}"))?.into_inner();
|
||||
super::import(&package)
|
||||
}
|
||||
|
||||
/// Save `doc` as flat OpenDocument text: its package's files in one `office:document`, the
|
||||
/// automatic styles of its content and its styles together, pictures and formulas inline.
|
||||
pub fn export_flat(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
let package = super::export(doc)?;
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(package)).map_err(|e| format!("FODT: {e}"))?;
|
||||
let mut read = |name: &str| -> String {
|
||||
let mut s = String::new();
|
||||
if let Ok(mut f) = z.by_name(name) {
|
||||
let _ = f.read_to_string(&mut s);
|
||||
}
|
||||
s
|
||||
};
|
||||
let (content, styles, meta, settings) = (read("content.xml"), read("styles.xml"), read("meta.xml"), read("settings.xml"));
|
||||
let mut bytes_of = |name: &str| -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
if let Ok(mut f) = z.by_name(name) {
|
||||
let _ = f.read_to_end(&mut v);
|
||||
}
|
||||
v
|
||||
};
|
||||
let automatic = format!("<{AUTOMATIC}>{}{}</{AUTOMATIC}>", inner(element(&content, AUTOMATIC)), inner(element(&styles, AUTOMATIC)));
|
||||
let mut body = String::new();
|
||||
for part in [
|
||||
element(&meta, META),
|
||||
element(&settings, SETTINGS),
|
||||
element(&styles, FONTS),
|
||||
element(&content, FONTS),
|
||||
element(&styles, STYLES),
|
||||
&automatic,
|
||||
element(&styles, MASTERS),
|
||||
element(&content, BODY),
|
||||
] {
|
||||
body.push_str(part);
|
||||
}
|
||||
// Pictures and formulas in the document, not beside it.
|
||||
let mut flat = String::with_capacity(body.len());
|
||||
let mut rest = body.as_str();
|
||||
while let Some(at) = rest.find("xlink:href=\"") {
|
||||
let (before, from) = rest.split_at(at);
|
||||
let target_start = "xlink:href=\"".len();
|
||||
let Some(len) = from.get(target_start..).and_then(|r| r.find('"')) else { break };
|
||||
let target = from.get(target_start..target_start + len).unwrap_or("");
|
||||
let after = from.get(target_start + len + 1..).unwrap_or("");
|
||||
let tag = before.rfind('<').and_then(|i| before.get(i..)).unwrap_or("");
|
||||
if tag.starts_with("<draw:image") && target.starts_with("Pictures/") {
|
||||
// `<draw:image xlink:href="…" …/>` → `<draw:image …><office:binary-data>…`.
|
||||
let data = bytes_of(target);
|
||||
let rest_tag_end = after.find("/>").unwrap_or(0);
|
||||
// The link's other attributes go with it.
|
||||
let attrs: String =
|
||||
after.get(..rest_tag_end).unwrap_or("").split_whitespace().filter(|a| !a.starts_with("xlink:")).map(|a| format!(" {a}")).collect();
|
||||
flat.push_str(before.trim_end());
|
||||
flat.push_str(&attrs);
|
||||
flat.push_str(&format!("><office:binary-data>{}</office:binary-data></draw:image>", STANDARD.encode(&data)));
|
||||
rest = after.get(rest_tag_end + 2..).unwrap_or("");
|
||||
} else if tag.starts_with("<draw:object") && target.starts_with("./Object ") {
|
||||
let math = bytes_of(&format!("{}/content.xml", target.trim_start_matches("./")));
|
||||
let math = String::from_utf8_lossy(&math);
|
||||
let math = math.split_once("?>").map_or(math.as_ref(), |(_, m)| m).trim();
|
||||
// A plain `draw:object` around it: the reference's other attributes go with it.
|
||||
let rest_tag_end = after.find("/>").unwrap_or(0);
|
||||
flat.push_str(before.rfind("<draw:object").and_then(|i| before.get(..i)).unwrap_or(before));
|
||||
flat.push_str(&format!("<draw:object>{math}</draw:object>"));
|
||||
rest = after.get(rest_tag_end + 2..).unwrap_or("");
|
||||
} else {
|
||||
flat.push_str(before);
|
||||
flat.push_str(from.get(..target_start + len + 1).unwrap_or(""));
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
flat.push_str(rest);
|
||||
// Every namespace the parts declare (the settings' `config`, say), each prefix once.
|
||||
let mut ns: Vec<String> = Vec::new();
|
||||
for part in [&content, &styles, &meta, &settings] {
|
||||
for decl in namespaces(part) {
|
||||
let prefix = decl.split('=').next().unwrap_or("");
|
||||
if !ns.iter().any(|d| d.split('=').next() == Some(prefix)) {
|
||||
ns.push(decl.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document {} office:version=\"1.3\" office:mimetype=\"application/vnd.oasis.opendocument.text\">{flat}</office:document>\n",
|
||||
ns.join(" ")
|
||||
)
|
||||
.into_bytes())
|
||||
}
|
||||
|
||||
/// The namespace declarations (`xmlns:p="…"`) on the root element of `xml`.
|
||||
fn namespaces(xml: &str) -> Vec<&str> {
|
||||
let start = xml.find("<office:").unwrap_or(0);
|
||||
let tag = xml.get(start..).and_then(|r| r.find('>').and_then(|e| r.get(..e))).unwrap_or("");
|
||||
let mut out = Vec::new();
|
||||
let mut rest = tag;
|
||||
while let Some(i) = rest.find("xmlns:") {
|
||||
let from = rest.get(i..).unwrap_or("");
|
||||
// `xmlns:p="…"`: up to the closing quote.
|
||||
let Some(q1) = from.find('"') else { break };
|
||||
let Some(q2) = from.get(q1 + 1..).and_then(|r| r.find('"')) else { break };
|
||||
out.push(from.get(..q1 + 1 + q2 + 1).unwrap_or(""));
|
||||
rest = from.get(q1 + 1 + q2 + 1..).unwrap_or("");
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod chars;
|
||||
mod comments;
|
||||
mod equations;
|
||||
mod fields;
|
||||
mod flat;
|
||||
mod floats;
|
||||
mod fonts;
|
||||
mod groups;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Flat OpenDocument text (`.fodt`, ODF 1.3 Part 3 §2.1.2: the whole document in one
|
||||
//! `office:document` element, pictures inline as `office:binary-data`, formulas as
|
||||
//! `math:math` inside their object): opened and saved like a package.
|
||||
|
||||
use crate::odt::{export_flat, import_flat};
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
|
||||
/// A 1×1 PNG, base64.
|
||||
const PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
|
||||
fn flat() -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:math="http://www.w3.org/1998/Math/MathML" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
|
||||
<office:meta><dc:title>Flat title</dc:title></office:meta>
|
||||
<office:styles><style:style style:name="Quiet" style:family="paragraph"><style:text-properties fo:font-style="italic"/></style:style></office:styles>
|
||||
<office:automatic-styles>
|
||||
<style:style style:name="T1" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style>
|
||||
<style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="5in" fo:page-height="7in"/></style:page-layout>
|
||||
</office:automatic-styles>
|
||||
<office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:footer><text:p>The footer</text:p></style:footer></style:master-page></office:master-styles>
|
||||
<office:body><office:text>
|
||||
<text:p text:style-name="Quiet">Some <text:span text:style-name="T1">bold</text:span> words.</text:p>
|
||||
<text:p><draw:frame text:anchor-type="as-char" svg:width="1in" svg:height="1in"><draw:image><office:binary-data>
|
||||
{PNG_B64}
|
||||
</office:binary-data></draw:image></draw:frame></text:p>
|
||||
<text:p><draw:frame text:anchor-type="as-char" svg:width="1in" svg:height="0.5in"><draw:object><math:math><math:semantics><math:mi>x</math:mi><math:annotation encoding="StarMath 5.0">x</math:annotation></math:semantics></math:math></draw:object></draw:frame></text:p>
|
||||
</office:text></office:body>
|
||||
</office:document>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// What a flat document holds: (its title, page width, body text, bold text, the paragraph
|
||||
/// style, its footer's text, its pictures' sizes in bytes, its equations).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn summary(d: &Document) -> (String, f32, Vec<String>, Vec<String>, Option<String>, String, Vec<usize>, Vec<String>) {
|
||||
let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
let text = paras.iter().map(|p| p.text.replace(wordcraft_doc::para::OBJ, "#")).collect();
|
||||
let bold = paras
|
||||
.iter()
|
||||
.flat_map(|p| p.run_ranges().filter(|(_, c)| c.bold == Some(true)).map(|(r, _)| p.text.get(r).unwrap_or("").to_string()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
let style = paras.first().and_then(|p| p.props.style.clone());
|
||||
let footer = d
|
||||
.last_section
|
||||
.footers
|
||||
.default
|
||||
.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();
|
||||
let mut pictures = Vec::new();
|
||||
let mut equations = Vec::new();
|
||||
for p in ¶s {
|
||||
for o in &p.objects {
|
||||
match o {
|
||||
InlineObject::Image { media, .. } => pictures.push(d.media.get(media).map_or(0, |m| m.len())),
|
||||
InlineObject::Equation { linear, .. } => equations.push(linear.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(d.core.title.clone(), d.last_section.page_w, text, bold, style, footer, pictures, equations)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_document_opens() {
|
||||
let d = import_flat(flat().as_bytes()).unwrap();
|
||||
let s = summary(&d);
|
||||
assert_eq!(s.0, "Flat title");
|
||||
assert_eq!(s.1, 360.0);
|
||||
assert_eq!(s.2, ["Some bold words.", "#", "#"]);
|
||||
assert_eq!(s.3, ["bold"]);
|
||||
assert_eq!(s.4.as_deref(), Some("Quiet"));
|
||||
assert_eq!(s.5, "The footer");
|
||||
// The picture's own bytes, decoded.
|
||||
assert_eq!(s.6, [70]);
|
||||
assert_eq!(s.7, ["x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_document_round_trips() {
|
||||
let d = import_flat(flat().as_bytes()).unwrap();
|
||||
let bytes = export_flat(&d).unwrap();
|
||||
let xml = String::from_utf8(bytes.clone()).unwrap();
|
||||
assert!(xml.contains("<office:document ") && xml.contains("office:mimetype=\"application/vnd.oasis.opendocument.text\""), "{xml}");
|
||||
assert!(xml.contains("<office:binary-data>") && !xml.contains("Pictures/"), "pictures inline: {xml}");
|
||||
assert_eq!(xml.matches("<office:automatic-styles>").count(), 1, "one set of automatic styles");
|
||||
assert_eq!(summary(&import_flat(&bytes).unwrap()), summary(&d));
|
||||
// And by its extension.
|
||||
assert_eq!(summary(&crate::import("fodt", &bytes).unwrap().unwrap()), summary(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_flat_documents_do_not_panic() {
|
||||
let f = flat();
|
||||
for bad in [
|
||||
String::new(),
|
||||
"<office:document>".to_string(),
|
||||
f.replace("</office:body>", ""),
|
||||
f.replace(PNG_B64, "!!!not base64!!!"),
|
||||
f.replace("<office:binary-data>", "<office:binary-data><office:binary-data>"),
|
||||
f.replace("</math:math>", ""),
|
||||
format!("<office:document>{}</office:document>", "<office:body>".repeat(10_000)),
|
||||
"\u{feff}<?xml version=\"1.0\"?><office:document-content/>".to_string(),
|
||||
] {
|
||||
if let Ok(d) = import_flat(bad.as_bytes()) {
|
||||
let _ = export_flat(&d).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub type FileDialogAnswer = Result<Vec<String>, String>;
|
||||
const MAX_FONTS_PER_PICK: usize = 2000;
|
||||
|
||||
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "fodt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
|
||||
/// The file types Open offers, as (label, extensions; `*` is any file): pictures for Insert/Change
|
||||
/// Picture, recipient lists for Select Recipients › Use an Existing List…, documents otherwise.
|
||||
|
||||
@@ -358,6 +358,7 @@ pub fn export_formats(app: &mut WordApp, ui: &mut Ui) {
|
||||
("Word XML Document (*.xml)", "xml"),
|
||||
("Word 2003 XML Document (*.xml)", EXPORT_WORD_2003),
|
||||
("OpenDocument Text (*.odt)", "odt"),
|
||||
("Flat OpenDocument Text (*.fodt)", "fodt"),
|
||||
("Rich Text Format (*.rtf)", "rtf"),
|
||||
("Web page (*.html)", "html"),
|
||||
("Markdown (*.md)", "md"),
|
||||
|
||||
@@ -82,5 +82,5 @@ impl AutoSaveBlock {
|
||||
/// AutoSave writes only these formats.
|
||||
pub fn keeps_everything(name: &str) -> bool {
|
||||
let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json" | "xml")
|
||||
matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "fodt" | "rtf" | "json" | "xml")
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** 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 corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in WoLine truncated
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** 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 corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a dLine 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 | 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
|
||||
| `.odt` OpenDocument Text (and flat `.fodt`) | 2% | R/W | 🟡 | 🟡 | ~80% | 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 | 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 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 paragraphLine 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 | Flat OpenDocument text (`.fodt`) opened and saved (Open, Save As, the CLI, the web app): taken apart into a package's files and put together again, its pictures inline as base64 and its formulas inline in their objects; LibreOffice's own flat documents round-trip as their packages do. ODT fidelity re-estimated at ~80% (tables of contents and indexes, field formats and references, bookmarks over text, page settings and flat files since ~72%; still missing: embedded charts and other objects, form controls, password-protected files, tracked formatting changes, variables, drop caps) |
|
||||
| 2026-11-01 | trivial | ODT: page settings read and written — page number format (the page style's, and a page number field's own as `PAGE \* Arabic` and the like), page colour, page borders (at ODF's margin, the text inside their padding; WordCraft measures them from the edge), footnote and endnote numbering and line numbering. Fixed: in a document with footnotes (or comments, or text boxes) and a header or footer, the header or footer took the story id of a note, so the note showed the footer's text (`a_footnote_keeps_its_text_beside_a_footer`). A LibreOffice document with all of these round-trips pixel for pixel, and WordCraft draws it 7,213 px from LibreOffice (24,025 before the border was placed right) |
|
||||
| 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`) |
|
||||
|
||||
Reference in new issue
Block a user