From 589e82e050d16571e7bcdbf8d6e7eb21ef360e74 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 19:31:25 +0200 Subject: [PATCH] Mail merge recipients from Excel and OpenDocument spreadsheets (#334) (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select Recipients › Use an Existing List… now reads .xlsx, .xlsm and .ods workbooks besides CSV. A new read-only SpreadsheetML / ODF spreadsheet reader in wordcraft-formats lists the sheets and turns one sheet into rows of display strings: shared, inline and rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 from the cell's number format, sparse cells, ODS value types, repeated rows/columns and covered cells. Zip entries, rows, columns, cells, string lengths and repeat counts are capped, and nothing recurses. mailings.recipients takes "data" (base64) and "sheet" and "headers"; a workbook with several sheets and no sheet answers with the sheet names, and the app shows a Select Table dialog with "First row of data contains column headers". The desktop picker, dropped files and the web inbox route spreadsheets to the recipient list. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 1 + ROADMAP.md | 2 + apps/wordcraft-web/src/web.rs | 2 +- apps/wordcraft/src/file_dialogs.rs | 2 +- crates/engine/src/cmd/mailings.rs | 145 +++- crates/formats/src/lib.rs | 4 +- crates/formats/src/sheet.rs | 1050 +++++++++++++++++++++++++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/dialogs.rs | 50 +- crates/ui-egui/src/file_dialogs.rs | 45 +- crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/et.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 36 +- docs/file-format-parity.md | 2 + docs/gaps.md | 3 +- docs/target-app-parity.md | 3 +- 25 files changed, 1330 insertions(+), 38 deletions(-) create mode 100644 crates/formats/src/sheet.rs diff --git a/Cargo.lock b/Cargo.lock index 0525589c8..dc6b79b97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5238,6 +5238,7 @@ dependencies = [ "wordcraft-layout", "wordcraft-render", "wordcraft-zotero", + "zip", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 86c3b648e..c57012cea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Mail-merge recipients from Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets, with Select Table to pick the sheet (#334) | | 2026-10-11 | Insert › Icons (#309): a library of 61 original icons drawn in code, a searchable picker and Icon Color | | 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field | | 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip | @@ -157,6 +158,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) | | 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) | | 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) | diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index c03215097..5d68374d2 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -10,7 +10,7 @@ use wordcraft_ui_egui::{Inbox, Services, WordApp}; const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]; const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"]; -const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt"]; +const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]; const CANVAS_ID: &str = "wordcraft_canvas"; const LOADING_ID: &str = "wordcraft_loading"; diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 8416b603d..f348b168a 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -73,7 +73,7 @@ fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog if purpose == "picture" { d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"]) } else if purpose == "recipients" { - d.add_filter("Recipient lists", &["csv", "tsv", "txt"]).add_filter("All files", &["*"]) + d.add_filter("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]).add_filter("All files", &["*"]) } else { d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]) .add_filter("Word document", &["docx"]) diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 0142ae9a0..f4f536807 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -1,5 +1,5 @@ -//! Mailings tab: mail merge (recipients from CSV/JSON, merge fields, preview, finish), envelopes -//! and labels. +//! Mailings tab: mail merge (recipients from CSV/JSON or an Excel/OpenDocument spreadsheet, +//! merge fields, preview, finish), envelopes and labels. use serde_json::{Value, json}; use wordcraft_doc::para::InlineObject; @@ -47,7 +47,7 @@ pub fn specs() -> Vec { .params(r#"{"kind": "letters|emails|envelopes|labels|directory|normal"}"#) .pure(), CommandSpec::new("mailings.recipients", "Select Recipients", "Mailings › Start Mail Merge", recipients) - .params(r#"{"csv"?: string, "path"?: string, "rows"?: [{field: value}] | [[value]], "fields"?: [string] (column order; required with array rows)}"#) + .params(r#"{"csv"?: string, "path"?: string (.csv/.tsv/.txt, or a .xlsx/.xlsm/.ods workbook), "data"?: base64 (a file's bytes, as for path), "sheet"?: name | number (1 = first; a workbook with several sheets and no sheet answers {"sheets": [...], "chooseSheet": true} and loads nothing), "headers"?: bool (default true: the first non-blank row names the fields; false: fields are Column 1…N), "rows"?: [{field: value}] | [[value]], "fields"?: [string] (column order; required with array rows)}"#) .pure(), CommandSpec::new("mailings.editRecipients", "Edit Recipient List", "Mailings › Start Mail Merge", |s, v| { if v.get("rows").and_then(Value::as_array).is_some() { @@ -254,37 +254,99 @@ fn table_from_rows(fields: Option<&Vec>, arr: &[Value]) -> Result<(Vec CmdResult { + let mut sheet = Value::Null; let (headers, rows) = if let Some(arr) = v.get("rows").and_then(Value::as_array) { table_from_rows(v.get("fields").and_then(Value::as_array), arr)? } else { - let text = if let Some(c) = p::str(v, "csv") { - c.to_string() - } else if let Some(path) = p::str(v, "path") { - #[cfg(not(target_arch = "wasm32"))] - { - let b = std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?; - crate::io::decode_text(&b) - } - #[cfg(target_arch = "wasm32")] - { - let _ = path; - return Err(CmdError::Failed("paths aren't available on the web".into())); - } + let all = if let Some(c) = p::str(v, "csv") { + parse_csv(c) } else { - return Err(CmdError::Params("`csv`, `path` or `rows` required".into())); + let (bytes, ext) = if let Some(d) = p::str(v, "data") { + let b = super::insert::base64_decode(d.trim()).ok_or_else(|| CmdError::Params("`data` isn't valid base64".into()))?; + (b, p::str(v, "path").and_then(extension)) + } else if let Some(path) = p::str(v, "path") { + (read_file(path)?, extension(path)) + } else { + return Err(CmdError::Params("`csv`, `path`, `data` or `rows` required".into())); + }; + let spreadsheet = + ext.as_deref().is_some_and(wordcraft_formats::sheet::is_extension) || wordcraft_formats::sheet::detect(&bytes).is_some(); + if spreadsheet { + use wordcraft_formats::sheet::{Pick, read_rows, sheet_names}; + let pick = match v.get("sheet") { + Some(Value::String(n)) => Pick::Name(n.as_str()), + Some(x) if x.as_u64().is_some_and(|n| n >= 1) => { + Pick::Index(usize::try_from(x.as_u64().unwrap_or(1).saturating_sub(1)).unwrap_or(usize::MAX)) + } + Some(Value::Null) | None => { + let names = sheet_names(&bytes).map_err(CmdError::Failed)?; + if names.len() > 1 { + return Ok(json!({"sheets": names, "chooseSheet": true})); + } + Pick::Index(0) + } + Some(_) => return Err(CmdError::Params("`sheet` must be a sheet name or number".into())), + }; + let (names, rows) = read_rows(&bytes, pick).map_err(CmdError::Failed)?; + let chosen = match pick { + Pick::Index(i) => names.get(i).cloned(), + Pick::Name(n) => { + names.iter().find(|s| s.as_str() == n).or_else(|| names.iter().find(|s| s.eq_ignore_ascii_case(n.trim()))).cloned() + } + }; + sheet = json!(chosen); + rows + } else { + parse_csv(&crate::io::decode_text(&bytes)) + } }; - let mut all = parse_csv(&text); - if all.is_empty() { - return Err(CmdError::Failed("the recipient list is empty".into())); - } - let headers: Vec = all.remove(0).into_iter().map(|h| h.trim().to_string()).collect(); - (headers, all) + header_split(all, p::bool(v, "headers").unwrap_or(true))? }; s.merge.headers = headers; s.merge.rows = rows; s.merge.record = 0; refresh(s); - Ok(json!({"fields": s.merge.headers, "records": s.merge.rows.len()})) + let mut r = json!({"fields": s.merge.headers, "records": s.merge.rows.len()}); + if !sheet.is_null() { + r["sheet"] = sheet; + } + Ok(r) +} + +/// A file's extension, lower case. +fn extension(path: &str) -> Option { + std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn read_file(path: &str) -> Result, CmdError> { + std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}"))) +} + +#[cfg(target_arch = "wasm32")] +fn read_file(_path: &str) -> Result, CmdError> { + Err(CmdError::Failed("paths aren't available on the web; pass `data`".into())) +} + +/// Field names and records from a table's rows: with `headers`, the first row names the fields +/// (blank names become `Column N`, repeats are numbered); otherwise every row is a record and +/// the fields are `Column 1`…`Column N`. Records are padded or cut to the fields, and capped. +fn header_split(mut all: Vec>, headers: bool) -> Result<(Vec, Vec>), CmdError> { + if all.is_empty() { + return Err(CmdError::Failed("the recipient list is empty".into())); + } + let fields = if headers { + let first = all.remove(0); + wordcraft_formats::sheet::field_names(&first[..first.len().min(MAX_FIELDS)]) + } else { + let width = all.iter().map(Vec::len).max().unwrap_or(0).min(MAX_FIELDS); + (1..=width).map(|i| format!("Column {i}")).collect() + }; + all.truncate(MAX_RECORDS); + for r in &mut all { + r.resize(fields.len(), String::new()); + } + Ok((fields, all)) } fn step(s: &mut Session, d: i64) -> CmdResult { @@ -796,6 +858,41 @@ mod tests { } /// A typed list (#240) keeps its column order, and rows may be arrays in that order. + #[test] + fn recipients_from_a_workbook_ask_for_the_sheet_when_there_are_several() { + use std::io::Write; + let cell = |t: &str| format!(r#"{t}"#); + let row = |cells: &[&str]| format!("{}", cells.iter().map(|c| cell(c)).collect::()); + let content = format!( + r#"{}{}{}{}"#, + row(&["n"]), + row(&["Name", "", "Name"]), + row(&["Ada", "London", "A"]), + row(&["Alan", "Wilmslow", "B"]), + ); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, body) in [("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", content.as_str())] { + z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(body.as_bytes()).unwrap(); + } + let data = super::super::insert::base64_encode(&z.finish().unwrap().into_inner()); + let mut s = Session::new(Document::new()); + // Two sheets and none chosen: the names come back and nothing loads. + let r = s.run("mailings.recipients", &json!({"data": data})).unwrap(); + assert_eq!(r, json!({"sheets": ["Notes", "Guests"], "chooseSheet": true})); + assert!(s.merge.headers.is_empty()); + let r = s.run("mailings.recipients", &json!({"data": data, "sheet": "guests"})).unwrap(); + assert_eq!(r, json!({"fields": ["Name", "Column 2", "Name 2"], "records": 2, "sheet": "Guests"})); + assert_eq!(s.merge.rows[1], ["Alan", "Wilmslow", "B"]); + // Without a header row every row is a record. + let r = s.run("mailings.recipients", &json!({"data": data, "sheet": 2, "headers": false})).unwrap(); + assert_eq!(r["fields"], json!(["Column 1", "Column 2", "Column 3"])); + assert_eq!(r["records"], 3); + assert!(s.run("mailings.recipients", &json!({"data": data, "sheet": 3})).is_err()); + assert!(s.run("mailings.recipients", &json!({"data": data, "sheet": 0})).is_err()); + assert!(s.run("mailings.recipients", &json!({"data": "%%%"})).is_err()); + } + #[test] fn recipients_from_fields_and_array_rows() { let mut s = Session::new(Document::new()); diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index 3bba3cbd8..d0aa5fd18 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -1,5 +1,6 @@ //! WordCraft import/export for the "other" text formats: plain text, Markdown (CommonMark subset -//! plus GFM tables and strikethrough), HTML, RTF, OpenDocument Text and LaTeX. +//! plus GFM tables and strikethrough), HTML, RTF, OpenDocument Text and LaTeX — and, read-only, +//! spreadsheet cells for mail-merge recipient lists ([`sheet`]: .xlsx/.xlsm/.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 @@ -14,6 +15,7 @@ pub mod markdown; pub mod model; pub mod odt; pub mod rtf; +pub mod sheet; pub mod txt; use wordcraft_doc::Document; diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs new file mode 100644 index 000000000..f534b723a --- /dev/null +++ b/crates/formats/src/sheet.rs @@ -0,0 +1,1050 @@ +//! Spreadsheets as mail-merge recipient lists (#334): the sheet names of an Excel workbook +//! (`.xlsx` / `.xlsm`, ECMA-376 Part 1 §18 SpreadsheetML) or an OpenDocument spreadsheet (`.ods`, +//! OASIS ODF 1.3 §9.1), and one sheet's cells as rows of display strings. +//! +//! Values: SpreadsheetML shared strings (rich-text runs joined, phonetic runs skipped), inline +//! strings, formula results (the cached ``), booleans (`TRUE`/`FALSE`), errors as their text +//! and numbers. A number whose cell format is a date or time (built-in formats 14–22, 27–36, +//! 45–47 and 50–58, or a custom format with `d`/`y`, `h`/`s` outside quotes and brackets) is +//! written in the locale-neutral ISO 8601 form `yyyy-mm-dd`, `yyyy-mm-dd hh:mm[:ss]` or +//! `hh:mm[:ss]` (both the 1900 and 1904 date systems); other numbers keep up to 15 significant +//! digits, as the spreadsheet stores them. OpenDocument cells use `office:value-type`: floats +//! from `office:value`, dates and times from `office:date-value` / `office:time-value` (same ISO +//! forms), booleans as `TRUE`/`FALSE`, everything else (strings, percentages, currencies) as the +//! cell's text. Merged and covered cells keep only the top-left value; blank rows are dropped. +//! +//! Every file is hostile: zip entries are inflated up to [`MAX_ENTRY`] (and [`MAX_TOTAL`] in +//! all), sheets, rows, columns, cells and string lengths are capped, repeat counts are clamped to +//! what is left, and nothing recurses, so XML depth costs nothing. + +use std::collections::{BTreeMap, HashMap}; +use std::io::{Cursor, Read}; + +use quick_xml::XmlVersion; +use quick_xml::events::{BytesStart, Event}; + +/// File extensions read as spreadsheets. +pub const EXTENSIONS: &[&str] = &["xlsx", "xlsm", "ods"]; + +/// Largest zip entry inflated. +pub const MAX_ENTRY: u64 = 64 << 20; +/// Most bytes inflated from one workbook. +pub const MAX_TOTAL: u64 = 160 << 20; +/// Most sheets listed. +pub const MAX_SHEETS: usize = 4_096; +/// Most non-blank rows kept (as many as a recipient list holds). +pub const MAX_ROWS: usize = 100_000; +/// Most columns kept; cells further right are ignored. +pub const MAX_COLS: usize = 1_000; +/// Most cells kept in all (blank cells between values count). +pub const MAX_CELLS: usize = 2_000_000; +/// Longest cell text in characters (a spreadsheet cell's own limit). +pub const MAX_CELL_CHARS: usize = 32_767; +/// Most shared strings kept. +const MAX_SHARED: usize = 2_000_000; +/// Most cell formats kept. +const MAX_XFS: usize = 65_536; +/// Elements nested deeper than this are skipped. +const MAX_XML_DEPTH: usize = 256; + +/// The spreadsheet kinds read here. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Xlsx, + Ods, +} + +/// Which sheet to read. +#[derive(Clone, Copy, Debug)] +pub enum Pick<'a> { + /// 0-based position in the workbook's sheet list. + Index(usize), + /// A sheet name (exact match first, then ignoring case). + Name(&'a str), +} + +/// Whether `ext` (no dot, any case) names a spreadsheet read here. +pub fn is_extension(ext: &str) -> bool { + let ext = ext.trim_start_matches('.').to_ascii_lowercase(); + EXTENSIONS.contains(&ext.as_str()) +} + +/// What kind of spreadsheet `bytes` holds, from the package contents (`None`: not one). +pub fn detect(bytes: &[u8]) -> Option { + if !bytes.starts_with(b"PK") { + return None; + } + let mut pkg = Package::open(bytes).ok()?; + pkg.kind() +} + +/// The sheet names, in workbook order. +pub fn sheet_names(bytes: &[u8]) -> Result, String> { + let mut pkg = Package::open(bytes)?; + match pkg.kind().ok_or("not an Excel or OpenDocument spreadsheet")? { + Kind::Xlsx => Ok(xlsx_sheets(&mut pkg)?.into_iter().map(|s| s.name).collect()), + Kind::Ods => Ok(ods_read(&pkg.read("content.xml")?, None)?.0), + } +} + +/// One sheet's non-blank rows of display strings (trailing blank cells trimmed), with the +/// workbook's sheet names. +pub fn read_rows(bytes: &[u8], pick: Pick<'_>) -> Result<(Vec, Vec>), String> { + let mut pkg = Package::open(bytes)?; + match pkg.kind().ok_or("not an Excel or OpenDocument spreadsheet")? { + Kind::Xlsx => { + let sheets = xlsx_sheets(&mut pkg)?; + let names: Vec = sheets.iter().map(|s| s.name.clone()).collect(); + let i = resolve(&names, pick)?; + let sheet = sheets.get(i).ok_or("no such sheet")?; + let rows = xlsx_rows(&mut pkg, &sheet.path)?; + Ok((names, rows)) + } + Kind::Ods => { + let content = pkg.read("content.xml")?; + // Names first, so a name can be resolved to its position. + let (names, _) = ods_read(&content, None)?; + let i = resolve(&names, pick)?; + let (_, rows) = ods_read(&content, Some(i))?; + Ok((names, rows)) + } + } +} + +/// Field names from a header row: trimmed, blanks named `Column N` (N = 1-based column) and +/// repeats numbered (`Name`, `Name 2`), ignoring case as merge fields do. +pub fn field_names(row: &[String]) -> Vec { + let mut out: Vec = Vec::with_capacity(row.len()); + for (i, h) in row.iter().enumerate() { + let base = match h.trim() { + "" => format!("Column {}", i + 1), + t => t.to_string(), + }; + let taken = |n: &str, out: &[String]| out.iter().any(|o| o.eq_ignore_ascii_case(n)); + let mut name = base.clone(); + let mut k = 2usize; + while taken(&name, &out) { + name = format!("{base} {k}"); + k = k.saturating_add(1); + } + out.push(name); + } + out +} + +fn resolve(names: &[String], pick: Pick<'_>) -> Result { + if names.is_empty() { + return Err("the workbook has no sheets".into()); + } + match pick { + Pick::Index(i) if i < names.len() => Ok(i), + Pick::Index(i) => Err(format!("the workbook has {} sheets; there is no sheet {}", names.len(), i.saturating_add(1))), + Pick::Name(n) => names + .iter() + .position(|s| s == n) + .or_else(|| names.iter().position(|s| s.eq_ignore_ascii_case(n.trim()))) + .ok_or_else(|| format!("no sheet named \"{n}\"")), + } +} + +// --------------------------------------------------------------------------------------------- +// Package + +struct Package<'a> { + zip: zip::ZipArchive>, + total: u64, +} + +impl<'a> Package<'a> { + fn open(bytes: &'a [u8]) -> Result { + let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a valid spreadsheet package ({e})"))?; + Ok(Package { zip, total: 0 }) + } + + fn has(&self, name: &str) -> bool { + self.zip.index_for_name(name).is_some() + } + + /// Inflate an entry, refusing ones (or a workbook) larger than the caps. + fn read(&mut self, name: &str) -> Result, String> { + let f = self.zip.by_name(name).map_err(|_| format!("{name} is missing"))?; + if f.size() > MAX_ENTRY { + return Err(format!("{name} is too large")); + } + let mut out = Vec::new(); + f.take(MAX_ENTRY + 1).read_to_end(&mut out).map_err(|e| format!("{name}: {e}"))?; + let len = out.len() as u64; + self.total = self.total.saturating_add(len); + if len > MAX_ENTRY || self.total > MAX_TOTAL { + return Err(format!("{name} is too large")); + } + Ok(out) + } + + fn kind(&mut self) -> Option { + if self.workbook_path().is_some() { + return Some(Kind::Xlsx); + } + if self.has("content.xml") { + let ods = match self.read("mimetype") { + Ok(m) => String::from_utf8_lossy(&m).trim().starts_with("application/vnd.oasis.opendocument.spreadsheet"), + // No mimetype entry: only a package without a text document is taken as one. + Err(_) => !self.has("word/document.xml"), + }; + if ods { + return Some(Kind::Ods); + } + } + None + } + + /// The SpreadsheetML workbook part: the package's main part when it is a workbook, + /// otherwise `xl/workbook.xml` when present. + fn workbook_path(&mut self) -> Option { + if let Ok(rels) = self.read("_rels/.rels") { + for r in relationships(&rels, "") { + if r.kind.ends_with("/officeDocument") && r.target.to_ascii_lowercase().contains("workbook") && self.has(&r.target) { + return Some(r.target); + } + } + } + self.has("xl/workbook.xml").then(|| "xl/workbook.xml".to_string()) + } +} + +// --------------------------------------------------------------------------------------------- +// XML + +/// A streamed XML event with namespace prefixes dropped; elements deeper than +/// [`MAX_XML_DEPTH`] (and their text) are skipped. +enum Ev<'e> { + Start(&'e BytesStart<'e>), + End(Vec), + Text(String), +} + +fn walk(xml: &[u8], mut f: impl FnMut(Ev<'_>)) { + let mut r = quick_xml::Reader::from_reader(xml); + let cfg = r.config_mut(); + cfg.trim_text(false); + cfg.check_end_names = false; + cfg.allow_unmatched_ends = true; + let mut buf = Vec::new(); + let mut depth = 0usize; + loop { + buf.clear(); + match r.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + depth = depth.saturating_add(1); + if depth <= MAX_XML_DEPTH { + f(Ev::Start(&e)); + } + } + Ok(Event::Empty(e)) => { + if depth < MAX_XML_DEPTH { + f(Ev::Start(&e)); + f(Ev::End(e.local_name().as_ref().to_vec())); + } + } + Ok(Event::End(e)) => { + if depth <= MAX_XML_DEPTH { + f(Ev::End(e.local_name().as_ref().to_vec())); + } + depth = depth.saturating_sub(1); + } + Ok(Event::Text(t)) if depth <= MAX_XML_DEPTH => f(Ev::Text(t.decode().map(|c| c.into_owned()).unwrap_or_default())), + Ok(Event::CData(t)) if depth <= MAX_XML_DEPTH => f(Ev::Text(String::from_utf8_lossy(&t).into_owned())), + Ok(Event::GeneralRef(g)) if depth <= MAX_XML_DEPTH => { + let s = match &g[..] { + b"amp" => "&".to_string(), + b"lt" => "<".to_string(), + b"gt" => ">".to_string(), + b"quot" => "\"".to_string(), + b"apos" => "'".to_string(), + _ => g.resolve_char_ref().ok().flatten().map(|c| c.to_string()).unwrap_or_default(), + }; + f(Ev::Text(s)); + } + Ok(Event::Eof) | Err(_) => break, + Ok(_) => {} + } + } +} + +/// An attribute's value by local name. +fn attr(e: &BytesStart<'_>, name: &[u8]) -> Option { + e.attributes().with_checks(false).flatten().take(64).find(|a| a.key.local_name().as_ref() == name).map(|a| { + a.normalized_value(XmlVersion::Explicit1_0).map(|c| c.into_owned()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).into_owned()) + }) +} + +/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters. +fn push_capped(s: &mut String, t: &str) { + let have = s.chars().count(); + if have < MAX_CELL_CHARS { + s.extend(t.chars().take(MAX_CELL_CHARS - have)); + } +} + +/// Rows collected under the caps: blank rows are dropped, rows past [`MAX_ROWS`] and cells past +/// [`MAX_CELLS`] are ignored. +#[derive(Default)] +struct Grid { + rows: Vec>, + cells: usize, +} + +impl Grid { + fn full(&self) -> bool { + self.rows.len() >= MAX_ROWS || self.cells >= MAX_CELLS + } + + /// Keep `row` (trailing blanks trimmed) `times` times, as far as the caps allow. + fn push(&mut self, mut row: Vec, times: u64) { + while row.last().is_some_and(String::is_empty) { + row.pop(); + } + if row.is_empty() { + return; + } + let mut left = times; + while left > 0 && !self.full() { + let room = MAX_CELLS.saturating_sub(self.cells); + let mut r = row.clone(); + r.truncate(room); + self.cells = self.cells.saturating_add(r.len()); + self.rows.push(r); + left -= 1; + } + } +} + +// --------------------------------------------------------------------------------------------- +// SpreadsheetML + +struct Rel { + id: String, + kind: String, + target: String, +} + +/// The relationships of a part in `dir` (targets resolved to package paths). +fn relationships(xml: &[u8], dir: &str) -> Vec { + let mut v = Vec::new(); + walk(xml, |ev| { + if let Ev::Start(e) = ev + && e.local_name().as_ref() == b"Relationship" + && v.len() < MAX_SHEETS * 2 + { + if attr(e, b"TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")) { + return; + } + let target = attr(e, b"Target").unwrap_or_default(); + v.push(Rel { id: attr(e, b"Id").unwrap_or_default(), kind: attr(e, b"Type").unwrap_or_default(), target: join(dir, &target) }); + } + }); + v +} + +/// A relative part name resolved against `dir` (`/` = package root). +fn join(dir: &str, target: &str) -> String { + let mut parts: Vec<&str> = if target.starts_with('/') { Vec::new() } else { dir.split('/').filter(|p| !p.is_empty()).collect() }; + for seg in target.split('/') { + match seg { + "" | "." => {} + ".." => { + parts.pop(); + } + s => parts.push(s), + } + } + parts.join("/") +} + +fn dir_of(path: &str) -> &str { + path.rsplit_once('/').map_or("", |(d, _)| d) +} + +fn rels_of(path: &str) -> String { + match path.rsplit_once('/') { + Some((d, f)) => format!("{d}/_rels/{f}.rels"), + None => format!("_rels/{path}.rels"), + } +} + +struct SheetRef { + name: String, + path: String, +} + +struct Workbook { + sheets: Vec, + date1904: bool, + shared: Option, + styles: Option, +} + +fn workbook(pkg: &mut Package<'_>) -> Result { + let path = pkg.workbook_path().ok_or("the workbook part is missing")?; + let xml = pkg.read(&path)?; + let rels = pkg.read(&rels_of(&path)).map(|r| relationships(&r, dir_of(&path))).unwrap_or_default(); + let mut listed: Vec<(String, String)> = Vec::new(); + let mut date1904 = false; + walk(&xml, |ev| { + if let Ev::Start(e) = ev { + match e.local_name().as_ref() { + b"sheet" if listed.len() < MAX_SHEETS => listed.push((attr(e, b"name").unwrap_or_default(), attr(e, b"id").unwrap_or_default())), + b"workbookPr" => date1904 = attr(e, b"date1904").is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")), + _ => {} + } + } + }); + let sheets = listed + .into_iter() + .filter_map(|(name, id)| { + // Worksheets only: chart and dialog sheets hold no cells. + let r = rels.iter().find(|r| r.id == id && r.kind.ends_with("/worksheet"))?; + Some(SheetRef { name, path: r.target.clone() }) + }) + .collect(); + let part = |suffix: &str| rels.iter().find(|r| r.kind.ends_with(suffix)).map(|r| r.target.clone()); + Ok(Workbook { sheets, date1904, shared: part("/sharedStrings"), styles: part("/styles") }) +} + +fn xlsx_sheets(pkg: &mut Package<'_>) -> Result, String> { + Ok(workbook(pkg)?.sheets) +} + +/// The shared string table (§18.4): each `si`'s text, rich-text runs joined, phonetic runs +/// (`rPh`) left out. +fn shared_strings(xml: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut cur: Option = None; + let (mut in_t, mut phonetic) = (false, 0usize); + walk(xml, |ev| match ev { + Ev::Start(e) => match e.local_name().as_ref() { + b"si" => cur = Some(String::new()), + b"t" => in_t = true, + b"rPh" => phonetic += 1, + _ => {} + }, + Ev::End(n) => match n.as_slice() { + b"si" => { + if let Some(s) = cur.take() + && out.len() < MAX_SHARED + { + out.push(s); + } + } + b"t" => in_t = false, + b"rPh" => phonetic = phonetic.saturating_sub(1), + _ => {} + }, + Ev::Text(t) => { + if in_t + && phonetic == 0 + && let Some(s) = cur.as_mut() + { + push_capped(s, &t); + } + } + }); + out +} + +/// Whether a number format shows a date, a time or both. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DateKind { + Date, + Time, + DateTime, +} + +/// The built-in number formats (§18.8.30) that show dates or times. +fn builtin_date(id: u32) -> Option { + match id { + 14..=17 | 27..=36 | 50..=58 => Some(DateKind::Date), + 18..=21 | 45..=47 => Some(DateKind::Time), + 22 => Some(DateKind::DateTime), + _ => None, + } +} + +/// Whether a custom format code (§18.8.31) shows a date or time: its first section, without +/// quoted text, escaped characters, `[…]` (colours, locales, conditions) and `_x` / `*x` +/// padding, has `d` or `y` (date), `h` or `s` (time), or `m` alone (a month). +fn custom_date(code: &str) -> Option { + let mut plain = String::new(); + let mut chars = code.chars(); + while let Some(c) = chars.next() { + match c { + ';' => break, + '"' => { + for q in chars.by_ref() { + if q == '"' { + break; + } + } + } + '[' => { + for q in chars.by_ref() { + if q == ']' { + break; + } + } + } + '\\' | '_' | '*' => { + chars.next(); + } + c => plain.extend(c.to_lowercase()), + } + } + let has = |c: char| plain.contains(c); + let date = has('d') || has('y'); + let time = has('h') || has('s'); + match (date, time) { + (true, true) => Some(DateKind::DateTime), + (true, false) => Some(DateKind::Date), + (false, true) => Some(DateKind::Time), + (false, false) if has('m') && !has('0') && !has('#') => Some(DateKind::Date), + _ => None, + } +} + +/// Each cell format's (`cellXfs/xf`, by `s` index) date kind. +fn cell_formats(xml: &[u8]) -> Vec> { + let mut custom: HashMap> = HashMap::new(); + let mut xfs: Vec = Vec::new(); + let mut in_xfs = false; + walk(xml, |ev| match ev { + Ev::Start(e) => match e.local_name().as_ref() { + b"numFmt" if custom.len() < MAX_XFS => { + if let Some(id) = attr(e, b"numFmtId").and_then(|v| v.trim().parse().ok()) { + custom.insert(id, custom_date(&attr(e, b"formatCode").unwrap_or_default())); + } + } + b"cellXfs" => in_xfs = true, + b"xf" if in_xfs && xfs.len() < MAX_XFS => xfs.push(attr(e, b"numFmtId").and_then(|v| v.trim().parse().ok()).unwrap_or(0)), + _ => {} + }, + Ev::End(n) if n.as_slice() == b"cellXfs" => in_xfs = false, + _ => {} + }); + xfs.into_iter().map(|id| custom.get(&id).copied().unwrap_or_else(|| builtin_date(id))).collect() +} + +/// A cell reference's 0-based column (`C5` → 2); `None` when it isn't one. +fn ref_col(r: &str) -> Option { + let mut col = 0usize; + let mut letters = 0; + for c in r.chars() { + if c.is_ascii_alphabetic() { + letters += 1; + if letters > 3 { + return None; + } + col = col * 26 + (c.to_ascii_uppercase() as usize - 'A' as usize + 1); + } else { + break; + } + } + col.checked_sub(1) +} + +#[derive(Default)] +struct XCell { + col: usize, + kind: String, + style: usize, + value: String, + inline: String, +} + +fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result>, String> { + let wb = workbook(pkg)?; + let shared = match wb.shared.as_deref().or(pkg.has("xl/sharedStrings.xml").then_some("xl/sharedStrings.xml")).map(str::to_string) { + Some(p) => shared_strings(&pkg.read(&p)?), + None => Vec::new(), + }; + let formats = match wb.styles.as_deref().or(pkg.has("xl/styles.xml").then_some("xl/styles.xml")).map(str::to_string) { + Some(p) => cell_formats(&pkg.read(&p)?), + None => Vec::new(), + }; + let xml = pkg.read(path)?; + // Rows by number, so out-of-order rows still come out in order. + let mut rows: BTreeMap> = BTreeMap::new(); + let mut cells = 0usize; + let (mut in_data, mut row_no, mut next_col) = (false, 0u64, 0usize); + let mut cell: Option = None; + let (mut in_v, mut in_is_t, mut phonetic) = (false, false, 0usize); + walk(&xml, |ev| match ev { + Ev::Start(e) => match e.local_name().as_ref() { + b"sheetData" => in_data = true, + b"row" if in_data => { + row_no = attr(e, b"r").and_then(|v| v.trim().parse().ok()).unwrap_or(row_no.saturating_add(1)); + next_col = 0; + } + b"c" if in_data => { + // A reference that isn't one (or is past the last column) drops the cell. + let col = match attr(e, b"r") { + Some(r) => ref_col(r.trim()).unwrap_or(usize::MAX), + None => next_col, + }; + next_col = col.saturating_add(1); + let style = attr(e, b"s").and_then(|v| v.trim().parse().ok()).unwrap_or(0); + cell = Some(XCell { col, kind: attr(e, b"t").unwrap_or_default(), style, ..Default::default() }); + } + b"v" => in_v = cell.is_some(), + b"t" => in_is_t = cell.is_some(), + b"rPh" => phonetic += 1, + _ => {} + }, + Ev::Text(t) => { + if let Some(c) = cell.as_mut() { + if in_v { + push_capped(&mut c.value, &t); + } else if in_is_t && phonetic == 0 { + push_capped(&mut c.inline, &t); + } + } + } + Ev::End(n) => match n.as_slice() { + b"sheetData" => in_data = false, + b"v" => in_v = false, + b"t" => in_is_t = false, + b"rPh" => phonetic = phonetic.saturating_sub(1), + b"c" => { + let Some(c) = cell.take() else { return }; + let text = xlsx_value(&c, &shared, &formats, wb.date1904); + if text.is_empty() || c.col >= MAX_COLS { + return; + } + if !rows.contains_key(&row_no) && rows.len() >= MAX_ROWS { + return; + } + let row = rows.entry(row_no).or_default(); + if row.len() <= c.col { + let grow = c.col + 1 - row.len(); + if cells.saturating_add(grow) > MAX_CELLS { + return; + } + cells += grow; + row.resize(c.col + 1, String::new()); + } + if let Some(slot) = row.get_mut(c.col) { + *slot = text; + } + } + _ => {} + }, + }); + let mut grid = Grid::default(); + for (_, r) in rows { + grid.push(r, 1); + } + Ok(grid.rows) +} + +fn xlsx_value(c: &XCell, shared: &[String], formats: &[Option], date1904: bool) -> String { + let v = c.value.trim(); + match c.kind.as_str() { + "s" => v.parse::().ok().and_then(|i| shared.get(i)).cloned().unwrap_or_default(), + "inlineStr" => c.inline.clone(), + "str" | "e" => c.value.clone(), + "b" => if v == "1" || v.eq_ignore_ascii_case("true") { "TRUE" } else { "FALSE" }.into(), + "d" => iso_datetime(v).unwrap_or_else(|| v.to_string()), + _ if v.is_empty() => c.inline.clone(), + _ => { + let Ok(x) = v.parse::() else { return v.to_string() }; + match formats.get(c.style).copied().flatten() { + Some(kind) => serial_date(x, date1904, kind).unwrap_or_else(|| number(x)), + None => number(x), + } + } + } +} + +/// A number as a spreadsheet shows it in General format: up to 15 significant digits, no +/// trailing zeros. +fn number(x: f64) -> String { + if !x.is_finite() { + return x.to_string(); + } + if x.fract() == 0.0 && x.abs() < 1e15 { + return format!("{x:.0}"); + } + let rounded: f64 = format!("{x:.14e}").parse().unwrap_or(x); + if rounded.abs() >= 1e15 || rounded.abs() < 1e-9 { format!("{rounded:E}") } else { rounded.to_string() } +} + +/// A serial date-time (days since the workbook's epoch) in ISO 8601 form. +fn serial_date(serial: f64, date1904: bool, kind: DateKind) -> Option { + // Day 2958465 is 9999-12-31. + if !serial.is_finite() || !(0.0..2_958_466.0).contains(&serial) { + return None; + } + let mut days = serial.floor() as i64; + let mut secs = ((serial - serial.floor()) * 86_400.0).round() as i64; + if secs >= 86_400 { + days += 1; + secs -= 86_400; + } + // Days from 1970-01-01. The 1900 system counts a 29 February 1900 that never was, so days + // before it start a day later. + let epoch = if date1904 { + -24_107 + } else if days < 60 { + -25_568 + } else { + -25_569 + }; + let (y, m, d) = civil(days + epoch); + let (h, mi, s) = (secs / 3600, secs / 60 % 60, secs % 60); + let time = if s == 0 { format!("{h:02}:{mi:02}") } else { format!("{h:02}:{mi:02}:{s:02}") }; + Some(match kind { + DateKind::Date => format!("{y:04}-{m:02}-{d:02}"), + DateKind::Time => time, + DateKind::DateTime => format!("{y:04}-{m:02}-{d:02} {time}"), + }) +} + +/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day). +fn civil(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(m <= 2); + (y, m, d) +} + +/// An ISO 8601 date or date-time (`2024-03-05`, `2024-03-05T13:45:00.000`) as `yyyy-mm-dd`, +/// with ` hh:mm[:ss]` when the time isn't midnight. +fn iso_datetime(v: &str) -> Option { + let (date, time) = v.split_once('T').unwrap_or((v, "")); + let mut it = date.splitn(3, '-'); + let y: i64 = it.next()?.parse().ok()?; + let m: u32 = it.next()?.parse().ok()?; + let d: u32 = it.next()?.get(..2)?.parse().ok()?; + if !(1..=12).contains(&m) || !(1..=31).contains(&d) { + return None; + } + let mut out = format!("{y:04}-{m:02}-{d:02}"); + let hms: Vec = time.split(['Z', '+', '.']).next().unwrap_or("").split(':').filter_map(|p| p.parse().ok()).collect(); + if let [h, mi, rest @ ..] = hms.as_slice() { + let s = rest.first().copied().unwrap_or(0); + if (*h, *mi, s) != (0, 0, 0) { + out.push_str(&if s == 0 { format!(" {h:02}:{mi:02}") } else { format!(" {h:02}:{mi:02}:{s:02}") }); + } + } + Some(out) +} + +/// An ISO 8601 duration time (`PT13H45M00S`) as `hh:mm[:ss]`. +fn iso_time(v: &str) -> Option { + let t = v.trim().strip_prefix("PT")?; + let (mut h, mut m, mut s) = (0u64, 0u64, 0f64); + let mut num = String::new(); + for c in t.chars() { + match c { + 'H' => h = std::mem::take(&mut num).parse().ok()?, + 'M' => m = std::mem::take(&mut num).parse().ok()?, + 'S' => s = std::mem::take(&mut num).parse().ok()?, + c if num.len() < 20 => num.push(c), + _ => return None, + } + } + let s = s.round() as u64; + Some(if s == 0 { format!("{h:02}:{m:02}") } else { format!("{h:02}:{m:02}:{s:02}") }) +} + +// --------------------------------------------------------------------------------------------- +// OpenDocument + +#[derive(Default)] +struct OCell { + repeat: u64, + kind: String, + value: Option, + text: String, + paras: usize, +} + +/// The sheet names of `content.xml`, and with `want` that sheet's rows (ODF 1.3 §9.1). +fn ods_read(xml: &[u8], want: Option) -> Result<(Vec, Vec>), String> { + let mut names = Vec::new(); + let mut grid = Grid::default(); + // Nesting of `table:table` (sub-tables inside cells are skipped), and whether the outermost + // one is the wanted sheet. + let mut tables = 0usize; + let mut collecting = false; + let mut row: Option<(Vec, u64, u64)> = None; // cells, pending blank cells, repeat + let mut cell: Option = None; + let (mut in_p, mut skip) = (0usize, 0usize); + walk(xml, |ev| match ev { + Ev::Start(e) => { + let n = e.local_name(); + let n = n.as_ref(); + if n == b"table" { + if tables == 0 && names.len() < MAX_SHEETS { + collecting = want == Some(names.len()); + names.push(attr(e, b"name").unwrap_or_else(|| format!("Sheet{}", names.len() + 1))); + } + tables += 1; + return; + } + if !collecting || tables != 1 { + return; + } + match n { + b"table-row" => { + let repeat = attr(e, b"number-rows-repeated").and_then(|v| v.trim().parse().ok()).unwrap_or(1u64).max(1); + row = Some((Vec::new(), 0, repeat)); + } + b"table-cell" | b"covered-table-cell" if row.is_some() => { + let repeat = attr(e, b"number-columns-repeated").and_then(|v| v.trim().parse().ok()).unwrap_or(1u64).max(1); + let kind = attr(e, b"value-type").unwrap_or_default(); + let value = match kind.as_str() { + "float" => attr(e, b"value"), + "date" => attr(e, b"date-value"), + "time" => attr(e, b"time-value"), + "boolean" => attr(e, b"boolean-value"), + _ => None, + }; + // A covered cell's own content is hidden by the merge. + let covered = n == b"covered-table-cell"; + cell = Some(OCell { + repeat, + kind: if covered { String::new() } else { kind }, + value: if covered { None } else { value }, + ..Default::default() + }); + if covered { + skip += 1; + } + } + b"annotation" if cell.is_some() => skip += 1, + b"p" | b"h" if cell.is_some() => { + if let Some(c) = cell.as_mut() + && skip == 0 + { + if c.paras > 0 { + push_capped(&mut c.text, "\n"); + } + c.paras += 1; + } + in_p += 1; + } + b"s" if in_p > 0 && skip == 0 => { + let k = attr(e, b"c").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, 1_000); + if let Some(c) = cell.as_mut() { + push_capped(&mut c.text, &" ".repeat(k)); + } + } + b"tab" if in_p > 0 && skip == 0 => { + if let Some(c) = cell.as_mut() { + push_capped(&mut c.text, "\t"); + } + } + b"line-break" if in_p > 0 && skip == 0 => { + if let Some(c) = cell.as_mut() { + push_capped(&mut c.text, "\n"); + } + } + _ => {} + } + } + Ev::Text(t) => { + if in_p > 0 + && skip == 0 + && let Some(c) = cell.as_mut() + { + push_capped(&mut c.text, &t); + } + } + Ev::End(n) => { + let n = n.as_slice(); + if n == b"table" { + tables = tables.saturating_sub(1); + if tables == 0 { + collecting = false; + } + return; + } + if !collecting || tables != 1 { + return; + } + match n { + b"p" | b"h" => in_p = in_p.saturating_sub(1), + b"annotation" => skip = skip.saturating_sub(1), + b"table-cell" | b"covered-table-cell" => { + if n == b"covered-table-cell" { + skip = skip.saturating_sub(1); + } + let (Some(c), Some((cells, pending, _))) = (cell.take(), row.as_mut()) else { return }; + let text = ods_value(&c); + if text.is_empty() { + *pending = pending.saturating_add(c.repeat); + return; + } + // Blank cells before this one, then the value as often as it repeats, as far + // as the columns allow. + let blanks = (*pending).min(MAX_COLS.saturating_sub(cells.len()) as u64) as usize; + cells.resize(cells.len() + blanks, String::new()); + *pending = 0; + let times = c.repeat.min(MAX_COLS.saturating_sub(cells.len()) as u64) as usize; + cells.extend(std::iter::repeat_n(text, times)); + } + b"table-row" => { + if let Some((cells, _, repeat)) = row.take() { + grid.push(cells, repeat); + } + } + _ => {} + } + } + }); + if names.is_empty() { + return Err("the spreadsheet has no sheets".into()); + } + Ok((names, grid.rows)) +} + +fn ods_value(c: &OCell) -> String { + let v = c.value.as_deref().map(str::trim); + match (c.kind.as_str(), v) { + ("float", Some(v)) => v.parse::().map(number).unwrap_or_else(|_| c.text.clone()), + ("date", Some(v)) => iso_datetime(v).unwrap_or_else(|| c.text.clone()), + ("time", Some(v)) => iso_time(v).unwrap_or_else(|| c.text.clone()), + ("boolean", Some(v)) => if v.eq_ignore_ascii_case("true") || v == "1" { "TRUE" } else { "FALSE" }.into(), + _ => c.text.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn zip(entries: &[(&str, &str)]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (name, body) in entries { + z.start_file(*name, o).unwrap(); + z.write_all(body.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() + } + + const RELS: &str = r#""#; + + fn xlsx(sheet1: &str) -> Vec { + let wb = r#""#; + let wb_rels = r#""#; + let sst = r#"NameBornAda LovelaceignoredName"#; + let styles = r#""#; + let sheet2 = r#"x"#; + zip(&[ + ("_rels/.rels", RELS), + ("xl/workbook.xml", wb), + ("xl/_rels/workbook.xml.rels", wb_rels), + ("xl/sharedStrings.xml", sst), + ("xl/styles.xml", styles), + ("xl/worksheets/sheet1.xml", sheet1), + ("xl/worksheets/sheet2.xml", sheet2), + ]) + } + + #[test] + fn xlsx_shared_strings_dates_numbers_and_gaps() { + let sheet = r#" + 0133 + 2453560.300000000000000041 + Alan112A4&"!"Alan!45356.5 + 1999 + "#; + let book = xlsx(sheet); + assert_eq!(detect(&book), Some(Kind::Xlsx)); + assert_eq!(sheet_names(&book).unwrap(), ["People", "Notes"], "chart sheets aren't listed"); + let (names, rows) = read_rows(&book, Pick::Index(0)).unwrap(); + assert_eq!(names.len(), 2); + assert_eq!(rows.len(), 3, "blank rows (2, and 5 with nothing readable) are dropped: {rows:?}"); + assert_eq!(rows[0], ["Name", "Born", "", "Name", "Name"]); + assert_eq!(field_names(&rows[0]), ["Name", "Born", "Column 3", "Name 2", "Name 3"]); + assert_eq!(rows[1], ["Ada Lovelace", "2024-03-05", "", "0.3", "TRUE"]); + assert_eq!(rows[2], ["Alan", "1900-01-01", "12", "Alan!", "2024-03-05 12:00"]); + let (_, notes) = read_rows(&book, Pick::Name("notes")).unwrap(); + assert_eq!(notes, [["x"]]); + assert!(read_rows(&book, Pick::Index(5)).is_err()); + assert!(read_rows(&book, Pick::Name("Missing")).is_err()); + } + + #[test] + fn ods_repeats_types_and_covered_cells() { + let content = r#" + only + + + NameWhenTag + AdaLxnotehidden3,5 + + "#; + let book = zip(&[("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", content)]); + assert_eq!(detect(&book), Some(Kind::Ods)); + assert_eq!(sheet_names(&book).unwrap(), ["First", "Guests"]); + let (_, rows) = read_rows(&book, Pick::Name("Guests")).unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0], ["Name", "", "", "When", "Tag", "Tag"]); + assert_eq!(rows[1], ["Ada L\nx", "", "3.5", "2024-03-05", "FALSE", "13:45"]); + assert_eq!(rows[1], rows[2]); + } + + #[test] + fn hostile_workbooks_are_refused_or_capped_without_panicking() { + // Huge repeat counts: rows and cells stay within the caps. + let row = r#"x"#; + let content = format!( + r#"{row}"# + ); + let book = zip(&[("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", &content)]); + let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap(); + assert!(rows.len() <= MAX_ROWS && rows.iter().all(|r| r.len() <= MAX_COLS)); + assert!(rows.iter().map(Vec::len).sum::() <= MAX_CELLS); + // A zip bomb: a tiny entry that inflates past the entry cap is refused. + let big = "".to_string() + &"x".repeat((MAX_ENTRY + 10) as usize) + ""; + let bomb = zip(&[("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", &big)]); + assert!(bomb.len() < 1 << 20, "the bomb is small: {}", bomb.len()); + assert!(read_rows(&bomb, Pick::Index(0)).unwrap_err().contains("too large")); + // Huge cell references, long strings and junk. + let long = "y".repeat(MAX_CELL_CHARS + 50); + let sheet = format!( + r#"1{long}-11e308"# + ); + let (_, rows) = read_rows(&xlsx(&sheet), Pick::Index(0)).unwrap(); + assert_eq!(rows[0][0].chars().count(), MAX_CELL_CHARS); + assert_eq!(rows[0], [long[..MAX_CELL_CHARS].to_string(), String::new(), "1E308".to_string()]); + for junk in [&b""[..], b"PK\x03\x04junk", b"not a zip", &zip(&[("content.xml", "<<<")])] { + let _ = sheet_names(junk); + let _ = read_rows(junk, Pick::Index(0)); + } + } + + #[test] + fn dates_and_formats() { + assert_eq!(custom_date("[$-409]mmmm d, yyyy;@"), Some(DateKind::Date)); + assert_eq!(custom_date("h:mm AM/PM"), Some(DateKind::Time)); + assert_eq!(custom_date("\"days\" 0.00"), None); + assert_eq!(custom_date("General"), None); + assert_eq!(custom_date("[Red]#,##0.00"), None); + assert_eq!(serial_date(61.0, false, DateKind::Date).as_deref(), Some("1900-03-01")); + assert_eq!(serial_date(0.0, true, DateKind::Date).as_deref(), Some("1904-01-01")); + assert_eq!(serial_date(0.75, false, DateKind::Time).as_deref(), Some("18:00")); + assert_eq!(serial_date(-1.0, false, DateKind::Date), None); + assert_eq!(number(1234567.0), "1234567"); + assert_eq!(number(0.1 + 0.2), "0.3"); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 83bcc832a..54924d07a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -31,6 +31,7 @@ js-sys = "0.3" [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +zip = { workspace = true } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 17eae229e..20062f7c6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2,7 +2,7 @@ //! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Table //! Properties, Enclose Characters, Command search, Icons, Paste Special, About, Save Changes, the password to open a //! document and Encrypt with Password, and the mail-merge Recipient List, Insert Merge Field, Find -//! Recipient, merge rules, Match Fields and Check for Errors (Customize Keyboard is in +//! Recipient, merge rules, Match Fields, Check for Errors and Select Table (a workbook's sheet) (Customize Keyboard is in //! `dialogs_keyboard`, Customize Ribbon in `dialogs_ribbon`). Every dialog ends by running a //! command (or shows one's result), so agents get the same result without the dialog. @@ -201,6 +201,17 @@ pub enum Dialog { rows: Vec>, message: String, }, + /// Mailings › Select Recipients › Use an Existing List… picked a workbook with several sheets + /// (#334): which sheet holds the recipients, and whether its first row names the fields. + /// OK runs `mailings.recipients` with `params` (the file) plus `sheet` and `headers`. + SelectTable { + sheets: Vec, + selected: usize, + headers: bool, + message: String, + #[serde(skip)] + params: Value, + }, /// Mailings › Insert Merge Field: one of the recipient list's fields (or a typed name). InsertMergeField { fields: Vec, @@ -683,6 +694,7 @@ impl Dialog { Dialog::About { .. } => "about", Dialog::SaveChanges { .. } => "saveChanges", Dialog::RecipientList { .. } => "recipientList", + Dialog::SelectTable { .. } => "selectTable", Dialog::InsertMergeField { .. } => "insertMergeField", Dialog::FindRecipient { .. } => "findRecipient", Dialog::FieldCode { edit: false, .. } => "fieldCode", @@ -1043,6 +1055,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::About { .. } => "About WordCraft", Dialog::SaveChanges { .. } => "WordCraft", Dialog::RecipientList { .. } => "Recipient List", + Dialog::SelectTable { .. } => "Select Table", Dialog::InsertMergeField { .. } => "Insert Merge Field", Dialog::FindRecipient { .. } => "Find Recipient", Dialog::FieldCode { edit: false, .. } => "Field", @@ -2002,6 +2015,41 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } cancel } + Dialog::SelectTable { sheets, selected, headers, message, params } => { + ui.label(tl!("Choose the sheet that holds the recipients.")); + ui.add_space(4.0); + // A double-click picks the sheet as OK does. + let mut picked = false; + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_min_width(300.0); + egui::ScrollArea::vertical().id_salt("select_table").max_height(220.0).show(ui, |ui| { + for (i, name) in sheets.iter().enumerate() { + let r = ui.selectable_label(*selected == i, name.as_str()); + if r.clicked() || r.double_clicked() { + *selected = i; + } + picked |= r.double_clicked(); + } + }); + }); + ui.checkbox(headers, tl!("First row of data contains column headers")); + if !message.is_empty() { + ui.label(egui::RichText::new(message.as_str()).color(Tokens::get(ui.ctx()).red)); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok || picked { + let mut p = params.clone(); + if let Some(o) = p.as_object_mut() { + o.insert("sheet".into(), json!(*selected + 1)); + o.insert("headers".into(), json!(*headers)); + } + match app.load_recipients(p) { + Ok(_) => return true, + Err(e) => *message = e, + } + } + cancel + } Dialog::FindRecipient { text, message } => { ui.horizontal(|ui| { ui.label(tl!("Find:")); diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index ade17760d..ea9c9b18f 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -33,7 +33,7 @@ pub(crate) enum AfterPick { SaveAs(AfterSave), /// Save a copy there in this format (`pdf`, `docx`, `png`…). Export { ext: String }, - /// Load the picked CSV/TSV/text file as the mail-merge recipients (#240). + /// Load the picked CSV/TSV/text file or spreadsheet as the mail-merge recipients (#240, #334). Recipients, } @@ -339,4 +339,47 @@ mod tests { assert_eq!(a.session.merge.rows.len(), 2); let _ = std::fs::remove_dir_all(&dir); } + + /// A workbook with several sheets (#334) shows Select Table; its answer loads that sheet. + #[test] + fn a_workbook_with_several_sheets_asks_which_one() { + use std::io::Write; + let dir = scratch("workbook"); + let book = dir.join("people.ods"); + let table = |name: &str, cells: &[&str]| { + let cells: String = cells + .iter() + .map(|c| { + format!( + r#"{c}"# + ) + }) + .collect(); + format!(r#"{cells}"#) + }; + let content = format!( + r#"{}{}"#, + table("Notes", &["n"]), + table("Guests", &["Name", "Ada", "Alan"]) + ); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, body) in [("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", content.as_str())] { + z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(body.as_bytes()).unwrap(); + } + std::fs::write(&book, z.finish().unwrap().into_inner()).unwrap(); + let (mut a, shown) = async_app(); + a.run("ui.openRecipientList", json!({})).unwrap(); + answer(&shown, Some(&book.to_string_lossy())); + a.poll_file_dialog().unwrap().unwrap(); + let Some(crate::dialogs::Dialog::SelectTable { sheets, params, headers: true, .. }) = &a.dialog else { panic!("Select Table expected") }; + assert_eq!(sheets, &["Notes", "Guests"]); + assert!(a.session.merge.headers.is_empty(), "nothing loads before a sheet is chosen"); + let mut p = params.clone(); + p["sheet"] = json!(2); + a.load_recipients(p).unwrap(); + assert_eq!(a.session.merge.headers, vec!["Name"]); + assert_eq!(a.session.merge.rows.len(), 2); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0a8c72812..9471fcff3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1116,6 +1116,8 @@ Type at least one recipient. Escribe al menos un destinatario. Type the recipients, or load them from a file. Escribe los destinatarios o cárgalos desde un archivo. Use an Existing List… Usar una lista existente… + Choose the sheet that holds the recipients. Elija la hoja que contiene los destinatarios. + First row of data contains column headers La primera fila de datos contiene encabezados de columna Interface theme: Tema de la interfaz: Interface\nTheme Tema de la\ninterfaz Interface Theme Tema de la interfaz diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 6b0cea8d4..876ab51cf 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1346,6 +1346,8 @@ Reset Character Formatting Lähtesta märgivorming Reset Paragraph Formatting Lähtesta lõiguvorming Trademark Sign Kaubamärgi märk + Choose the sheet that holds the recipients. Valige leht, millel on adressaadid. + First row of data contains column headers Andmete esimene rida sisaldab veerupäiseid Formatted Vormindatud Interface size: Kasutajaliidese suurus: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 75917acc4..27854a859 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1100,6 +1100,8 @@ Type at least one recipient. 宛先を1件以上入力してください。 Type the recipients, or load them from a file. 宛先を入力するか、ファイルから読み込みます。 Use an Existing List… 既存のリストを使用… + Choose the sheet that holds the recipients. 宛先が入っているシートを選んでください。 + First row of data contains column headers データの先頭行を列見出しとして使う Interface theme: 表示テーマ: Interface\nTheme 表示テーマ Interface Theme 表示テーマ diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 525178fab..ae8b80789 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1112,6 +1112,8 @@ Type at least one recipient. Digite pelo menos um destinatário. Type the recipients, or load them from a file. Digite os destinatários ou carregue-os de um arquivo. Use an Existing List… Usar uma lista existente… + Choose the sheet that holds the recipients. Escolha a planilha que contém os destinatários. + First row of data contains column headers A primeira linha de dados contém cabeçalhos de coluna Interface theme: Tema da interface: Interface\nTheme Tema da\ninterface Interface Theme Tema da interface diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b4b4412de..7fbf5a144 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1790,6 +1790,8 @@ preview Title Пример Left Only Только слева Right Only Только справа Largest Only Только по большей стороне + Choose the sheet that holds the recipients. Выберите лист с получателями. + First row of data contains column headers Первая строка данных содержит заголовки столбцов Communication Связь Office Офис People Люди diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 2c687e124..6c70e3195 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1172,6 +1172,8 @@ Type at least one recipient. Unesite bar jednog primaoca. Type the recipients, or load them from a file. Unesite primaoce ili ih učitajte iz fajla. Use an Existing List… Koristi postojeću listu… + Choose the sheet that holds the recipients. Izaberite list na kome su primaoci. + First row of data contains column headers Prvi red podataka sadrži zaglavlja kolona A file dialog is already open. Dijalog za fajlove je već otvoren. Height: Visina: Width: Širina: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index a994d5123..f7b00cb91 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1176,6 +1176,8 @@ Type at least one recipient. Унесите бар једног примаоца. Type the recipients, or load them from a file. Унесите примаоце или их учитајте из фајла. Use an Existing List… Користи постојећу листу… + Choose the sheet that holds the recipients. Изаберите лист на коме су примаоци. + First row of data contains column headers Први ред података садржи заглавља колона A file dialog is already open. Дијалог за фајлове је већ отворен. Height: Висина: Width: Ширина: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c29774d06..a5d5164fa 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1408,6 +1408,8 @@ Left Only Yalnızca Sol Right Only Yalnızca Sağ Largest Only Yalnızca En Geniş + Choose the sheet that holds the recipients. Alıcıları içeren sayfayı seçin. + First row of data contains column headers Verinin ilk satırı sütun başlıklarını içerir Arrows Oklar Communication İletişim Office Ofis diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 836e765b9..b14b173ff 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1112,6 +1112,8 @@ Type at least one recipient. Введіть принаймні одного одержувача. Type the recipients, or load them from a file. Введіть одержувачів або завантажте їх із файлу. Use an Existing List… Використати наявний список… + Choose the sheet that holds the recipients. Виберіть аркуш з одержувачами. + First row of data contains column headers Перший рядок даних містить заголовки стовпців Interface theme: Тема інтерфейсу: Interface\nTheme Тема\nінтерфейсу Interface Theme Тема інтерфейсу diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 87367776e..c34248317 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1112,6 +1112,8 @@ Type at least one recipient. 请至少键入一个收件人。 Type the recipients, or load them from a file. 键入收件人,或从文件加载。 Use an Existing List… 使用现有列表… + Choose the sheet that holds the recipients. 选择包含收件人的工作表。 + First row of data contains column headers 数据首行包含列标题 Interface theme: 界面主题: Interface\nTheme 界面主题 Interface Theme 界面主题 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e65d67a57..896b89c8a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1101,6 +1101,8 @@ Type at least one recipient. 請至少輸入一位收件者。 Type the recipients, or load them from a file. 輸入收件者,或從檔案載入。 Use an Existing List… 使用現有清單… + Choose the sheet that holds the recipients. 選擇包含收件者的工作表。 + First row of data contains column headers 資料第一列包含欄標題 Interface theme: 介面主題: Interface\nTheme 介面主題 Interface Theme 介面主題 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fa77836bf..cceda7159 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -765,10 +765,20 @@ impl WordApp { let _ = self.ask_file(file_dialogs::FileDialogRequest::Open { purpose: "recipients".into() }, file_dialogs::AfterPick::Recipients); } - /// Load mail-merge recipients and say how many there are. + /// Load mail-merge recipients and say how many there are. A workbook with several sheets + /// shows Select Table to choose one first (#334). pub(crate) fn load_recipients(&mut self, params: Value) -> Result { - let r = self.run("mailings.recipients", params); - if let Ok(v) = &r { + let r = self.run("mailings.recipients", params.clone()); + if let Ok(v) = &r + && v.get("chooseSheet").and_then(Value::as_bool) == Some(true) + { + let sheets = v + .get("sheets") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + self.dialog = Some(dialogs::Dialog::SelectTable { sheets, selected: 0, headers: true, message: String::new(), params }); + } else if let Ok(v) = &r { let records = v.get("records").and_then(Value::as_u64).unwrap_or(0).to_string(); self.status(i18n::fmt(tl!("Recipients: {count}"), &[("count", &records)])); } @@ -855,7 +865,15 @@ impl WordApp { let path = f.path().to_string_lossy().to_string(); let lp = path.to_ascii_lowercase(); let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lp.ends_with(e)); - let _ = if img { self.run("insert.picture", json!({"path": path})) } else { self.run("file.open", json!({"path": path})) }; + let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lp.ends_with(e)); + let _ = if img { + self.run("insert.picture", json!({"path": path})) + } else if sheet { + // A spreadsheet isn't a document: it's a recipient list (#334). + self.load_recipients(json!({"path": path})) + } else { + self.run("file.open", json!({"path": path})) + }; } } self.report_dirty(); @@ -983,7 +1001,7 @@ impl WordApp { } /// Files that arrived asynchronously (web picker, drops): documents open, pictures insert, - /// recipient lists (CSV/TSV, or a text file asked for as one) load as recipients. + /// recipient lists (CSV/TSV, spreadsheets, or a text file asked for as one) load as recipients. fn drain_inbox(&mut self) { let Some(inbox) = self.services.inbox.clone() else { return }; let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())); @@ -992,7 +1010,11 @@ impl WordApp { let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lower.ends_with(e)); let list = lower.ends_with(".csv") || lower.ends_with(".tsv") || (lower.ends_with(".txt") && std::mem::take(&mut self.recipient_list_pending)); - let r = if list { + let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lower.ends_with(e)); + let r = if sheet { + self.recipient_list_pending = false; + self.load_recipients(json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})) + } else if list { self.load_recipients(json!({"csv": wordcraft_engine::io::decode_text(&bytes)})) } else if img { self.insert_or_change_picture(json!({"data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})) @@ -1134,7 +1156,7 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { Some("SKIPIF") => Some("ruleSkipIf"), _ => None, }, - "mailings.recipients" if !["csv", "path", "rows"].into_iter().any(has) => Some("recipientList"), + "mailings.recipients" if !["csv", "path", "data", "rows"].into_iter().any(has) => Some("recipientList"), "mailings.editRecipients" if !has("rows") => Some("recipientList"), "mailings.insertField" if !has("field") => Some("insertMergeField"), "mailings.findRecipient" if !has("text") => Some("findRecipient"), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7cc155a53..01f9396b1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -34,6 +34,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | ❌ | ❌ | 0% | 5–8 | — | Legacy | | `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value | | Encrypted/password-protected `.docx`/`.doc` | 1% | R/W | ❌ | ❌ | 0% | 6–10 | — | ECMA-376 Agile Encryption; `.doc` RC4/XOR detected and refused (`docbin` fib). Issue #55 | +| `.xlsx`/`.xlsm`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell). Not shown: number formats other than dates (currency, percent, thousands separators) in `.xlsx`; no `.xls`, Access or Outlook sources | | `.md` Markdown | — | (no) | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word | | `.tex` LaTeX | — | — | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word (#11), incl. math | | `.png` page images | — | (Save as picture, Mac) | — | ✅ | — | — | `io_ext.rs` | | @@ -84,6 +85,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Spreadsheets (.xlsx/.xlsm/.ods) read as mail-merge recipient lists (#334) | | 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` | | 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) | | 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) | diff --git a/docs/gaps.md b/docs/gaps.md index d14d7ea70..a4fbbd176 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -31,7 +31,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 18 | **Screen-reader access** to the document canvas untested | | AccessKit enabled; no tests | Blind and low-vision users; public-sector procurement | 10–15 | [ui-parity.md](ui-parity.md) | | 19 | **RTF and ODT depth**: notes, comments, revisions, sections, RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | -| 21 | **Mail merge data sources**: no Excel, Outlook/Contacts, email merge; CSV and typed lists only (#247) | | | Office administrators | 6–10 | [target-app-parity.md](target-app-parity.md) | +| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | | 24 | **Default shortcuts**: ~135 of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and the Quick Access Toolbar #379 landed) | | [ui-parity.md](ui-parity.md) | Power users | 4–7 | [ui-parity.md](ui-parity.md) | @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | #21: Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets as recipient lists (#334); hours 6–10 → 4–8 | | 2026-10-11 | trivial | Insert › Icons landed (#309); removed from the objects gap | | 2026-10-11 | trivial | #24 narrowed: Customize Ribbon and Quick Access Toolbar customization landed (#379); the default shortcuts remain | | 2026-10-11 | trivial | #24 narrowed: Customize Keyboard landed (#368); Customize Ribbon and the Quick Access Toolbar remain | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cba08a1b0..3f611b844 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -67,7 +67,7 @@ full parity for the area. | References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); source manager depth; bibliography sources not yet in DOCX (#169 open). | | Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Missing: move tracking, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | | Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | -| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. | +| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. | | Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, Drawing Canvas, online pictures, screenshot. | | Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. | | Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; lasso, ink to shape/math and replay missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). | @@ -249,6 +249,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Mailings: Excel and OpenDocument spreadsheets as recipient lists (#334); score unchanged | | 2026-10-11 | trivial | Insert › Icons (#309): catalog 402/434 → 403/434 (92.9%); moved from missing to done under pictures and shapes | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 402/434 → 403/434 (92.9%) | | 2026-10-11 | trivial | Phonetic Guide (ruby) landed (#288): catalog +1 feature |