Files
wordcraft/crates/engine/src/io.rs
T
Diego GueriniandClaude Opus 5.5 2d0169e718 A Word 2003 XML or web page saved as .doc opens by what it holds (#2853) (#2942)
* #2853: Word XML, web pages and MHTML saved as .doc open by what they hold

A Word 2003 XML Document named `.doc` went to the Word 97-2003 reader, which refused it ("Invalid
CFB file"), and the window showed a blank page. Word XML (2003 or Flat OPC), a web page and a
Single File Web Page (MHTML) under a `.doc`, `.docx` or `.rtf` name now open by what they hold,
as in Word. MHTML is read for the first time (`.mht`/`.mhtml` too): the MIME parts are split and
decoded, the page is imported as HTML in its charset and its pictures load from the other parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #2853: leave MHTML to #1104, keep Word XML and web pages saved as .doc

#1104 adds an MHT reader (`wordcraft_formats::mht`), so this branch drops its own MHTML reader and
the `.mht`/`.mhtml` extensions instead of adding a second one. A Word XML Document or a web page
under a `.doc`, `.docx` or `.rtf` name still opens by what it holds (the #2853 fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #2853: a web page's signature needs its tag name to end (`<htmlish>` is other XML)

CodeRabbit: `starts_with("<html")` took XML with a root like `<htmlish>` under a `.doc` name for a
web page, though XML that isn't a Word document stays an error. `<html` and `<!DOCTYPE html` now
need white space, `>` or `/` after them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #2853: rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:04:33 -04:00

644 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File format dispatch by extension. Format crates plug in here.
use wordcraft_doc::Document;
#[cfg(not(target_arch = "wasm32"))]
mod safe_save;
/// 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"];
/// Magic number for ZIP package signatures (used by .docx/.docm/.dotx/.dotm).
const ZIP_SIG: &[u8] = b"PK\x03\x04";
/// Magic number for OLE compound document signatures (used by .doc/.dot).
const OLE_SIG: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
/// 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,
/// An `.xml` copy is a Word 2003 XML Document (WordprocessingML 2003) instead of a Word XML
/// Document (Flat OPC): `format: "wordml2003"` on the save commands.
pub word2003: 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)
}
/// Is the file at `path` empty, zero bytes long? File Explorer's New › Microsoft Word Document
/// makes one, and `file.open` opens it as a new blank document (#1384). False for a file that
/// can't be read: opening it says why.
#[cfg(not(target_arch = "wasm32"))]
pub fn is_empty_file(path: &std::path::Path) -> bool {
crate::file_access::check_read(&path.to_string_lossy()).is_ok() && std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() == 0)
}
/// [`is_empty_file`]: on the web files come through the browser, never by path.
#[cfg(target_arch = "wasm32")]
pub fn is_empty_file(_path: &std::path::Path) -> bool {
false
}
/// 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))
}
/// Finish a document just read from a file: never leave it empty, and bring its unlocked
/// `DATE`/`TIME` fields up to now the way Word does on open (see
/// [`crate::cmd::references::refresh_date_fields`]).
fn opened(doc: &mut Document) {
doc.ensure_nonempty();
crate::cmd::references::refresh_date_fields(doc);
}
/// 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 a Word 97-2003 binary file, decrypting it with `password` when it is password-protected
/// (XOR, RC4, RC4 CryptoAPI). The errors for a missing or wrong password are the same texts the
/// `.docx` reader gives, so [`needs_password`] and [`wrong_password`] recognise them.
fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
use wordcraft_docbin::DocbinError;
let encrypted = wordcraft_docbin::is_encrypted(bytes);
match wordcraft_docbin::read_with_password(bytes, password) {
Ok(doc) => Ok((doc, encrypted)),
Err(DocbinError::PasswordRequired) => Err(wordcraft_docx::PASSWORD_REQUIRED.to_string()),
Err(DocbinError::WrongPassword) => Err(wordcraft_docx::WRONG_PASSWORD.to_string()),
Err(e) => Err(e.to_string()),
}
}
/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a
/// password ([MS-OFFCRYPTO] agile encryption, or the Word 97-2003 schemes) 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> {
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default())
}
/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s
/// paper, as a new blank document (#838, #1381), with the default margins.
pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionProps {
let (_, w, h) = region.paper();
wordcraft_doc::SectionProps { page_w: w, page_h: h, ..Default::default() }
}
/// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper
/// (see [`region_page`]); a document with its own page size keeps it.
pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
let page = region_page(region);
let (doc, encrypted) = read_bytes(name, bytes, password, &page)?;
load_embedded_fonts(&doc);
Ok((doc, encrypted))
}
/// Load the fonts `doc` embeds (#1171) for this run, in memory only: text set in a font that isn't
/// installed then lays out and exports with it instead of a fallback. Faces already available
/// stay as they are.
fn load_embedded_fonts(doc: &Document) {
let files: Vec<(Vec<u8>, String)> = wordcraft_docx::embedded_fonts(doc)
.into_iter()
.filter_map(|f| wordcraft_fonts::decode_font_file(&f.data).ok().map(|data| (data, f.name)))
.collect();
if !files.is_empty() {
wordcraft_fonts::FontDb::global().add_document_fonts(files);
}
}
fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> 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())?;
opened(&mut doc);
return Ok((doc, true));
}
if ext.is_empty() || signature_contradicts(&ext, bytes) {
return open_by_content(name, &ext, bytes, password, page);
}
let mut doc = match ext.as_str() {
"doc" | "dot" => {
let (mut doc, encrypted) = open_binary_doc(bytes, password)?;
opened(&mut doc);
return Ok((doc, encrypted));
}
// Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252.
"txt" | "text" => wordcraft_formats::txt::import_on(bytes, page),
"json" => {
let mut d = serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?;
d.normalize_runs();
d
}
other => match crate::io_ext::open(other, bytes, page) {
Some(r) => r?,
None => return open_by_content(name, other, bytes, password, page),
},
};
opened(&mut doc);
Ok((doc, false))
}
/// The narrowest text width of the document's sections, points: what a picture must fit.
pub(crate) fn text_width(doc: &Document) -> f32 {
doc.sections().iter().map(|(_, sp)| sp.text_width()).fold(f32::INFINITY, f32::min).clamp(36.0, 4000.0)
}
/// Shrink inline pictures wider than `max_w` to it, keeping their proportions. Layout keeps a
/// picture's size (as Word does), so formats whose pictures come at their pixel size (HTML,
/// Markdown, ODT, RTF) are fitted when they are read, as Insert › Pictures does.
pub(crate) fn fit_block_pictures(b: &mut wordcraft_doc::Block, max_w: f32) {
crate::cmd::paste::each_para(b, 0, &mut |p| {
let mut changed = false;
for o in &mut p.objects {
if let wordcraft_doc::InlineObject::Image { w, h, float, .. } = o
&& float.wrap == wordcraft_doc::para::Wrap::Inline
&& w.is_finite()
&& *w > max_w
{
let k = max_w / *w;
*w = max_w;
*h = (*h * k).max(1.0);
changed = true;
}
}
if changed {
p.touch();
}
});
}
/// [`fit_block_pictures`] for a whole document read from a file, against its own text width.
pub(crate) fn fit_pictures(doc: &mut Document) {
let max_w = text_width(doc);
for b in &mut doc.body {
fit_block_pictures(std::sync::Arc::make_mut(b), max_w);
}
}
/// Does the file hold a different document format than its Word or RTF extension says? An RTF
/// file saved as `.doc` (as accounting systems and report generators often save their
/// documents), a `.docx` package renamed `.doc`, a Word 97-2003 file renamed `.docx`, a Word XML
/// Document (2003 or Flat OPC) or a web page saved as `.doc` (#2853): Word opens
/// each by what it holds, so they go through [`open_by_content`] instead of failing.
fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool {
let expected = match ext {
"docx" | "docm" | "dotx" | "dotm" => "zip",
"doc" | "dot" => "ole",
"rtf" => "rtf",
_ => return false,
};
let held = if bytes.starts_with(ZIP_SIG) {
"zip"
} else if bytes.starts_with(OLE_SIG) {
"ole"
} else if bytes.starts_with(b"{\\rtf") {
"rtf"
} else if is_word_xml(bytes) {
"xml"
} else if looks_like_web_page(bytes) {
"html"
} else {
return false;
};
held != expected
}
/// The bytes after a UTF-8 byte-order mark and leading white space.
fn after_bom(bytes: &[u8]) -> &[u8] {
let b = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF][..]).unwrap_or(bytes);
let start = b.iter().position(|c| !c.is_ascii_whitespace()).unwrap_or(b.len());
b.get(start..).unwrap_or_default()
}
/// Is this a Word XML Document: Word 2003 XML (`w:wordDocument`) or Flat OPC (`pkg:package`)?
/// Only text that starts with markup is parsed (UTF-16 has its byte-order mark).
fn is_word_xml(bytes: &[u8]) -> bool {
let utf16 = bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]);
(utf16 || after_bom(bytes).starts_with(b"<")) && !matches!(wordcraft_docx::sniff_xml(bytes), wordcraft_docx::XmlKind::Other)
}
/// Does the file start like a web page (`<!DOCTYPE html …>` or `<html …>`, after an XML
/// declaration and comments)? Word's Save as Web Page and many report generators write HTML
/// under a `.doc` name, and Word opens it as a web page.
fn looks_like_web_page(bytes: &[u8]) -> bool {
let head = wordcraft_formats::txt::decode(bytes.get(..bytes.len().min(4096)).unwrap_or(bytes)).to_ascii_lowercase();
let mut t = head.trim_start_matches(['\u{FEFF}', ' ', '\t', '\r', '\n']);
for _ in 0..64 {
let skip = if t.starts_with("<!--") {
t.find("-->").map(|i| i + 3)
} else if t.starts_with("<?") {
t.find("?>").map(|i| i + 2)
} else {
break;
};
let Some(rest) = skip.and_then(|i| t.get(i..)) else { return false };
t = rest.trim_start();
}
// The tag name ends there: `<htmlish>` is some other XML, not a web page.
let tag = |name: &str| {
t.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with(|c: char| c.is_ascii_whitespace() || c == '>' || c == '/'))
};
tag("<!doctype html") || tag("<html")
}
/// 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, a Word
/// XML Document by its root element, a web page under a Word or RTF name as one
/// (#2853), 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>,
page: &wordcraft_doc::SectionProps,
) -> Result<(Document, bool), String> {
let (mut doc, encrypted) = if bytes.starts_with(OLE_SIG) && 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_SIG) {
(wordcraft_docx::read(bytes).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false)
} else if bytes.starts_with(OLE_SIG) {
open_binary_doc(bytes, password)
.map_err(|e| if needs_password(&e) || wrong_password(&e) { e } else { format!("{name}: not a document WordCraft can open ({e})") })?
} else if bytes.starts_with(b"{\\rtf") {
(wordcraft_formats::rtf::import(bytes)?, false)
} else if is_word_xml(bytes) {
(wordcraft_docx::read_xml(bytes).map_err(|e| format!("{name}: {e}"))?, false)
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) {
let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
(opened?, false)
} else if wordcraft_formats::txt::is_text(bytes) {
(wordcraft_formats::txt::import_on(bytes, page), 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)"));
};
opened(&mut doc);
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> {
open_path_in(path, password, &wordcraft_geom::Region::default())
}
/// [`open_path_with`] in `region` (see [`open_bytes_in`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
crate::file_access::check_read(&path.to_string_lossy())?;
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 html = wordcraft_formats::html::decode(&bytes);
let mut doc = wordcraft_formats::html::import_on(&html, &|src| images.load(src), &region_page(region));
fit_pictures(&mut doc);
opened(&mut doc);
return Ok((doc, false));
}
open_bytes_in(&name, &bytes, password, region)
}
/// 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. The folder is
/// the HTML file's own, which `open_path_with` has already checked against the automation read root. 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)
}
/// The bytes of the file at `path`, for commands that take a path (pictures, mail-merge data).
/// Every such read goes through here so the automation read root applies ([`crate::file_access`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
crate::file_access::check_read(path)?;
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
}
#[cfg(target_arch = "wasm32")]
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
Err(format!("{path}: files are opened through the browser on the web"))
}
/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before
/// anything is read (a font named by the user, which could be any size).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result<Vec<u8>, String> {
crate::file_access::check_read(path)?;
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
if !meta.is_file() {
return Err(format!("{path}: not a file"));
}
if meta.len() > max {
return Err(format!("{path}: the {what} is larger than {} MB", max >> 20));
}
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
}
/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation
/// read root when there is one: enough to tell a file's format by its start.
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file_head(path: &str, max: u64) -> Result<Vec<u8>, String> {
use std::io::Read;
crate::file_access::check_read(path)?;
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let mut head = Vec::new();
f.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?;
Ok(head)
}
/// Rename without replacing another file, respecting both automation access roots.
#[cfg(not(target_arch = "wasm32"))]
pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> {
crate::file_access::check_read(&source.to_string_lossy())?;
crate::file_access::check_write(&source.to_string_lossy())?;
crate::file_access::check_write(&destination.to_string_lossy())?;
// Creating a sibling hard link refuses collisions atomically and keeps the original bytes.
if let Err(e) = std::fs::hard_link(source, destination) {
if e.kind() == std::io::ErrorKind::AlreadyExists && only_case_differs(source, destination) {
return std::fs::rename(source, destination).map_err(|e| format!("Could not rename document: {e}"));
}
return Err(format!("Could not rename document: {e}"));
}
if let Err(e) = std::fs::remove_file(source) {
let _ = std::fs::remove_file(destination);
return Err(format!("Could not rename document: {e}"));
}
Ok(())
}
/// Whether `destination` names `source` in the same directory spelled with different case, and
/// the directory holds no entry spelled exactly as `destination`.
#[cfg(not(target_arch = "wasm32"))]
fn only_case_differs(source: &std::path::Path, destination: &std::path::Path) -> bool {
let (Some(dir), Some(from), Some(to)) = (source.parent(), source.file_name(), destination.file_name()) else {
return false;
};
if destination.parent() != Some(dir) || from == to || from.to_string_lossy().to_lowercase() != to.to_string_lossy().to_lowercase() {
return false;
}
let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir };
std::fs::read_dir(dir).is_ok_and(|entries| entries.filter_map(Result::ok).all(|entry| entry.file_name() != to))
}
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
crate::file_access::check_write(path)?;
// Like a document save: the old file is replaced only once the new bytes are fully written.
safe_save::write(std::path::Path::new(path), bytes).map_err(|e| format!("{path}: {e}"))
}
#[cfg(target_arch = "wasm32")]
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> {
Err(format!("{path}: files are saved through the browser on the web"))
}
#[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()))
}
#[cfg(target_arch = "wasm32")]
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
open_path_with(path, password)
}
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> {
crate::file_access::check_write(&path.to_string_lossy())?;
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?;
safe_save::write(path, &bytes).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 the
/// legacy encoding they look like — GBK, Big5, Shift_JIS, Windows-1252… (see
/// [`wordcraft_formats::txt::decode`]).
pub fn decode_text(b: &[u8]) -> String {
wordcraft_formats::txt::decode(b)
}