mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
Select Recipients › Use an Existing List… now reads .xlsx, .xlsm and .ods workbooks besides CSV. A new read-only SpreadsheetML / ODF spreadsheet reader in wordcraft-formats lists the sheets and turns one sheet into rows of display strings: shared, inline and rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 from the cell's number format, sparse cells, ODS value types, repeated rows/columns and covered cells. Zip entries, rows, columns, cells, string lengths and repeat counts are capped, and nothing recurses. mailings.recipients takes "data" (base64) and "sheet" and "headers"; a workbook with several sheets and no sheet answers with the sheet names, and the app shows a Select Table dialog with "First row of data contains column headers". The desktop picker, dropped files and the web inbox route spreadsheets to the recipient list. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
8a2a8bb60d
commit
589e82e050
Generated
+1
@@ -5238,6 +5238,7 @@ dependencies = [
|
||||
"wordcraft-layout",
|
||||
"wordcraft-render",
|
||||
"wordcraft-zotero",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Mail-merge recipients from Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets, with Select Table to pick the sheet (#334) |
|
||||
| 2026-10-11 | Insert › Icons (#309): a library of 61 original icons drawn in code, a searchable picker and Icon Color |
|
||||
| 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field |
|
||||
| 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip |
|
||||
@@ -157,6 +158,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) |
|
||||
| 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) |
|
||||
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) |
|
||||
| 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) |
|
||||
|
||||
@@ -10,7 +10,7 @@ use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt"];
|
||||
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"];
|
||||
const CANVAS_ID: &str = "wordcraft_canvas";
|
||||
const LOADING_ID: &str = "wordcraft_loading";
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog
|
||||
if purpose == "picture" {
|
||||
d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"])
|
||||
} else if purpose == "recipients" {
|
||||
d.add_filter("Recipient lists", &["csv", "tsv", "txt"]).add_filter("All files", &["*"])
|
||||
d.add_filter("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]).add_filter("All files", &["*"])
|
||||
} else {
|
||||
d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"])
|
||||
.add_filter("Word document", &["docx"])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Mailings tab: mail merge (recipients from CSV/JSON, merge fields, preview, finish), envelopes
|
||||
//! and labels.
|
||||
//! Mailings tab: mail merge (recipients from CSV/JSON or an Excel/OpenDocument spreadsheet,
|
||||
//! merge fields, preview, finish), envelopes and labels.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"kind": "letters|emails|envelopes|labels|directory|normal"}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("mailings.recipients", "Select Recipients", "Mailings › Start Mail Merge", recipients)
|
||||
.params(r#"{"csv"?: string, "path"?: string, "rows"?: [{field: value}] | [[value]], "fields"?: [string] (column order; required with array rows)}"#)
|
||||
.params(r#"{"csv"?: string, "path"?: string (.csv/.tsv/.txt, or a .xlsx/.xlsm/.ods workbook), "data"?: base64 (a file's bytes, as for path), "sheet"?: name | number (1 = first; a workbook with several sheets and no sheet answers {"sheets": [...], "chooseSheet": true} and loads nothing), "headers"?: bool (default true: the first non-blank row names the fields; false: fields are Column 1…N), "rows"?: [{field: value}] | [[value]], "fields"?: [string] (column order; required with array rows)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("mailings.editRecipients", "Edit Recipient List", "Mailings › Start Mail Merge", |s, v| {
|
||||
if v.get("rows").and_then(Value::as_array).is_some() {
|
||||
@@ -254,37 +254,99 @@ fn table_from_rows(fields: Option<&Vec<Value>>, arr: &[Value]) -> Result<(Vec<St
|
||||
}
|
||||
|
||||
fn recipients(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let mut sheet = Value::Null;
|
||||
let (headers, rows) = if let Some(arr) = v.get("rows").and_then(Value::as_array) {
|
||||
table_from_rows(v.get("fields").and_then(Value::as_array), arr)?
|
||||
} else {
|
||||
let text = if let Some(c) = p::str(v, "csv") {
|
||||
c.to_string()
|
||||
} else if let Some(path) = p::str(v, "path") {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let b = std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
|
||||
crate::io::decode_text(&b)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
return Err(CmdError::Failed("paths aren't available on the web".into()));
|
||||
}
|
||||
let all = if let Some(c) = p::str(v, "csv") {
|
||||
parse_csv(c)
|
||||
} else {
|
||||
return Err(CmdError::Params("`csv`, `path` or `rows` required".into()));
|
||||
let (bytes, ext) = if let Some(d) = p::str(v, "data") {
|
||||
let b = super::insert::base64_decode(d.trim()).ok_or_else(|| CmdError::Params("`data` isn't valid base64".into()))?;
|
||||
(b, p::str(v, "path").and_then(extension))
|
||||
} else if let Some(path) = p::str(v, "path") {
|
||||
(read_file(path)?, extension(path))
|
||||
} else {
|
||||
return Err(CmdError::Params("`csv`, `path`, `data` or `rows` required".into()));
|
||||
};
|
||||
let spreadsheet =
|
||||
ext.as_deref().is_some_and(wordcraft_formats::sheet::is_extension) || wordcraft_formats::sheet::detect(&bytes).is_some();
|
||||
if spreadsheet {
|
||||
use wordcraft_formats::sheet::{Pick, read_rows, sheet_names};
|
||||
let pick = match v.get("sheet") {
|
||||
Some(Value::String(n)) => Pick::Name(n.as_str()),
|
||||
Some(x) if x.as_u64().is_some_and(|n| n >= 1) => {
|
||||
Pick::Index(usize::try_from(x.as_u64().unwrap_or(1).saturating_sub(1)).unwrap_or(usize::MAX))
|
||||
}
|
||||
Some(Value::Null) | None => {
|
||||
let names = sheet_names(&bytes).map_err(CmdError::Failed)?;
|
||||
if names.len() > 1 {
|
||||
return Ok(json!({"sheets": names, "chooseSheet": true}));
|
||||
}
|
||||
Pick::Index(0)
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`sheet` must be a sheet name or number".into())),
|
||||
};
|
||||
let (names, rows) = read_rows(&bytes, pick).map_err(CmdError::Failed)?;
|
||||
let chosen = match pick {
|
||||
Pick::Index(i) => names.get(i).cloned(),
|
||||
Pick::Name(n) => {
|
||||
names.iter().find(|s| s.as_str() == n).or_else(|| names.iter().find(|s| s.eq_ignore_ascii_case(n.trim()))).cloned()
|
||||
}
|
||||
};
|
||||
sheet = json!(chosen);
|
||||
rows
|
||||
} else {
|
||||
parse_csv(&crate::io::decode_text(&bytes))
|
||||
}
|
||||
};
|
||||
let mut all = parse_csv(&text);
|
||||
if all.is_empty() {
|
||||
return Err(CmdError::Failed("the recipient list is empty".into()));
|
||||
}
|
||||
let headers: Vec<String> = all.remove(0).into_iter().map(|h| h.trim().to_string()).collect();
|
||||
(headers, all)
|
||||
header_split(all, p::bool(v, "headers").unwrap_or(true))?
|
||||
};
|
||||
s.merge.headers = headers;
|
||||
s.merge.rows = rows;
|
||||
s.merge.record = 0;
|
||||
refresh(s);
|
||||
Ok(json!({"fields": s.merge.headers, "records": s.merge.rows.len()}))
|
||||
let mut r = json!({"fields": s.merge.headers, "records": s.merge.rows.len()});
|
||||
if !sheet.is_null() {
|
||||
r["sheet"] = sheet;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A file's extension, lower case.
|
||||
fn extension(path: &str) -> Option<String> {
|
||||
std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &str) -> Result<Vec<u8>, CmdError> {
|
||||
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>, CmdError> {
|
||||
Err(CmdError::Failed("paths aren't available on the web; pass `data`".into()))
|
||||
}
|
||||
|
||||
/// Field names and records from a table's rows: with `headers`, the first row names the fields
|
||||
/// (blank names become `Column N`, repeats are numbered); otherwise every row is a record and
|
||||
/// the fields are `Column 1`…`Column N`. Records are padded or cut to the fields, and capped.
|
||||
fn header_split(mut all: Vec<Vec<String>>, headers: bool) -> Result<(Vec<String>, Vec<Vec<String>>), CmdError> {
|
||||
if all.is_empty() {
|
||||
return Err(CmdError::Failed("the recipient list is empty".into()));
|
||||
}
|
||||
let fields = if headers {
|
||||
let first = all.remove(0);
|
||||
wordcraft_formats::sheet::field_names(&first[..first.len().min(MAX_FIELDS)])
|
||||
} else {
|
||||
let width = all.iter().map(Vec::len).max().unwrap_or(0).min(MAX_FIELDS);
|
||||
(1..=width).map(|i| format!("Column {i}")).collect()
|
||||
};
|
||||
all.truncate(MAX_RECORDS);
|
||||
for r in &mut all {
|
||||
r.resize(fields.len(), String::new());
|
||||
}
|
||||
Ok((fields, all))
|
||||
}
|
||||
|
||||
fn step(s: &mut Session, d: i64) -> CmdResult {
|
||||
@@ -796,6 +858,41 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A typed list (#240) keeps its column order, and rows may be arrays in that order.
|
||||
#[test]
|
||||
fn recipients_from_a_workbook_ask_for_the_sheet_when_there_are_several() {
|
||||
use std::io::Write;
|
||||
let cell = |t: &str| format!(r#"<table:table-cell office:value-type="string"><text:p>{t}</text:p></table:table-cell>"#);
|
||||
let row = |cells: &[&str]| format!("<table:table-row>{}</table:table-row>", cells.iter().map(|c| cell(c)).collect::<String>());
|
||||
let content = format!(
|
||||
r#"<office:document-content xmlns:office="o" xmlns:table="t" xmlns:text="x"><office:body><office:spreadsheet><table:table table:name="Notes">{}</table:table><table:table table:name="Guests">{}{}{}</table:table></office:spreadsheet></office:body></office:document-content>"#,
|
||||
row(&["n"]),
|
||||
row(&["Name", "", "Name"]),
|
||||
row(&["Ada", "London", "A"]),
|
||||
row(&["Alan", "Wilmslow", "B"]),
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, body) in [("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", content.as_str())] {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
let data = super::super::insert::base64_encode(&z.finish().unwrap().into_inner());
|
||||
let mut s = Session::new(Document::new());
|
||||
// Two sheets and none chosen: the names come back and nothing loads.
|
||||
let r = s.run("mailings.recipients", &json!({"data": data})).unwrap();
|
||||
assert_eq!(r, json!({"sheets": ["Notes", "Guests"], "chooseSheet": true}));
|
||||
assert!(s.merge.headers.is_empty());
|
||||
let r = s.run("mailings.recipients", &json!({"data": data, "sheet": "guests"})).unwrap();
|
||||
assert_eq!(r, json!({"fields": ["Name", "Column 2", "Name 2"], "records": 2, "sheet": "Guests"}));
|
||||
assert_eq!(s.merge.rows[1], ["Alan", "Wilmslow", "B"]);
|
||||
// Without a header row every row is a record.
|
||||
let r = s.run("mailings.recipients", &json!({"data": data, "sheet": 2, "headers": false})).unwrap();
|
||||
assert_eq!(r["fields"], json!(["Column 1", "Column 2", "Column 3"]));
|
||||
assert_eq!(r["records"], 3);
|
||||
assert!(s.run("mailings.recipients", &json!({"data": data, "sheet": 3})).is_err());
|
||||
assert!(s.run("mailings.recipients", &json!({"data": data, "sheet": 0})).is_err());
|
||||
assert!(s.run("mailings.recipients", &json!({"data": "%%%"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipients_from_fields_and_array_rows() {
|
||||
let mut s = Session::new(Document::new());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! WordCraft import/export for the "other" text formats: plain text, Markdown (CommonMark subset
|
||||
//! plus GFM tables and strikethrough), HTML, RTF, OpenDocument Text and LaTeX.
|
||||
//! plus GFM tables and strikethrough), HTML, RTF, OpenDocument Text and LaTeX — and, read-only,
|
||||
//! spreadsheet cells for mail-merge recipient lists ([`sheet`]: .xlsx/.xlsm/.ods).
|
||||
//!
|
||||
//! Every format maps to a small flow model ([`model::Flow`]: paragraphs with a kind, list
|
||||
//! membership, alignment and formatted spans; tables of cells), which [`model::to_doc`] and
|
||||
@@ -14,6 +15,7 @@ pub mod markdown;
|
||||
pub mod model;
|
||||
pub mod odt;
|
||||
pub mod rtf;
|
||||
pub mod sheet;
|
||||
pub mod txt;
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ js-sys = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
zip = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Table
|
||||
//! Properties, Enclose Characters, Command search, Icons, Paste Special, About, Save Changes, the password to open a
|
||||
//! document and Encrypt with Password, and the mail-merge Recipient List, Insert Merge Field, Find
|
||||
//! Recipient, merge rules, Match Fields and Check for Errors (Customize Keyboard is in
|
||||
//! Recipient, merge rules, Match Fields, Check for Errors and Select Table (a workbook's sheet) (Customize Keyboard is in
|
||||
//! `dialogs_keyboard`, Customize Ribbon in `dialogs_ribbon`). Every dialog ends by running a
|
||||
//! command (or shows one's result), so agents get the same result without the dialog.
|
||||
|
||||
@@ -201,6 +201,17 @@ pub enum Dialog {
|
||||
rows: Vec<Vec<String>>,
|
||||
message: String,
|
||||
},
|
||||
/// Mailings › Select Recipients › Use an Existing List… picked a workbook with several sheets
|
||||
/// (#334): which sheet holds the recipients, and whether its first row names the fields.
|
||||
/// OK runs `mailings.recipients` with `params` (the file) plus `sheet` and `headers`.
|
||||
SelectTable {
|
||||
sheets: Vec<String>,
|
||||
selected: usize,
|
||||
headers: bool,
|
||||
message: String,
|
||||
#[serde(skip)]
|
||||
params: Value,
|
||||
},
|
||||
/// Mailings › Insert Merge Field: one of the recipient list's fields (or a typed name).
|
||||
InsertMergeField {
|
||||
fields: Vec<String>,
|
||||
@@ -683,6 +694,7 @@ impl Dialog {
|
||||
Dialog::About { .. } => "about",
|
||||
Dialog::SaveChanges { .. } => "saveChanges",
|
||||
Dialog::RecipientList { .. } => "recipientList",
|
||||
Dialog::SelectTable { .. } => "selectTable",
|
||||
Dialog::InsertMergeField { .. } => "insertMergeField",
|
||||
Dialog::FindRecipient { .. } => "findRecipient",
|
||||
Dialog::FieldCode { edit: false, .. } => "fieldCode",
|
||||
@@ -1043,6 +1055,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
Dialog::About { .. } => "About WordCraft",
|
||||
Dialog::SaveChanges { .. } => "WordCraft",
|
||||
Dialog::RecipientList { .. } => "Recipient List",
|
||||
Dialog::SelectTable { .. } => "Select Table",
|
||||
Dialog::InsertMergeField { .. } => "Insert Merge Field",
|
||||
Dialog::FindRecipient { .. } => "Find Recipient",
|
||||
Dialog::FieldCode { edit: false, .. } => "Field",
|
||||
@@ -2002,6 +2015,41 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
cancel
|
||||
}
|
||||
Dialog::SelectTable { sheets, selected, headers, message, params } => {
|
||||
ui.label(tl!("Choose the sheet that holds the recipients."));
|
||||
ui.add_space(4.0);
|
||||
// A double-click picks the sheet as OK does.
|
||||
let mut picked = false;
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(300.0);
|
||||
egui::ScrollArea::vertical().id_salt("select_table").max_height(220.0).show(ui, |ui| {
|
||||
for (i, name) in sheets.iter().enumerate() {
|
||||
let r = ui.selectable_label(*selected == i, name.as_str());
|
||||
if r.clicked() || r.double_clicked() {
|
||||
*selected = i;
|
||||
}
|
||||
picked |= r.double_clicked();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.checkbox(headers, tl!("First row of data contains column headers"));
|
||||
if !message.is_empty() {
|
||||
ui.label(egui::RichText::new(message.as_str()).color(Tokens::get(ui.ctx()).red));
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok || picked {
|
||||
let mut p = params.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.insert("sheet".into(), json!(*selected + 1));
|
||||
o.insert("headers".into(), json!(*headers));
|
||||
}
|
||||
match app.load_recipients(p) {
|
||||
Ok(_) => return true,
|
||||
Err(e) => *message = e,
|
||||
}
|
||||
}
|
||||
cancel
|
||||
}
|
||||
Dialog::FindRecipient { text, message } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Find:"));
|
||||
|
||||
@@ -33,7 +33,7 @@ pub(crate) enum AfterPick {
|
||||
SaveAs(AfterSave),
|
||||
/// Save a copy there in this format (`pdf`, `docx`, `png`…).
|
||||
Export { ext: String },
|
||||
/// Load the picked CSV/TSV/text file as the mail-merge recipients (#240).
|
||||
/// Load the picked CSV/TSV/text file or spreadsheet as the mail-merge recipients (#240, #334).
|
||||
Recipients,
|
||||
}
|
||||
|
||||
@@ -339,4 +339,47 @@ mod tests {
|
||||
assert_eq!(a.session.merge.rows.len(), 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A workbook with several sheets (#334) shows Select Table; its answer loads that sheet.
|
||||
#[test]
|
||||
fn a_workbook_with_several_sheets_asks_which_one() {
|
||||
use std::io::Write;
|
||||
let dir = scratch("workbook");
|
||||
let book = dir.join("people.ods");
|
||||
let table = |name: &str, cells: &[&str]| {
|
||||
let cells: String = cells
|
||||
.iter()
|
||||
.map(|c| {
|
||||
format!(
|
||||
r#"<table:table-row><table:table-cell office:value-type="string"><text:p>{c}</text:p></table:table-cell></table:table-row>"#
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(r#"<table:table table:name="{name}">{cells}</table:table>"#)
|
||||
};
|
||||
let content = format!(
|
||||
r#"<office:document-content xmlns:office="o" xmlns:table="t" xmlns:text="x"><office:body><office:spreadsheet>{}{}</office:spreadsheet></office:body></office:document-content>"#,
|
||||
table("Notes", &["n"]),
|
||||
table("Guests", &["Name", "Ada", "Alan"])
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, body) in [("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", content.as_str())] {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
std::fs::write(&book, z.finish().unwrap().into_inner()).unwrap();
|
||||
let (mut a, shown) = async_app();
|
||||
a.run("ui.openRecipientList", json!({})).unwrap();
|
||||
answer(&shown, Some(&book.to_string_lossy()));
|
||||
a.poll_file_dialog().unwrap().unwrap();
|
||||
let Some(crate::dialogs::Dialog::SelectTable { sheets, params, headers: true, .. }) = &a.dialog else { panic!("Select Table expected") };
|
||||
assert_eq!(sheets, &["Notes", "Guests"]);
|
||||
assert!(a.session.merge.headers.is_empty(), "nothing loads before a sheet is chosen");
|
||||
let mut p = params.clone();
|
||||
p["sheet"] = json!(2);
|
||||
a.load_recipients(p).unwrap();
|
||||
assert_eq!(a.session.merge.headers, vec!["Name"]);
|
||||
assert_eq!(a.session.merge.rows.len(), 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1116,6 +1116,8 @@
|
||||
Type at least one recipient. Escribe al menos un destinatario.
|
||||
Type the recipients, or load them from a file. Escribe los destinatarios o cárgalos desde un archivo.
|
||||
Use an Existing List… Usar una lista existente…
|
||||
Choose the sheet that holds the recipients. Elija la hoja que contiene los destinatarios.
|
||||
First row of data contains column headers La primera fila de datos contiene encabezados de columna
|
||||
Interface theme: Tema de la interfaz:
|
||||
Interface\nTheme Tema de la\ninterfaz
|
||||
Interface Theme Tema de la interfaz
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1346,6 +1346,8 @@
|
||||
Reset Character Formatting Lähtesta märgivorming
|
||||
Reset Paragraph Formatting Lähtesta lõiguvorming
|
||||
Trademark Sign Kaubamärgi märk
|
||||
Choose the sheet that holds the recipients. Valige leht, millel on adressaadid.
|
||||
First row of data contains column headers Andmete esimene rida sisaldab veerupäiseid
|
||||
Formatted Vormindatud
|
||||
Interface size: Kasutajaliidese suurus:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 421 and column 13.
|
@@ -1100,6 +1100,8 @@
|
||||
Type at least one recipient. 宛先を1件以上入力してください。
|
||||
Type the recipients, or load them from a file. 宛先を入力するか、ファイルから読み込みます。
|
||||
Use an Existing List… 既存のリストを使用…
|
||||
Choose the sheet that holds the recipients. 宛先が入っているシートを選んでください。
|
||||
First row of data contains column headers データの先頭行を列見出しとして使う
|
||||
Interface theme: 表示テーマ:
|
||||
Interface\nTheme 表示テーマ
|
||||
Interface Theme 表示テーマ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 504 and column 13.
|
@@ -1112,6 +1112,8 @@
|
||||
Type at least one recipient. Digite pelo menos um destinatário.
|
||||
Type the recipients, or load them from a file. Digite os destinatários ou carregue-os de um arquivo.
|
||||
Use an Existing List… Usar uma lista existente…
|
||||
Choose the sheet that holds the recipients. Escolha a planilha que contém os destinatários.
|
||||
First row of data contains column headers A primeira linha de dados contém cabeçalhos de coluna
|
||||
Interface theme: Tema da interface:
|
||||
Interface\nTheme Tema da\ninterface
|
||||
Interface Theme Tema da interface
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1790,6 +1790,8 @@ preview Title Пример
|
||||
Left Only Только слева
|
||||
Right Only Только справа
|
||||
Largest Only Только по большей стороне
|
||||
Choose the sheet that holds the recipients. Выберите лист с получателями.
|
||||
First row of data contains column headers Первая строка данных содержит заголовки столбцов
|
||||
Communication Связь
|
||||
Office Офис
|
||||
People Люди
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -1172,6 +1172,8 @@
|
||||
Type at least one recipient. Unesite bar jednog primaoca.
|
||||
Type the recipients, or load them from a file. Unesite primaoce ili ih učitajte iz fajla.
|
||||
Use an Existing List… Koristi postojeću listu…
|
||||
Choose the sheet that holds the recipients. Izaberite list na kome su primaoci.
|
||||
First row of data contains column headers Prvi red podataka sadrži zaglavlja kolona
|
||||
A file dialog is already open. Dijalog za fajlove je već otvoren.
|
||||
Height: Visina:
|
||||
Width: Širina:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1176,6 +1176,8 @@
|
||||
Type at least one recipient. Унесите бар једног примаоца.
|
||||
Type the recipients, or load them from a file. Унесите примаоце или их учитајте из фајла.
|
||||
Use an Existing List… Користи постојећу листу…
|
||||
Choose the sheet that holds the recipients. Изаберите лист на коме су примаоци.
|
||||
First row of data contains column headers Први ред података садржи заглавља колона
|
||||
A file dialog is already open. Дијалог за фајлове је већ отворен.
|
||||
Height: Висина:
|
||||
Width: Ширина:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -1408,6 +1408,8 @@
|
||||
Left Only Yalnızca Sol
|
||||
Right Only Yalnızca Sağ
|
||||
Largest Only Yalnızca En Geniş
|
||||
Choose the sheet that holds the recipients. Alıcıları içeren sayfayı seçin.
|
||||
First row of data contains column headers Verinin ilk satırı sütun başlıklarını içerir
|
||||
Arrows Oklar
|
||||
Communication İletişim
|
||||
Office Ofis
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 430 and column 13.
|
@@ -1112,6 +1112,8 @@
|
||||
Type at least one recipient. Введіть принаймні одного одержувача.
|
||||
Type the recipients, or load them from a file. Введіть одержувачів або завантажте їх із файлу.
|
||||
Use an Existing List… Використати наявний список…
|
||||
Choose the sheet that holds the recipients. Виберіть аркуш з одержувачами.
|
||||
First row of data contains column headers Перший рядок даних містить заголовки стовпців
|
||||
Interface theme: Тема інтерфейсу:
|
||||
Interface\nTheme Тема\nінтерфейсу
|
||||
Interface Theme Тема інтерфейсу
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -1112,6 +1112,8 @@
|
||||
Type at least one recipient. 请至少键入一个收件人。
|
||||
Type the recipients, or load them from a file. 键入收件人,或从文件加载。
|
||||
Use an Existing List… 使用现有列表…
|
||||
Choose the sheet that holds the recipients. 选择包含收件人的工作表。
|
||||
First row of data contains column headers 数据首行包含列标题
|
||||
Interface theme: 界面主题:
|
||||
Interface\nTheme 界面主题
|
||||
Interface Theme 界面主题
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -1101,6 +1101,8 @@
|
||||
Type at least one recipient. 請至少輸入一位收件者。
|
||||
Type the recipients, or load them from a file. 輸入收件者,或從檔案載入。
|
||||
Use an Existing List… 使用現有清單…
|
||||
Choose the sheet that holds the recipients. 選擇包含收件者的工作表。
|
||||
First row of data contains column headers 資料第一列包含欄標題
|
||||
Interface theme: 介面主題:
|
||||
Interface\nTheme 介面主題
|
||||
Interface Theme 介面主題
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 505 and column 13.
|
@@ -765,10 +765,20 @@ impl WordApp {
|
||||
let _ = self.ask_file(file_dialogs::FileDialogRequest::Open { purpose: "recipients".into() }, file_dialogs::AfterPick::Recipients);
|
||||
}
|
||||
|
||||
/// Load mail-merge recipients and say how many there are.
|
||||
/// Load mail-merge recipients and say how many there are. A workbook with several sheets
|
||||
/// shows Select Table to choose one first (#334).
|
||||
pub(crate) fn load_recipients(&mut self, params: Value) -> Result<Value, String> {
|
||||
let r = self.run("mailings.recipients", params);
|
||||
if let Ok(v) = &r {
|
||||
let r = self.run("mailings.recipients", params.clone());
|
||||
if let Ok(v) = &r
|
||||
&& v.get("chooseSheet").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
let sheets = v
|
||||
.get("sheets")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
self.dialog = Some(dialogs::Dialog::SelectTable { sheets, selected: 0, headers: true, message: String::new(), params });
|
||||
} else if let Ok(v) = &r {
|
||||
let records = v.get("records").and_then(Value::as_u64).unwrap_or(0).to_string();
|
||||
self.status(i18n::fmt(tl!("Recipients: {count}"), &[("count", &records)]));
|
||||
}
|
||||
@@ -855,7 +865,15 @@ impl WordApp {
|
||||
let path = f.path().to_string_lossy().to_string();
|
||||
let lp = path.to_ascii_lowercase();
|
||||
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lp.ends_with(e));
|
||||
let _ = if img { self.run("insert.picture", json!({"path": path})) } else { self.run("file.open", json!({"path": path})) };
|
||||
let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lp.ends_with(e));
|
||||
let _ = if img {
|
||||
self.run("insert.picture", json!({"path": path}))
|
||||
} else if sheet {
|
||||
// A spreadsheet isn't a document: it's a recipient list (#334).
|
||||
self.load_recipients(json!({"path": path}))
|
||||
} else {
|
||||
self.run("file.open", json!({"path": path}))
|
||||
};
|
||||
}
|
||||
}
|
||||
self.report_dirty();
|
||||
@@ -983,7 +1001,7 @@ impl WordApp {
|
||||
}
|
||||
|
||||
/// Files that arrived asynchronously (web picker, drops): documents open, pictures insert,
|
||||
/// recipient lists (CSV/TSV, or a text file asked for as one) load as recipients.
|
||||
/// recipient lists (CSV/TSV, spreadsheets, or a text file asked for as one) load as recipients.
|
||||
fn drain_inbox(&mut self) {
|
||||
let Some(inbox) = self.services.inbox.clone() else { return };
|
||||
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
@@ -992,7 +1010,11 @@ impl WordApp {
|
||||
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lower.ends_with(e));
|
||||
let list =
|
||||
lower.ends_with(".csv") || lower.ends_with(".tsv") || (lower.ends_with(".txt") && std::mem::take(&mut self.recipient_list_pending));
|
||||
let r = if list {
|
||||
let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lower.ends_with(e));
|
||||
let r = if sheet {
|
||||
self.recipient_list_pending = false;
|
||||
self.load_recipients(json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))
|
||||
} else if list {
|
||||
self.load_recipients(json!({"csv": wordcraft_engine::io::decode_text(&bytes)}))
|
||||
} else if img {
|
||||
self.insert_or_change_picture(json!({"data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))
|
||||
@@ -1134,7 +1156,7 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
|
||||
Some("SKIPIF") => Some("ruleSkipIf"),
|
||||
_ => None,
|
||||
},
|
||||
"mailings.recipients" if !["csv", "path", "rows"].into_iter().any(has) => Some("recipientList"),
|
||||
"mailings.recipients" if !["csv", "path", "data", "rows"].into_iter().any(has) => Some("recipientList"),
|
||||
"mailings.editRecipients" if !has("rows") => Some("recipientList"),
|
||||
"mailings.insertField" if !has("field") => Some("insertMergeField"),
|
||||
"mailings.findRecipient" if !has("text") => Some("findRecipient"),
|
||||
|
||||
@@ -34,6 +34,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | ❌ | ❌ | 0% | 5–8 | — | Legacy |
|
||||
| `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value |
|
||||
| Encrypted/password-protected `.docx`/`.doc` | 1% | R/W | ❌ | ❌ | 0% | 6–10 | — | ECMA-376 Agile Encryption; `.doc` RC4/XOR detected and refused (`docbin` fib). Issue #55 |
|
||||
| `.xlsx`/`.xlsm`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell). Not shown: number formats other than dates (currency, percent, thousands separators) in `.xlsx`; no `.xls`, Access or Outlook sources |
|
||||
| `.md` Markdown | — | (no) | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word |
|
||||
| `.tex` LaTeX | — | — | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word (#11), incl. math |
|
||||
| `.png` page images | — | (Save as picture, Mac) | — | ✅ | — | — | `io_ext.rs` | |
|
||||
@@ -84,6 +85,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Spreadsheets (.xlsx/.xlsm/.ods) read as mail-merge recipient lists (#334) |
|
||||
| 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` |
|
||||
| 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) |
|
||||
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
|
||||
|
||||
+2
-1
@@ -31,7 +31,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
|
||||
| 18 | **Screen-reader access** to the document canvas untested | | AccessKit enabled; no tests | Blind and low-vision users; public-sector procurement | 10–15 | [ui-parity.md](ui-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions, sections, RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
|
||||
| 21 | **Mail merge data sources**: no Excel, Outlook/Contacts, email merge; CSV and typed lists only (#247) | | | Office administrators | 6–10 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) |
|
||||
| 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 24 | **Default shortcuts**: ~135 of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and the Quick Access Toolbar #379 landed) | | [ui-parity.md](ui-parity.md) | Power users | 4–7 | [ui-parity.md](ui-parity.md) |
|
||||
@@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | #21: Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets as recipient lists (#334); hours 6–10 → 4–8 |
|
||||
| 2026-10-11 | trivial | Insert › Icons landed (#309); removed from the objects gap |
|
||||
| 2026-10-11 | trivial | #24 narrowed: Customize Ribbon and Quick Access Toolbar customization landed (#379); the default shortcuts remain |
|
||||
| 2026-10-11 | trivial | #24 narrowed: Customize Keyboard landed (#368); Customize Ribbon and the Quick Access Toolbar remain |
|
||||
|
||||
@@ -67,7 +67,7 @@ full parity for the area.
|
||||
| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); source manager depth; bibliography sources not yet in DOCX (#169 open). |
|
||||
| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Missing: move tracking, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. |
|
||||
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. |
|
||||
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. |
|
||||
| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, Drawing Canvas, online pictures, screenshot. |
|
||||
| Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. |
|
||||
| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; lasso, ink to shape/math and replay missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). |
|
||||
@@ -249,6 +249,7 @@ can be used without committing Word output.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Mailings: Excel and OpenDocument spreadsheets as recipient lists (#334); score unchanged |
|
||||
| 2026-10-11 | trivial | Insert › Icons (#309): catalog 402/434 → 403/434 (92.9%); moved from missing to done under pictures and shapes |
|
||||
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 402/434 → 403/434 (92.9%) |
|
||||
| 2026-10-11 | trivial | Phonetic Guide (ruby) landed (#288): catalog +1 feature |
|
||||
|
||||
Reference in New Issue
Block a user