From ce411edc9196290bb1710453e2011fe037a9dbf1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:24:25 +0200 Subject: [PATCH] Checkboxes: ribbon button, Excel's value and Delete rules, XLSX cell controls (#196) insert.checkbox existed but had no ribbon button, replaced existing values with FALSE and was written to XLSX as an invalid number format. Now: Insert > Checkbox (Cell Controls); empty cells start FALSE, other values stay and display as General; toggling an emptied box checks it and only checkbox cells toggle; Delete unchecks first, then removes the boxes; XLSX reads and writes the checkbox as Excel's feature-property-bag cell control ([MS-XLSX] 2.1.22, 2.3.9, 2.4.99). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 29 +++++++++++- crates/engine/src/cmd/extra.rs | 28 ++++++++--- crates/engine/src/tests.rs | 52 +++++++++++++++++++++ crates/numfmt/src/lib.rs | 4 +- crates/ui-egui/src/grid.rs | 5 +- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/icons.rs | 5 ++ crates/ui-egui/src/ribbon.rs | 5 ++ crates/xlsx/src/feature_bags.rs | 83 +++++++++++++++++++++++++++++++++ crates/xlsx/src/lib.rs | 1 + crates/xlsx/src/read.rs | 10 +++- crates/xlsx/src/styles.rs | 24 +++++++--- crates/xlsx/src/write.rs | 5 ++ 18 files changed, 243 insertions(+), 19 deletions(-) create mode 100644 crates/xlsx/src/feature_bags.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..a6e1429 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Cell checkboxes: Insert › Checkbox on the ribbon, Excel's Delete and value rules, XLSX round trip as Excel cell controls (#196) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb54fe0..35af7c0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1050,6 +1050,18 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: & fn clear(s: &mut Session, p: &Json, what: &str) -> Result { let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; + // Delete on checkboxes works in two passes (as in Excel): checked boxes are unchecked first, + // and only when every box in the selection is unchecked are the boxes removed. + let uncheck_only = what == "contents" && { + let d = s.doc()?; + d.wb.sheet(sheet).is_some_and(|sh| { + ranges.iter().any(|r| { + r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| { + sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c)) + }) + }) + }) + }; edit(s, |cx| { for r in &ranges { let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) { @@ -1061,6 +1073,21 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }; let cells: Vec<(CellRef, Cell)> = cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default(); + let mut cells = cells; + if what == "contents" { + for (_, cell) in cells.iter_mut() { + let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox"; + cell.formula = None; + if checkbox && uncheck_only { + cell.value = Value::Bool(false); + } else { + cell.value = Value::Empty; + if checkbox { + cell.style = cx.wb.styles.derive(cell.style, |st| st.num_fmt = gridcraft_model::NumFmt::new("General")); + } + } + } + } let sh = cx.sheet_mut(sheet)?; for (c, mut cell) in cells { match what { @@ -1068,8 +1095,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { sh.remove_cell(c); } "contents" => { - cell.value = Value::Empty; - cell.formula = None; sh.set_cell(c, cell); } "formats" => { diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index b6a362b..9819319 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -181,7 +181,7 @@ pub fn specs() -> Vec { "Checkbox", ["Insert", "Cell Controls"], None, - "{range?} (cells hold TRUE/FALSE and draw as checkboxes; Space toggles)", + "{range?} (TRUE/FALSE and empty cells draw as checkboxes, empty ones become FALSE, other values show as they are; click or Space toggles; Delete unchecks, then removes; Clear Formats removes and keeps the values)", has_doc, checkbox ), @@ -828,6 +828,11 @@ fn delete_script(s: &mut Session, p: &Json) -> Result { /// Number format that marks checkbox cells (TRUE/FALSE values drawn as boxes). pub const CHECKBOX_FORMAT: &str = "\"☐\";\"☐\";\"☐\";[=1]\"☑\""; +/// Whether `c` has checkbox formatting. +pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool { + wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" +} + fn checkbox(s: &mut Session, p: &Json) -> Result { let ranges = target_ranges(s, p)?; let si = target_sheet(s, p)?; @@ -843,9 +848,14 @@ fn checkbox(s: &mut Session, p: &Json) -> Result { s.align.h = HAlign::Center; }); let sh = cx.sheet_mut(si)?; - let v = sh.value(c); - let value = if matches!(v, Value::Bool(_)) { v } else { Value::Bool(false) }; - sh.set_cell(c, Cell { value, formula: None, style: new }); + // Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty + // cell starts unchecked. + let mut cell = sh.cell(c).cloned().unwrap_or_default(); + if cell.value.is_empty() && cell.formula.is_none() { + cell.value = Value::Bool(false); + } + cell.style = new; + sh.set_cell(c, cell); cx.touch(si, c); } } @@ -862,12 +872,18 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { }; edit(s, |cx| { for c in cells { + let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c)); let sh = cx.sheet_mut(si)?; if let Some(cell) = sh.cells.get_mut(c) - && let Value::Bool(b) = cell.value && cell.formula.is_none() { - cell.value = Value::Bool(!b); + // An empty checkbox cell counts as FALSE. + let next = match cell.value { + Value::Bool(b) if checkbox => Value::Bool(!b), + Value::Empty if checkbox => Value::Bool(true), + _ => continue, + }; + cell.value = next; cx.changed.push((si, c)); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d5..3b14998 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1036,6 +1036,58 @@ fn every_command_survives_empty_params() { } } +fn fmt_of(s: &Session, a: &str) -> String { + let wb = &s.doc().unwrap().wb; + let sh = wb.active().unwrap(); + wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).num_fmt.as_str().to_string() +} + +#[test] +fn checkboxes_keep_values_toggle_and_delete_in_two_passes() { + // Issue #196. + let mut s = s(); + s.execute("cell.set", json!({"cell": "B1", "input": "hi"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap(); + s.execute("insert.checkbox", json!({"range": "A1:B2"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Bool(false), "an empty cell starts unchecked"); + assert_eq!(v(&s, "B1"), Value::from("hi"), "other values stay"); + s.execute("cell.toggleCheckbox", json!({"cell": "A1"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Bool(true)); + // Space over a selection toggles only checkbox cells; an emptied checkbox counts as FALSE. + s.execute("cell.set", json!({"cell": "A2", "input": ""})).unwrap(); + s.execute("selection.set", json!({"range": "A2:C2"})).unwrap(); + s.execute("cell.toggleCheckbox", json!({})).unwrap(); + assert_eq!(v(&s, "A2"), Value::Bool(true)); + s.execute("selection.set", json!({"range": "C1"})).unwrap(); + s.execute("cell.toggleCheckbox", json!({})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Bool(true), "a plain TRUE is not a checkbox"); + // Delete: checked boxes are unchecked first, then the boxes go. + s.execute("edit.clearContents", json!({"range": "A1:A2"})).unwrap(); + assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Bool(false), Value::Bool(false))); + assert_eq!(fmt_of(&s, "A1"), "checkbox"); + s.execute("edit.clearContents", json!({"range": "A1:A2"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Empty); + assert_ne!(fmt_of(&s, "A1"), "checkbox"); + // Clear Formats removes the box and keeps the value (B2 was checked by the selection toggle). + s.execute("edit.clearFormats", json!({"range": "B2"})).unwrap(); + assert_eq!(v(&s, "B2"), Value::Bool(true)); + assert_ne!(fmt_of(&s, "B2"), "checkbox"); +} + +#[test] +fn checkboxes_survive_xlsx_as_cell_controls() { + let mut s = s(); + s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap(); + s.execute("cell.toggleCheckbox", json!({"cell": "A1"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "0.5"})).unwrap(); + reopen(&mut s); + assert_eq!(fmt_of(&s, "A1"), "checkbox"); + assert_eq!(fmt_of(&s, "A2"), "checkbox"); + assert_eq!(fmt_of(&s, "B1"), "General"); + assert_eq!(v(&s, "A1"), Value::Bool(true)); + assert_eq!(v(&s, "A2"), Value::Bool(false)); +} + fn reopen(s: &mut Session) { let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); let b64 = r["base64"].as_str().unwrap().to_string(); diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index dcc53d1..d471f3b 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -102,7 +102,9 @@ impl NumberFormat { /// Parses a format code. Never fails: unknown or malformed parts degrade to literal text. pub fn parse(code: &str) -> NumberFormat { let trimmed = code.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") { + // `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other + // value displays as General. + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" { return NumberFormat { inner: Arc::new(Inner { code: code.to_string(), general: true, sections: Vec::new(), num_sections: 0, text_section: None }), }; diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..f977d13 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -294,7 +294,8 @@ fn display_text( return (String::new(), None, false, None); } let code = st.num_fmt.as_str(); - if code == "checkbox" { + // A checkbox cell draws TRUE/FALSE (and empty) as a box; other values show as General. + if code == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { return (String::new(), None, false, None); } if code == "General" @@ -582,7 +583,7 @@ fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, paint_cf_icon(p, r, set, *idx); text_left += 15.0 * z; } - if lk.style.num_fmt.as_str() == "checkbox" { + if lk.style.num_fmt.as_str() == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { let s = (11.0 * z * 1.2).clamp(8.0, 24.0); let b = Rect::from_center_size(rect.center(), vec2(s, s)); let on = v == Value::Bool(true); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b212..aa76f9f 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -257,6 +257,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Link", "リンク"), ("Symbol", "記号"), ("Sparklines", "スパークライン"), + ("Checkbox", "チェックボックス"), + ("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"), ("Header & Footer", "ヘッダーとフッター"), ("Draw with ink", "インクで描画"), ("Eraser", "消しゴム"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3c..1dddbfc 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Link" => "링크", "Symbol" => "기호", "Sparklines" => "스파크라인", + "Checkbox" => "확인란", + "Insert checkboxes in the selected cells" => "선택한 셀에 확인란 삽입", "Header & Footer" => "머리글 및 바닥글", "Draw with ink" => "잉크로 그리기", "Eraser" => "지우개", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3..c83a702 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -109,6 +109,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Link" => "Vínculo", "Symbol" => "Símbolo", "Sparklines" => "Minigráficos", + "Checkbox" => "Caixa de Seleção", + "Insert checkboxes in the selected cells" => "Inserir caixas de seleção nas células selecionadas", "Header & Footer" => "Cabeçalho e Rodapé", "Draw with ink" => "Desenhar com Tinta", "Eraser" => "Borracha", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d54856..a75e6df 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Link" => "Ссылка", "Symbol" => "Символ", "Sparklines" => "Спарклайны", + "Checkbox" => "Флажок", + "Insert checkboxes in the selected cells" => "Вставить флажки в выбранные ячейки", "Header & Footer" => "Верхний и нижний колонтитулы", "Draw with ink" => "Рисование от руки", "Eraser" => "Ластик", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada..d655e32 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Link" => "链接", "Symbol" => "符号", "Sparklines" => "迷你图", + "Checkbox" => "复选框", + "Insert checkboxes in the selected cells" => "在所选单元格中插入复选框", "Header & Footer" => "页眉和页脚", "Draw with ink" => "墨迹绘制", "Eraser" => "橡皮擦", diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b2079c8..19009c6 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -80,6 +80,7 @@ pub enum Icon { TextBox, Symbol, Sparkline, + Checkbox, Function, Name, Trace, @@ -415,6 +416,10 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.line(&[(12.0, 4.0), (4.0, 12.0)], c, 1.3); } Icon::Check => g.line(&[(3.0, 8.5), (6.5, 12.0), (13.0, 4.5)], c, 1.3), + Icon::Checkbox => { + g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c)); + g.line(&[(4.5, 8.0), (7.0, 10.5), (11.5, 5.0)], c, 1.4); + } Icon::Fx => g.text(8.0, 8.0, "fx", 10.5, c, false, true), Icon::Plus => { g.line(&[(8.0, 3.0), (8.0, 13.0)], c, 1.3); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a..d20270c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1228,6 +1228,11 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { } }); sep(ui); + // Cell Controls. + if big_button(ui, Icon::Checkbox, "Checkbox", "Insert checkboxes in the selected cells", false).clicked() { + act(app, "insert.checkbox", json!({})); + } + sep(ui); if big_button(ui, Icon::Link, "Link", "Insert Link (⌘K)", false).clicked() { app.open_dialog("insertLink", json!({})); } diff --git a/crates/xlsx/src/feature_bags.rs b/crates/xlsx/src/feature_bags.rs new file mode 100644 index 0000000..71a6514 --- /dev/null +++ b/crates/xlsx/src/feature_bags.rs @@ -0,0 +1,83 @@ +//! Feature property bags ([MS-XLSX] 2.1.22, 2.3.9): how Excel stores cell checkboxes. +//! +//! A checkbox is a cell format (`xf`) whose `extLst` carries an `xfComplement i="n"` element +//! ([MS-XLSX] 2.4.99, styles extension `{C7286773-470A-42A8-94C5-96B5CB345126}`). `n` indexes the +//! `MappedFeaturePropertyBags` array of the `XFComplements` bag; that entry is an `XFComplement` +//! bag whose `XFControls` key names an `XFControls` bag, whose `CellControl` key names a +//! `Checkbox` bag. Bag references are zero-based indexes into the part's list of `bag` elements. +//! +//! GridCraft keeps a checkbox as the `checkbox` number format in its model, so reading maps each +//! `xfComplement` that resolves to a `Checkbox` bag onto that format, and writing emits one chain +//! that every checkbox `xf` points at. + +use crate::xml::El; + +/// Namespace of the part and of the `xfComplement` element. +pub const NS: &str = "http://schemas.microsoft.com/office/spreadsheetml/2022/featurepropertybag"; +/// Relationship type from the workbook part. +pub const REL: &str = "http://schemas.microsoft.com/office/2022/11/relationships/FeaturePropertyBag"; +/// Content type of the part. +pub const CONTENT_TYPE: &str = "application/vnd.ms-excel.featurepropertybag+xml"; +/// Part name we write. +pub const PART: &str = "xl/featurePropertyBag/featurePropertyBag.xml"; +/// `ext` URI of the `xfComplement` extension on an `xf`. +pub const XF_EXT_URI: &str = "{C7286773-470A-42A8-94C5-96B5CB345126}"; + +/// The bags for one checkbox cell control, referenced as `xfComplement i="0"`: Checkbox (0), +/// XFControls → 0 (1), XFComplement → 1 (2), XFComplements mapping index 0 → 2 (3). Every +/// reference points to an earlier bag, as [MS-XLSX] 2.6.230 requires. +pub fn checkbox_part() -> String { + format!( + "\n012" + ) +} + +/// The `extLst` that marks an `xf` as a checkbox (complement index 0 of [`checkbox_part`]). +pub fn checkbox_xf_ext() -> String { + format!("") +} + +/// For each `xfComplement` index, whether it resolves to a Checkbox cell control. Unknown, +/// malformed or out-of-range references simply resolve to `false`. +pub fn checkbox_complements(root: &El) -> Vec { + let bags: Vec<&El> = root.kids("bag").collect(); + let bag = |i: Option, ty: &str| i.and_then(|i| bags.get(i as usize).copied()).filter(|b| b.attr("type") == Some(ty)); + let key = |b: &El, k: &str| b.kids("bagId").find(|e| e.attr("k") == Some(k)).and_then(|e| e.text.trim().parse::().ok()); + let Some(map) = bags.iter().find(|b| b.attr("type") == Some("XFComplements")) else { return Vec::new() }; + let Some(arr) = map.kids("a").find(|a| a.attr("k") == Some("MappedFeaturePropertyBags")) else { return Vec::new() }; + arr.kids("bagId") + .take(100_000) + .map(|e| { + let complement = bag(e.text.trim().parse::().ok(), "XFComplement"); + let controls = complement.and_then(|c| bag(key(c, "XFControls"), "XFControls")); + controls.and_then(|c| bag(key(c, "CellControl"), "Checkbox")).is_some() + }) + .collect() +} + +/// Whether an `xf` element carries an `xfComplement` that resolves to a checkbox. +pub fn xf_is_checkbox(xf: &El, complements: &[bool]) -> bool { + let Some(ext) = xf.child("extLst") else { return false }; + ext.kids("ext") + .filter(|e| e.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(XF_EXT_URI))) + .filter_map(|e| e.child("xfComplement")) + .filter_map(|c| c.attr_u32("i")) + .any(|i| complements.get(i as usize).copied().unwrap_or(false)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn written_chain_resolves_and_hostile_chains_do_not() { + let root = crate::xml::parse(checkbox_part().as_bytes()).unwrap_or_default(); + assert_eq!(checkbox_complements(&root), vec![true]); + // Out-of-range, wrong-type and self references resolve to false without panicking. + let bad = format!( + "99114294967295x" + ); + let root = crate::xml::parse(bad.as_bytes()).unwrap_or_default(); + assert_eq!(checkbox_complements(&root), vec![false, false, false]); + } +} diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 05d1afc..75188fe 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -16,6 +16,7 @@ mod chart; mod chartex; mod csv; mod drawing; +mod feature_bags; mod fmla; mod ods; mod package; diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 050a9f3..f76f35d 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -108,10 +108,16 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { let mut wb = Workbook::new(); wb.sheets.clear(); - // Styles and theme. + // Styles and theme. Cell checkboxes live in the feature property bags, which styles refer to. + let mut checkboxes = Vec::new(); + if let Some(r) = find_rel(&wb_rels, "FeaturePropertyBag").cloned() + && let Some(x) = cx.optional_xml(&r.target)? + { + checkboxes = crate::feature_bags::checkbox_complements(&x); + } if let Some(r) = find_rel(&wb_rels, "styles").cloned() { if let Some(x) = cx.optional_xml(&r.target)? { - let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x); + let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x, &checkboxes); cx.num_fmts = num_fmts; cx.xf_map = xfs.into_iter().enumerate().map(|(i, s)| if i == 0 { StyleId::DEFAULT } else { cx.styles.intern(s) }).collect(); cx.dxfs = dxfs; diff --git a/crates/xlsx/src/styles.rs b/crates/xlsx/src/styles.rs index 75df762..1c98a54 100644 --- a/crates/xlsx/src/styles.rs +++ b/crates/xlsx/src/styles.rs @@ -274,7 +274,7 @@ fn num_fmt_code(id: u32, custom: &HashMap) -> NumFmt { NumFmt::new(builtin_format(id).unwrap_or("General")) } -fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: &HashMap) -> Style { +fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: &HashMap, checkboxes: &[bool]) -> Style { let mut s = Style::default(); if let Some(f) = xf.attr_u32("fontId").and_then(|i| fonts.get(i as usize)) { s.font = f.clone(); @@ -288,6 +288,10 @@ fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: & s.borders = *b; } s.num_fmt = num_fmt_code(xf.attr_u32("numFmtId").unwrap_or(0), fmts); + // A checkbox cell control (Insert › Checkbox) is a feature-property-bag complement on the xf. + if crate::feature_bags::xf_is_checkbox(xf, checkboxes) { + s.num_fmt = NumFmt::new("checkbox"); + } if let Some(a) = xf.child("alignment") { s.align = read_alignment(a); } @@ -297,7 +301,9 @@ fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: & s } -pub fn read_styles(root: &El) -> StylesIn { +/// `checkboxes`: for each `xfComplement` index, whether it is a checkbox (see +/// [`crate::feature_bags::checkbox_complements`]); empty when the file has no feature property bags. +pub fn read_styles(root: &El, checkboxes: &[bool]) -> StylesIn { let mut palette: Vec = INDEXED.to_vec(); if let Some(ix) = root.path(&["colors", "indexedColors"]) { let custom: Vec = ix @@ -322,9 +328,10 @@ pub fn read_styles(root: &El) -> StylesIn { let fonts: Vec = root.child("fonts").map(|f| f.kids("font").map(|e| read_font(e, &palette)).collect()).unwrap_or_default(); let fills: Vec = root.child("fills").map(|f| f.kids("fill").map(|e| read_fill(e, &palette, false)).collect()).unwrap_or_default(); let borders: Vec = root.child("borders").map(|f| f.kids("border").map(|e| read_border(e, &palette)).collect()).unwrap_or_default(); - let xfs = root.child("cellXfs").map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &fmts)).collect()).unwrap_or_default(); + let xfs = + root.child("cellXfs").map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &fmts, checkboxes)).collect()).unwrap_or_default(); let style_xfs: Vec