mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 05:22:51 +00:00
- 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>
288 lines
12 KiB
Rust
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)
|
|
}
|