Merge pull request #305 from vangel76/feat/cell-checkbox

Checkboxes: ribbon button, Excel's value and Delete rules, XLSX cell controls (#196)

# Conflicts:
#	ROADMAP.md
This commit is contained in:
Brandon Thomas 2026-10-11 12:05:28 -04:00
commit 4dce1181e1
18 changed files with 243 additions and 19 deletions

View File

@ -144,6 +144,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-11 | View › Formula Bar and Zoom 100% reachable from the CLI and MCP (#289) |
| 2026-10-11 | Freeze panes by dragging the freeze handles on the header edges (#195) |
| 2026-10-11 | XLSX: threaded comments (replies, resolved, timestamps) and note visibility survive a save (#300) |
| 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 |

View File

@ -1165,6 +1165,18 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
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()) {
@ -1176,6 +1188,21 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
};
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 {
@ -1183,8 +1210,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
sh.remove_cell(c);
}
"contents" => {
cell.value = Value::Empty;
cell.formula = None;
sh.set_cell(c, cell);
}
"formats" => {

View File

@ -181,7 +181,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Json> {
/// 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<Json> {
let ranges = target_ranges(s, p)?;
let si = target_sheet(s, p)?;
@ -843,9 +848,14 @@ fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
};
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));
}
}

View File

@ -1193,6 +1193,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();

View File

@ -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 }),
};

View File

@ -345,7 +345,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"
@ -706,7 +707,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);

View File

@ -265,6 +265,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Link", "リンク"),
("Symbol", "記号"),
("Sparklines", "スパークライン"),
("Checkbox", "チェックボックス"),
("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"),
("Header & Footer", "ヘッダーとフッター"),
("Draw with ink", "インクで描画"),
("Eraser", "消しゴム"),

View File

@ -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" => "지우개",

View File

@ -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",

View File

@ -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" => "Ластик",

View File

@ -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" => "橡皮擦",

View File

@ -80,6 +80,7 @@ pub enum Icon {
TextBox,
Symbol,
Sparkline,
Checkbox,
Function,
Name,
Trace,
@ -418,6 +419,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);

View File

@ -1278,6 +1278,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!({}));
}

View File

@ -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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<FeaturePropertyBags xmlns=\"{NS}\" count=\"4\"><bag type=\"Checkbox\"/><bag type=\"XFControls\"><bagId k=\"CellControl\">0</bagId></bag><bag type=\"XFComplement\"><bagId k=\"XFControls\">1</bagId></bag><bag type=\"XFComplements\"><a k=\"MappedFeaturePropertyBags\"><bagId>2</bagId></a></bag></FeaturePropertyBags>"
)
}
/// The `extLst` that marks an `xf` as a checkbox (complement index 0 of [`checkbox_part`]).
pub fn checkbox_xf_ext() -> String {
format!("<extLst><ext uri=\"{XF_EXT_URI}\" xmlns:xfpb=\"{NS}\"><xfpb:xfComplement i=\"0\"/></ext></extLst>")
}
/// 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<bool> {
let bags: Vec<&El> = root.kids("bag").collect();
let bag = |i: Option<u32>, 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::<u32>().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::<u32>().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!(
"<FeaturePropertyBags xmlns=\"{NS}\"><bag type=\"XFControls\"><bagId k=\"CellControl\">99</bagId></bag><bag type=\"XFComplement\"><bagId k=\"XFControls\">1</bagId></bag><bag type=\"XFComplements\"><a k=\"MappedFeaturePropertyBags\"><bagId>1</bagId><bagId>4294967295</bagId><bagId>x</bagId></a></bag></FeaturePropertyBags>"
);
let root = crate::xml::parse(bad.as_bytes()).unwrap_or_default();
assert_eq!(checkbox_complements(&root), vec![false, false, false]);
}
}

View File

@ -16,6 +16,7 @@ mod chart;
mod chartex;
mod csv;
mod drawing;
mod feature_bags;
mod fmla;
mod ods;
mod package;

View File

@ -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;

View File

@ -274,7 +274,7 @@ fn num_fmt_code(id: u32, custom: &HashMap<u32, String>) -> NumFmt {
NumFmt::new(builtin_format(id).unwrap_or("General"))
}
fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: &HashMap<u32, String>) -> Style {
fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: &HashMap<u32, String>, 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<u32> = INDEXED.to_vec();
if let Some(ix) = root.path(&["colors", "indexedColors"]) {
let custom: Vec<u32> = ix
@ -322,9 +328,10 @@ pub fn read_styles(root: &El) -> StylesIn {
let fonts: Vec<Font> = root.child("fonts").map(|f| f.kids("font").map(|e| read_font(e, &palette)).collect()).unwrap_or_default();
let fills: Vec<Fill> = root.child("fills").map(|f| f.kids("fill").map(|e| read_fill(e, &palette, false)).collect()).unwrap_or_default();
let borders: Vec<Borders> = 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<Style> =
root.child("cellStyleXfs").map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &fmts)).collect()).unwrap_or_default();
root.child("cellStyleXfs").map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &fmts, &[])).collect()).unwrap_or_default();
let mut named = Vec::new();
if let Some(cs) = root.child("cellStyles") {
for c in cs.kids("cellStyle") {
@ -587,7 +594,9 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St
let font = intern(&mut fonts, &mut font_map, &st.font);
let fill = intern(&mut fills, &mut fill_map, &st.fill);
let border = intern(&mut borders, &mut border_map, &st.borders);
let fmt = nf.id(st.num_fmt.as_str());
// A checkbox is General plus the feature-property-bag complement (see `feature_bags`).
let checkbox = st.num_fmt.as_str() == "checkbox";
let fmt = if checkbox { 0 } else { nf.id(st.num_fmt.as_str()) };
let _ = write!(xfs, "<xf numFmtId=\"{fmt}\" fontId=\"{font}\" fillId=\"{fill}\" borderId=\"{border}\" xfId=\"0\"");
if fmt != 0 {
xfs.push_str(" applyNumberFormat=\"1\"");
@ -609,12 +618,15 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St
if pr.is_some() {
xfs.push_str(" applyProtection=\"1\"");
}
if al.is_none() && pr.is_none() {
if al.is_none() && pr.is_none() && !checkbox {
xfs.push_str("/>");
} else {
xfs.push('>');
xfs.push_str(&al.unwrap_or_default());
xfs.push_str(&pr.unwrap_or_default());
if checkbox {
xfs.push_str(&crate::feature_bags::checkbox_xf_ext());
}
xfs.push_str("</xf>");
}
count += 1;

View File

@ -289,6 +289,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
pivots.finish(wb, &mut out, &|code: &str| crate::tables::builtin_id(code).or_else(|| fmt_ids.get(code).copied()));
out.part("xl/styles.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"), styles.into_bytes());
wb_rels.add("styles", "styles.xml");
if wb.styles.iter().any(|(_, st)| st.num_fmt.as_str() == "checkbox") {
use crate::feature_bags as fpb;
out.part(fpb::PART, Some(fpb::CONTENT_TYPE), fpb::checkbox_part().into_bytes());
wb_rels.add_uri(fpb::REL, "featurePropertyBag/featurePropertyBag.xml");
}
out.part(
"xl/theme/theme1.xml",
Some("application/vnd.openxmlformats-officedocument.theme+xml"),