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:
commit
4dce1181e1
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -265,6 +265,8 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Link", "リンク"),
|
||||
("Symbol", "記号"),
|
||||
("Sparklines", "スパークライン"),
|
||||
("Checkbox", "チェックボックス"),
|
||||
("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"),
|
||||
("Header & Footer", "ヘッダーとフッター"),
|
||||
("Draw with ink", "インクで描画"),
|
||||
("Eraser", "消しゴム"),
|
||||
|
|
|
|||
|
|
@ -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" => "지우개",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" => "Ластик",
|
||||
|
|
|
|||
|
|
@ -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" => "橡皮擦",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ mod chart;
|
|||
mod chartex;
|
||||
mod csv;
|
||||
mod drawing;
|
||||
mod feature_bags;
|
||||
mod fmla;
|
||||
mod ods;
|
||||
mod package;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue