Files
wordcraft/crates/formats/src/lib.rs
T
eb54d88ec0 Mail merge: .xls recipients, legacy encodings, Filter and Sort (#778) (#885)
* #778: .xls and legacy-encoding recipient lists; filter and sort recipients

- Recipient lists: legacy Excel 97-2003 .xls (BIFF8 reader on the cfb crate already in the
  tree); Excel 5.0/95 and password-protected .xls refused with a clear message; a CSV named
  .xls reads as text, an HTML one gets an error. .xls added to the picker filters (desktop,
  web, built-in browser) and drop handling.
- Text lists (and plain-text import) detect legacy encodings when not UTF-8/UTF-16: GBK/GB18030,
  Big5, Shift_JIS, EUC-KR, Windows code pages (encoding_rs + chardetng, MIT/Apache); an
  `encoding` param overrides.
- mailings.filterRecipients / mailings.sortRecipients: field, comparison (equal, notEqual,
  less, greater, lessOrEqual, greaterOrEqual, blank, notBlank, contains, notContains), value,
  And/Or joins; sort keys. Preview, Finish & Merge and labels use only the matching records
  in sort order. Edit Recipient List edits every record and keeps the filter.
- Recipient List dialog: Filter and Sort section and a merge-order column; catalog rows;
  strings in every catalog; docs.

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

* i18n: Hungarian and French strings for this change

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

* Review fixes for #885: total order for recipient sort, Bulgarian strings

- compare_cells now ranks numbers (compared numerically) before text (compared
  case-insensitively), so it is a total order; a column mixing 9, 10 and 1a
  could make slice::sort_by panic. Regression test sorts mixed columns.
- Bulgarian catalog: the 24 Filter and Sort strings.

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

* vi: mail merge Filter and Sort strings

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

---------

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>
2026-10-14 16:57:06 -04:00

96 lines
3.9 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 tests;