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 <noreply@anthropic.com>

* i18n: Hungarian and French strings for this change

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

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

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

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

* #1287: placeholder (work in progress)

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

* #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 <noreply@anthropic.com>

* #1287: rustfmt

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-18 11:56:19 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov Brandon Thomas
parent 914e92b163
commit b8b1ec6141
6 changed files with 227 additions and 38 deletions
+36 -6
View File
@@ -347,7 +347,8 @@ pub fn specs() -> Vec<CommandSpec> {
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("<?xml") {
return Err(CmdError::Failed(
"this spreadsheet file is an XML spreadsheet, not a workbook; open it in a spreadsheet program, save it as .xlsx or .csv and select it again"
.into(),
));
}
if text.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 spreadsheet {
use wordcraft_formats::sheet::{Pick, read_rows, sheet_names};
@@ -1712,6 +1723,10 @@ mod tests {
assert_eq!(r["fields"], json!(["Name", "City"]));
let e = s.run("mailings.recipients", &json!({"path": "export.xls", "data": b64(b"<html><table><tr><td>x</td></tr></table></html>")}));
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"<?xml version=\"1.0\"?>\n<Workbook xmlns=\"urn:schemas-microsoft-com:office:spreadsheet\"></Workbook>";
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]
+57 -3
View File
@@ -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<Vec<String>>,
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<Vec<Vec<String>>, Stri
// Rows by number, so out-of-order rows still come out in order.
let mut rows: BTreeMap<u64, Vec<String>> = 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<XCell> = 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<Vec<Vec<String>>, 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#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="S" sheetId="1" r:id="rId1"/></sheets></workbook>"#;
let wb_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>"#;
let sst =
format!(r#"<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><si><t>{}</t></si></sst>"#, "x".repeat(MAX_CELL_CHARS));
let mut sheet = String::from("<worksheet><sheetData>");
for r in 1..=4_000 {
sheet.push_str(&format!(r#"<row r="{r}"><c r="A{r}" t="s"><v>0</v></c></row>"#));
}
sheet.push_str("</sheetData></worksheet>");
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#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
+83 -17
View File
@@ -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<Vec<Vec<Strin
Some(kind) => 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<u16, BTreeMap<u16, String>>, 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<u16, BTreeMap<u16, String>>, 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<Vec<Vec<Strin
}
if kind == STRING {
if let (Some((r, c)), Some(s)) = (pending.take(), unicode_string(d, 0)) {
put(r, c, s, &mut count);
put(&mut cells, r, c, &s, &mut count);
}
continue;
}
if !matches!(kind, SHRFMLA | ARRAY | TABLE) {
pending = None;
}
let (Some(row), Some(col), Some(xf)) = (u16_at(d, 0), u16_at(d, 2), u16_at(d, 4)) else { continue };
match kind {
LABELSST => {
// 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<Vec<Vec<Strin
for k in 0..n {
let at = 4 + k * 6;
if let (Some(x), Some(v)) = (u16_at(d, at), u32_at(d, at + 2)) {
put(row, col.saturating_add(k as u16), num(rk(v), x), &mut count);
put(&mut cells, row, col.saturating_add(k as u16), &num(rk(v), x), &mut count);
}
}
}
@@ -362,18 +373,18 @@ fn sheet_rows(stream: &[u8], offset: usize, g: &Globals) -> Result<Vec<Vec<Strin
} else {
"FALSE".into()
};
put(row, col, s, &mut count);
put(&mut cells, row, col, &s, &mut count);
}
FORMULA => {
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<u8>)]) -> Vec<u8> {
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<u8> {
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<String>) -> 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();
+45 -9
View File
@@ -768,6 +768,10 @@ pub struct RecipientFilter {
/// The grid cell that had the keyboard last frame.
#[serde(skip)]
pub(crate) editing: Option<egui::Id>,
/// 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<Option<usize>>)>,
}
/// 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<FilterRule>, Vec<SortKey>) {
/// 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<FilterRule>, Vec<SortKey>) {
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<String>]) -> &[Option<usize>] {
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::<Vec<_>>(),
"sort": sort.iter().map(|k| json!({"field": k.field, "descending": k.descending})).collect::<Vec<_>>(),
@@ -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<String>], filter: &RecipientFilter) -> Option<Vec<Option<usize>>> {
(!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<Vec<Option<usize>>> = (!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) => {
+4 -2
View File
@@ -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.
+2 -1
View File
@@ -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) |