mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 02:17:24 -06:00
* #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:
co-authored by
Claude Opus 5.5
Dmitrii Khlapov
Brandon Thomas
parent
914e92b163
commit
b8b1ec6141
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user