From eb54d88ec08b5f0fe88ed7392c08156d64734eb2 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 22:57:06 +0200 Subject: [PATCH] Mail merge: .xls recipients, legacy encodings, Filter and Sort (#778) (#885) * #778: .xls and legacy-encoding recipient lists; filter and sort recipients - Recipient lists: legacy Excel 97-2003 .xls (BIFF8 reader on the cfb crate already in the tree); Excel 5.0/95 and password-protected .xls refused with a clear message; a CSV named .xls reads as text, an HTML one gets an error. .xls added to the picker filters (desktop, web, built-in browser) and drop handling. - Text lists (and plain-text import) detect legacy encodings when not UTF-8/UTF-16: GBK/GB18030, Big5, Shift_JIS, EUC-KR, Windows code pages (encoding_rs + chardetng, MIT/Apache); an `encoding` param overrides. - mailings.filterRecipients / mailings.sortRecipients: field, comparison (equal, notEqual, less, greater, lessOrEqual, greaterOrEqual, blank, notBlank, contains, notContains), value, And/Or joins; sort keys. Preview, Finish & Merge and labels use only the matching records in sort order. Edit Recipient List edits every record and keeps the filter. - Recipient List dialog: Filter and Sort section and a merge-order column; catalog rows; strings in every catalog; docs. Co-Authored-By: Claude Opus 5.5 * 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 * vi: mail merge Filter and Sort strings 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 --- Cargo.lock | 41 ++ Cargo.toml | 5 + ROADMAP.md | 4 +- apps/wordcraft-web/src/web.rs | 2 +- crates/engine/Cargo.toml | 1 + crates/engine/src/catalog.rs | 2 + crates/engine/src/cmd/mailings.rs | 599 +++++++++++++++++++++++++++- crates/engine/src/io.rs | 5 +- crates/formats/Cargo.toml | 3 + crates/formats/src/lib.rs | 2 +- crates/formats/src/sheet.rs | 25 +- crates/formats/src/sheet/xls.rs | 556 ++++++++++++++++++++++++++ crates/formats/src/txt.rs | 34 +- crates/ui-egui/src/dialogs.rs | 205 +++++++++- crates/ui-egui/src/file_dialogs.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 25 ++ crates/ui-egui/src/i18n/cs.tsv | 25 ++ crates/ui-egui/src/i18n/de.tsv | 25 ++ crates/ui-egui/src/i18n/es.tsv | 25 ++ crates/ui-egui/src/i18n/et.tsv | 25 ++ crates/ui-egui/src/i18n/fr.tsv | 24 ++ crates/ui-egui/src/i18n/hu.tsv | 24 ++ crates/ui-egui/src/i18n/it.tsv | 25 ++ crates/ui-egui/src/i18n/ja.tsv | 25 ++ crates/ui-egui/src/i18n/pl.tsv | 25 ++ crates/ui-egui/src/i18n/pt-br.tsv | 25 ++ crates/ui-egui/src/i18n/ru.tsv | 25 ++ crates/ui-egui/src/i18n/sr-latn.tsv | 25 ++ crates/ui-egui/src/i18n/sr.tsv | 25 ++ crates/ui-egui/src/i18n/tr.tsv | 25 ++ crates/ui-egui/src/i18n/uk.tsv | 25 ++ crates/ui-egui/src/i18n/vi.tsv | 26 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 25 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 25 ++ crates/ui-egui/src/lib.rs | 30 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 6 +- docs/ui-parity.md | 3 +- 38 files changed, 1970 insertions(+), 34 deletions(-) create mode 100644 crates/formats/src/sheet/xls.rs diff --git a/Cargo.lock b/Cargo.lock index 9c4db4969..2a49771b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -693,6 +693,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chardetng" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + [[package]] name = "cipher" version = "0.5.2" @@ -819,6 +830,12 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -1184,6 +1201,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -2363,6 +2394,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "30.0.1" @@ -5198,6 +5235,7 @@ dependencies = [ name = "wordcraft-engine" version = "0.7.0" dependencies = [ + "encoding_rs", "image", "js-sys", "log", @@ -5237,6 +5275,9 @@ dependencies = [ name = "wordcraft-formats" version = "0.7.0" dependencies = [ + "cfb", + "chardetng", + "encoding_rs", "image", "js-sys", "log", diff --git a/Cargo.toml b/Cargo.toml index ab1210428..8bfa8fbf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,11 @@ zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. miniz_oxide = "0.8" cfb = "0.15" +# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain +# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its +# detector chardetng (Apache-2.0 OR MIT), both pure Rust (Firefox's). +encoding_rs = "0.8" +chardetng = "1" # Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives. aes = "0.9" sha1 = "0.11" diff --git a/ROADMAP.md b/ROADMAP.md index a971b664e..f90b734b8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: an option to show only the selected text's comment, #1055; previously trivial: STYLEREF running headers, #775; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -334,6 +334,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-07 | v0.1.0 released; contributor credits in About (#6) | | 2026-10-06 | Layout arc: wrap around floats, text boxes, page borders, line numbers, row splitting, drop caps, hyphenation, comment balloons; release pipeline for every platform; first estimate (~62% real feature parity, 120–150 h to 100%) | | 2026-10-05 | M0–M12 first versions: model, layout, render, engine with 200+ commands, Word-style UI, DOCX I/O, PDF and other formats, references, review, mailings, CLI, MCP, web | +| 2026-10-12 | Mail-merge recipients (#778): legacy Excel .xls workbooks; CSV/TXT lists in GBK, Big5, Shift_JIS, UTF-16 and other encodings read correctly (plain-text import too); Edit Recipient List filters (equal, not equal, less, greater, blank, contains… joined by And/Or) and sorts, and preview, Finish & Merge and labels use only the matching records in that order (`mailings.filterRecipients`, `mailings.sortRecipients`) | | 2026-10-13 | The interface-translation audit runs in CI again (#1128): the size labels of Envelopes, Labels and Layout › Size are translated (`4,13 × 9,5 дюйма`) and use the region's decimal separator, and status-bar messages (table split and merge, ink replay, citations, SmartArt, Researcher, Design defaults), the Reading Mode toolbar, Go To and Search's Suggested heading are translated in every interface language, and the Read Aloud player no longer shows a speech error a frame before the status bar does, the race behind the audit's rare failure under load | ## Revision history @@ -477,3 +478,4 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-10 | major | Full re-measure; restructured to the progress-docs standard (stage banner, two numbers, dimensions, languages, upcoming, progress log); parity tables moved to `docs/target-app-parity.md`, milestones to `docs/roadmap.md` | | 2026-10-09 | minor | Recently landed: RTL, Ukrainian and Brazilian Portuguese | | 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way | +| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) | diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index 51e4e2705..a11266032 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -11,7 +11,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", "xml"]; const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"]; -const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]; +const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS; mod font_store; const CANVAS_ID: &str = "wordcraft_canvas"; const LOADING_ID: &str = "wordcraft_loading"; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4d58323eb..f3d94f7e8 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -33,4 +33,5 @@ zeroize = { workspace = true } js-sys = "0.3" [dev-dependencies] +encoding_rs = { workspace = true } zip = { workspace = true } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 7bc7ebad3..c6a36a378 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -265,6 +265,8 @@ Mailings|Create|Labels|mailings.labels Mailings|Start Mail Merge|Start Mail Merge|mailings.start Mailings|Start Mail Merge|Select Recipients|mailings.recipients Mailings|Start Mail Merge|Edit Recipient List|mailings.editRecipients +Mailings|Start Mail Merge|Filter Recipients|mailings.filterRecipients +Mailings|Start Mail Merge|Sort Recipients|mailings.sortRecipients Mailings|Write & Insert Fields|Highlight Merge Fields|mailings.highlightFields Mailings|Write & Insert Fields|Address Block|mailings.addressBlock Mailings|Write & Insert Fields|Greeting Line|mailings.greetingLine diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index d8f7891c6..c10e65edd 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 or an Excel/OpenDocument spreadsheet, -//! merge fields, preview, finish), envelopes and labels. +//! filtered and sorted; merge fields, preview, finish), envelopes and labels. use serde_json::{Value, json}; use wordcraft_doc::para::InlineObject; @@ -15,12 +15,298 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; pub struct MergeState { pub kind: String, pub headers: Vec, + /// The records the merge uses: [`MergeState::source`] filtered and sorted. pub rows: Vec>, + /// Every record of the list, in file order (Edit Recipient List edits these). + pub source: Vec>, + /// Filter Recipients: which records merge (none: all). + pub filter: Vec, + /// Sort Recipients: the order they merge in (none: the list's order). + pub sort: Vec, pub preview: bool, pub record: usize, pub highlight: bool, } +/// A Filter Recipients comparison. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FilterOp { + Equal, + NotEqual, + Less, + Greater, + LessOrEqual, + GreaterOrEqual, + Blank, + NotBlank, + Contains, + NotContains, +} + +impl FilterOp { + /// Every comparison, in the order the dialog lists them. + pub const ALL: [FilterOp; 10] = [ + FilterOp::Equal, + FilterOp::NotEqual, + FilterOp::Less, + FilterOp::Greater, + FilterOp::LessOrEqual, + FilterOp::GreaterOrEqual, + FilterOp::Blank, + FilterOp::NotBlank, + FilterOp::Contains, + FilterOp::NotContains, + ]; + + /// The id used in command params. + pub fn id(self) -> &'static str { + match self { + FilterOp::Equal => "equal", + FilterOp::NotEqual => "notEqual", + FilterOp::Less => "less", + FilterOp::Greater => "greater", + FilterOp::LessOrEqual => "lessOrEqual", + FilterOp::GreaterOrEqual => "greaterOrEqual", + FilterOp::Blank => "blank", + FilterOp::NotBlank => "notBlank", + FilterOp::Contains => "contains", + FilterOp::NotContains => "notContains", + } + } + + /// The dialog's label (English source text). + pub fn label(self) -> &'static str { + match self { + FilterOp::Equal => "Equal to", + FilterOp::NotEqual => "Not equal to", + FilterOp::Less => "Less than", + FilterOp::Greater => "Greater than", + FilterOp::LessOrEqual => "Less than or equal", + FilterOp::GreaterOrEqual => "Greater than or equal", + FilterOp::Blank => "Is blank", + FilterOp::NotBlank => "Is not blank", + FilterOp::Contains => "Contains", + FilterOp::NotContains => "Does not contain", + } + } + + /// An id, a symbol (`=`, `<>`, `<`, `>`, `<=`, `>=`) or a spelled-out alias, any case. + pub fn parse(t: &str) -> Option { + let k: String = t.trim().chars().filter(|c| !matches!(c, ' ' | '_' | '-')).collect::().to_ascii_lowercase(); + Some(match k.as_str() { + "equal" | "equals" | "equalto" | "=" | "==" | "eq" => FilterOp::Equal, + "notequal" | "notequalto" | "<>" | "!=" | "ne" => FilterOp::NotEqual, + "less" | "lessthan" | "<" | "lt" => FilterOp::Less, + "greater" | "greaterthan" | ">" | "gt" => FilterOp::Greater, + "lessorequal" | "lessthanorequal" | "<=" | "le" => FilterOp::LessOrEqual, + "greaterorequal" | "greaterthanorequal" | ">=" | "ge" => FilterOp::GreaterOrEqual, + "blank" | "isblank" | "empty" => FilterOp::Blank, + "notblank" | "isnotblank" | "notempty" => FilterOp::NotBlank, + "contains" => FilterOp::Contains, + "notcontains" | "doesnotcontain" => FilterOp::NotContains, + _ => return None, + }) + } + + /// Whether the comparison takes a value (Is blank / Is not blank don't). + pub fn has_value(self) -> bool { + !matches!(self, FilterOp::Blank | FilterOp::NotBlank) + } +} + +/// One Filter Recipients row: `field op value`, joined to the row before by And (`or` false) or +/// Or. And binds tighter than Or: `a And b Or c` keeps records matching both a and b, or c. +#[derive(Clone, Debug, PartialEq)] +pub struct FilterRule { + pub field: String, + pub op: FilterOp, + pub value: String, + pub or: bool, +} + +/// One Sort Recipients key. +#[derive(Clone, Debug, PartialEq)] +pub struct SortKey { + pub field: String, + pub descending: bool, +} + +/// Most filter rows and sort keys a list keeps. +const MAX_RULES: usize = 64; +const MAX_SORT_KEYS: usize = 8; + +/// Two cell values compared: as numbers when both are numbers, else as text ignoring case. +/// Orders two cells: numbers (compared numerically) before text (compared +/// case-insensitively). This is a total order, so `sort_by` stays consistent +/// (and can't panic) on columns that mix numbers and text, such as `9`, `10`, `1A`. +fn compare_cells(a: &str, b: &str) -> std::cmp::Ordering { + use std::cmp::Ordering::{Greater, Less}; + let number = |s: &str| s.parse::().ok().filter(|x| x.is_finite()); + let (a, b) = (a.trim(), b.trim()); + match (number(a), number(b)) { + (Some(x), Some(y)) => x.total_cmp(&y), + (Some(_), None) => Less, + (None, Some(_)) => Greater, + (None, None) => a.to_lowercase().cmp(&b.to_lowercase()), + } +} + +fn rule_holds(r: &FilterRule, cell: &str) -> bool { + use std::cmp::Ordering::{Equal, Greater, Less}; + let ord = || compare_cells(cell, &r.value); + let lower = || (cell.to_lowercase(), r.value.trim().to_lowercase()); + match r.op { + FilterOp::Equal => ord() == Equal, + FilterOp::NotEqual => ord() != Equal, + FilterOp::Less => ord() == Less, + FilterOp::Greater => ord() == Greater, + FilterOp::LessOrEqual => ord() != Greater, + FilterOp::GreaterOrEqual => ord() != Less, + FilterOp::Blank => cell.trim().is_empty(), + FilterOp::NotBlank => !cell.trim().is_empty(), + FilterOp::Contains => { + let (c, v) = lower(); + c.contains(&v) + } + FilterOp::NotContains => { + let (c, v) = lower(); + !c.contains(&v) + } + } +} + +/// The records (indices into `rows`) that `filter` keeps, in `sort` order (stable: ties keep the +/// list's order). Rules and keys naming a field that isn't in `headers` are ignored. +pub fn select_records(headers: &[String], rows: &[Vec], filter: &[FilterRule], sort: &[SortKey]) -> Vec { + let col = |f: &str| headers.iter().position(|h| h.eq_ignore_ascii_case(f.trim())); + let rules: Vec<(Option, &FilterRule)> = filter.iter().map(|r| (col(&r.field), r)).filter(|(c, _)| c.is_some()).collect(); + let keep = |row: &Vec| { + if rules.is_empty() { + return true; + } + // Or-groups of And-ed rules. + let mut any = false; + let mut all = true; + for (k, (c, r)) in rules.iter().enumerate() { + if k > 0 && r.or { + any |= all; + all = true; + } + let cell = c.and_then(|c| row.get(c)).map(String::as_str).unwrap_or(""); + all = all && rule_holds(r, cell); + } + any || all + }; + let mut out: Vec = rows.iter().enumerate().filter(|(_, r)| keep(r)).map(|(i, _)| i).collect(); + let keys: Vec<(usize, bool)> = sort.iter().filter_map(|k| col(&k.field).map(|c| (c, k.descending))).collect(); + if !keys.is_empty() { + out.sort_by(|a, b| { + for (c, desc) in &keys { + let get = |i: &usize| rows.get(*i).and_then(|r| r.get(*c)).map(String::as_str).unwrap_or(""); + let o = compare_cells(get(a), get(b)); + let o = if *desc { o.reverse() } else { o }; + if o != std::cmp::Ordering::Equal { + return o; + } + } + std::cmp::Ordering::Equal + }); + } + out +} + +/// Recompute the merged records from the list, its filter and its sort; back to the first. +fn apply_filter(s: &mut Session) { + let m = &mut s.merge; + let headers = &m.headers; + m.filter.retain(|r| headers.iter().any(|h| h.eq_ignore_ascii_case(r.field.trim()))); + m.sort.retain(|k| headers.iter().any(|h| h.eq_ignore_ascii_case(k.field.trim()))); + m.rows = select_records(&m.headers, &m.source, &m.filter, &m.sort).into_iter().filter_map(|i| m.source.get(i).cloned()).collect(); + m.record = 0; + if m.rows.is_empty() { + m.preview = false; + } + refresh(s); +} + +/// The canonical field name for `f` (any case), or a params error. +fn known_field(headers: &[String], f: &str) -> Result { + headers + .iter() + .find(|h| h.eq_ignore_ascii_case(f.trim())) + .cloned() + .ok_or_else(|| CmdError::Params(format!("no field named `{f}` in the recipient list"))) +} + +fn parse_rules(headers: &[String], v: Option<&Value>) -> Result, CmdError> { + let arr = match v { + None | Some(Value::Null) => return Ok(Vec::new()), + Some(Value::Array(a)) => a, + Some(_) => return Err(CmdError::Params("`rules` must be an array of {field, op, value, join}".into())), + }; + if arr.len() > MAX_RULES { + return Err(CmdError::Params(format!("at most {MAX_RULES} filter rules"))); + } + let mut out = Vec::new(); + for r in arr { + let field = p::str(r, "field").ok_or_else(|| CmdError::Params("each rule needs a `field`".into()))?; + let op = p::str(r, "op").or_else(|| p::str(r, "comparison")).unwrap_or("equal"); + let op = FilterOp::parse(op).ok_or_else(|| { + CmdError::Params(format!("unknown `op` `{op}`; one of {}", FilterOp::ALL.iter().map(|o| o.id()).collect::>().join(", "))) + })?; + let or = match p::str(r, "join").map(|j| j.trim().to_ascii_lowercase()) { + None => false, + Some(j) if j == "and" => false, + Some(j) if j == "or" => true, + Some(_) => return Err(CmdError::Params("`join` must be \"and\" or \"or\"".into())), + }; + let value = r.get("value").map(cell).unwrap_or_default(); + out.push(FilterRule { field: known_field(headers, field)?, op, value, or }); + } + Ok(out) +} + +fn parse_sort(headers: &[String], v: Option<&Value>) -> Result, CmdError> { + let arr: Vec = match v { + None | Some(Value::Null) => return Ok(Vec::new()), + Some(Value::Array(a)) => a.clone(), + Some(o @ (Value::Object(_) | Value::String(_))) => vec![o.clone()], + Some(_) => return Err(CmdError::Params("`sort` must be an array of {field, descending}".into())), + }; + if arr.len() > MAX_SORT_KEYS { + return Err(CmdError::Params(format!("at most {MAX_SORT_KEYS} sort keys"))); + } + arr.iter() + .map(|k| { + let (field, descending) = match k { + Value::String(f) => (f.as_str(), false), + _ => ( + p::str(k, "field").ok_or_else(|| CmdError::Params("each sort key needs a `field`".into()))?, + p::bool(k, "descending").unwrap_or(false), + ), + }; + Ok(SortKey { field: known_field(headers, field)?, descending }) + }) + .collect() +} + +fn rules_json(m: &MergeState) -> Value { + json!( + m.filter + .iter() + .map(|r| json!({"field": r.field, "op": r.op.id(), "value": r.value, "join": if r.or { "or" } else { "and" }})) + .collect::>() + ) +} + +fn sort_json(m: &MergeState) -> Value { + json!(m.sort.iter().map(|k| json!({"field": k.field, "descending": k.descending})).collect::>()) +} + +fn filter_result(s: &Session) -> CmdResult { + Ok(json!({"records": s.merge.rows.len(), "of": s.merge.source.len(), "rules": rules_json(&s.merge), "sort": sort_json(&s.merge)})) +} + fn has_recipients(s: &Session) -> Option<&'static str> { if s.merge.rows.is_empty() { Some("select recipients first") } else { None } } @@ -47,18 +333,53 @@ 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 (.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)}"#) + .params(r#"{"csv"?: string, "path"?: string (.csv/.tsv/.txt, or a .xlsx/.xlsm/.xls/.ods workbook), "data"?: base64 (a file's bytes, as for path), "encoding"?: string (a text list's encoding, e.g. "gbk", "big5", "shift_jis", "utf-16le"; default: detected), "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() { - return recipients(s, &json!({"rows": v.get("rows"), "fields": v.get("fields")})); + // 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)); + let r = recipients(s, &json!({"rows": v.get("rows"), "fields": v.get("fields")}))?; + s.merge.filter = filter; + s.merge.sort = sort; + apply_filter(s); + return Ok(json!({"fields": r.get("fields"), "records": s.merge.rows.len(), "of": s.merge.source.len()})); } - Ok(json!({"headers": s.merge.headers, "rows": s.merge.rows})) + Ok(json!({ + "headers": s.merge.headers, + "rows": s.merge.source, + "records": s.merge.rows.len(), + "rules": rules_json(&s.merge), + "sort": sort_json(&s.merge), + })) }) - .params(r#"{"rows"?: [{field: value}] | [[value]], "fields"?: [string]}"#) + .params(r#"{"rows"?: [{field: value}] | [[value]], "fields"?: [string]} (no params: the list as {headers, rows (every record), records (how many merge), rules, sort})"#) .key(super::non_mac("Alt+Shift+E")) .when(has_list) .pure(), + CommandSpec::new("mailings.filterRecipients", "Filter Recipients", "Mailings › Start Mail Merge › Edit Recipient List", |s, v| { + let filter = parse_rules(&s.merge.headers, v.get("rules"))?; + let sort = match v.get("sort") { + Some(x) => parse_sort(&s.merge.headers, Some(x))?, + None if v.get("rules").is_none() => Vec::new(), + None => s.merge.sort.clone(), + }; + s.merge.filter = filter; + s.merge.sort = sort; + apply_filter(s); + filter_result(s) + }) + .params(r#"{"rules"?: [{"field": string, "op": "equal|notEqual|less|greater|lessOrEqual|greaterOrEqual|blank|notBlank|contains|notContains", "value"?: string, "join"?: "and|or" (with the rule before; And binds tighter)}], "sort"?: [{"field": string, "descending"?: bool}]} (replaces the filter; `sort` too when given; {} clears both). Preview, Finish & Merge and labels use only the matching records, in sort order"#) + .when(has_list) + .pure(), + CommandSpec::new("mailings.sortRecipients", "Sort Recipients", "Mailings › Start Mail Merge › Edit Recipient List", |s, v| { + s.merge.sort = parse_sort(&s.merge.headers, v.get("sort").or_else(|| v.get("field").map(|_| v)))?; + apply_filter(s); + filter_result(s) + }) + .params(r#"{"sort"?: [{"field": string, "descending"?: bool}]} | {"field": string, "descending"?: bool} (no params: the list's own order)"#) + .when(has_list) + .pure(), CommandSpec::new("mailings.insertField", "Insert Merge Field", "Mailings › Write & Insert Fields", |s, v| { let f = p::req_str(v, "field")?.to_string(); insert_field(s, &format!("MERGEFIELD {}", quote(&f)), &format!("«{f}»")) @@ -271,8 +592,17 @@ fn recipients(s: &mut Session, v: &Value) -> CmdResult { } 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(); + // A spreadsheet by its contents, or by its name unless it is really text (a CSV or tab- + // separated export named .xls, as some programs write). + 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 spreadsheet { use wordcraft_formats::sheet::{Pick, read_rows, sheet_names}; let pick = match v.get("sheet") { @@ -299,13 +629,16 @@ fn recipients(s: &mut Session, v: &Value) -> CmdResult { sheet = json!(chosen); rows } else { - parse_csv(&crate::io::decode_text(&bytes)) + parse_csv(&decode(&bytes, v)?) } }; header_split(all, p::bool(v, "headers").unwrap_or(true))? }; s.merge.headers = headers; + s.merge.source = rows.clone(); s.merge.rows = rows; + s.merge.filter.clear(); + s.merge.sort.clear(); s.merge.record = 0; refresh(s); let mut r = json!({"fields": s.merge.headers, "records": s.merge.rows.len()}); @@ -315,6 +648,24 @@ fn recipients(s: &mut Session, v: &Value) -> CmdResult { Ok(r) } +/// A text recipient list's characters: in `encoding` when given (a label such as `gbk`, +/// `big5`, `shift_jis`, `utf-16le`, `windows-1251`), else as detected (UTF-8 or UTF-16 by BOM or +/// shape, else the legacy encoding the bytes look like: GBK, Big5, Shift_JIS, Windows-1252…). +fn decode(bytes: &[u8], v: &Value) -> Result { + match p::str(v, "encoding").map(str::trim).filter(|e| !e.is_empty()) { + Some(label) => wordcraft_formats::txt::decode_as(bytes, label).ok_or_else(|| CmdError::Params(format!("unknown `encoding` `{label}`"))), + None => Ok(crate::io::decode_text(bytes)), + } +} + +/// File extensions Select Recipients › Use an Existing List… opens. +pub const RECIPIENT_EXTENSIONS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "xls", "ods"]; + +/// Whether a file name is a spreadsheet read as a recipient list (.xlsx, .xlsm, .xls, .ods). +pub fn is_spreadsheet_name(name: &str) -> bool { + std::path::Path::new(name).extension().is_some_and(|e| wordcraft_formats::sheet::is_extension(&e.to_string_lossy())) +} + /// 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()) @@ -1153,7 +1504,10 @@ mod tests { assert_eq!(s.merge.kind, "directory", "a rejected kind leaves the current one"); assert!(matches!(s.run("mailings.editRecipients", &json!({})), Err(CmdError::Disabled(_)))); s.run("mailings.recipients", &json!({"csv": "Name\nAda"})).unwrap(); - assert_eq!(s.run("mailings.editRecipients", &json!({})).unwrap(), json!({"headers": ["Name"], "rows": [["Ada"]]})); + assert_eq!( + s.run("mailings.editRecipients", &json!({})).unwrap(), + json!({"headers": ["Name"], "rows": [["Ada"]], "records": 1, "rules": [], "sort": []}) + ); } fn merged_text(rule: Option, rows: Value) -> String { @@ -1279,4 +1633,231 @@ mod tests { assert!(matches!(cell.map(|b| &**b), Some(Block::Para(p)) if p.text == "Bo")); assert!(s.run("mailings.labels", &json!({"product": "nope"})).is_err()); } + + fn b64(b: &[u8]) -> String { + super::super::insert::base64_encode(b) + } + + /// #778: a CSV saved by a Chinese-locale spreadsheet is GBK, not UTF-8; one saved as + /// "Unicode text" is UTF-16 with tabs. Both read as the characters they hold. + #[test] + fn recipient_lists_in_legacy_and_utf16_encodings() { + let text = "物料编号,送检日期,检验日期,批号,数量\nPLND0119,2024/4/16,2024/4/16,4164001,12\nE655,2024/4/17,2024/4/17,4174001,1\n"; + let (gbk, _, bad) = encoding_rs::GBK.encode(text); + assert!(!bad && std::str::from_utf8(&gbk).is_err()); + let mut s = Session::new(Document::new()); + let r = s.run("mailings.recipients", &json!({"path": "list.csv", "data": b64(&gbk)})).unwrap(); + assert_eq!(r["fields"], json!(["物料编号", "送检日期", "检验日期", "批号", "数量"])); + assert_eq!(s.merge.rows[1], ["E655", "2024/4/17", "2024/4/17", "4174001", "1"]); + // Big5 (Traditional Chinese) and Shift_JIS too. + for (enc, t) in [ + (encoding_rs::BIG5, "姓名,城市\n陳大文,臺北市\n林小明,高雄市\n"), + (encoding_rs::SHIFT_JIS, "氏名,住所\n山田太郎,東京都\n鈴木花子,大阪府\n"), + ] { + let (b, _, _) = enc.encode(t); + s.run("mailings.recipients", &json!({"data": b64(&b)})).unwrap(); + assert_eq!(s.merge.headers.join(","), t.lines().next().unwrap(), "{}", enc.name()); + assert_eq!(s.merge.rows[0].join(","), t.lines().nth(1).unwrap(), "{}", enc.name()); + } + // UTF-16 LE with BOM, tab-separated. + let mut u16le = vec![0xFF, 0xFE]; + for u in "姓名\t城市\r\n张三\t北京\r\n".encode_utf16() { + u16le.extend_from_slice(&u.to_le_bytes()); + } + s.run("mailings.recipients", &json!({"path": "list.txt", "data": b64(&u16le)})).unwrap(); + assert_eq!(s.merge.headers, ["姓名", "城市"]); + assert_eq!(s.merge.rows, [["张三", "北京"]]); + // An explicit encoding wins over detection; an unknown one is a params error. + let (sj, _, _) = encoding_rs::SHIFT_JIS.encode("名前\n田中\n"); + s.run("mailings.recipients", &json!({"data": b64(&sj), "encoding": "shift_jis"})).unwrap(); + assert_eq!(s.merge.rows, [["田中"]]); + assert!(matches!(s.run("mailings.recipients", &json!({"data": b64(&sj), "encoding": "klingon"})), Err(CmdError::Params(_)))); + // Plain UTF-8 and Windows-1252 still read as before. + s.run("mailings.recipients", &json!({"data": b64(b"Name\nJos\xE9\n")})).unwrap(); + assert_eq!(s.merge.rows, [["José"]]); + } + + /// #778: legacy Excel 97–2003 workbooks are recipient lists; a CSV named .xls reads as text. + #[test] + fn recipients_from_an_xls_workbook() { + let row = |r: &[&str]| r.iter().map(|c| c.to_string()).collect::>(); + let book = wordcraft_formats::sheet::xls_fixture(&[( + "收件人", + vec![row(&["姓名", "城市", "数量"]), row(&["张三", "北京", "12"]), row(&["李四", "上海", "2"])], + )]) + .unwrap(); + let mut s = Session::new(Document::new()); + let r = s.run("mailings.recipients", &json!({"path": "名单.xls", "data": b64(&book)})).unwrap(); + assert_eq!(r, json!({"fields": ["姓名", "城市", "数量"], "records": 2, "sheet": "收件人"})); + assert_eq!(s.merge.rows[1], ["李四", "上海", "2"]); + // Detected by content without a name. + assert_eq!(s.run("mailings.recipients", &json!({"data": b64(&book)})).unwrap()["records"], 2); + let r = s.run("mailings.recipients", &json!({"path": "export.xls", "data": b64(b"Name\tCity\nAda\tLondon\n")})).unwrap(); + assert_eq!(r["fields"], json!(["Name", "City"])); + let e = s.run("mailings.recipients", &json!({"path": "export.xls", "data": b64(b"
x
")})); + assert!(matches!(e, Err(CmdError::Failed(m)) if m.contains("web page"))); + assert!( + s.run("mailings.recipients", &json!({"path": "junk.xls", "data": b64(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 1])})).is_err() + ); + assert!(super::is_spreadsheet_name("a.XLS") && super::is_spreadsheet_name("b.ods") && !super::is_spreadsheet_name("c.csv")); + assert!(super::RECIPIENT_EXTENSIONS.contains(&"xls")); + } + + fn people() -> Session { + let mut s = Session::new(Document::new()); + s.run( + "mailings.recipients", + &json!({"csv": "Name,City,Age,Email\nAda,London,36,ada@x.org\nAlan,Wilmslow,41,\nGrace,New York,85,grace@navy.mil\nKatherine,Hampton,101,\nLinus,Helsinki,9,linus@kernel.org"}), + ) + .unwrap(); + s + } + + fn names(s: &Session) -> Vec { + s.merge.rows.iter().map(|r| r[0].clone()).collect() + } + + fn filter(s: &mut Session, rules: Value) -> Vec { + s.run("mailings.filterRecipients", &json!({"rules": rules})).unwrap(); + names(s) + } + + /// #778: Edit Recipient List › Filter: every comparison, numbers as numbers, And / Or. + #[test] + fn filter_recipients_comparisons_and_joins() { + let mut s = people(); + assert_eq!(filter(&mut s, json!([{"field": "city", "op": "equal", "value": "london"}])), ["Ada"]); + assert_eq!(filter(&mut s, json!([{"field": "City", "op": "<>", "value": "London"}])), ["Alan", "Grace", "Katherine", "Linus"]); + // 9 < 36 as numbers, though "9" > "36" as text. + assert_eq!(filter(&mut s, json!([{"field": "Age", "op": "less", "value": "36"}])), ["Linus"]); + assert_eq!(filter(&mut s, json!([{"field": "Age", "op": "greater", "value": "41"}])), ["Grace", "Katherine"]); + assert_eq!(filter(&mut s, json!([{"field": "Age", "op": "lessOrEqual", "value": 36}])), ["Ada", "Linus"]); + assert_eq!(filter(&mut s, json!([{"field": "Age", "op": ">=", "value": "85"}])), ["Grace", "Katherine"]); + assert_eq!(filter(&mut s, json!([{"field": "Email", "op": "blank"}])), ["Alan", "Katherine"]); + assert_eq!(filter(&mut s, json!([{"field": "Email", "op": "isNotBlank"}])), ["Ada", "Grace", "Linus"]); + assert_eq!(filter(&mut s, json!([{"field": "Email", "op": "contains", "value": ".ORG"}])), ["Ada", "Linus"]); + assert_eq!(filter(&mut s, json!([{"field": "Email", "op": "doesNotContain", "value": "org"}])), ["Alan", "Grace", "Katherine"]); + // And binds tighter than Or: (Email blank And Age > 50) Or City = London. + let r = json!([ + {"field": "Email", "op": "blank"}, + {"field": "Age", "op": "greater", "value": "50", "join": "and"}, + {"field": "City", "op": "equal", "value": "London", "join": "or"} + ]); + assert_eq!(filter(&mut s, r), ["Ada", "Katherine"]); + let r = s + .run( + "mailings.filterRecipients", + &json!({"rules": [{"field": "Age", "op": "less", "value": 50}, {"field": "City", "op": "contains", "value": "on", "join": "and"}]}), + ) + .unwrap(); + assert_eq!(r["records"], 1); + assert_eq!(r["of"], 5); + assert_eq!(r["rules"][1], json!({"field": "City", "op": "contains", "value": "on", "join": "and"})); + // {} clears. + assert_eq!(s.run("mailings.filterRecipients", &json!({})).unwrap()["records"], 5); + // Bad rules are params errors and leave the filter alone. + filter(&mut s, json!([{"field": "City", "op": "equal", "value": "Hampton"}])); + for bad in [ + json!({"rules": [{"field": "Nope", "op": "equal", "value": "x"}]}), + json!({"rules": [{"field": "City", "op": "resembles", "value": "x"}]}), + json!({"rules": [{"field": "City", "join": "xor"}]}), + json!({"rules": [{"op": "equal"}]}), + json!({"rules": "City = London"}), + json!({"rules": [], "sort": [{"field": "Nope"}]}), + ] { + assert!(matches!(s.run("mailings.filterRecipients", &bad), Err(CmdError::Params(_))), "{bad}"); + } + assert_eq!(names(&s), ["Katherine"]); + // Loading another list clears the filter. + s.run("mailings.recipients", &json!({"csv": "Name\nA\nB"})).unwrap(); + assert_eq!(names(&s), ["A", "B"]); + assert!(s.merge.filter.is_empty()); + } + + /// #778: Sort Recipients: by one or more columns, numbers as numbers, ties in list order. + #[test] + fn sort_recipients() { + let mut s = people(); + s.run("mailings.sortRecipients", &json!({"field": "Age", "descending": true})).unwrap(); + assert_eq!(names(&s), ["Katherine", "Grace", "Alan", "Ada", "Linus"]); + s.run("mailings.sortRecipients", &json!({"sort": [{"field": "Email"}, {"field": "Name", "descending": true}]})).unwrap(); + assert_eq!(names(&s), ["Katherine", "Alan", "Ada", "Grace", "Linus"]); + // Filter and sort together; a filter call without `sort` keeps the sort. + s.run("mailings.filterRecipients", &json!({"rules": [{"field": "Age", "op": "greater", "value": 20}], "sort": [{"field": "City"}]})).unwrap(); + assert_eq!(names(&s), ["Katherine", "Ada", "Grace", "Alan"]); + s.run("mailings.filterRecipients", &json!({"rules": [{"field": "Age", "op": "less", "value": 90}]})).unwrap(); + assert_eq!(names(&s), ["Linus", "Ada", "Grace", "Alan"]); + s.run("mailings.sortRecipients", &json!({})).unwrap(); + assert_eq!(names(&s), ["Ada", "Alan", "Grace", "Linus"]); + // Edit Recipient List shows every record, and editing keeps the filter. + let r = s.run("mailings.editRecipients", &json!({})).unwrap(); + assert_eq!(r["rows"].as_array().unwrap().len(), 5); + assert_eq!(r["records"], 4); + let mut rows: Vec = r["rows"].as_array().unwrap().clone(); + rows.push(json!(["Margaret", "Boston", "88", ""])); + s.run("mailings.editRecipients", &json!({"fields": ["Name", "City", "Age", "Email"], "rows": rows})).unwrap(); + assert_eq!(names(&s), ["Ada", "Alan", "Grace", "Linus", "Margaret"]); + assert_eq!(s.merge.filter.len(), 1); + // Removing the filtered field drops its rule. + s.run("mailings.editRecipients", &json!({"fields": ["Name"], "rows": [["X"], ["Y"]]})).unwrap(); + assert!(s.merge.filter.is_empty()); + assert_eq!(names(&s), ["X", "Y"]); + } + + /// 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] + fn sort_mixed_number_and_text_column() { + let headers = vec!["Id".to_string()]; + let rows = |vals: &[&str]| vals.iter().map(|v| vec![v.to_string()]).collect::>(); + let sorted = |rows: &[Vec], desc: bool| { + let order = select_records(&headers, rows, &[], &[SortKey { field: "Id".into(), descending: desc }]); + order.iter().map(|&i| rows[i][0].clone()).collect::>() + }; + let r = rows(&["1a", "9", "10", "b", "2"]); + assert_eq!(sorted(&r, false), ["2", "9", "10", "1a", "b"]); + assert_eq!(sorted(&r, true), ["b", "1a", "10", "9", "2"]); + // Equal keys stay in list order. + let r = rows(&["x", "1", "X", "1.0", ""]); + assert_eq!(sorted(&r, false), ["1", "1.0", "", "x", "X"]); + // A long mixed column (where an inconsistent comparator makes `sort_by` panic). + let vals: Vec = (0..400) + .map(|i| match i % 4 { + 0 => format!("{}", (i * 37) % 101), + 1 => format!("{}a", (i * 13) % 17), + 2 => format!("{}", (i * 7) % 23), + _ => format!("Z{}", i % 11), + }) + .collect(); + let r: Vec> = vals.iter().map(|v| vec![v.clone()]).collect(); + let out = sorted(&r, false); + assert!(out.windows(2).all(|w| compare_cells(&w[0], &w[1]) != std::cmp::Ordering::Greater)); + } + + /// #778: preview, Finish & Merge and labels use only the filtered records, in sort order; a + /// filter matching nothing leaves nothing to merge. + #[test] + fn merge_uses_only_filtered_records_in_sort_order() { + let mut s = people(); + s.run("mailings.insertField", &json!({"field": "Name"})).unwrap(); + s.run("mailings.filterRecipients", &json!({"rules": [{"field": "Email", "op": "notBlank"}], "sort": [{"field": "Age", "descending": true}]})) + .unwrap(); + s.run("mailings.preview", &json!({"value": true})).unwrap(); + assert!(s.doc.plain_text(StoryRef::Body).contains("Grace")); + s.run("mailings.next", &json!({})).unwrap(); + assert!(s.doc.plain_text(StoryRef::Body).contains("Ada")); + assert_eq!(s.run("mailings.next", &json!({})).unwrap()["of"], 3); + assert!(s.run("mailings.findRecipient", &json!({"text": "Alan"})).is_err()); + let r = s.run("mailings.finish", &json!({})).unwrap(); + assert_eq!(r["records"], 3); + let t = s.doc.plain_text(StoryRef::Body); + let (g, a, l) = (t.find("Grace").unwrap(), t.find("Ada").unwrap(), t.find("Linus").unwrap()); + assert!(g < a && a < l, "{t}"); + assert!(!t.contains("Alan") && !t.contains("Katherine"), "{t}"); + let mut s = people(); + s.run("mailings.filterRecipients", &json!({"rules": [{"field": "City", "op": "equal", "value": "Atlantis"}]})).unwrap(); + assert!(s.merge.rows.is_empty()); + assert!(s.run("mailings.finish", &json!({})).is_err()); + assert!(s.run("mailings.editRecipients", &json!({})).is_ok()); + } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 2c4df20f1..c9d383e3d 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -534,8 +534,9 @@ pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option Err(format!("{}: files are saved through the browser on the web", path.display())) } -/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else -/// Windows-1252 (see [`wordcraft_formats::txt::decode`]). +/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else the +/// legacy encoding they look like — GBK, Big5, Shift_JIS, Windows-1252… (see +/// [`wordcraft_formats::txt::decode`]). pub fn decode_text(b: &[u8]) -> String { wordcraft_formats::txt::decode(b) } diff --git a/crates/formats/Cargo.toml b/crates/formats/Cargo.toml index a892251ed..6db6e4658 100644 --- a/crates/formats/Cargo.toml +++ b/crates/formats/Cargo.toml @@ -14,6 +14,9 @@ wordcraft-geom = { workspace = true } image = { workspace = true } quick-xml = { workspace = true } zip = { workspace = true } +cfb = { workspace = true } +encoding_rs = { workspace = true } +chardetng = { workspace = true } log = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index a8254ae71..6678b5c95 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -1,6 +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 — and, read-only, -//! spreadsheet cells for mail-merge recipient lists ([`sheet`]: .xlsx/.xlsm/.ods). +//! spreadsheet cells for mail-merge recipient lists ([`sheet`]: .xlsx/.xlsm/.xls/.ods). //! //! Every format maps to a small flow model ([`model::Flow`]: paragraphs with a kind, list //! membership, alignment and formatted spans; tables of cells), which [`model::to_doc`] and diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index f534b723a..658f0f607 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -1,6 +1,7 @@ //! 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. +//! (`.xlsx` / `.xlsm`, ECMA-376 Part 1 §18 SpreadsheetML; legacy `.xls` BIFF8 in [`xls`], #778) +//! 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 @@ -23,8 +24,13 @@ use std::io::{Cursor, Read}; use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; +mod xls; + +#[doc(hidden)] +pub use xls::build as xls_fixture; + /// File extensions read as spreadsheets. -pub const EXTENSIONS: &[&str] = &["xlsx", "xlsm", "ods"]; +pub const EXTENSIONS: &[&str] = &["xlsx", "xlsm", "xls", "ods"]; /// Largest zip entry inflated. pub const MAX_ENTRY: u64 = 64 << 20; @@ -51,6 +57,8 @@ const MAX_XML_DEPTH: usize = 256; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { Xlsx, + /// Excel 97–2003 (BIFF8 in a compound file). + Xls, Ods, } @@ -71,6 +79,9 @@ pub fn is_extension(ext: &str) -> bool { /// What kind of spreadsheet `bytes` holds, from the package contents (`None`: not one). pub fn detect(bytes: &[u8]) -> Option { + if xls::is_xls(bytes) { + return Some(Kind::Xls); + } if !bytes.starts_with(b"PK") { return None; } @@ -80,16 +91,23 @@ pub fn detect(bytes: &[u8]) -> Option { /// The sheet names, in workbook order. pub fn sheet_names(bytes: &[u8]) -> Result, String> { + if bytes.starts_with(&xls::MAGIC) { + return xls::sheet_names(bytes); + } 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), + Kind::Xls => xls::sheet_names(bytes), } } /// 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> { + if bytes.starts_with(&xls::MAGIC) { + return xls::read_rows(bytes, pick); + } let mut pkg = Package::open(bytes)?; match pkg.kind().ok_or("not an Excel or OpenDocument spreadsheet")? { Kind::Xlsx => { @@ -100,6 +118,7 @@ pub fn read_rows(bytes: &[u8], pick: Pick<'_>) -> Result<(Vec, Vec xls::read_rows(bytes, pick), Kind::Ods => { let content = pkg.read("content.xml")?; // Names first, so a name can be resolved to its position. diff --git a/crates/formats/src/sheet/xls.rs b/crates/formats/src/sheet/xls.rs new file mode 100644 index 000000000..4123ceb67 --- /dev/null +++ b/crates/formats/src/sheet/xls.rs @@ -0,0 +1,556 @@ +//! Legacy Excel 97–2003 workbooks (`.xls`, BIFF8 in a compound file; #778), read from the +//! published [MS-XLS] structure: the `Workbook` stream's globals (BOUNDSHEET sheet list, +//! shared-string table SST with its CONTINUE records, FORMAT and XF number formats, DATEMODE) +//! and each worksheet's cell records (LABELSST, LABEL, RSTRING, NUMBER, RK, MULRK, BOOLERR and +//! FORMULA cached results with their STRING records). Values become the same display strings +//! as `.xlsx` cells (see the parent module). Excel 5.0/95 (BIFF5, a `Book` stream) and +//! password-protected workbooks are refused with a message suggesting `.xlsx`. +//! +//! Every file is hostile: the stream is read up to [`super::MAX_ENTRY`], every record is bounds +//! checked, and rows, columns, cells, strings and sheets are capped as for `.xlsx`. + +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, +}; + +/// The compound-file signature every `.xls` (and `.doc`) starts with. +pub const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + +const BOF: u16 = 0x0809; +const EOF: u16 = 0x000A; +const CONTINUE: u16 = 0x003C; +const FILEPASS: u16 = 0x002F; +const DATEMODE: u16 = 0x0022; +const FORMAT: u16 = 0x041E; +const XF: u16 = 0x00E0; +const BOUNDSHEET: u16 = 0x0085; +const SST: u16 = 0x00FC; +const LABELSST: u16 = 0x00FD; +const LABEL: u16 = 0x0204; +const RSTRING: u16 = 0x00D6; +const NUMBER: u16 = 0x0203; +const RK: u16 = 0x027E; +const MULRK: u16 = 0x00BD; +const BOOLERR: u16 = 0x0205; +const FORMULA: u16 = 0x0006; +const STRING: u16 = 0x0207; + +/// Whether `bytes` is a compound file holding an Excel workbook stream. +pub fn is_xls(bytes: &[u8]) -> bool { + if !bytes.starts_with(&MAGIC) { + return false; + } + let Ok(comp) = cfb::CompoundFile::open(Cursor::new(bytes)) else { return false }; + comp.is_stream("/Workbook") || comp.is_stream("/Book") +} + +/// The worksheet names, in workbook order. +pub fn sheet_names(bytes: &[u8]) -> Result, String> { + Ok(globals(&workbook_stream(bytes)?)?.sheets.into_iter().map(|s| s.0).collect()) +} + +/// One worksheet's non-blank rows, with the workbook's worksheet names. +pub fn read_rows(bytes: &[u8], pick: super::Pick<'_>) -> Result<(Vec, Vec>), String> { + let stream = workbook_stream(bytes)?; + let g = globals(&stream)?; + let names: Vec = g.sheets.iter().map(|s| s.0.clone()).collect(); + let i = super::resolve(&names, pick)?; + let offset = g.sheets.get(i).map(|s| s.1).ok_or("no such sheet")?; + let rows = sheet_rows(&stream, offset, &g)?; + Ok((names, rows)) +} + +fn workbook_stream(bytes: &[u8]) -> Result, String> { + let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| format!("not a valid Excel 97–2003 workbook ({e})"))?; + if !comp.is_stream("/Workbook") { + if comp.is_stream("/Book") { + return Err("this is an Excel 5.0/95 workbook, which isn't supported; save it as .xlsx (Excel Workbook) and select it again".into()); + } + return Err("not an Excel workbook (no Workbook stream)".into()); + } + let mut s = comp.open_stream("/Workbook").map_err(|e| format!("the Workbook stream can't be read ({e})"))?; + let mut v = Vec::new(); + s.by_ref().take(MAX_ENTRY + 1).read_to_end(&mut v).map_err(|e| format!("the Workbook stream can't be read ({e})"))?; + if v.len() as u64 > MAX_ENTRY { + return Err("the workbook is too large".into()); + } + Ok(v) +} + +/// A record: its type, its data and where the next one starts. +fn record(stream: &[u8], pos: usize) -> Option<(u16, &[u8], usize)> { + let head = stream.get(pos..pos.checked_add(4)?)?; + let kind = u16::from_le_bytes([head[0], head[1]]); + let len = usize::from(u16::from_le_bytes([head[2], head[3]])); + let start = pos + 4; + let end = start.checked_add(len)?; + // A truncated last record keeps what is there. + let data = stream.get(start..end.min(stream.len()))?; + Some((kind, data, end)) +} + +fn u16_at(d: &[u8], i: usize) -> Option { + Some(u16::from_le_bytes([*d.get(i)?, *d.get(i + 1)?])) +} + +fn u32_at(d: &[u8], i: usize) -> Option { + Some(u32::from_le_bytes([*d.get(i)?, *d.get(i + 1)?, *d.get(i + 2)?, *d.get(i + 3)?])) +} + +fn f64_at(d: &[u8], i: usize) -> Option { + let b = d.get(i..i + 8)?; + let mut a = [0u8; 8]; + a.copy_from_slice(b); + Some(f64::from_le_bytes(a)) +} + +/// Reads across a record and the CONTINUE records after it; a string's characters that cross +/// into a CONTINUE record carry a fresh option byte there ([MS-XLS] 2.5.293). +struct Pieces<'a> { + parts: Vec<&'a [u8]>, + part: usize, + at: usize, +} + +impl<'a> Pieces<'a> { + fn new(parts: Vec<&'a [u8]>) -> Self { + Pieces { parts, part: 0, at: 0 } + } + + /// Move past exhausted parts; false at the end. + fn settle(&mut self) -> bool { + while let Some(p) = self.parts.get(self.part) { + if self.at < p.len() { + return true; + } + self.part += 1; + self.at = 0; + } + false + } + + fn byte(&mut self) -> Option { + if !self.settle() { + return None; + } + let b = *self.parts.get(self.part)?.get(self.at)?; + self.at += 1; + Some(b) + } + + fn u16(&mut self) -> Option { + Some(u16::from_le_bytes([self.byte()?, self.byte()?])) + } + + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes([self.byte()?, self.byte()?, self.byte()?, self.byte()?])) + } + + fn skip(&mut self, mut n: usize) -> Option<()> { + while n > 0 { + if !self.settle() { + return None; + } + let p = self.parts.get(self.part)?; + let step = n.min(p.len() - self.at); + self.at += step; + n -= step; + } + Some(()) + } + + /// `cch` characters, one byte each (`high` false: Latin-1) or UTF-16LE; at a part boundary + /// a new option byte says which for the rest. + fn chars(&mut self, cch: usize, mut high: bool) -> Option { + let mut units: Vec = Vec::with_capacity(cch.min(MAX_CELL_CHARS)); + let mut left = cch; + while left > 0 { + let at_end = self.parts.get(self.part).is_none_or(|p| self.at >= p.len()); + if at_end { + if !self.settle() { + break; + } + // The first byte of the CONTINUE record repeats the high-byte flag. + if self.at == 0 { + high = self.byte()? & 1 != 0; + continue; + } + } + let u = if high { self.u16()? } else { u16::from(self.byte()?) }; + if units.len() < MAX_CELL_CHARS { + units.push(u); + } + left -= 1; + } + Some(String::from_utf16_lossy(&units)) + } + + /// An XLUnicodeRichExtendedString (SST entries). + fn rich_string(&mut self) -> Option { + let cch = usize::from(self.u16()?); + let flags = self.byte()?; + let runs = if flags & 0x08 != 0 { usize::from(self.u16()?) } else { 0 }; + let ext = if flags & 0x04 != 0 { self.u32()? as usize } else { 0 }; + let s = self.chars(cch, flags & 1 != 0)?; + self.skip(runs.saturating_mul(4))?; + self.skip(ext)?; + Some(s) + } +} + +/// An XLUnicodeString (u16 count) inside one record. +fn unicode_string(d: &[u8], at: usize) -> Option { + let cch = usize::from(u16_at(d, at)?); + let flags = *d.get(at + 2)?; + let mut p = Pieces::new(vec![d.get(at + 3..)?]); + p.chars(cch, flags & 1 != 0) +} + +struct Globals { + /// Worksheet names with the stream offsets of their BOF records. + sheets: Vec<(String, usize)>, + sst: Vec, + /// Each XF's date kind, by XF index. + xfs: Vec>, + date1904: bool, +} + +fn globals(stream: &[u8]) -> Result { + let Some((BOF, bof, mut pos)) = record(stream, 0) else { return Err("not an Excel 97–2003 workbook (no BIFF8 BOF record)".into()) }; + if u16_at(bof, 0) != Some(0x0600) { + return Err("this Excel workbook is older than Excel 97 and isn't supported; save it as .xlsx and select it again".into()); + } + let mut g = Globals { sheets: Vec::new(), sst: Vec::new(), xfs: Vec::new(), date1904: false }; + let mut formats: HashMap> = HashMap::new(); + let mut xf_formats: Vec = Vec::new(); + while let Some((kind, data, next)) = record(stream, pos) { + match kind { + EOF => break, + FILEPASS => { + return Err("the workbook is password-protected; remove the password in Excel (or save it as .xlsx) and select it again".into()); + } + DATEMODE => g.date1904 = u16_at(data, 0) == Some(1), + FORMAT if formats.len() < MAX_XFS => { + if let (Some(id), Some(code)) = (u16_at(data, 0), unicode_string(data, 2)) { + formats.insert(id, custom_date(&code)); + } + } + XF if xf_formats.len() < MAX_XFS => xf_formats.push(u16_at(data, 2).unwrap_or(0)), + BOUNDSHEET if g.sheets.len() < MAX_SHEETS => { + // Worksheets only (dt 0); charts, macro and VB module sheets have no cells. + if let (Some(off), Some(0)) = (u32_at(data, 0), data.get(5).copied()) { + let cch = usize::from(data.get(6).copied().unwrap_or(0)); + let high = data.get(7).is_some_and(|f| f & 1 != 0); + let name = data.get(8..).and_then(|rest| Pieces::new(vec![rest]).chars(cch, high)).unwrap_or_default(); + g.sheets.push((name, off as usize)); + } + } + SST => { + let mut parts = vec![data.get(8..).unwrap_or(&[])]; + let mut p = next; + while let Some((CONTINUE, d, n)) = record(stream, p) { + parts.push(d); + p = n; + } + let unique = u32_at(data, 4).unwrap_or(0) as usize; + let mut r = Pieces::new(parts); + while g.sst.len() < unique.min(MAX_SHARED) { + match r.rich_string() { + Some(s) => g.sst.push(s), + None => break, + } + } + pos = p; + continue; + } + _ => {} + } + pos = next; + } + g.xfs = xf_formats.into_iter().map(|id| formats.get(&id).copied().unwrap_or_else(|| builtin_date(u32::from(id)))).collect(); + Ok(g) +} + +/// An RK number ([MS-XLS] 2.5.217): a 30-bit integer or the top of a double, maybe ÷ 100. +fn rk(v: u32) -> f64 { + let x = if v & 2 != 0 { f64::from((v as i32) >> 2) } else { f64::from_bits(u64::from(v & 0xFFFF_FFFC) << 32) }; + if v & 1 != 0 { x / 100.0 } else { x } +} + +fn error_text(code: u8) -> &'static str { + match code { + 0x00 => "#NULL!", + 0x07 => "#DIV/0!", + 0x0F => "#VALUE!", + 0x17 => "#REF!", + 0x1D => "#NAME?", + 0x24 => "#NUM!", + _ => "#N/A", + } +} + +fn sheet_rows(stream: &[u8], offset: usize, g: &Globals) -> Result>, String> { + let Some((BOF, _, mut pos)) = record(stream, offset) else { return Err("the sheet can't be found in the workbook".into()) }; + let mut cells: BTreeMap> = BTreeMap::new(); + let mut count = 0usize; + let num = |x: f64, xf: u16| match g.xfs.get(usize::from(xf)).copied().flatten() { + 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() { + *count += 1; + } + }; + // A FORMULA with a string result waits for the STRING record after it. + let mut pending: Option<(u16, u16)> = None; + while let Some((kind, d, next)) = record(stream, pos) { + pos = next; + if kind == EOF || kind == BOF { + break; + } + if kind == STRING { + if let (Some((r, c)), Some(s)) = (pending.take(), unicode_string(d, 0)) { + put(r, c, s, &mut count); + } + continue; + } + let (Some(row), Some(col), Some(xf)) = (u16_at(d, 0), u16_at(d, 2), u16_at(d, 4)) else { continue }; + match kind { + LABELSST => { + if let Some(s) = u32_at(d, 6).and_then(|i| g.sst.get(i as usize)) { + put(row, col, s.clone(), &mut count); + } + } + LABEL | RSTRING => { + if let Some(s) = unicode_string(d, 6) { + put(row, col, s, &mut count); + } + } + NUMBER => { + if let Some(x) = f64_at(d, 6) { + put(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); + } + } + MULRK => { + // rw, colFirst, then (ixfe, rk) pairs, then colLast. + let n = d.len().saturating_sub(6) / 6; + 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); + } + } + } + BOOLERR => { + let (v, err) = (d.get(6).copied().unwrap_or(0), d.get(7).copied().unwrap_or(0)); + let s = if err != 0 { + error_text(v).to_string() + } else if v != 0 { + "TRUE".into() + } else { + "FALSE".into() + }; + put(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), + _ => {} + } + } else if let Some(x) = f64_at(d, 6) { + put(row, col, num(x, xf), &mut count); + } + } + _ => {} + } + } + let mut grid = Grid::default(); + for (_, r) in cells { + let width = r.keys().next_back().map_or(0, |c| usize::from(*c) + 1); + let mut row = vec![String::new(); width]; + for (c, v) in r { + if let Some(slot) = row.get_mut(usize::from(c)) { + *slot = v; + } + } + grid.push(row, 1); + } + Ok(grid.rows) +} + +/// A minimal BIFF8 workbook (tests and fixtures): one worksheet per `(name, rows)`; cells that +/// read as numbers are NUMBER records, a cell `date:` is a NUMBER with a date format, +/// everything else goes through the shared-string table (as UTF-16 when it isn't Latin-1). +#[doc(hidden)] +pub fn build(sheets: &[(&str, Vec>)]) -> Result, String> { + fn rec(out: &mut Vec, kind: u16, data: &[u8]) { + out.extend_from_slice(&kind.to_le_bytes()); + out.extend_from_slice(&(data.len().min(usize::from(u16::MAX)) as u16).to_le_bytes()); + out.extend_from_slice(data); + } + fn chars(out: &mut Vec, s: &str) { + let wide = s.chars().any(|c| u32::from(c) > 0xFF); + out.push(u8::from(wide)); + if wide { + for u in s.encode_utf16() { + out.extend_from_slice(&u.to_le_bytes()); + } + } else { + out.extend(s.chars().map(|c| u32::from(c) as u8)); + } + } + let bof = |dt: u16| { + let mut d = vec![0x00, 0x06]; + d.extend_from_slice(&dt.to_le_bytes()); + d.extend_from_slice(&[0; 12]); + d + }; + let mut sst: Vec = Vec::new(); + let mut out = Vec::new(); + rec(&mut out, BOF, &bof(0x0005)); + for ifmt in [0u16, 14] { + let mut xf = vec![0u8; 20]; + xf[2..4].copy_from_slice(&ifmt.to_le_bytes()); + rec(&mut out, XF, &xf); + } + let mut patches = Vec::new(); + for (name, _) in sheets { + patches.push(out.len() + 4); + let mut d = vec![0, 0, 0, 0, 0, 0, name.encode_utf16().count().min(255) as u8]; + chars(&mut d, name); + rec(&mut out, BOUNDSHEET, &d); + } + for (_, rows) in sheets { + for c in rows.iter().flatten() { + if c.parse::().is_err() && !c.starts_with("date:") && !sst.contains(c) { + sst.push(c.clone()); + } + } + } + let mut d = Vec::new(); + d.extend_from_slice(&(sst.len() as u32).to_le_bytes()); + d.extend_from_slice(&(sst.len() as u32).to_le_bytes()); + for s in &sst { + d.extend_from_slice(&(s.encode_utf16().count() as u16).to_le_bytes()); + chars(&mut d, s); + } + rec(&mut out, SST, &d); + rec(&mut out, EOF, &[]); + for ((_, rows), at) in sheets.iter().zip(patches) { + let off = (out.len() as u32).to_le_bytes(); + if let Some(slot) = out.get_mut(at..at + 4) { + slot.copy_from_slice(&off); + } + rec(&mut out, BOF, &bof(0x0010)); + for (r, row) in rows.iter().enumerate() { + for (c, v) in row.iter().enumerate() { + if v.is_empty() { + continue; + } + let mut d = Vec::new(); + d.extend_from_slice(&(r as u16).to_le_bytes()); + d.extend_from_slice(&(c as u16).to_le_bytes()); + if let Some(x) = v.strip_prefix("date:").and_then(|x| x.parse::().ok()) { + d.extend_from_slice(&1u16.to_le_bytes()); + d.extend_from_slice(&x.to_le_bytes()); + rec(&mut out, NUMBER, &d); + } else if let Ok(x) = v.parse::() { + d.extend_from_slice(&0u16.to_le_bytes()); + d.extend_from_slice(&x.to_le_bytes()); + rec(&mut out, NUMBER, &d); + } else { + d.extend_from_slice(&0u16.to_le_bytes()); + let i = sst.iter().position(|s| s == v).unwrap_or(0) as u32; + d.extend_from_slice(&i.to_le_bytes()); + rec(&mut out, LABELSST, &d); + } + } + } + rec(&mut out, EOF, &[]); + } + let mut comp = cfb::CompoundFile::create(Cursor::new(Vec::new())).map_err(|e| e.to_string())?; + { + use std::io::Write; + let mut s = comp.create_stream("/Workbook").map_err(|e| e.to_string())?; + s.write_all(&out).map_err(|e| e.to_string())?; + } + comp.flush().map_err(|e| e.to_string())?; + Ok(comp.into_inner().into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sheet::{Pick, detect, read_rows, sheet_names}; + + fn rows(r: &[&[&str]]) -> Vec> { + r.iter().map(|r| r.iter().map(|c| c.to_string()).collect()).collect() + } + + #[test] + fn reads_a_biff8_workbook() { + let book = build(&[ + ("名单", rows(&[&["姓名", "城市", "Amount", "Since"], &["张三", "北京", "12", "date:45397"], &["Ann", "Paris", "2.5", ""]])), + ("Other", rows(&[&["x"]])), + ]) + .unwrap(); + assert_eq!(detect(&book), Some(crate::sheet::Kind::Xls)); + assert_eq!(sheet_names(&book).unwrap(), vec!["名单", "Other"]); + let (_, r) = read_rows(&book, Pick::Index(0)).unwrap(); + assert_eq!(r, rows(&[&["姓名", "城市", "Amount", "Since"], &["张三", "北京", "12", "2024-04-15"], &["Ann", "Paris", "2.5"]])); + let (_, r) = read_rows(&book, Pick::Name("other")).unwrap(); + assert_eq!(r, rows(&[&["x"]])); + } + + #[test] + fn rk_numbers_and_strings_across_continue_records() { + assert_eq!(rk((42 << 2) | 2), 42.0); + assert_eq!(rk((1234 << 2) | 3), 12.34); + assert_eq!(rk(0x3FF0_0000), 1.0); + // "AB" compressed, then the rest in UTF-16 after a CONTINUE's fresh option byte. + let a: &[u8] = &[4, 0, 0, b'A', b'B']; + let b: &[u8] = &[1, 0x2D, 0x4E, 0x87, 0x65]; + let mut p = Pieces::new(vec![a, b]); + assert_eq!(p.rich_string().as_deref(), Some("AB中文")); + } + + #[test] + fn hostile_workbooks_fail_cleanly() { + let book = build(&[("S", rows(&[&["a", "1"]]))]).unwrap(); + // Truncations and bit flips never panic. + for cut in (0..book.len()).step_by(97) { + let _ = sheet_names(&book[..cut]); + let _ = read_rows(&book[..cut], Pick::Index(0)); + } + let stream = workbook_stream(&book).unwrap(); + for i in 0..stream.len() { + let mut s = stream.clone(); + s[i] ^= 0xFF; + if let Ok(g) = globals(&s) { + for (_, off) in &g.sheets { + let _ = sheet_rows(&s, *off, &g); + } + } + } + // Excel 5.0/95 and non-workbook compound files say what they are. + let mut comp = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap(); + comp.create_stream("/Book").unwrap(); + let old = comp.into_inner().into_inner(); + assert!(sheet_names(&old).unwrap_err().contains("Excel 5.0/95")); + } +} diff --git a/crates/formats/src/txt.rs b/crates/formats/src/txt.rs index 82d546bb3..ee745f44d 100644 --- a/crates/formats/src/txt.rs +++ b/crates/formats/src/txt.rs @@ -1,4 +1,6 @@ -//! Plain text: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else Windows-1252. +//! Plain text: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else the legacy +//! encoding the bytes look like (GBK/GB18030, Big5, Shift_JIS, EUC-KR, Windows code pages…; +//! #778), Windows-1252 when nothing fits better. //! Export writes UTF-8 with CRLF line ends; list paragraphs get their label and a tab, table //! cells are separated by tabs. @@ -60,8 +62,8 @@ pub fn is_text(b: &[u8]) -> bool { sample.iter().filter(|c| odd(u32::from(**c))).count().saturating_mul(100) <= sample.len() } -/// Decode text bytes: BOMs first, then UTF-16 without BOM (by its zero bytes), UTF-8, and -/// Windows-1252 as the last resort. +/// Decode text bytes: BOMs first, then UTF-16 without BOM (by its zero bytes), UTF-8, and the +/// legacy encoding the bytes look like ([`legacy`]) as the last resort. pub fn decode(b: &[u8]) -> String { if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) { return String::from_utf8_lossy(rest).into_owned(); @@ -77,10 +79,34 @@ pub fn decode(b: &[u8]) -> String { } match std::str::from_utf8(b) { Ok(s) => s.to_string(), - Err(_) => b.iter().map(|c| cp1252(*c)).collect(), + Err(_) => legacy(b), } } +/// Most bytes looked at to guess a legacy encoding. +const SNIFF: usize = 1 << 20; + +/// Bytes that aren't UTF-8 in the legacy encoding they look like (chardetng, the detector +/// Firefox uses for unlabelled pages): GBK/GB18030 for Simplified Chinese, Big5, Shift_JIS or +/// EUC-JP, EUC-KR, or a single-byte Windows / ISO code page; Windows-1252 as before when the +/// guess is a single-byte Western one. +pub fn legacy(b: &[u8]) -> String { + let mut det = chardetng::EncodingDetector::new(chardetng::Iso2022JpDetection::Deny); + det.feed(b.get(..SNIFF).unwrap_or(b), b.len() <= SNIFF); + let enc = det.guess(None, chardetng::Utf8Detection::Deny); + if enc == encoding_rs::WINDOWS_1252 { + return b.iter().map(|c| cp1252(*c)).collect(); + } + enc.decode_without_bom_handling(b).0.into_owned() +} + +/// Decode bytes in a named encoding (a WHATWG label such as `gbk`, `gb18030`, `big5`, +/// `shift_jis`, `euc-kr`, `utf-16le`, `windows-1251`); `None` when the label isn't known. +pub fn decode_as(b: &[u8], label: &str) -> Option { + let enc = encoding_rs::Encoding::for_label(label.trim().as_bytes())?; + Some(enc.decode(b).0.into_owned()) +} + /// A document with one paragraph per line. pub fn import(bytes: &[u8]) -> Document { let text = decode(bytes); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9f8e73299..965f79a94 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -11,6 +11,7 @@ use serde::Serialize; use serde_json::{Value, json}; use wordcraft_engine::Password; +use wordcraft_engine::cmd::mailings::{FilterOp, FilterRule, SortKey}; use crate::WordApp; use crate::options::MeasureExt; @@ -386,6 +387,8 @@ pub enum Dialog { fields: Vec, rows: Vec>, message: String, + /// Filter and Sort (#778): applied with `mailings.filterRecipients` on OK. + filter: RecipientFilter, }, /// 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. @@ -665,6 +668,67 @@ pub fn recipient_table(fields: &[String], rows: &[Vec]) -> Result, + /// Sort by, then by, then by: a field (empty: none) and descending. + pub sort: [(String, bool); 3], +} + +/// One filter condition row: `field op value`, joined to the row above with And or Or. +#[derive(Clone, Debug, Serialize)] +pub struct FilterRow { + pub field: String, + #[serde(serialize_with = "op_id")] + pub op: FilterOp, + pub value: String, + pub or: bool, +} + +fn op_id(op: &FilterOp, s: S) -> Result { + s.serialize_str(op.id()) +} + +impl RecipientFilter { + /// The session's current filter and sort. + pub fn from_session(m: &wordcraft_engine::cmd::mailings::MergeState) -> Self { + let mut sort: [(String, bool); 3] = Default::default(); + for (slot, k) in sort.iter_mut().zip(&m.sort) { + *slot = (k.field.clone(), k.descending); + } + let rules: Vec = + m.filter.iter().map(|r| FilterRow { field: r.field.clone(), op: r.op, value: r.value.clone(), or: r.or }).collect(); + RecipientFilter { open: !rules.is_empty() || !m.sort.is_empty(), rules, sort } + } + + fn engine(&self) -> (Vec, Vec) { + let rules = self + .rules + .iter() + .filter(|r| !r.field.is_empty()) + .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(); + (rules, sort) + } + + 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(); + 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::>(), + }) + } +} + /// One table style region in the Table Style dialog: colours are hex, empty for none. #[derive(Clone, Debug, Default, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -1163,11 +1227,17 @@ impl Dialog { "recipientList" | "newRecipientList" => { let m = &app.session.merge; if name == "recipientList" && !m.headers.is_empty() { - Dialog::RecipientList { fields: m.headers.clone(), rows: m.rows.clone(), message: String::new() } + // Every record (not only the filtered ones), with the filter to edit. + Dialog::RecipientList { + fields: m.headers.clone(), + rows: m.source.clone(), + message: String::new(), + filter: RecipientFilter::from_session(m), + } } else { let fields: Vec = NEW_LIST_FIELDS.iter().map(|f| f.to_string()).collect(); let rows = vec![vec![String::new(); fields.len()]]; - Dialog::RecipientList { fields, rows, message: String::new() } + Dialog::RecipientList { fields, rows, message: String::new(), filter: RecipientFilter::default() } } } "insertMergeField" => { @@ -2570,7 +2640,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let _ = app.run("ui.saveChanges", json!({"answer": answer})); true } - Dialog::RecipientList { fields, rows, message } => recipient_list(app, ui, fields, rows, message), + Dialog::RecipientList { fields, rows, message, filter } => recipient_list(app, ui, fields, rows, message, filter), Dialog::InsertMergeField { fields, field } => { if fields.is_empty() { ui.label(egui::RichText::new(tl!("Select recipients first to choose from their fields.")).small().weak()); @@ -2842,13 +2912,32 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } /// The Recipient List dialog's body: a file button, the editable table, OK/Cancel. True to close. -fn recipient_list(app: &mut WordApp, ui: &mut Ui, fields: &mut Vec, rows: &mut Vec>, message: &mut String) -> bool { +fn recipient_list( + app: &mut WordApp, + ui: &mut Ui, + fields: &mut Vec, + rows: &mut Vec>, + message: &mut String, + filter: &mut RecipientFilter, +) -> bool { const CELL_W: f32 = 120.0; const X_W: f32 = 18.0; const ROW_H: f32 = 24.0; + const NUM_W: f32 = 34.0; + // Each record's place in the merge (None: filtered out), when there is a filter or sort. + let order: Option>> = (!filter.is_empty()).then(|| { + let (rules, sort) = filter.engine(); + 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) { + *slot = Some(k + 1); + } + } + order + }); // Wide enough for every field (up to a limit; more scroll sideways). let gap = ui.spacing().item_spacing.x; - let table_w = fields.len() as f32 * (CELL_W + X_W + 2.0 * gap) + X_W + 2.0 * gap; + let table_w = NUM_W + gap + fields.len() as f32 * (CELL_W + X_W + 2.0 * gap) + X_W + 2.0 * gap; ui.set_min_width(table_w.clamp(340.0, 720.0)); ui.label(tl!("Type the recipients, or load them from a file.")); if ui.button(tl!("Use an Existing List…")).clicked() { @@ -2864,6 +2953,7 @@ fn recipient_list(app: &mut WordApp, ui: &mut Ui, fields: &mut Vec, rows egui::ScrollArea::horizontal().id_salt("recipients_h").max_width(720.0).show(ui, |ui| { ui.vertical(|ui| { ui.horizontal(|ui| { + ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover()); for (i, f) in fields.iter_mut().enumerate() { ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(f).font(egui::TextStyle::Button).hint_text(tl!("Field name"))); let x = ui.add_enabled(can_remove, egui::Button::new("×").small().min_size(vec2(X_W, X_W))).on_hover_text(tl!("Remove field")); @@ -2878,6 +2968,15 @@ fn recipient_list(app: &mut WordApp, ui: &mut Ui, fields: &mut Vec, rows for r in range { let Some(row) = rows.get_mut(r) else { continue }; ui.horizontal(|ui| { + // The record's number in the merge, a dash when the filter leaves it out. + let (num, kept) = match order.as_ref().map(|o| o.get(r).copied().flatten()) { + None => ((r + 1).to_string(), true), + Some(Some(k)) => (k.to_string(), true), + Some(None) => ("–".to_string(), false), + }; + let num = egui::RichText::new(num).weak(); + let (rect, _) = ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover()); + ui.put(rect, egui::Label::new(if kept { num } else { num.strikethrough() })); for c in row.iter_mut() { ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(c)); // Under the field's remove button. @@ -2913,6 +3012,22 @@ fn recipient_list(app: &mut WordApp, ui: &mut Ui, fields: &mut Vec, rows } } }); + ui.add_space(4.0); + let toggle = egui::Button::new(tl!("Filter and Sort")).selected(filter.open); + if ui.add(toggle).clicked() { + filter.open = !filter.open; + } + if filter.open { + recipient_filter(ui, fields, filter); + } + if let Some(order) = &order { + let count = order.iter().flatten().count().to_string(); + let total = rows.len().to_string(); + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("{count} of {total} recipients will be merged."), &[("count", &count), ("total", &total)])) + .weak(), + ); + } if !message.is_empty() { ui.label(egui::RichText::new(message.as_str()).color(Tokens::get(ui.ctx()).red)); } @@ -2920,7 +3035,10 @@ fn recipient_list(app: &mut WordApp, ui: &mut Ui, fields: &mut Vec, rows if !ok { return cancel; } - match recipient_table(fields, rows).map_err(|e| tl!(e).to_string()).and_then(|params| app.load_recipients(params)) { + 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()) }); + match r { Ok(_) => true, Err(e) => { *message = e; @@ -2929,6 +3047,81 @@ fn recipient_list(app: &mut WordApp, ui: &mut Ui, fields: &mut Vec, rows } } +/// Filter and Sort: condition rows (And/Or, field, comparison, value) and three sort keys. +fn recipient_filter(ui: &mut Ui, fields: &[String], filter: &mut RecipientFilter) { + let names: Vec = fields.iter().map(|f| f.trim().to_string()).filter(|f| !f.is_empty()).collect(); + let field_combo = |ui: &mut Ui, id: (&str, usize), value: &mut String, none: bool| { + let shown = if value.is_empty() { tl!("(none)").to_string() } else { value.clone() }; + egui::ComboBox::from_id_salt(id).width(130.0).selected_text(shown).show_ui(ui, |ui| { + if none { + ui.selectable_value(value, String::new(), tl!("(none)")); + } + for n in &names { + ui.selectable_value(value, n.clone(), n.as_str()); + } + }); + }; + let mut remove = None; + egui::Grid::new("recipient_filter").num_columns(5).spacing(vec2(6.0, 4.0)).show(ui, |ui| { + ui.label(""); + ui.label(egui::RichText::new(tl!("Field")).weak()); + ui.label(egui::RichText::new(tl!("Comparison")).weak()); + ui.label(egui::RichText::new(tl!("Compare to")).weak()); + ui.end_row(); + for (i, r) in filter.rules.iter_mut().enumerate() { + if i == 0 { + ui.label(""); + } else { + egui::ComboBox::from_id_salt(("rf_join", i)).width(60.0).selected_text(tl!(if r.or { "Or" } else { "And" })).show_ui(ui, |ui| { + ui.selectable_value(&mut r.or, false, tl!("And")); + ui.selectable_value(&mut r.or, true, tl!("Or")); + }); + } + field_combo(ui, ("rf_field", i), &mut r.field, false); + egui::ComboBox::from_id_salt(("rf_op", i)).width(150.0).selected_text(tl!(r.op.label())).show_ui(ui, |ui| { + for op in FilterOp::ALL { + ui.selectable_value(&mut r.op, op, tl!(op.label())); + } + }); + ui.add_enabled(r.op.has_value(), egui::TextEdit::singleline(&mut r.value).desired_width(130.0)); + if ui.add(egui::Button::new("×").small()).on_hover_text(tl!("Remove condition")).clicked() { + remove = Some(i); + } + ui.end_row(); + } + }); + if let Some(i) = remove.filter(|i| *i < filter.rules.len()) { + filter.rules.remove(i); + } + ui.horizontal(|ui| { + if ui.button(tl!("Add Condition")).clicked() && filter.rules.len() < 64 { + let field = names.first().cloned().unwrap_or_default(); + filter.rules.push(FilterRow { field, op: FilterOp::Equal, value: String::new(), or: false }); + } + if ui.button(tl!("Clear All")).clicked() { + filter.rules.clear(); + filter.sort = Default::default(); + } + }); + ui.add_space(4.0); + egui::Grid::new("recipient_sort").num_columns(3).spacing(vec2(6.0, 4.0)).show(ui, |ui| { + for (i, (field, desc)) in filter.sort.iter_mut().enumerate() { + ui.label(tl!(if i == 0 { "Sort by" } else { "Then by" })); + field_combo(ui, ("rs_field", i), field, true); + ui.add_enabled_ui(!field.is_empty(), |ui| { + egui::ComboBox::from_id_salt(("rs_dir", i)).width(110.0).selected_text(tl!(if *desc { "Descending" } else { "Ascending" })).show_ui( + ui, + |ui| { + ui.selectable_value(desc, false, tl!("Ascending")); + ui.selectable_value(desc, true, tl!("Descending")); + }, + ); + }); + ui.end_row(); + } + }); +} + /// The Table Properties dialog's fields: Table, Row, Column and Cell sections. fn table_properties(ui: &mut Ui, f: &mut TableForm) { let unit = crate::options::unit(); diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index ff770eac8..1d9903dac 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -41,7 +41,7 @@ pub fn open_filters(purpose: &str) -> &'static [(&'static str, &'static [&'stati match purpose { "picture" => &[("Pictures", PICTURES)], "font" => &[("Fonts", wordcraft_fonts::FONT_FILE_EXTS)], - "recipients" => &[("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]), ("All files", &["*"])], + "recipients" => &[("Recipient lists", wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS), ("All files", &["*"])], _ => &[("Documents", DOCUMENTS), ("Word document", &["docx"]), ("All files", &["*"])], } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3572a31a4..919b6c140 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2449,6 +2449,31 @@ Insert paragraph number in full context Вмъкване на номер на абзац в пълен контекст Suppress all non-delimiter characters Премахване на всички знаци освен разделителите Insert position (above or below) Вмъкване на позиция (над или под) +# Mail merge: filter and sort recipients (#778) + Filter Recipients Филтриране на получателите + Sort Recipients Сортиране на получателите + Filter and Sort Филтриране и сортиране + And И + Or Или + Comparison Сравнение + Compare to Стойност за сравнение + Add Condition Добавяне на условие + Remove condition Премахване на условието + Sort by Сортиране по + Then by След това по + Ascending Възходящо + Descending Низходящо + Equal to Равно на + Not equal to Не е равно на + Less than По-малко от + Greater than По-голямо от + Less than or equal По-малко или равно на + Greater than or equal По-голямо или равно на + Is blank Празно + Is not blank Не е празно + Contains Съдържа + Does not contain Не съдържа + {count} of {total} recipients will be merged. Ще бъдат обединени {count} от {total} получатели. Undo 1 Action Отмяна на 1 действие Undo Actions Отмяна на действия Undo History История на отмяната diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6dfa1ff04..e9c0a4d3a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2457,6 +2457,31 @@ preview Title Ukázka Hindi Hindské Context Podle kontextu System Systémové +# Mail merge: filter and sort recipients (#778) + Filter Recipients Filtrovat příjemce + Sort Recipients Seřadit příjemce + Filter and Sort Filtrovat a seřadit + And A + Or Nebo + Comparison Porovnání + Compare to Porovnat s + Add Condition Přidat podmínku + Remove condition Odebrat podmínku + Sort by Seřadit podle + Then by Potom podle + Ascending Vzestupně + Descending Sestupně + Equal to Rovná se + Not equal to Nerovná se + Less than Menší než + Greater than Větší než + Less than or equal Menší nebo rovno + Greater than or equal Větší nebo rovno + Is blank Je prázdné + Is not blank Není prázdné + Contains Obsahuje + Does not contain Neobsahuje + {count} of {total} recipients will be merged. Příjemci ke sloučení: {count} z {total}. Show KeyTips (Alt or F10) Zobrazit klávesové tipy (Alt nebo F10) Reading Čtení Reading Column Width Šířka sloupce pro čtení diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 635bca13d..e9e89628b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2072,6 +2072,31 @@ Hindi Hindi Context Kontext System System +# Mail merge: filter and sort recipients (#778) + Filter Recipients Empfänger filtern + Sort Recipients Empfänger sortieren + Filter and Sort Filtern und Sortieren + And Und + Or Oder + Comparison Vergleich + Compare to Vergleichen mit + Add Condition Bedingung hinzufügen + Remove condition Bedingung entfernen + Sort by Sortieren nach + Then by Dann nach + Ascending Aufsteigend + Descending Absteigend + Equal to Gleich + Not equal to Ungleich + Less than Kleiner als + Greater than Größer als + Less than or equal Kleiner oder gleich + Greater than or equal Größer oder gleich + Is blank Ist leer + Is not blank Ist nicht leer + Contains Enthält + Does not contain Enthält nicht + {count} of {total} recipients will be merged. {count} von {total} Empfängern werden zusammengeführt. Show KeyTips (Alt or F10) Zugriffstasten anzeigen (Alt oder F10) Reading Lesen Reading Column Width Spaltenbreite beim Lesen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bda804c56..75ea9f1a7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2455,6 +2455,31 @@ preview Title Título Insert paragraph number in full context Insertar número de párrafo en contexto completo Suppress all non-delimiter characters Suprimir todos los caracteres que no sean delimitadores Insert position (above or below) Insertar posición (encima o debajo) +# Mail merge: filter and sort recipients (#778) + Filter Recipients Filtrar destinatarios + Sort Recipients Ordenar destinatarios + Filter and Sort Filtrar y ordenar + And Y + Or O + Comparison Comparación + Compare to Comparar con + Add Condition Agregar condición + Remove condition Quitar condición + Sort by Ordenar por + Then by Luego por + Ascending Ascendente + Descending Descendente + Equal to Igual a + Not equal to Distinto de + Less than Menor que + Greater than Mayor que + Less than or equal Menor o igual que + Greater than or equal Mayor o igual que + Is blank Está vacío + Is not blank No está vacío + Contains Contiene + Does not contain No contiene + {count} of {total} recipients will be merged. Se combinarán {count} de {total} destinatarios. Start-up Inicio de la aplicación Show the Start screen when WordCraft starts Mostrar la pantalla de inicio al abrir WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Activado: WordCraft se abre en Archivo › Inicio, con un documento en blanco, las plantillas y tus documentos recientes. Desactivado: abre un documento en blanco. diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 1f24d26df..48c4012bd 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2123,6 +2123,31 @@ Insert paragraph number in full context Lisa lõigu number täielikus kontekstis Suppress all non-delimiter characters Jäta välja kõik märgid peale eraldajate Insert position (above or below) Lisa asukoht (ülal või all) +# Mail merge: filter and sort recipients (#778) + Filter Recipients Filtreeri adressaate + Sort Recipients Sordi adressaate + Filter and Sort Filtreerimine ja sortimine + And Ja + Or Või + Comparison Võrdlus + Compare to Võrdle väärtusega + Add Condition Lisa tingimus + Remove condition Eemalda tingimus + Sort by Sordi välja järgi + Then by Seejärel + Ascending Kasvavalt + Descending Kahanevalt + Equal to Võrdne + Not equal to Ei võrdu + Less than Väiksem kui + Greater than Suurem kui + Less than or equal Väiksem või võrdne + Greater than or equal Suurem või võrdne + Is blank On tühi + Is not blank Pole tühi + Contains Sisaldab + Does not contain Ei sisalda + {count} of {total} recipients will be merged. Ühendatavaid adressaate: {count} / {total}. Start-up Käivitamine Show the Start screen when WordCraft starts Näita WordCrafti käivitamisel avakuva On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Sees: WordCraft avaneb vaates Fail › Avaleht, kus on tühi dokument, mallid ja hiljutised dokumendid. Väljas: avaneb tühi dokument. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5213d602d..4179c60a1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2070,6 +2070,30 @@ Reading Column Width Largeur de colonne de lecture Reading Line Spacing Interligne de lecture Reading Text Size Taille du texte de lecture + Filter Recipients Filtrer les destinataires + Sort Recipients Trier les destinataires + Filter and Sort Filtrer et trier + And Et + Or Ou + Comparison Comparaison + Compare to Comparer à + Add Condition Ajouter une condition + Remove condition Supprimer la condition + Sort by Trier par + Then by Puis par + Ascending Croissant + Descending Décroissant + Equal to Égal à + Not equal to Différent de + Less than Inférieur à + Greater than Supérieur à + Less than or equal Inférieur ou égal à + Greater than or equal Supérieur ou égal à + Is blank Est vide + Is not blank N'est pas vide + Contains Contient + Does not contain Ne contient pas + {count} of {total} recipients will be merged. {count} destinataires sur {total} seront fusionnés. Undo Actions Annuler plusieurs actions Undo History Historique des annulations Undo 1 Action Annuler 1 action diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 23c2125e5..e24ceb52d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2451,6 +2451,30 @@ Reading Column Width Olvasási oszlopszélesség Reading Line Spacing Olvasási sorköz Reading Text Size Olvasási szövegméret + Filter Recipients Címzettek szűrése + Sort Recipients Címzettek rendezése + Filter and Sort Szűrés és rendezés + And És + Or Vagy + Comparison Összehasonlítás + Compare to Összehasonlítás ezzel + Add Condition Feltétel hozzáadása + Remove condition Feltétel eltávolítása + Sort by Rendezés alapja + Then by Majd + Ascending Növekvő + Descending Csökkenő + Equal to Egyenlő + Not equal to Nem egyenlő + Less than Kisebb, mint + Greater than Nagyobb, mint + Less than or equal Kisebb vagy egyenlő + Greater than or equal Nagyobb vagy egyenlő + Is blank Üres + Is not blank Nem üres + Contains Tartalmazza + Does not contain Nem tartalmazza + {count} of {total} recipients will be merged. {total} címzettből {count} kerül egyesítésre. Undo Actions Több művelet visszavonása Undo History Visszavonási előzmények Undo 1 Action 1 művelet visszavonása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 040d38be5..58de2c67b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2448,6 +2448,31 @@ preview Title Esempio Hindi Hindi Context Contesto System Sistema +# Mail merge: filter and sort recipients (#778) + Filter Recipients Filtra destinatari + Sort Recipients Ordina destinatari + Filter and Sort Filtra e ordina + And E + Or O + Comparison Confronto + Compare to Confronta con + Add Condition Aggiungi condizione + Remove condition Rimuovi condizione + Sort by Ordina per + Then by Poi per + Ascending Crescente + Descending Decrescente + Equal to Uguale a + Not equal to Diverso da + Less than Minore di + Greater than Maggiore di + Less than or equal Minore o uguale a + Greater than or equal Maggiore o uguale a + Is blank È vuoto + Is not blank Non è vuoto + Contains Contiene + Does not contain Non contiene + {count} of {total} recipients will be merged. Destinatari da unire: {count} su {total}. Show KeyTips (Alt or F10) Mostra i suggerimenti tasti (Alt o F10) Reading Lettura Reading Column Width Larghezza colonna di lettura diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 032ffc1a2..1f8644159 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2089,6 +2089,31 @@ Insert paragraph number in full context 段落番号を完全な形式で挿入する Suppress all non-delimiter characters 区切り文字以外の文字をすべて非表示にする Insert position (above or below) 位置 (上または下) を挿入する +# Mail merge: filter and sort recipients (#778) + Filter Recipients 宛先のフィルター + Sort Recipients 宛先の並べ替え + Filter and Sort フィルターと並べ替え + And かつ + Or または + Comparison 比較 + Compare to 比較する値 + Add Condition 条件の追加 + Remove condition 条件の削除 + Sort by 並べ替えの基準 + Then by 次の基準 + Ascending 昇順 + Descending 降順 + Equal to 等しい + Not equal to 等しくない + Less than より小さい + Greater than より大きい + Less than or equal 以下 + Greater than or equal 以上 + Is blank 空白 + Is not blank 空白ではない + Contains 含む + Does not contain 含まない + {count} of {total} recipients will be merged. 差し込む宛先: {total} 件中 {count} 件 Start-up 起動 Show the Start screen when WordCraft starts WordCraft の起動時にスタート画面を表示する On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. オン: WordCraft は [ファイル] › [ホーム] で開き、白紙の文書、テンプレート、最近使った文書を表示します。オフ: 白紙の文書を開きます。 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 17a037a00..f88ecfeac 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2090,6 +2090,31 @@ Hindi Hindi Context Kontekstowe System Systemowe +# Mail merge: filter and sort recipients (#778) + Filter Recipients Filtruj adresatów + Sort Recipients Sortuj adresatów + Filter and Sort Filtrowanie i sortowanie + And I + Or Lub + Comparison Porównanie + Compare to Porównaj z + Add Condition Dodaj warunek + Remove condition Usuń warunek + Sort by Sortuj według + Then by Następnie według + Ascending Rosnąco + Descending Malejąco + Equal to Równe + Not equal to Różne od + Less than Mniejsze niż + Greater than Większe niż + Less than or equal Mniejsze lub równe + Greater than or equal Większe lub równe + Is blank Jest puste + Is not blank Nie jest puste + Contains Zawiera + Does not contain Nie zawiera + {count} of {total} recipients will be merged. Adresaci do scalenia: {count} z {total}. Book Title Tytuł książki Category Kategoria Contributors Współtwórcy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 768ad1cc4..adaf10cae 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2101,6 +2101,31 @@ Insert paragraph number in full context Inserir número do parágrafo em contexto completo Suppress all non-delimiter characters Suprimir todos os caracteres que não sejam delimitadores Insert position (above or below) Inserir posição (acima ou abaixo) +# Mail merge: filter and sort recipients (#778) + Filter Recipients Filtrar destinatários + Sort Recipients Classificar destinatários + Filter and Sort Filtrar e classificar + And E + Or Ou + Comparison Comparação + Compare to Comparar com + Add Condition Adicionar condição + Remove condition Remover condição + Sort by Classificar por + Then by Depois por + Ascending Crescente + Descending Decrescente + Equal to Igual a + Not equal to Diferente de + Less than Menor que + Greater than Maior que + Less than or equal Menor ou igual a + Greater than or equal Maior ou igual a + Is blank Está em branco + Is not blank Não está em branco + Contains Contém + Does not contain Não contém + {count} of {total} recipients will be merged. {count} de {total} destinatários serão mesclados. Start-up Inicialização Show the Start screen when WordCraft starts Mostrar a tela inicial ao abrir o WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Ativado: o WordCraft abre em Arquivo › Início, com um documento em branco, os modelos e seus documentos recentes. Desativado: abre um documento em branco. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fc8d61d0b..e1b4e21ef 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2472,6 +2472,31 @@ preview Title Пример Insert paragraph number in full context Вставлять номер абзаца в полном контексте Suppress all non-delimiter characters Скрывать все символы, кроме разделителей Insert position (above or below) Вставлять положение (выше или ниже) +# Mail merge: filter and sort recipients (#778) + Filter Recipients Фильтр получателей + Sort Recipients Сортировка получателей + Filter and Sort Фильтр и сортировка + And И + Or Или + Comparison Сравнение + Compare to Значение + Add Condition Добавить условие + Remove condition Удалить условие + Sort by Сортировать по + Then by Затем по + Ascending По возрастанию + Descending По убыванию + Equal to Равно + Not equal to Не равно + Less than Меньше + Greater than Больше + Less than or equal Меньше или равно + Greater than or equal Больше или равно + Is blank Пусто + Is not blank Не пусто + Contains Содержит + Does not contain Не содержит + {count} of {total} recipients will be merged. Получателей для слияния: {count} из {total}. Start-up Запуск Show the Start screen when WordCraft starts Показывать начальный экран при запуске WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Вкл.: WordCraft открывается на странице Файл › Главная с пустым документом, шаблонами и недавними документами. Выкл.: открывается пустой документ. diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 7026be449..285c816ce 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2099,6 +2099,31 @@ Insert paragraph number in full context Umetni broj pasusa u punom kontekstu Suppress all non-delimiter characters Izostavi sve znakove osim graničnika Insert position (above or below) Umetni položaj (iznad ili ispod) +# Mail merge: filter and sort recipients (#778) + Filter Recipients Filtriraj primaoce + Sort Recipients Sortiraj primaoce + Filter and Sort Filtriranje i sortiranje + And I + Or Ili + Comparison Poređenje + Compare to Uporedi sa + Add Condition Dodaj uslov + Remove condition Ukloni uslov + Sort by Sortiraj po + Then by Zatim po + Ascending Rastuće + Descending Opadajuće + Equal to Jednako + Not equal to Nije jednako + Less than Manje od + Greater than Veće od + Less than or equal Manje ili jednako + Greater than or equal Veće ili jednako + Is blank Prazno + Is not blank Nije prazno + Contains Sadrži + Does not contain Ne sadrži + {count} of {total} recipients will be merged. Primalaca za spajanje: {count} od {total}. Start-up Pokretanje Show the Start screen when WordCraft starts Prikaži početni ekran pri pokretanju programa WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Uključeno: WordCraft se otvara na Fajl › Početak, sa praznim dokumentom, šablonima i nedavnim dokumentima. Isključeno: otvara prazan dokument. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 508ce19cb..18cc9c012 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2103,6 +2103,31 @@ Insert paragraph number in full context Уметни број пасуса у пуном контексту Suppress all non-delimiter characters Изостави све знакове осим граничника Insert position (above or below) Уметни положај (изнад или испод) +# Mail merge: filter and sort recipients (#778) + Filter Recipients Филтрирај примаоце + Sort Recipients Сортирај примаоце + Filter and Sort Филтрирање и сортирање + And И + Or Или + Comparison Поређење + Compare to Упореди са + Add Condition Додај услов + Remove condition Уклони услов + Sort by Сортирај по + Then by Затим по + Ascending Растуће + Descending Опадајуће + Equal to Једнако + Not equal to Није једнако + Less than Мање од + Greater than Веће од + Less than or equal Мање или једнако + Greater than or equal Веће или једнако + Is blank Празно + Is not blank Није празно + Contains Садржи + Does not contain Не садржи + {count} of {total} recipients will be merged. Прималаца за спајање: {count} од {total}. Start-up Покретање Show the Start screen when WordCraft starts Прикажи почетни екран при покретању програма WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Укључено: WordCraft се отвара на Фајл › Почетак, са празним документом, шаблонима и недавним документима. Искључено: отвара празан документ. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f8f34de71..28074a26d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2471,6 +2471,31 @@ preview Title Örnek Insert paragraph number in full context Paragraf numarasını tam bağlamda ekle Suppress all non-delimiter characters Ayırıcı olmayan tüm karakterleri gizle Insert position (above or below) Konum ekle (yukarıda veya aşağıda) +# Mail merge: filter and sort recipients (#778) + Filter Recipients Alıcıları filtrele + Sort Recipients Alıcıları sırala + Filter and Sort Filtrele ve sırala + And Ve + Or Veya + Comparison Karşılaştırma + Compare to Karşılaştırılacak değer + Add Condition Koşul ekle + Remove condition Koşulu kaldır + Sort by Sıralama ölçütü + Then by Sonra + Ascending Artan + Descending Azalan + Equal to Eşittir + Not equal to Eşit değildir + Less than Küçüktür + Greater than Büyüktür + Less than or equal Küçük veya eşittir + Greater than or equal Büyük veya eşittir + Is blank Boştur + Is not blank Boş değildir + Contains İçerir + Does not contain İçermez + {count} of {total} recipients will be merged. Birleştirilecek alıcılar: {count} / {total}. Start-up Başlangıç Show the Start screen when WordCraft starts WordCraft açılırken Başlangıç ekranını göster On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Açık: WordCraft, Dosya › Giriş sayfasında boş belge, şablonlar ve son belgelerinizle açılır. Kapalı: boş bir belge açar. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 30d6711c0..f9acf8cf6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2101,6 +2101,31 @@ Insert paragraph number in full context Вставляти номер абзацу в повному контексті Suppress all non-delimiter characters Приховувати всі символи, крім роздільників Insert position (above or below) Вставляти положення (вище чи нижче) +# Mail merge: filter and sort recipients (#778) + Filter Recipients Фільтр одержувачів + Sort Recipients Сортування одержувачів + Filter and Sort Фільтр і сортування + And І + Or Або + Comparison Порівняння + Compare to Значення + Add Condition Додати умову + Remove condition Видалити умову + Sort by Сортувати за + Then by Потім за + Ascending За зростанням + Descending За спаданням + Equal to Дорівнює + Not equal to Не дорівнює + Less than Менше + Greater than Більше + Less than or equal Менше або дорівнює + Greater than or equal Більше або дорівнює + Is blank Порожнє + Is not blank Не порожнє + Contains Містить + Does not contain Не містить + {count} of {total} recipients will be merged. Одержувачів для злиття: {count} з {total}. Start-up Запуск Show the Start screen when WordCraft starts Показувати початковий екран під час запуску WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Увімкнено: WordCraft відкривається на сторінці Файл › Основне з порожнім документом, шаблонами та нещодавніми документами. Вимкнено: відкривається порожній документ. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f7eddfddf..c5fd6e2cd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2570,3 +2570,29 @@ Style name: Tên kiểu: Suppress all non-delimiter characters Bỏ mọi ký tự không phải dấu phân cách Text of the nearest paragraph with a given style (running headers) Văn bản của đoạn gần nhất có kiểu đã cho (đầu trang chạy) + +# Mail merge Filter and Sort (#885) + Add Condition Thêm điều kiện + And Và + Ascending Tăng dần + Compare to So sánh với + Comparison Phép so sánh + Contains Chứa + Descending Giảm dần + Does not contain Không chứa + Equal to Bằng + Filter Recipients Lọc người nhận + Filter and Sort Lọc và sắp xếp + Greater than Lớn hơn + Greater than or equal Lớn hơn hoặc bằng + Is blank Trống + Is not blank Không trống + Less than Nhỏ hơn + Less than or equal Nhỏ hơn hoặc bằng + Not equal to Khác + Or Hoặc + Remove condition Xóa điều kiện + Sort Recipients Sắp xếp người nhận + Sort by Sắp xếp theo + Then by Sau đó theo + {count} of {total} recipients will be merged. Sẽ phối {count} trên {total} người nhận. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 27269da14..32dcfc08f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2101,6 +2101,31 @@ Insert paragraph number in full context 以完整上下文插入段落编号 Suppress all non-delimiter characters 隐藏分隔符以外的所有字符 Insert position (above or below) 插入位置(上方或下方) +# Mail merge: filter and sort recipients (#778) + Filter Recipients 筛选收件人 + Sort Recipients 排序收件人 + Filter and Sort 筛选和排序 + And 与 + Or 或 + Comparison 比较 + Compare to 比较值 + Add Condition 添加条件 + Remove condition 删除条件 + Sort by 排序依据 + Then by 然后依据 + Ascending 升序 + Descending 降序 + Equal to 等于 + Not equal to 不等于 + Less than 小于 + Greater than 大于 + Less than or equal 小于或等于 + Greater than or equal 大于或等于 + Is blank 为空 + Is not blank 不为空 + Contains 包含 + Does not contain 不包含 + {count} of {total} recipients will be merged. 将合并 {total} 位收件人中的 {count} 位。 Start-up 启动 Show the Start screen when WordCraft starts 启动 WordCraft 时显示开始屏幕 On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. 开:WordCraft 打开时显示“文件 › 开始”,其中有空白文档、模板和最近使用的文档。关:直接打开空白文档。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5688ce178..5d6417261 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2090,6 +2090,31 @@ Insert paragraph number in full context 以完整內容插入段落編號 Suppress all non-delimiter characters 隱藏分隔字元以外的所有字元 Insert position (above or below) 插入位置(上方或下方) +# Mail merge: filter and sort recipients (#778) + Filter Recipients 篩選收件者 + Sort Recipients 排序收件者 + Filter and Sort 篩選與排序 + And 且 + Or 或 + Comparison 比較 + Compare to 比較值 + Add Condition 新增條件 + Remove condition 移除條件 + Sort by 排序依據 + Then by 次要依據 + Ascending 遞增 + Descending 遞減 + Equal to 等於 + Not equal to 不等於 + Less than 小於 + Greater than 大於 + Less than or equal 小於或等於 + Greater than or equal 大於或等於 + Is blank 空白 + Is not blank 非空白 + Contains 包含 + Does not contain 不包含 + {count} of {total} recipients will be merged. 將合併 {total} 位收件者中的 {count} 位。 Start-up 啟動 Show the Start screen when WordCraft starts 啟動 WordCraft 時顯示開始畫面 On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. 開:WordCraft 開啟時顯示「檔案 › 常用」,其中有空白文件、範本和最近使用的文件。關:直接開啟空白文件。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8837be67b..49657a210 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1688,7 +1688,7 @@ 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", ".wmf", ".emf"].iter().any(|e| lp.ends_with(e)); - let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lp.ends_with(e)); + let sheet = wordcraft_engine::cmd::mailings::is_spreadsheet_name(&lp); let _ = if img { self.run("insert.picture", json!({"path": path})) } else if sheet { @@ -1938,7 +1938,7 @@ impl WordApp { // Any recipient file ends the pending picker request, so a later .txt drop opens normally. let asked = std::mem::take(&mut self.recipient_list_pending); let list = lower.ends_with(".csv") || lower.ends_with(".tsv") || (lower.ends_with(".txt") && asked); - let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lower.ends_with(e)); + let sheet = wordcraft_engine::cmd::mailings::is_spreadsheet_name(&lower); self.recipient_list_pending = asked && !list && !sheet; let r = if sheet { self.load_recipients(json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})) @@ -5618,6 +5618,32 @@ mod tests { a.dialog.as_ref().map(dialogs::Dialog::name) } + /// #778: Edit Recipient List shows every record with the list's filter and sort, and its + /// Filter and Sort section maps to `mailings.filterRecipients`. + #[test] + fn recipient_list_dialog_carries_the_filter() { + let mut a = app(); + a.run("mailings.recipients", json!({"csv": "Name,City\nAda,London\nAlan,Wilmslow\nGrace,London"})).unwrap(); + a.run( + "mailings.filterRecipients", + json!({"rules": [{"field": "City", "op": "equal", "value": "London"}], "sort": [{"field": "Name", "descending": true}]}), + ) + .unwrap(); + a.run("mailings.editRecipients", json!({})).unwrap(); + let Some(dialogs::Dialog::RecipientList { rows, filter, .. }) = &a.dialog else { panic!() }; + assert_eq!(rows.len(), 3, "every record, not only the filtered ones"); + assert!(filter.open); + let params = filter.params(); + 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. + a.run("mailings.recipients", json!({"csv": "Name,City\nAda,London\nAlan,Wilmslow\nGrace,London"})).unwrap(); + a.run("mailings.filterRecipients", params).unwrap(); + let names: Vec<&str> = a.session.merge.rows.iter().map(|r| r[0].as_str()).collect(); + assert_eq!(names, ["Grace", "Ada"]); + assert!(open_filters("recipients")[0].1.contains(&"xls")); + } + /// #240: Select Recipients needs data, so clicking it opens the recipient list instead of /// reporting missing parameters; scripts and agents still get the error. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fb30241ca..eb8fc48d7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** trivial (DOCX save keeps unmodelled parts and settings, #1034; previously trivial: All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: password-protected DOCX row corrected, #55 #281; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** trivial (DOCX save keeps unmodelled parts and settings, #1034; previously trivial: recipient lists: .xls and legacy-encoding text, #778; previously trivial: All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: password-protected DOCX row corrected, #55 #281; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -34,9 +34,9 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | | `.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 | +| `.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 | | 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 | -| `.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 | | Pictures for Insert › Picture, drag-and-drop and paste | — | R | ✅ | — | — | — | `insert_picture_takes_a_windows_metafile` (engine), `paste_picture.rs` | PNG, JPEG, GIF, WebP, BMP, and Windows Metafile `.wmf` / Enhanced Metafile `.emf` at their own frame size (#802), drawn by `crates/metafile`. Not yet: SVG, TIFF, ICO, HEIC | @@ -271,3 +271,4 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | 2026-10-10 | major | First version: Word's full format list, read/write status, DOCX element coverage from the source, fidelity and hours | | 2026-10-10 | trivial | Freeform shapes and ink as DrawingML custom geometry, read and written (#307) | | 2026-10-12 | trivial | Charts of types WordCraft doesn't draw (radar, stock…) show a placeholder with their title and type (#998) | +| 2026-10-12 | trivial | Recipient lists: legacy .xls workbooks and GBK/Big5/Shift_JIS/UTF-16 CSV/TXT (#778); plain-text import detects legacy encodings too | diff --git a/docs/gaps.md b/docs/gaps.md index 61fa65104..040831fae 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (re-audited every row against main: stale PR and issue states, counts and landed items corrected; previously trivial: title rename and account settings; previously trivial: ODT tracked insertions and deletions, gap 19; previously trivial: RTF tracked insertions and deletions, gap 19; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (re-audited every row against main: stale PR and issue states, counts and landed items corrected; previously trivial: #21: .xls recipient lists, legacy-encoding CSV, recipient filter and sort, #778; previously trivial: title rename and account settings; previously trivial: ODT tracked insertions and deletions, gap 19; previously trivial: RTF tracked insertions and deletions, gap 19; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -36,6 +36,9 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323) and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | | 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done), sections (RTF import done, #1378; RTF export and ODT left), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-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 Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-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; open PR #885 adds .xls, legacy encodings, Filter and Sort | 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) | @@ -166,5 +169,6 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | #491: vertical sections landed; vertical text removed from the East Asian gap | | 2026-10-10 | major | First version, from the full re-measure: 29 ranked gaps with evidence, impact and hours | | 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink landed; ink gap narrowed | +| 2026-10-12 | trivial | #21: .xls workbooks and GBK/Big5/Shift_JIS/UTF-16 text lists as recipients; Filter and Sort in Edit Recipient List (#778) | | 2026-10-12 | trivial | #811: large-table scrolling fixed; performance gap (#20) narrowed | | 2026-10-12 | trivial | #13: radar, stock and other undrawn chart types show a placeholder instead of blank space (#998) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 922698858..9b14de544 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Recipient List dialog gains Filter and Sort, #778; previously trivial: Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -158,4 +158,5 @@ buttons, the remaining labels start with zero and use the same number of digits labels unambiguous. Those extra-number assignments have not been compared with Word. | 2026-10-14 | trivial | Draw: Ink to Shape recognises single-stroke arrows | | 2026-10-11 | trivial | Draw: Lasso, Add Pen gallery, Ink to Shape and Ink Replay (#395) | +| 2026-10-12 | trivial | Recipient List dialog: Filter and Sort section (conditions joined by And/Or, three sort keys, merge order column), .xls in the picker (#778) | | 2026-10-13 | trivial | Double-clicking the top or bottom margin of a page with no header or footer creates the one that page shows and edits it, as in Word; one left empty is taken away again on leaving; `insert.editHeader`/`insert.editFooter {"page"}` for agents (#960); making one is an undo step (`insert.createHeader`/`insert.createFooter`), refused read-only, linked from the first section |