mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-26 03:17:28 -06:00
Rebased onto main as one commit (the branch had merge commits from main). Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
339 lines
14 KiB
Rust
339 lines
14 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",
|
||
"xml",
|
||
"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", "xml"];
|
||
|
||
/// The extension of a file name or path, lower-case; empty when the file name has none (a dot
|
||
/// in a folder name doesn't count).
|
||
fn ext_of(name: &str) -> String {
|
||
let file = name.rsplit(['/', '\\']).next().unwrap_or(name);
|
||
file.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).unwrap_or_default()
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
/// How a copy in another format is written.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub struct ExportOptions {
|
||
/// Print Markup: PDFs show tracked changes marked up and, with `comments`, each page scaled
|
||
/// down beside a markup area with the comments in balloons (page images get the area too);
|
||
/// HTML, Markdown and text copies end with the comments.
|
||
pub markup: bool,
|
||
/// With `markup`, show the comments (Track Changes Options › Comments).
|
||
pub comments: bool,
|
||
}
|
||
|
||
/// 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));
|
||
}
|
||
if ext.is_empty() {
|
||
return open_by_content(name, &ext, bytes, password);
|
||
}
|
||
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 open_by_content(name, other, bytes, password),
|
||
},
|
||
};
|
||
doc.ensure_nonempty();
|
||
Ok((doc, false))
|
||
}
|
||
|
||
/// A file whose extension names no format WordCraft knows (`.m3u`, `.log`, `.ini`, none…),
|
||
/// opened by what it holds, as Word does: a Word document or RTF file by its signature, any
|
||
/// other text through the plain-text import with encoding detection (#569). Anything else is
|
||
/// refused with a clear error rather than shown as garbage.
|
||
fn open_by_content(name: &str, ext: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
|
||
const ZIP: &[u8] = b"PK\x03\x04";
|
||
const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||
let (mut doc, encrypted) = if bytes.starts_with(OLE) && wordcraft_docx::is_encrypted(bytes) {
|
||
(wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?, true)
|
||
} else if bytes.starts_with(ZIP) {
|
||
(wordcraft_docx::read(bytes).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false)
|
||
} else if bytes.starts_with(OLE) {
|
||
(wordcraft_docbin::read(bytes).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false)
|
||
} else if bytes.starts_with(b"{\\rtf") {
|
||
(wordcraft_formats::rtf::import(bytes)?, false)
|
||
} else if wordcraft_formats::txt::is_text(bytes) {
|
||
(wordcraft_formats::txt::import(bytes), false)
|
||
} else if ext.is_empty() {
|
||
return Err(format!("{name}: not a document or text file WordCraft can open"));
|
||
} else {
|
||
return Err(format!("{name}: unsupported format `.{ext}` (not a document or text file)"));
|
||
};
|
||
doc.ensure_nonempty();
|
||
Ok((doc, encrypted))
|
||
}
|
||
|
||
/// Serialise a document; `name` gives the format by extension.
|
||
pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
|
||
save_bytes_opts(name, doc, &ExportOptions::default())
|
||
}
|
||
|
||
/// [`save_bytes`] with export options (Print Markup).
|
||
pub fn save_bytes_opts(name: &str, doc: &Document, opts: &ExportOptions) -> 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" => {
|
||
let bytes = wordcraft_formats::txt::export(doc);
|
||
Ok(if opts.markup && opts.comments { wordcraft_formats::comments::append("txt", doc, bytes) } else { bytes })
|
||
}
|
||
"json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()),
|
||
other => match crate::io_ext::save(other, doc, opts) {
|
||
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>, opts: &ExportOptions) -> Result<Vec<u8>, String> {
|
||
let bytes = save_bytes_opts(name, doc, opts)?;
|
||
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, &ExportOptions::default())
|
||
}
|
||
|
||
/// [`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>, opts: &ExportOptions) -> Result<(), String> {
|
||
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?;
|
||
// 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>, _opts: &ExportOptions) -> 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)
|
||
}
|