mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 11:50:20 -06: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:
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")
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user