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:
Claude
2026-11-01 10:05:45 +00:00
parent 5570236b70
commit 694ef8da96
16 changed files with 421 additions and 8 deletions
Generated
+1
View File
@@ -6802,6 +6802,7 @@ dependencies = [
name = "wordcraft-formats"
version = "0.13.2"
dependencies = [
"base64 0.23.1",
"cfb",
"chardetng",
"chrono",
+2 -1
View File
@@ -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",
+1
View File
@@ -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(),
+1 -1
View File
@@ -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();
+2 -1
View File
@@ -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";
+11
View File
@@ -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."]);
}
+1
View File
@@ -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 }
+3 -1
View File
@@ -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,
})
+2
View File
@@ -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};
+279
View File
@@ -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
}
+1
View File
@@ -6,6 +6,7 @@ mod chars;
mod comments;
mod equations;
mod fields;
mod flat;
mod floats;
mod fonts;
mod groups;
+111
View File
@@ -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 &paras {
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();
}
}
}
+1 -1
View File
@@ -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.
+1
View File
@@ -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"),
+1 -1
View File
@@ -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")
}
File diff suppressed because one or more lines are too long