diff --git a/Cargo.lock b/Cargo.lock
index 0500086433..35b2360afa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6802,6 +6802,7 @@ dependencies = [
name = "wordcraft-formats"
version = "0.13.2"
dependencies = [
+ "base64 0.23.1",
"cfb",
"chardetng",
"chrono",
diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs
index 7fa947f02b..fd6c770671 100644
--- a/apps/wordcraft-web/src/web.rs
+++ b/apps/wordcraft-web/src/web.rs
@@ -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",
diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs
index 667298a72b..dbdb18ad38 100644
--- a/apps/wordcraft/src/file_dialogs.rs
+++ b/apps/wordcraft/src/file_dialogs.rs
@@ -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(),
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 1dbdd1ba42..ddadaabf5a 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -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();
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
index 960255030b..464648efb1 100644
--- a/crates/engine/src/io.rs
+++ b/crates/engine/src/io.rs
@@ -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";
diff --git a/crates/engine/tests/it/odt_fields.rs b/crates/engine/tests/it/odt_fields.rs
index 4c1db11b10..86eb1322da 100644
--- a/crates/engine/tests/it/odt_fields.rs
+++ b/crates/engine/tests/it/odt_fields.rs
@@ -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#"Flat words."#;
+ 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" Option> {
@@ -34,6 +34,7 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option> {
"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, 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,
})
diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs
index a10d189ef6..87b2b1bf3a 100644
--- a/crates/formats/src/odt.rs
+++ b/crates/formats/src/odt.rs
@@ -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};
diff --git a/crates/formats/src/odt/flat.rs b/crates/formats/src/odt/flat.rs
new file mode 100644
index 0000000000..511116ca9e
--- /dev/null
+++ b/crates/formats/src/odt/flat.rs
@@ -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"