Files
wordcraft/crates/formats/src/lib.rs
T
Martin BallandClaude Opus 5.5 2c4f4669c6 #3713: review — importer limits that drop fonts, links, style names or sheet rows log a warning once per document
Every limit this branch added that leaves data out now says so:
- font names over 256 bytes and links over 8 KiB (HTML, ODT, RTF,
  LaTeX, Markdown), and RTF style names cut to 256 bytes;
- runs' links and fonts past the 64 MiB run-string budget;
- spreadsheet rows refused for being over the text budget (ODS and
  Grid::push), once per sheet.

model::dropped logs each kind once per imported document; each
importer's entry point starts a new document with begin_import. Tests
capture the warnings (crate::test_log) and check each is logged once,
again for the next document, and not at all when nothing is dropped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:00:20 -04:00

98 lines
4.0 KiB
Rust

//! WordCraft import/export for the "other" text formats: plain text, Markdown (CommonMark subset
//! plus GFM tables and strikethrough), HTML, RTF, OpenDocument Text and LaTeX — and, read-only,
//! spreadsheet cells for mail-merge recipient lists ([`sheet`]: .xlsx/.xlsm/.xls/.ods).
//!
//! Every format maps to a small flow model ([`model::Flow`]: paragraphs with a kind, list
//! membership, alignment and formatted spans; tables of cells), which [`model::to_doc`] and
//! [`model::from_doc`] convert to and from the full [`Document`]. All parsers are written from
//! the public specifications, are lenient by design and never panic on hostile input: nesting is
//! depth-limited and sizes are capped.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod comments;
pub mod html;
pub mod latex;
pub mod markdown;
pub mod mathml;
pub mod model;
pub mod odt;
pub mod rtf;
pub mod sheet;
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"];
/// 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>> {
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
let r = match ext.as_str() {
"txt" | "text" => Ok(txt::import(bytes)),
"md" | "markdown" => Ok(markdown::import(&txt::decode(bytes))),
"html" | "htm" | "xhtml" => Ok(html::import(&html::decode(bytes))),
"rtf" => rtf::import(bytes),
"odt" => odt::import(bytes),
"tex" | "latex" | "ltx" => Ok(latex::import(bytes)),
_ => return None,
};
Some(r.map(|mut d| {
d.ensure_nonempty();
d
}))
}
/// [`import`] onto `page` (its size and margins) for the formats whose files give no page size:
/// plain text, Markdown and HTML start on it, and their tables size to it (#1381). The other
/// formats keep the page their file gives.
pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option<Result<Document, String>> {
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
let mut doc = match ext.as_str() {
"txt" | "text" => txt::import_on(bytes, page),
"md" | "markdown" => markdown::import_on(&txt::decode(bytes), page),
"html" | "htm" | "xhtml" => html::import_on(&html::decode(bytes), &|_| None, page),
_ => return import(&ext, bytes),
};
doc.ensure_nonempty();
Some(Ok(doc))
}
/// Export `doc` in the format named by `ext`. `None` = not our format.
pub fn export(ext: &str, doc: &Document) -> Option<Result<Vec<u8>, String>> {
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
Some(match ext.as_str() {
"txt" | "text" => Ok(txt::export(doc)),
"md" | "markdown" => Ok(markdown::export(doc).into_bytes()),
"html" | "htm" | "xhtml" => Ok(html::export(doc).into_bytes()),
"rtf" => Ok(rtf::export(doc).into_bytes()),
"odt" => odt::export(doc),
"tex" | "latex" | "ltx" => Ok(latex::export(doc).into_bytes()),
_ => return None,
})
}
/// How [`export_with`] writes a copy.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ExportOptions {
/// Print Markup: HTML, Markdown and plain text end with the document's comments (who wrote
/// each, the text it comments on, what it says, its replies). Other formats keep comments
/// their own way or not at all.
pub comments: bool,
}
/// [`export`] with options.
pub fn export_with(ext: &str, doc: &Document, opts: &ExportOptions) -> Option<Result<Vec<u8>, String>> {
let out = export(ext, doc)?;
if !opts.comments {
return Some(out);
}
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
Some(out.map(|bytes| comments::append(&ext, doc, bytes)))
}
#[cfg(test)]
mod test_log;
#[cfg(test)]
mod tests;