Files
wordcraft/crates/engine/src/io.rs
T
1581f30176 Import/export fixes: TXT encodings and list labels, RTF Unicode, ODT styles and cell colours, HTML borders and pre spaces, LaTeX alignment groups (#433–#440) (#482)
- TXT: the engine opens and saves .txt through wordcraft-formats (BOM/unmarked
  UTF-16 sniffing, Windows-1252 fallback; list labels and a tab on export)
  instead of its own narrower decoder and body plain text.
- RTF: \uN and \ucN are handled in document info, bookmark names and field
  instructions, not only in body text.
- ODT: common paragraph styles (and common parents of automatic styles) give
  their text formatting, except the style that sets the paragraph kind;
  table-cell-properties fo:background-color becomes cell shading.
- HTML: tables without style or visible borders export without cell borders;
  white-space:pre paragraphs keep leading/trailing spaces.
- LaTeX: brace groups restore the paragraph alignment set inside them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 11:15:08 -04:00

288 lines
12 KiB
Rust

//! File format dispatch by extension. Format crates plug in here.
use wordcraft_doc::Document;
/// Formats WordCraft opens.
pub const OPEN_EXTS: &[&str] = &[
"docx",
"docm",
"dotx",
"dotm",
"doc",
"dot",
"txt",
"md",
"markdown",
"html",
"htm",
"rtf",
"odt",
"tex",
"latex",
"ltx",
"wcraft.json",
"json",
];
/// Formats WordCraft saves (Save As).
pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json"];
fn ext_of(name: &str) -> String {
let lower = name.to_ascii_lowercase();
lower.rsplit('.').next().unwrap_or("").to_string()
}
/// Is `ext` one of Word's package formats (.docx/.docm/.dotx/.dotm), which save without loss?
pub fn is_word_package(ext: &str) -> bool {
wordcraft_docx::Flavor::from_ext(ext).is_some()
}
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
#[derive(Clone, Default, PartialEq, Eq)]
pub struct Password(zeroize::Zeroizing<String>);
impl Password {
pub fn new(s: &str) -> Password {
Password(zeroize::Zeroizing::new(s.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
/// The text, for a password box to edit in place.
pub fn as_mut_string(&mut self) -> &mut String {
&mut self.0
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl std::fmt::Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Password(***)")
}
}
/// Does this open error mean the file is password-protected and no password was given?
pub fn needs_password(err: &str) -> bool {
err.contains(wordcraft_docx::PASSWORD_REQUIRED)
}
/// Does this open error mean the password given was wrong?
pub fn wrong_password(err: &str) -> bool {
err.contains(wordcraft_docx::WRONG_PASSWORD)
}
/// Can files with this extension be saved with a password (Word's packages)?
pub fn can_encrypt(name: &str) -> bool {
is_word_package(&ext_of(name))
}
/// Parse a document from bytes; `name` gives the format by extension.
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Document, String> {
open_bytes_with(name, bytes, None).map(|(doc, _)| doc)
}
/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a
/// password ([MS-OFFCRYPTO] agile encryption) is decrypted with `password` first. Also says
/// whether the file was encrypted. Without the password needed the error satisfies
/// [`needs_password`]; with a wrong one, [`wrong_password`].
pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
let ext = ext_of(name);
if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) {
let mut doc = wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?;
doc.ensure_nonempty();
return Ok((doc, true));
}
let mut doc = match ext.as_str() {
// Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252.
"txt" | "text" | "" => wordcraft_formats::txt::import(bytes),
"json" => serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?,
other => match crate::io_ext::open(other, bytes) {
Some(r) => r?,
None => return Err(format!("{name}: unsupported format `.{other}`")),
},
};
doc.ensure_nonempty();
Ok((doc, false))
}
/// Serialise a document; `name` gives the format by extension.
pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
let ext = ext_of(name);
match ext.as_str() {
// List paragraphs get their label and a tab, as in Word's plain-text save.
"txt" | "text" => Ok(wordcraft_formats::txt::export(doc)),
"json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()),
other => match crate::io_ext::save(other, doc) {
Some(r) => r,
None => Err(format!("{name}: can't save as `.{other}`")),
},
}
}
/// [`save_bytes`], encrypted with `password` when there is one and the format is a Word package
/// (other formats are copies and are written as they are).
pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>) -> Result<Vec<u8>, String> {
let bytes = save_bytes(name, doc)?;
match password {
Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()),
_ => Ok(bytes),
}
}
pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
open_path_with(path, None).map(|(doc, _)| doc)
}
/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> {
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if meta.len() > 2 << 30 {
return Err(format!("{}: file is larger than 2 GB", path.display()));
}
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
let name = path.to_string_lossy();
if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") {
// Pictures referenced by a relative path load from beside the HTML file.
let images = LocalImages::new(path.parent().unwrap_or(std::path::Path::new("")));
let mut doc = wordcraft_formats::html::import_with(&wordcraft_formats::html::decode(&bytes), &|src| images.load(src));
doc.ensure_nonempty();
return Ok((doc, false));
}
open_bytes_with(&name, &bytes, password)
}
/// The pictures an HTML file names by a path relative to its folder.
///
/// Only regular files inside the folder (after resolving `..` and symlinks) are read. URLs
/// (`http:`, `file:`…) and absolute paths are not followed. Each file is read at most once and
/// at most `max_file` bytes of it; every reference, cached or not, counts against a budget for
/// the whole document, so one large picture can't be multiplied by referencing it many times.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) struct LocalImages {
/// The folder, canonical; `None` when it can't be resolved (nothing loads then).
dir: Option<std::path::PathBuf>,
max_file: u64,
/// Bytes the document may still load.
left: std::cell::Cell<u64>,
cache: std::cell::RefCell<Vec<(std::path::PathBuf, std::sync::Arc<Vec<u8>>)>>,
}
#[cfg(not(target_arch = "wasm32"))]
impl LocalImages {
/// Largest picture file read.
const MAX_FILE: u64 = 256 << 20;
/// Picture bytes one document may load, counting every reference.
const BUDGET: u64 = 1 << 30;
/// Files whose bytes are kept for further references.
const CACHE: usize = 64;
pub(crate) fn new(dir: &std::path::Path) -> LocalImages {
LocalImages::with_limits(dir, LocalImages::MAX_FILE, LocalImages::BUDGET)
}
pub(crate) fn with_limits(dir: &std::path::Path, max_file: u64, budget: u64) -> LocalImages {
let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir };
LocalImages { dir: std::fs::canonicalize(dir).ok(), max_file, left: std::cell::Cell::new(budget), cache: Default::default() }
}
/// Bytes of the file `src` names, or `None` when it isn't a regular file inside the folder,
/// is too large, or the budget is spent.
pub(crate) fn load(&self, src: &str) -> Option<std::sync::Arc<Vec<u8>>> {
let path = self.resolve(src)?;
let cached = self.cache.borrow().iter().find(|(p, _)| *p == path).map(|(_, d)| d.clone());
if let Some(data) = cached {
self.charge(data.len())?;
return Some(data);
}
let limit = self.max_file.min(self.left.get());
let bytes = read_regular_file(&path, limit)?;
// What was read is spent even when it's over the limit.
self.charge(bytes.len())?;
if bytes.len() as u64 > limit {
return None;
}
let data = std::sync::Arc::new(bytes);
let mut cache = self.cache.borrow_mut();
if cache.len() < LocalImages::CACHE {
cache.push((path, data.clone()));
}
Some(data)
}
/// Take `n` bytes from the budget; `None` (and the budget emptied) when there aren't enough.
fn charge(&self, n: usize) -> Option<()> {
let left = self.left.get().checked_sub(n as u64);
self.left.set(left.unwrap_or(0));
left.map(|_| ())
}
/// The canonical path `src` names, if it's inside the folder.
fn resolve(&self, src: &str) -> Option<std::path::PathBuf> {
let src = src.split(['#', '?']).next().unwrap_or("");
if src.is_empty() || src.starts_with("//") || src.split_once(':').is_some_and(|(scheme, _)| !scheme.contains('/')) {
return None;
}
let rel = String::from_utf8(wordcraft_formats::model::percent_decode(src)).ok()?;
let rel = std::path::Path::new(&rel);
if rel.components().any(|c| matches!(c, std::path::Component::Prefix(_) | std::path::Component::RootDir)) {
return None;
}
let dir = self.dir.as_ref()?;
// Canonical paths have `..` and symlinks resolved, so this keeps both inside the folder.
let path = std::fs::canonicalize(dir.join(rel)).ok()?;
path.starts_with(dir).then_some(path)
}
}
/// Up to `limit + 1` bytes of the regular file at `path` (more than `limit` means too large), or
/// `None` for anything else. A named pipe blocks when opened and a device reports a length of 0
/// and may never end, so the path is checked before opening, the opened handle is checked again,
/// and the read is bounded whatever length the file reports.
#[cfg(not(target_arch = "wasm32"))]
fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
use std::io::Read;
if !std::fs::metadata(path).ok()?.is_file() {
return None;
}
let file = std::fs::File::open(path).ok()?;
let meta = file.metadata().ok()?;
if !meta.is_file() {
return None;
}
let mut out = Vec::with_capacity(usize::try_from(meta.len().min(limit)).unwrap_or(0));
file.take(limit.saturating_add(1)).read_to_end(&mut out).ok()?;
Some(out)
}
#[cfg(target_arch = "wasm32")]
pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> {
Err(format!("{}: files are opened through the browser on the web", path.display()))
}
pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
save_path_with(path, doc, None)
}
/// [`save_path`], encrypted with `password` as [`save_bytes_with`] does.
#[cfg(not(target_arch = "wasm32"))]
pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>) -> Result<(), String> {
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password)?;
// Write atomically: temp file next to the target, then rename.
let tmp = path.with_extension(format!("{}.tmp", ext_of(&path.to_string_lossy())));
std::fs::write(&tmp, &bytes).map_err(|e| format!("{}: {e}", tmp.display()))?;
std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(target_arch = "wasm32")]
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>) -> Result<(), String> {
Err(format!("{}: files are saved through the browser on the web", path.display()))
}
/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else
/// Windows-1252 (see [`wordcraft_formats::txt::decode`]).
pub fn decode_text(b: &[u8]) -> String {
wordcraft_formats::txt::decode(b)
}