From b8b1ec6141430e391c5ee09e96a5c67287ba699b Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 17:56:19 +0200 Subject: [PATCH] Mail merge review follow-ups: keep the filter on failure, cap spreadsheet text (#1287) (#1295) * #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 * i18n: Hungarian and French strings for this change Co-Authored-By: Claude Opus 5.5 * 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 * #1287: placeholder (work in progress) Co-Authored-By: Claude Opus 5.5 * #1287: keep the filter on a failed Edit Recipients; cap .xls/.xlsx cell text; dialog drops rules on unknown fields and caches the merge order Co-Authored-By: Claude Opus 5.5 * #1287: rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/mailings.rs | 42 +++++++++++-- crates/formats/src/sheet.rs | 60 +++++++++++++++++- crates/formats/src/sheet/xls.rs | 100 +++++++++++++++++++++++++----- crates/ui-egui/src/dialogs.rs | 54 +++++++++++++--- crates/ui-egui/src/lib.rs | 6 +- docs/file-format-parity.md | 3 +- 6 files changed, 227 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 8e341a6ff..eebbc162d 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -347,7 +347,8 @@ pub fn specs() -> Vec { CommandSpec::new("mailings.editRecipients", "Edit Recipient List", "Mailings β€Ί Start Mail Merge", |s, v| { if v.get("rows").and_then(Value::as_array).is_some() { // Edited records keep the list's filter and sort (rules on removed fields drop). - let (filter, sort) = (std::mem::take(&mut s.merge.filter), std::mem::take(&mut s.merge.sort)); + // Copies, so a list that fails to load leaves the filter and sort as they were. + let (filter, sort) = (s.merge.filter.clone(), s.merge.sort.clone()); let r = recipients(s, &json!({"rows": v.get("rows"), "fields": v.get("fields")}))?; s.merge.filter = filter; s.merge.sort = sort; @@ -613,11 +614,21 @@ fn recipients(s: &mut Session, v: &Value) -> CmdResult { let named = ext.as_deref().is_some_and(wordcraft_formats::sheet::is_extension); let text = wordcraft_formats::txt::is_text(&bytes); let spreadsheet = wordcraft_formats::sheet::detect(&bytes).is_some() || (named && !text); - if named && !spreadsheet && decode(&bytes, v)?.trim_start().starts_with('<') { - return Err(CmdError::Failed( - "this spreadsheet file is a web page (HTML), not a workbook; open it in a spreadsheet program, save it as .xlsx or .csv and select it again" - .into(), - )); + if named && !spreadsheet { + let text = decode(&bytes, v)?; + let text = text.trim_start().trim_start_matches('\u{feff}'); + if text.starts_with("
x
")})); assert!(matches!(e, Err(CmdError::Failed(m)) if m.contains("web page"))); + // #1287: an Excel 2003 XML spreadsheet named .xls says what it is. + let xml = b"\n"; + let e = s.run("mailings.recipients", &json!({"path": "export.xls", "data": b64(xml)})); + assert!(matches!(e, Err(CmdError::Failed(m)) if m.contains("XML spreadsheet"))); assert!( s.run("mailings.recipients", &json!({"path": "junk.xls", "data": b64(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 1])})).is_err() ); @@ -1820,6 +1835,21 @@ mod tests { assert_eq!(names(&s), ["X", "Y"]); } + /// #1287: an Edit Recipient List that fails to load keeps the list's filter and sort. + #[test] + fn failed_edit_recipients_keeps_filter_and_sort() { + let mut s = people(); + s.run("mailings.filterRecipients", &json!({"rules": [{"field": "Age", "op": "greater", "value": 20}], "sort": [{"field": "City"}]})).unwrap(); + let before = names(&s); + // Array rows without `fields` fail to load. + let e = s.run("mailings.editRecipients", &json!({"rows": [["X", "Y"]]})); + assert!(matches!(e, Err(CmdError::Params(_)))); + assert_eq!(s.merge.filter.len(), 1); + assert_eq!(s.merge.sort.len(), 1); + assert_eq!(names(&s), before); + assert_eq!(s.run("mailings.editRecipients", &json!({})).unwrap()["rules"].as_array().map(Vec::len), Some(1)); + } + /// Review fix for #885: sorting a column that mixes numbers and text is a total order /// (numbers first, numerically; then text; ties keep list order) and never panics. #[test] diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index a5db47ca4..4585d5c48 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -46,6 +46,10 @@ pub const MAX_COLS: usize = 1_000; 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 bytes of cell text kept from one sheet: a shared string (or a repeated cell) is stored +/// once in the file but copied into every cell that uses it, so a small file could otherwise +/// ask for many gigabytes. Cells past the budget are ignored. +pub const MAX_TEXT_BYTES: usize = 64 << 20; /// Most shared strings kept. const MAX_SHARED: usize = 2_000_000; /// Most cell formats kept. @@ -305,17 +309,35 @@ fn push_capped(s: &mut String, t: &str) { } } -/// Rows collected under the caps: blank rows are dropped, rows past [`MAX_ROWS`] and cells past -/// [`MAX_CELLS`] are ignored. +/// Running total of the cell text kept, against [`MAX_TEXT_BYTES`]. +#[derive(Default, Clone, Copy)] +struct TextBudget(usize); + +impl TextBudget { + /// Spend `bytes`; false (and nothing spent) when they don't fit. + fn take(&mut self, bytes: usize) -> bool { + match self.0.checked_add(bytes) { + Some(t) if t <= MAX_TEXT_BYTES => { + self.0 = t; + true + } + _ => false, + } + } +} + +/// Rows collected under the caps: blank rows are dropped, rows past [`MAX_ROWS`], cells past +/// [`MAX_CELLS`] and rows past [`MAX_TEXT_BYTES`] of text are ignored. #[derive(Default)] struct Grid { rows: Vec>, cells: usize, + text: TextBudget, } impl Grid { fn full(&self) -> bool { - self.rows.len() >= MAX_ROWS || self.cells >= MAX_CELLS + self.rows.len() >= MAX_ROWS || self.cells >= MAX_CELLS || self.text.0 >= MAX_TEXT_BYTES } /// Keep `row` (trailing blanks trimmed) `times` times, as far as the caps allow. @@ -331,6 +353,9 @@ impl Grid { let room = MAX_CELLS.saturating_sub(self.cells); let mut r = row.clone(); r.truncate(room); + if !self.text.take(r.iter().map(String::len).sum()) { + return; + } self.cells = self.cells.saturating_add(r.len()); self.rows.push(r); left -= 1; @@ -593,6 +618,7 @@ fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result>, Stri // 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 budget = TextBudget::default(); 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); @@ -641,6 +667,9 @@ fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result>, Stri if !rows.contains_key(&row_no) && rows.len() >= MAX_ROWS { return; } + if !budget.take(text.len()) { + return; + } let row = rows.entry(row_no).or_default(); if row.len() <= c.col { let grow = c.col + 1 - row.len(); @@ -981,6 +1010,31 @@ mod tests { ]) } + /// #1287: one long shared string in thousands of cells stays under the text budget. + #[test] + fn xlsx_shared_strings_cannot_blow_up_memory() { + let wb = r#""#; + let wb_rels = r#""#; + let sst = + format!(r#"{}"#, "x".repeat(MAX_CELL_CHARS)); + let mut sheet = String::from(""); + for r in 1..=4_000 { + sheet.push_str(&format!(r#"0"#)); + } + sheet.push_str(""); + let book = zip(&[ + ("_rels/.rels", RELS), + ("xl/workbook.xml", wb), + ("xl/_rels/workbook.xml.rels", wb_rels), + ("xl/sharedStrings.xml", &sst), + ("xl/worksheets/sheet1.xml", &sheet), + ]); + let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap(); + let total: usize = rows.iter().flatten().map(String::len).sum(); + assert!(total <= MAX_TEXT_BYTES, "{total} bytes of cell text"); + assert!(rows.len() > 1_000, "the budget still keeps a real list: {}", rows.len()); + } + #[test] fn xlsx_shared_strings_dates_numbers_and_gaps() { let sheet = r#" diff --git a/crates/formats/src/sheet/xls.rs b/crates/formats/src/sheet/xls.rs index 4123ceb67..ce42f9238 100644 --- a/crates/formats/src/sheet/xls.rs +++ b/crates/formats/src/sheet/xls.rs @@ -13,7 +13,8 @@ use std::collections::{BTreeMap, HashMap}; use std::io::{Cursor, Read}; use super::{ - DateKind, Grid, MAX_CELL_CHARS, MAX_CELLS, MAX_COLS, MAX_ENTRY, MAX_SHARED, MAX_SHEETS, MAX_XFS, builtin_date, custom_date, number, serial_date, + DateKind, Grid, MAX_CELL_CHARS, MAX_CELLS, MAX_COLS, MAX_ENTRY, MAX_ROWS, MAX_SHARED, MAX_SHEETS, MAX_XFS, TextBudget, builtin_date, custom_date, + number, serial_date, }; /// The compound-file signature every `.xls` (and `.doc`) starts with. @@ -37,6 +38,9 @@ const MULRK: u16 = 0x00BD; const BOOLERR: u16 = 0x0205; const FORMULA: u16 = 0x0006; const STRING: u16 = 0x0207; +const SHRFMLA: u16 = 0x04BC; +const ARRAY: u16 = 0x0221; +const TABLE: u16 = 0x0236; /// Whether `bytes` is a compound file holding an Excel workbook stream. pub fn is_xls(bytes: &[u8]) -> bool { @@ -300,15 +304,18 @@ fn sheet_rows(stream: &[u8], offset: usize, g: &Globals) -> Result serial_date(x, g.date1904, kind).unwrap_or_else(|| number(x)), None => number(x), }; - let mut put = |row: u16, col: u16, v: String, count: &mut usize| { - if usize::from(col) >= MAX_COLS || *count >= MAX_CELLS || v.is_empty() { - return; - } - if cells.entry(row).or_default().insert(col, v).is_none() { + let mut budget = TextBudget::default(); + // Whether a cell of `len` bytes at (row, col) fits under the caps; spends its text budget. + let mut fits = |cells: &BTreeMap>, row: u16, col: u16, len: usize, count: usize| { + usize::from(col) < MAX_COLS && count < MAX_CELLS && len > 0 && (cells.contains_key(&row) || cells.len() < MAX_ROWS) && budget.take(len) + }; + let mut put = |cells: &mut BTreeMap>, row: u16, col: u16, v: &str, count: &mut usize| { + if fits(cells, row, col, v.len(), *count) && cells.entry(row).or_default().insert(col, v.to_string()).is_none() { *count += 1; } }; - // A FORMULA with a string result waits for the STRING record after it. + // A FORMULA with a string result waits for the STRING record after it (only shared-formula, + // array and table records may come between). let mut pending: Option<(u16, u16)> = None; while let Some((kind, d, next)) = record(stream, pos) { pos = next; @@ -317,30 +324,34 @@ fn sheet_rows(stream: &[u8], offset: usize, g: &Globals) -> Result { + // Borrowed until it fits: a shared string may be used by millions of cells. if let Some(s) = u32_at(d, 6).and_then(|i| g.sst.get(i as usize)) { - put(row, col, s.clone(), &mut count); + put(&mut cells, row, col, s, &mut count); } } LABEL | RSTRING => { if let Some(s) = unicode_string(d, 6) { - put(row, col, s, &mut count); + put(&mut cells, row, col, &s, &mut count); } } NUMBER => { if let Some(x) = f64_at(d, 6) { - put(row, col, num(x, xf), &mut count); + put(&mut cells, row, col, &num(x, xf), &mut count); } } RK => { if let Some(v) = u32_at(d, 6) { - put(row, col, num(rk(v), xf), &mut count); + put(&mut cells, row, col, &num(rk(v), xf), &mut count); } } MULRK => { @@ -349,7 +360,7 @@ fn sheet_rows(stream: &[u8], offset: usize, g: &Globals) -> Result Result { if d.get(12..14) == Some(&[0xFF, 0xFF]) { match d.get(6).copied() { Some(0) => pending = Some((row, col)), - Some(1) => put(row, col, if d.get(8).is_some_and(|b| *b != 0) { "TRUE".into() } else { "FALSE".into() }, &mut count), - Some(2) => put(row, col, error_text(d.get(8).copied().unwrap_or(0x2A)).to_string(), &mut count), + Some(1) => put(&mut cells, row, col, if d.get(8).is_some_and(|b| *b != 0) { "TRUE" } else { "FALSE" }, &mut count), + Some(2) => put(&mut cells, row, col, error_text(d.get(8).copied().unwrap_or(0x2A)), &mut count), _ => {} } } else if let Some(x) = f64_at(d, 6) { - put(row, col, num(x, xf), &mut count); + put(&mut cells, row, col, &num(x, xf), &mut count); } } _ => {} @@ -529,6 +540,61 @@ mod tests { assert_eq!(p.rich_string().as_deref(), Some("ABδΈ­ζ–‡")); } + /// A worksheet stream from `(kind, data)` records, BOF first and EOF last. + fn sheet_stream(recs: &[(u16, Vec)]) -> Vec { + let mut out = Vec::new(); + let mut put = |kind: u16, d: &[u8]| { + out.extend_from_slice(&kind.to_le_bytes()); + out.extend_from_slice(&(d.len() as u16).to_le_bytes()); + out.extend_from_slice(d); + }; + put(BOF, &[0x00, 0x06, 0x10, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + for (k, d) in recs { + put(*k, d); + } + put(EOF, &[]); + out + } + + fn cell(row: u16, col: u16, rest: &[u8]) -> Vec { + let mut d = Vec::new(); + d.extend_from_slice(&row.to_le_bytes()); + d.extend_from_slice(&col.to_le_bytes()); + d.extend_from_slice(&0u16.to_le_bytes()); + d.extend_from_slice(rest); + d + } + + fn globals_with(sst: Vec) -> Globals { + Globals { sheets: Vec::new(), sst, xfs: Vec::new(), date1904: false } + } + + /// #1287: one long shared string used by thousands of cells stays under the text budget. + #[test] + fn shared_strings_cannot_blow_up_memory() { + let g = globals_with(vec!["x".repeat(MAX_CELL_CHARS)]); + let recs: Vec<_> = (0..4_000u16).map(|r| (LABELSST, cell(r, 0, &0u32.to_le_bytes()))).collect(); + let rows = sheet_rows(&sheet_stream(&recs), 0, &g).unwrap(); + let total: usize = rows.iter().flatten().map(String::len).sum(); + assert!(total <= crate::sheet::MAX_TEXT_BYTES, "{total} bytes of cell text"); + assert!(rows.len() > 1_000, "the budget still keeps a real list: {}", rows.len()); + } + + /// #1287: a FORMULA's string result goes only to the STRING record right after it. + #[test] + fn late_formula_string_does_not_land_in_the_formula_cell() { + let mut formula = vec![0u8, 0, 0, 0, 0, 0, 0xFF, 0xFF]; + formula.extend_from_slice(&[0; 6]); + let string = vec![4u8, 0, 0, b'l', b'a', b't', b'e']; + let g = globals_with(Vec::new()); + // Another cell record between them: the string is dropped. + let recs = vec![(FORMULA, cell(0, 0, &formula)), (NUMBER, cell(1, 0, &5f64.to_le_bytes())), (STRING, string.clone())]; + assert_eq!(sheet_rows(&sheet_stream(&recs), 0, &g).unwrap(), rows(&[&["5"]])); + // A shared-formula record between them is allowed. + let recs = vec![(FORMULA, cell(0, 0, &formula)), (SHRFMLA, vec![0; 10]), (STRING, string)]; + assert_eq!(sheet_rows(&sheet_stream(&recs), 0, &g).unwrap(), rows(&[&["late"]])); + } + #[test] fn hostile_workbooks_fail_cleanly() { let book = build(&[("S", rows(&[&["a", "1"]]))]).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3322efb44..ae635970d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -768,6 +768,10 @@ pub struct RecipientFilter { /// The grid cell that had the keyboard last frame. #[serde(skip)] pub(crate) editing: Option, + /// The last merge order worked out, keyed by a hash of the list, filter and sort, so a long + /// list isn't filtered and sorted again on every frame. + #[serde(skip)] + pub(crate) order_cache: Option<(u64, Vec>)>, } /// One filter condition row: `field op value`, joined to the row above with And or Or. @@ -796,24 +800,54 @@ impl RecipientFilter { RecipientFilter { open: !rules.is_empty() || !m.sort.is_empty(), rules, sort, ..Default::default() } } - fn engine(&self) -> (Vec, Vec) { + /// The rules and sort keys on `fields` (a rule on a field renamed or removed in the table + /// drops, as the engine drops it when the list is edited). + fn engine(&self, fields: &[String]) -> (Vec, Vec) { + let known = |f: &str| !f.trim().is_empty() && fields.iter().any(|h| h.trim().eq_ignore_ascii_case(f.trim())); let rules = self .rules .iter() - .filter(|r| !r.field.is_empty()) + .filter(|r| known(&r.field)) .map(|r| FilterRule { field: r.field.clone(), op: r.op, value: r.value.clone(), or: r.or }) .collect(); - let sort = self.sort.iter().filter(|(f, _)| !f.is_empty()).map(|(f, d)| SortKey { field: f.clone(), descending: *d }).collect(); + let sort = self.sort.iter().filter(|(f, _)| known(f)).map(|(f, d)| SortKey { field: f.clone(), descending: *d }).collect(); (rules, sort) } + /// Each record's place in the merge (None: filtered out), from the cache when the list, + /// filter and sort haven't changed. + fn order(&mut self, fields: &[String], rows: &[Vec]) -> &[Option] { + use std::hash::{Hash, Hasher}; + let (rules, sort) = self.engine(fields); + let mut h = std::collections::hash_map::DefaultHasher::new(); + fields.hash(&mut h); + rows.hash(&mut h); + for r in &rules { + (&r.field, r.op.id(), &r.value, r.or).hash(&mut h); + } + for k in &sort { + (&k.field, k.descending).hash(&mut h); + } + let key = h.finish(); + if self.order_cache.as_ref().is_none_or(|(k, _)| *k != key) { + let mut order = vec![None; rows.len()]; + for (n, i) in wordcraft_engine::cmd::mailings::select_records(fields, rows, &rules, &sort).into_iter().enumerate() { + if let Some(slot) = order.get_mut(i) { + *slot = Some(n + 1); + } + } + self.order_cache = Some((key, order)); + } + self.order_cache.as_ref().map_or(&[], |(_, o)| o.as_slice()) + } + fn is_empty(&self) -> bool { self.rules.iter().all(|r| r.field.is_empty()) && self.sort.iter().all(|(f, _)| f.is_empty()) } - /// `mailings.filterRecipients` params. - pub fn params(&self) -> Value { - let (rules, sort) = self.engine(); + /// `mailings.filterRecipients` params for a list with `fields`. + pub fn params(&self, fields: &[String]) -> Value { + let (rules, sort) = self.engine(fields); json!({ "rules": rules.iter().map(|r| json!({"field": r.field, "op": r.op.id(), "value": r.value, "join": if r.or { "or" } else { "and" }})).collect::>(), "sort": sort.iter().map(|k| json!({"field": k.field, "descending": k.descending})).collect::>(), @@ -822,9 +856,10 @@ impl RecipientFilter { } /// Each record's place in the merge (None: filtered out), when `filter` filters or sorts. +#[cfg(test)] pub(crate) fn merge_order(fields: &[String], rows: &[Vec], filter: &RecipientFilter) -> Option>> { (!filter.is_empty()).then(|| { - let (rules, sort) = filter.engine(); + let (rules, sort) = filter.engine(fields); let mut order = vec![None; rows.len()]; for (k, i) in wordcraft_engine::cmd::mailings::select_records(fields, rows, &rules, &sort).into_iter().enumerate() { if let Some(slot) = order.get_mut(i) { @@ -3025,7 +3060,8 @@ pub(crate) fn recipient_list( const X_W: f32 = 18.0; const ROW_H: f32 = 24.0; const NUM_W: f32 = 34.0; - let order = merge_order(fields, rows, filter); + // Each record's place in the merge (None: filtered out), when there is a filter or sort. + let order: Option>> = (!filter.is_empty()).then(|| filter.order(fields, rows).to_vec()); // Wide enough for every field (up to a limit; more scroll sideways). let gap = ui.spacing().item_spacing.x; // Plus the rows' scroll bar, so each row's remove button stays in view (#1576). @@ -3184,7 +3220,7 @@ pub(crate) fn recipient_list( } let r = recipient_table(fields, rows).map_err(|e| tl!(e).to_string()).and_then(|params| app.load_recipients(params)); // A new list starts unfiltered; the dialog's filter and sort apply to it. - let r = r.and_then(|_| if filter.is_empty() { Ok(Value::Null) } else { app.run("mailings.filterRecipients", filter.params()) }); + let r = r.and_then(|_| if filter.is_empty() { Ok(Value::Null) } else { app.run("mailings.filterRecipients", filter.params(fields)) }); match r { Ok(_) => true, Err(e) => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 195772cbb..3ee1ba083 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7266,10 +7266,12 @@ mod tests { ) .unwrap(); a.run("mailings.editRecipients", json!({})).unwrap(); - let Some(dialogs::Dialog::RecipientList { rows, filter, .. }) = &a.dialog else { panic!() }; + let Some(dialogs::Dialog::RecipientList { rows, filter, fields, .. }) = &a.dialog else { panic!() }; assert_eq!(rows.len(), 3, "every record, not only the filtered ones"); assert!(filter.open); - let params = filter.params(); + let params = filter.params(fields); + // A rule or sort key on a field renamed or removed in the table drops (#1287). + assert_eq!(filter.params(&["Name".to_string()]), json!({"rules": [], "sort": [{"field": "Name", "descending": true}]})); assert_eq!(params["rules"], json!([{"field": "City", "op": "equal", "value": "London", "join": "and"}])); assert_eq!(params["sort"], json!([{"field": "Name", "descending": true}])); // Applying the dialog's filter to a reloaded list gives the same records. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 410aa10aa..649adaa57 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -51,7 +51,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: βœ… | `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟑 | 🟑 | ~55% | 1–2 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File β€Ί Export β€Ί Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | | `.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` XOR, RC4 and RC4 CryptoAPI open with a password (`docbin` `crypt.rs`, #1079; synthetic tests only, untested against Word-written files; AES `.doc` refused). Issue #55 | -| `.xlsx`/`.xlsm`/`.xls`/`.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). Legacy `.xls` (Excel 97–2003 BIFF8, #778): SST/LABELSST, LABEL, NUMBER, RK/MULRK, BOOLERR and cached FORMULA results with the same date handling; Excel 5.0/95 and password-protected `.xls` are refused with a message; a CSV/TSV named `.xls` reads as text. Text lists (.csv/.tsv/.txt) decode UTF-8/UTF-16 by BOM or shape, else the detected legacy encoding (GBK/GB18030, Big5, Shift_JIS, EUC-KR, Windows code pages; `encoding` param overrides) (#778). Not shown: number formats other than dates (currency, percent, thousands separators); no Access or Outlook sources | +| `.xlsx`/`.xlsm`/`.xls`/`.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, 64 MiB of cell text a sheet so a shared string repeated in every cell can't exhaust memory, #1287). Legacy `.xls` (Excel 97–2003 BIFF8, #778): SST/LABELSST, LABEL, NUMBER, RK/MULRK, BOOLERR and cached FORMULA results with the same date handling; Excel 5.0/95 and password-protected `.xls` are refused with a message; a CSV/TSV named `.xls` reads as text; an HTML page or XML spreadsheet named `.xls` gets a message saying which it is (#1287). Text lists (.csv/.tsv/.txt) decode UTF-8/UTF-16 by BOM or shape, else the detected legacy encoding (GBK/GB18030, Big5, Shift_JIS, EUC-KR, Windows code pages; `encoding` param overrides) (#778). Not shown: number formats other than dates (currency, percent, thousands separators); no Access or Outlook sources | | Encrypted/password-protected `.docx` | 1% | R/W | βœ… | βœ… | ~60% | 2–4 | `crates/docx/src/crypt.rs` (6 unit tests), `crates/engine/src/tests.rs::password_protected_documents_round_trip` | ECMA-376 / [MS-OFFCRYPTO] agile encryption (AES-256-CBC, SHA-512): a protected `.docx` opens with its password and saves encrypted (#55, #281). Synthetic files only; never checked against Word-made protected files | | Encrypted/password-protected `.doc` (RC4/XOR) | <1% | R | ❌ | β€” | 0% | 4–6 | `crates/docbin/src/tests.rs::encrypted_rejected` | Detected and refused (`DocbinError::Encrypted`, `docbin` fib). Not decrypted | | `.md` Markdown | β€” | (no) | βœ… | βœ… | β€” | β€” | `crates/formats/src/tests.rs` | Beyond Word | @@ -261,6 +261,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-18 | trivial | DOCX: every VML shape anchored in a header table cell is placed on the page whatever `o:allowincell` says (user-checked in Word for Mac, #1775) | | 2026-10-16 | trivial | DOCX: `wp:anchor/@layoutInCell="0"` and VML `o:allowincell="f"` are read and written back (`layoutInCell="0"`), so a header-cell picture watermark is placed on the page (#1775) | | 2026-10-16 | trivial | Equations from Word files no longer lose content: body-level `m:oMathPara`, `mc:AlternateContent Requires="m"`, hyperlinks/simple fields/`mc:AlternateContent` inside an equation, `w:sym` and Symbol-font text in math (#1273) | +| 2026-10-13 | trivial | Recipient lists: cell text capped at 64 MiB a sheet (shared-string amplification), an XML spreadsheet named .xls explained (#1287) | | 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) | | 2026-10-14 | trivial | Row table border exceptions survive DOCX saves | | 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips β€” on buffered fields and range-field begin markers alike β€” and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) |