Integration: track t14

# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	crates/formula/src/tests.rs
#	docs/file-format-parity.md
#	docs/localization-parity.md
#	docs/ui-parity.md
This commit is contained in:
aiongg
2026-10-22 03:05:30 +00:00
31 changed files with 1221 additions and 110 deletions
+2
View File
@@ -157,6 +157,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | Charts: a huge trendline forecast no longer collapses the chart: on a category axis it adds at most four times the data in empty slots (at least 32), the line is sampled over the plot only, and forecasts read from XLSX or set by command are clamped, NaN and infinity becoming 0 (#1148)
| 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) |
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
@@ -233,6 +234,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | trivial | Progress log: More Colors… and Recent Colors in the colour menus (#928) |
| 2026-10-13 | minor | Multi-level category axes (read, drawn, written); progress log row |
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
+194 -25
View File
@@ -34,7 +34,43 @@ struct RuleStats {
counts: HashMap<String, usize>,
}
fn numbers_in(sh: &Sheet, ranges: &[RangeRef]) -> (Vec<f64>, HashMap<String, usize>) {
/// Which statistics a rule reads. Scanning the rule's whole range for them costs O(range), so
/// rules that read none (cell value, formula, text, blanks, errors, dates) never scan.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum StatsNeed {
/// Sorted numbers, their mean and deviation (top/bottom N, above average, scales, bars, icons).
Numbers,
/// How often each displayed text occurs (duplicate / unique values).
Counts,
}
fn stats_need(rule: &CfRule) -> Option<StatsNeed> {
match rule {
CfRule::Duplicate { .. } | CfRule::Unique { .. } => Some(StatsNeed::Counts),
CfRule::Top10 { .. } | CfRule::AboveAverage { .. } | CfRule::ColorScale { .. } | CfRule::DataBar { .. } | CfRule::IconSet { .. } => {
Some(StatsNeed::Numbers)
}
_ => None,
}
}
impl RuleStats {
fn build(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> RuleStats {
let (sorted, counts) = numbers_in(sh, ranges, need);
let n = sorted.len().max(1) as f64;
let avg = sorted.iter().sum::<f64>() / n;
let std = (sorted.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
RuleStats { sorted, avg, std, counts }
}
}
#[cfg(test)]
thread_local! {
/// Cells `numbers_in` visited on this thread (tests count the work a lookup does).
static SCANNED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn numbers_in(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> (Vec<f64>, HashMap<String, usize>) {
let mut nums = Vec::new();
let mut counts = HashMap::new();
for r in ranges {
@@ -43,12 +79,20 @@ fn numbers_in(sh: &Sheet, ranges: &[RangeRef]) -> (Vec<f64>, HashMap<String, usi
None => continue,
};
for c in r.iter().take(2_000_000) {
#[cfg(test)]
SCANNED.with(|n| n.set(n.get() + 1));
let v = sh.value(c);
if let Value::Number(n) = v {
nums.push(n);
}
if !v.is_empty() {
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
match need {
StatsNeed::Numbers => {
if let Value::Number(n) = v {
nums.push(n);
}
}
StatsNeed::Counts => {
if !v.is_empty() {
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
}
}
}
}
}
@@ -109,19 +153,13 @@ impl CfCache {
sh.cond_formats.iter().enumerate().filter(|(_, cf)| cf.ranges.iter().any(|r| r.contains(c))).collect();
rules.sort_by_key(|(_, cf)| cf.priority);
let v = sh.value(c);
let no_stats = RuleStats::default();
for (idx, cf) in rules {
let first = cf.ranges.first().map(|r| r.start).unwrap_or(c);
let st = self
.stats
.entry(idx)
.or_insert_with(|| {
let (sorted, counts) = numbers_in(sh, &cf.ranges);
let n = sorted.len().max(1) as f64;
let avg = sorted.iter().sum::<f64>() / n;
let std = (sorted.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
RuleStats { sorted, avg, std, counts }
})
.clone();
let st: &RuleStats = match stats_need(&cf.rule) {
Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)),
None => &no_stats,
};
let text = v.display();
let matched_style: Option<&Style> = match &cf.rule {
CfRule::CellIs { op, a, b, style } => {
@@ -147,10 +185,18 @@ impl CfCache {
CfRule::Expression { formula, style } => {
gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref())
}
CfRule::ContainsText { text: t, style } => text.to_lowercase().contains(&t.to_lowercase()).then_some(style.as_ref()),
CfRule::NotContainsText { text: t, style } => (!text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()),
CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()),
CfRule::EndsWith { text: t, style } => text.to_lowercase().ends_with(&t.to_lowercase()).then_some(style.as_ref()),
// As in Excel, an error value never contains, begins or ends with anything (its rule
// formulas SEARCH/LEFT/RIGHT fail on it), so it always "does not contain" (#1162).
CfRule::ContainsText { text: t, style } => {
(!v.is_error() && text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::NotContainsText { text: t, style } => {
(v.is_error() || !text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::BeginsWith { text: t, style } => {
(!v.is_error() && text.to_lowercase().starts_with(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::EndsWith { text: t, style } => (!v.is_error() && text.to_lowercase().ends_with(&t.to_lowercase())).then_some(style.as_ref()),
CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
CfRule::Errors { style } => v.is_error().then_some(style.as_ref()),
@@ -218,7 +264,7 @@ impl CfCache {
if let Some(n) = v.as_f64() {
let pts: Vec<(f64, [u8; 3])> = stops
.iter()
.map(|(k, col)| (threshold(wb, sheet, c, &st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255])))
.map(|(k, col)| (threshold(wb, sheet, c, st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255])))
.collect();
let rgb = match pts.as_slice() {
[(a, ca), (b, cb)] => lerp(*ca, *cb, if b > a { (n - a) / (b - a) } else { 0.0 }),
@@ -240,8 +286,8 @@ impl CfCache {
}
CfRule::DataBar { min, max, color, gradient, show_value } => {
if let Some(n) = v.as_f64() {
let lo = threshold(wb, sheet, c, &st, min);
let hi = threshold(wb, sheet, c, &st, max);
let lo = threshold(wb, sheet, c, st, min);
let hi = threshold(wb, sheet, c, st, max);
let lo = if lo > 0.0 { 0.0 } else { lo };
let frac = if hi > lo { ((n - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 1.0 };
if out.bar.is_none() {
@@ -270,7 +316,7 @@ impl CfCache {
defaults.get(1..).unwrap_or_default()
}
};
let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect();
let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, st, t)).collect();
let mut idx = cuts.iter().filter(|cut| n >= **cut).count();
if *reverse {
idx = k - 1 - idx.min(k - 1);
@@ -397,6 +443,105 @@ mod tests {
assert_eq!([look("A1"), look("A2"), look("A4")].map(border), [true, false, false]);
}
/// The statistics as every rule used to get them: one pass collecting both parts.
fn all_stats_before(sh: &Sheet, ranges: &[RangeRef]) -> RuleStats {
let mut nums = Vec::new();
let mut counts = HashMap::new();
for r in ranges {
let Some(r) = sh.used_range().and_then(|u| r.intersection(&u)) else { continue };
for c in r.iter() {
let v = sh.value(c);
if let Value::Number(n) = v {
nums.push(n);
}
if !v.is_empty() {
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
}
}
}
nums.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = nums.len().max(1) as f64;
let avg = nums.iter().sum::<f64>() / n;
let std = (nums.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
RuleStats { sorted: nums, avg, std, counts }
}
#[test]
fn needed_statistics_equal_the_full_ones() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[3, "x"], [7, "X"], [3, null], ["=NA()", 2.5], [" ", 9], [-1, "y"]]})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.sheet(0).unwrap();
for ranges in ["A1:B6", "A1:A3", "B2:B9", "D1:E4", "A1:A2"].map(|r| vec![RangeRef::parse(r).unwrap()]) {
let full = all_stats_before(sh, &ranges);
let numbers = RuleStats::build(sh, &ranges, StatsNeed::Numbers);
assert_eq!((numbers.sorted, numbers.avg.to_bits(), numbers.std.to_bits()), (full.sorted, full.avg.to_bits(), full.std.to_bits()));
assert_eq!(RuleStats::build(sh, &ranges, StatsNeed::Counts).counts, full.counts);
}
}
#[test]
fn rules_that_read_statistics_still_match() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, "a"], [2, "A"], [3, "b"], [10, "c"], [2, null]]})).unwrap();
let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r});
s.execute("home.conditionalFormat", rule("B1:B5", json!({"type": "duplicate", "preset": "lightRedFill"}))).unwrap();
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "top10", "rank": 1, "preset": "redBorder"}))).unwrap();
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "colorScale"}))).unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap());
let fill = |l: Option<CfLook>| l.and_then(|l| l.style).is_some_and(|st| st.fill.pattern == PatternType::Solid);
// Duplicates are case-insensitive texts: "a" and "A".
assert_eq!(["B1", "B2", "B3", "B4", "B5"].map(|a| fill(look(a))), [true, true, false, false, false]);
// The top value gets the border, the scale runs from the minimum to the maximum.
assert!(look("A4").and_then(|l| l.style).is_some_and(|st| !st.borders.top.is_none()));
assert!(look("A1").and_then(|l| l.style).is_none_or(|st| st.borders.top.is_none()));
assert_eq!(look("A1").and_then(|l| l.scale_fill), Some([0xF8, 0x69, 0x6B]));
assert_eq!(look("A4").and_then(|l| l.scale_fill), Some([0x63, 0xBE, 0x7B]));
}
/// Looking cells up does work in proportion to the cells asked for, not the rule's range: the
/// statistics are only gathered for rules that read them, and once per rule.
#[test]
fn lookups_do_not_scan_the_range_for_rules_without_statistics() {
let mut s = crate::Session::new();
s.new_workbook();
let rows: Vec<serde_json::Value> = (1..=10_000).map(|i| json!([i, i % 7])).collect();
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute(
"home.conditionalFormat",
json!({"range": "A1:B10000", "rule": {"type": "expression", "formula": "=MOD(ROW(),2)=0", "preset": "lightRedFill"}}),
)
.unwrap();
s.execute(
"home.conditionalFormat",
json!({"range": "A1:B10000", "rule": {"type": "cellIs", "operator": "greater", "value": "5", "preset": "redBorder"}}),
)
.unwrap();
s.execute("home.conditionalFormat", json!({"range": "A1:B10000", "rule": {"type": "noBlanks", "preset": "redBorder"}})).unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
SCANNED.with(|n| n.set(0));
for r in 1..=50 {
for col in ["A", "B"] {
cache.look(&d.wb, 0, CellRef::parse(&format!("{col}{r}")).unwrap());
}
}
assert_eq!(SCANNED.with(|n| n.get()), 0);
// A rule that does read them scans its range once, however many cells are looked up.
s.execute("home.conditionalFormat", json!({"range": "A1:A10000", "rule": {"type": "top10", "preset": "redBorder"}})).unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
SCANNED.with(|n| n.set(0));
for r in 1..=50 {
cache.look(&d.wb, 0, CellRef::parse(&format!("A{r}")).unwrap());
}
assert_eq!(SCANNED.with(|n| n.get()), 10_000);
}
#[test]
fn cell_value_operator_names() {
let mut s = crate::Session::new();
@@ -410,4 +555,28 @@ mod tests {
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
assert_eq!([look("A1"), look("A2")], [true, false]);
}
#[test]
fn text_rules_skip_error_values() {
let mut s = crate::Session::new();
s.new_workbook();
let values = json!([["=NA()", "=NA()", "=1/0", "=NA()"], ["abc", "#x", "x!", "abc"], ["xyz", "y", "y", "xyz"]]);
s.execute("range.setValues", json!({"range": "A1", "values": values})).unwrap();
let rule = |range: &str, kind: &str, text: &str| json!({"range": range, "rule": {"type": kind, "text": text, "preset": "lightRedFill"}});
s.execute("home.conditionalFormat", rule("A1:A3", "containsText", "a")).unwrap();
s.execute("home.conditionalFormat", rule("B1:B3", "beginsWith", "#")).unwrap();
s.execute("home.conditionalFormat", rule("C1:C3", "endsWith", "!")).unwrap();
s.execute("home.conditionalFormat", rule("D1:D3", "notContainsText", "a")).unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
// Contains "a": abc, never #N/A although its text holds an "a".
assert_eq!([look("A1"), look("A2"), look("A3")], [false, true, false]);
// Begins with "#": #x, never #N/A.
assert_eq!([look("B1"), look("B2"), look("B3")], [false, true, false]);
// Ends with "!": x!, never #DIV/0!.
assert_eq!([look("C1"), look("C2"), look("C3")], [false, true, false]);
// Does not contain "a": #N/A and xyz, not abc.
assert_eq!([look("D1"), look("D2"), look("D3")], [true, false, true]);
}
}
+2 -1
View File
@@ -153,8 +153,9 @@ fn save_as(s: &mut Session, p: &Json) -> Result<Json> {
fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
let fmt = str_param(p, "format").unwrap_or("xlsx");
let d = s.doc()?;
let bytes = crate::io::save_bytes(&d.wb, &format!("x.{fmt}"))?;
let name = format!("{}.{fmt}", std::path::Path::new(&d.display_title()).file_stem().and_then(|x| x.to_str()).unwrap_or("Book"));
// The file name goes into a PivotChart's link to its PivotTable.
let bytes = crate::io::save_bytes(&d.wb, &name)?;
Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()}))
}
+5 -1
View File
@@ -302,7 +302,11 @@ fn underline(s: &mut Session, p: &Json) -> Result<Json> {
fn font_name(s: &mut Session, p: &Json) -> Result<Json> {
let Some(name) = str_param(p, "name").map(str::to_string) else { return Err(bad("home.fontName", "missing `name`")) };
apply_style(s, p, |st| st.font.name = name.clone())
apply_style(s, p, |st| {
st.font.name = name.clone();
st.font.charset = None;
st.font.family = None;
})
}
fn font_size(s: &mut Session, p: &Json) -> Result<Json> {
+113 -3
View File
@@ -190,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Conditional Formatting",
["Home", "Styles"],
None,
"{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font..fill..}|preset: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}}",
"{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font?: {bold?, italic?, color?}, fill?: \"#FFC7CE\" | {pattern?, fg?, bg?}, borders?, num_fmt?} (colours \"#RRGGBB\") | preset?: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}; a text `value` is matched as text, or kept when already a quoted literal}",
has_doc,
add_cf
),
@@ -1399,6 +1399,110 @@ fn preset_style(name: &str) -> Style {
s
}
/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled.
fn is_string_literal(v: &str) -> bool {
v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"'))
}
/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`,
/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be
/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a
/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than
/// dropped: dropped, it left a rule with an empty format that matches and shows nothing.
fn cf_style_json(st: &Json) -> Result<Json> {
const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"];
let err = |m: String| bad("home.conditionalFormat", m);
let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) };
let mut st = obj.clone();
if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) {
let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" };
return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", "))));
}
// A colour written the way the other formatting commands take it, as the model stores it.
let color = |v: &mut Json, what: &str| -> Result<()> {
if v.is_string() || v.get("theme").is_some() {
let c = super::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?;
*v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?;
}
Ok(())
};
if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut)
&& let Some(c) = f.get_mut("color")
{
color(c, "font.color")?;
}
if let Some(fill) = st.get_mut("fill") {
// `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one.
if fill.is_string() && super::format::color_param(Some(fill)) == Some(Color::Auto) {
*fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?;
} else if fill.is_string() || fill.get("theme").is_some() {
*fill = json!({"pattern": "Solid", "fg": fill.clone()});
}
if let Some(f) = fill.as_object_mut() {
for k in ["fg", "bg"] {
if let Some(c) = f.get_mut(k) {
color(c, &format!("fill.{k}"))?;
}
}
// A colour with no pattern is a solid fill, not an invisible one.
if f.contains_key("fg") && !f.contains_key("pattern") {
f.insert("pattern".into(), json!("Solid"));
}
}
}
if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) {
for (side, line) in b.iter_mut() {
if let Some(c) = line.get_mut("color") {
color(c, &format!("borders.{side}.color"))?;
}
}
}
Ok(Json::Object(st))
}
/// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open
/// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog
/// (which dispatches this command) share one gate, the way Excel's object model refuses.
fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> {
// Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1'
// only parses after several strips and would still be saved unparseable. (#1999 review)
if gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() {
return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file")));
}
Ok(())
}
fn check_cf_value(what: &str, v: &gridcraft_model::features::CfValueKind) -> Result<()> {
if let gridcraft_model::features::CfValueKind::Formula(f) = v {
check_cf_formula("home.conditionalFormat", what, f)?;
}
Ok(())
}
fn check_cf_rule(rule: &CfRule) -> Result<()> {
const CMD: &str = "home.conditionalFormat";
match rule {
CfRule::CellIs { a, b, .. } => {
check_cf_formula(CMD, "comparison value", a)?;
if let Some(b) = b {
check_cf_formula(CMD, "second comparison value", b)?;
}
}
CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?,
CfRule::DataBar { min, max, .. } => {
check_cf_value("data bar minimum", min)?;
check_cf_value("data bar maximum", max)?;
}
CfRule::ColorScale { stops } => {
for (kind, _) in stops {
check_cf_value("color scale threshold", kind)?;
}
}
_ => {}
}
Ok(())
}
fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let sheet = target_sheet(s, p)?;
@@ -1408,9 +1512,10 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
};
let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string);
let style = Box::new(match rule.get("style") {
Some(st) if st.is_object() => {
Some(st) if !st.is_null() => {
let st = cf_style_json(st)?;
let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?;
serde_json::from_value(super::format::merge_json(base, st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
serde_json::from_value(super::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
}
_ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")),
});
@@ -1449,8 +1554,12 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
let b = rule.get("value2").map(super::edit::json_to_input);
let quote = |v: String| {
// A number, a formula, or text already written as a string literal (`"Closed"`, as
// in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted.
if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') {
v.trim_start_matches('=').to_string()
} else if is_string_literal(&v) {
v
} else {
format!("\"{}\"", v.replace('"', "\"\""))
}
@@ -1507,6 +1616,7 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
},
other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))),
};
check_cf_rule(&rule)?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1;
+4 -1
View File
@@ -238,7 +238,10 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Xlsx => {
let book = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or_default();
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
}
FileKind::Xls => {
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
}
+84
View File
@@ -1504,6 +1504,24 @@ fn structured_refs_to_columns_with_edge_spaces() {
assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3")], [n(5.0), n(5.0), n(5.0)]);
}
#[test]
fn changing_the_font_name_drops_the_old_fonts_charset_and_family() {
let mut s = s();
{
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
let arabic = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| {
st.font.name = "Traditional Arabic".into();
st.font.charset = Some(178);
st.font.family = Some(1);
});
wb.sheet_mut(0).unwrap().set_style(CellRef::parse("A1").unwrap(), arabic);
}
s.execute("home.fontName", json!({"range": "A1", "name": "Arial"})).unwrap();
let d = s.doc().unwrap();
let font = &d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("A1").unwrap())).font;
assert_eq!((font.name.as_str(), font.charset, font.family), ("Arial", None, None));
}
#[test]
fn table_rename_keeps_a_defined_name_with_the_same_name() {
let mut s = s();
@@ -3641,6 +3659,72 @@ fn icon_set_thresholds_from_files_pick_the_icon() {
assert_eq!([icon(&s, "A1"), icon(&s, "A2"), icon(&s, "A3"), icon(&s, "A4")], [Some(0), Some(1), Some(1), Some(2)]);
}
/// `home.conditionalFormat` styles take colours the way the other formatting commands do, refuse
/// keys they would drop, and keep a text criterion already written as a string literal.
#[test]
fn conditional_format_styles_take_hex_colours_and_quoted_values() {
use gridcraft_model::{CfRule, Color, Fill, PatternType};
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Open"], ["Closed"], ["Blocked"]]})).unwrap();
let rule = |s: &Session, i: usize| s.doc().unwrap().wb.active().unwrap().cond_formats[i].rule.clone();
let style = |r: &CfRule| match r {
CfRule::Expression { style, .. } | CfRule::CellIs { style, .. } => (**style).clone(),
other => panic!("{other:?}"),
};
s.execute(
"home.conditionalFormat",
json!({"range": "A1:A3", "rule": {"type": "expression", "formula": "=A1=\"Open\"", "style": {"fill": "#FFF2CC", "font": {"bold": true, "color": "#9C0006"}}}}),
)
.unwrap();
let st = style(&rule(&s, 0));
assert_eq!(st.fill, Fill::solid(Color::Rgb(0xFFF2CC)));
assert_eq!((st.font.bold, st.font.color), (true, Color::Rgb(0x9C0006)));
// A fill colour without a pattern is solid; the model's own shape still works.
s.execute(
"home.conditionalFormat",
json!({"range": "A1", "rule": {"type": "expression", "formula": "=TRUE", "style": {"fill": {"fg": "C6EFCE"}}}}),
)
.unwrap();
assert_eq!(style(&rule(&s, 0)).fill, Fill { pattern: PatternType::Solid, fg: Color::Rgb(0xC6EFCE), bg: Color::Auto });
let internal = json!({"fill": {"pattern": "Solid", "fg": {"Rgb": 16773836}}, "borders": {"left": {"style": "Thin", "color": "#C00000"}}});
s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "expression", "formula": "=TRUE", "style": internal}})).unwrap();
let st = style(&rule(&s, 0));
assert_eq!((st.fill.fg, st.borders.left.color), (Color::Rgb(0xFFF2CC), Color::Rgb(0xC00000)));
// `"none"` is no fill (the format only sets the font), as `home.fillColor` reads it.
s.execute(
"home.conditionalFormat",
json!({"range": "A1", "rule": {"type": "expression", "formula": "=TRUE", "style": {"fill": "none", "font": {"bold": true}}}}),
)
.unwrap();
let st = style(&rule(&s, 0));
assert_eq!((st.fill, st.font.bold), (Fill::default(), true));
// Keys that would be dropped, leaving a rule that shows nothing, are refused.
let n = s.doc().unwrap().wb.active().unwrap().cond_formats.len();
for bad in [json!({"preset": "greenFill"}), json!({"fillColor": "#FFF2CC"}), json!({"fill": "#nothex"}), json!("bold")] {
let e = s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "expression", "formula": "=TRUE", "style": bad}}));
assert!(e.is_err(), "{bad} should be refused");
}
assert_eq!(s.doc().unwrap().wb.active().unwrap().cond_formats.len(), n);
// `"Closed"` (a string literal, as in the file) and `Closed` both match the text Closed.
for value in ["\"Closed\"", "Closed"] {
s.execute(
"home.conditionalFormat",
json!({"range": "A1:A3", "rule": {"type": "cellIs", "operator": "equal", "value": value, "preset": "greenFill"}}),
)
.unwrap();
assert!(matches!(rule(&s, 0), CfRule::CellIs { ref a, .. } if a == "\"Closed\""), "{value}");
let wb = s.doc().unwrap().wb.clone();
let mut cache = crate::cf::CfCache::new();
let mut fill = |c: &str| cache.look(&wb, 0, CellRef::parse(c).unwrap()).and_then(|l| l.style).map(|st| st.fill.fg);
assert_eq!(fill("A2"), Some(Color::Rgb(0xC6EFCE)), "{value}");
s.execute("edit.undo", json!({})).unwrap();
}
// Text with quotes inside that isn't one literal is still quoted as text.
s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "cellIs", "operator": "equal", "value": "say \"hi\""}})).unwrap();
assert!(matches!(rule(&s, 0), CfRule::CellIs { ref a, .. } if a == "\"say \"\"hi\"\"\""));
}
#[test]
fn row_height_and_column_width_take_a_list_of_areas() {
// Issue #341: header drags on a multi-area selection size every selected line in one step.
@@ -0,0 +1,61 @@
//! #1999: a conditional-format rule whose formula doesn't parse must be rejected at
//! creation (Excel's object model refuses it too), not saved and poison the file.
use gridcraft_engine::Session;
use serde_json::json;
fn session() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
#[test]
fn invalid_cf_formulas_are_rejected() {
for rule in [
// exact repros from the issue's measured table
json!({"type": "expression", "formula": "=A1>", "preset": "lightRedFill"}),
json!({"type": "cellIs", "operator": "greaterThan", "value": "=1+", "preset": "lightRedFill"}),
json!({"type": "dataBar", "min": {"type": "formula", "value": "1+"}}),
// more garbage that the parser rejects
json!({"type": "expression", "formula": "=IF(A1", "preset": "lightRedFill"}),
json!({"type": "cellIs", "operator": "equal", "value": "=((A1", "preset": "lightRedFill"}),
// multiple leading '=' on a stored-as-typed threshold: the writer strips one,
// the rest must still parse (#1999 review)
json!({"type": "dataBar", "min": {"type": "formula", "value": "===A1"}}),
json!({"type": "dataBar", "max": {"type": "formula", "value": "==1+"}}),
] {
let err = session()
.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": rule.clone()}))
.expect_err("invalid formula {rule} must be rejected");
let msg = err.to_string();
assert!(msg.to_lowercase().contains("formula"), "error should mention the formula: {msg}");
}
}
#[test]
fn valid_cf_formulas_still_work() {
let mut s = session();
// bare comparison without '=' (the issue: this one saves fine today)
s.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": {"type": "expression", "formula": "A1>5", "preset": "lightRedFill"}}))
.unwrap();
// leading '=' is accepted and stripped
s.execute("home.conditionalFormat", json!({"range": "B1:B5", "rule": {"type": "expression", "formula": "=B1>5", "preset": "lightRedFill"}}))
.unwrap();
// cellIs numeric and text values are not formulas, must pass untouched
s.execute(
"home.conditionalFormat",
json!({"range": "C1:C5", "rule": {"type": "cellIs", "operator": "greaterThan", "value": "5", "preset": "lightRedFill"}}),
)
.unwrap();
s.execute(
"home.conditionalFormat",
json!({"range": "D1:D5", "rule": {"type": "cellIs", "operator": "equal", "value": "hello there", "preset": "lightRedFill"}}),
)
.unwrap();
// dataBar formula thresholds that parse
s.execute("home.conditionalFormat", json!({"range": "E1:E5", "rule": {"type": "dataBar", "min": {"type": "formula", "value": "MIN(E1:E5)"}, "max": {"type": "formula", "value": "MAX(E1:E5)"}}}))
.unwrap();
// non-formula rule types are untouched
s.execute("home.conditionalFormat", json!({"range": "F1:F5", "rule": {"type": "duplicate", "preset": "lightRedFill"}})).unwrap();
}
+41
View File
@@ -0,0 +1,41 @@
//! #1447: a PivotChart is bound to its PivotTable behind the workbook's name.
use std::io::Read;
use gridcraft_engine::Session;
use serde_json::{Value as Json, json};
fn run(s: &mut Session, command: &str, params: Json) -> Json {
s.execute(command, params).unwrap()
}
fn source() -> Session {
let mut s = Session::new();
s.new_workbook();
run(
&mut s,
"range.setValues",
json!({"range": "A1", "values": [["Region ", " Sales ", "Qty"], ["East", 10, 1], ["West", 20, 2], ["East", 5, 3]]}),
);
s
}
#[test]
fn pivot_chart_link_names_the_workbook_in_brackets() {
let mut s = source();
run(&mut s, "selection.set", json!({"range": "A1"}));
run(&mut s, "insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]}));
run(&mut s, "insert.pivotChart", json!({}));
let bytes = gridcraft_engine::io::save_bytes(&s.doc().unwrap().wb, "Q1 Report.xlsx").unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut chart = String::new();
zip.by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut chart).unwrap();
assert!(chart.contains("<c:name>[Q1 Report.xlsx]Sheet2!PivotTable1</c:name>"), "{chart}");
// Without a file name, a placeholder workbook is named; reading drops the brackets again.
let saved = run(&mut s, "file.saveBytes", json!({"format": "xlsx"}));
let mut reopened = Session::new();
run(&mut reopened, "file.open", json!({"name": "q.xlsx", "base64": saved["base64"]}));
let link = reopened.doc().unwrap().wb.sheets.iter().flat_map(|sh| sh.charts.iter()).find_map(|c| c.pivot.clone());
assert_eq!(link.as_deref(), Some("Sheet2!PivotTable1"));
}
+3
View File
@@ -260,6 +260,9 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
.replace(locale.decimal_separator(), ".")
.parse()
.map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?;
if !v.is_finite() {
return Err(LexError { msg: format!("number too large `{text}`"), pos: start });
}
push(&mut out, Tok::Number(v), j);
i = j;
}
+8
View File
@@ -495,3 +495,11 @@ fn missing_closing_parentheses_are_closed_and_errors_describe_the_typed_text() {
}
assert_eq!(entry("1+").unwrap_err().msg, "unexpected end of formula");
}
#[test]
fn number_literal_too_large_for_f64_is_a_parse_error() {
for bad in ["1E+400", "-1E+400", "1E+400*A1", "SUM(1E309,1)", "9".repeat(400).as_str()] {
assert!(parse(bad).is_err(), "{bad} should fail");
}
assert!(parse("1E+308").is_ok());
}
+30 -1
View File
@@ -187,6 +187,12 @@ pub struct Font {
pub strike: bool,
pub color: Color,
pub vert: VertAlign,
/// Windows character set the font was chosen for (178 = Arabic); kept so Excel substitutes a font for the same script.
#[serde(default)]
pub charset: Option<u8>,
/// Font family class (1 roman, 2 swiss, 3 modern…); `None` is swiss, the default.
#[serde(default)]
pub family: Option<u8>,
}
impl Eq for Font {}
@@ -194,7 +200,7 @@ impl Hash for Font {
fn hash<H: Hasher>(&self, h: &mut H) {
self.name.hash(h);
self.size.to_bits().hash(h);
(self.bold, self.italic, self.underline, self.strike, self.color, self.vert).hash(h);
(self.bold, self.italic, self.underline, self.strike, self.color, self.vert, self.charset, self.family).hash(h);
}
}
@@ -209,6 +215,8 @@ impl Default for Font {
strike: false,
color: Color::Auto,
vert: VertAlign::Baseline,
charset: None,
family: None,
}
}
}
@@ -365,6 +373,27 @@ pub struct Style {
/// Number format code; "General" by default.
pub num_fmt: NumFmt,
pub protection: Protection,
/// Differential formats only.
#[serde(default)]
pub dxf_stated: DxfStated,
}
/// The parts a differential format states at a value its `Style` can't tell from unset: a dxf's
/// default parts are unset, so a bold turned off, a face, a size or a protection that matches the
/// defaults would otherwise read as nothing. The values are the style's own; this records that
/// the format states them, and only where they match the defaults, so a format built in the app
/// and the same format read back from a file stay equal.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DxfStated {
pub bold: bool,
pub italic: bool,
pub strike: bool,
pub underline: bool,
pub size: bool,
pub name: bool,
/// `<scheme val>`: the theme font the face follows (`major`, `minor` or `none`).
pub scheme: Option<String>,
pub protection: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+3
View File
@@ -141,6 +141,9 @@ pub struct GridState {
pub ink: Vec<[f32; 2]>,
/// Page ranges for Page Break Preview, cached per (doc uid, revision, sheet).
pub pages: Option<((u64, u64, usize), Vec<(RangeRef, u32)>)>,
/// The PivotTable Fields pane's `pivot.fields` answer, cached per (doc uid, revision, pivot):
/// it scans the whole source list, far too slow to repeat every frame (#2113).
pub pivot_fields: Option<((u64, u64, String), serde_json::Value)>,
pub header_menu: Option<(Pos2, bool)>,
/// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns).
pub paper: Option<crate::paper_view::PaperCache>,
+12 -7
View File
@@ -2,7 +2,7 @@
//! report. Ticking a field adds it (text → Rows, numbers → Values); each area lists its fields
//! with move/remove menus. Everything runs `pivot.*` commands.
use egui::{Color32, Ui, vec2};
use egui::{Ui, vec2};
use serde_json::{Value as Json, json};
use crate::SheetApp;
@@ -21,16 +21,21 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
let Some(name) = active_pivot(app) else { return };
let t = Tokens::get(ui.ctx());
let lang = i18n::current(ui.ctx());
let info = match app.session.run("pivot.fields", json!({"pivot": name})) {
Ok(v) => v,
Err(_) => return,
};
let Some(key) = app.session.active().map(|d| (d.uid, d.revision, name.clone())) else { return };
if app.grid.pivot_fields.as_ref().is_none_or(|(k, _)| *k != key) {
let info = match app.session.run("pivot.fields", json!({"pivot": name})) {
Ok(v) => v,
Err(_) => return,
};
app.grid.pivot_fields = Some((key, info));
}
let Some((_, info)) = app.grid.pivot_fields.clone() else { return };
egui::Panel::right("pivot_pane").default_size(280.0).resizable(true).frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| {
ui.label(egui::RichText::new(lang.tr("PivotTable Fields")).font(theme::ui_bold(15.0)));
ui.label(egui::RichText::new(lang.tr("Choose fields to add to report:")).small().color(t.text_dim));
ui.add_space(4.0);
let fields = info["fields"].as_array().cloned().unwrap_or_default();
egui::ScrollArea::vertical().max_height(220.0).id_salt("pv_fields").show(ui, |ui| {
egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).id_salt("pv_fields").show(ui, |ui| {
for f in &fields {
let fname = f["name"].as_str().unwrap_or("").to_string();
let areas: Vec<String> =
@@ -88,7 +93,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
let is_values = f == VALUES_FIELD;
let b = ui.add(
egui::Button::new(egui::RichText::new(format!("{f} ▾")).font(theme::ui_font(12.0)))
.fill(Color32::WHITE)
.fill(t.ribbon)
.min_size(vec2(ui.available_width(), 20.0)),
);
egui::Popup::menu(&b).show(|ui| {
+124 -40
View File
@@ -425,8 +425,6 @@ pub(crate) enum ComboResult {
None,
/// A row was applied: clicked or Enter on the highlighted row.
Picked(String),
/// Enter with free text that matches no row (e.g. a custom font size).
Entered(String),
}
/// Excel-style dropdown: a filter field (type to narrow the list) plus arrow/Enter selection.
@@ -439,6 +437,26 @@ pub(crate) fn editable_combo(
current_value: &str,
items: &[(&str, String)],
forced: bool,
) -> ComboResult {
combo_popup(ui, anchor, salt, current_value, items, forced, true, None)
}
/// The dropdown of [`editable_combo`] without the filter field: the whole list, picked by click or
/// arrows and Enter. It drops directly below `under` and is as wide as it, like Excel's Font Size
/// list under its box.
fn list_combo(ui: &mut Ui, anchor: &egui::Response, under: Rect, salt: &'static str, current_value: &str, items: &[(&str, String)]) -> ComboResult {
combo_popup(ui, anchor, salt, current_value, items, false, false, Some(under))
}
fn combo_popup(
ui: &mut Ui,
anchor: &egui::Response,
salt: &'static str,
current_value: &str,
items: &[(&str, String)],
forced: bool,
filter: bool,
under: Option<Rect>,
) -> ComboResult {
let popup_id = anchor.id.with("popup");
let state_id = anchor.id.with(salt).with("__combo_state");
@@ -453,7 +471,12 @@ pub(crate) fn editable_combo(
}
st.was_open = open_now;
let mut result = ComboResult::None;
egui::Popup::menu(anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| {
let mut popup = egui::Popup::menu(anchor).open_memory(popup_command(forced, anchor.clicked()));
if let Some(r) = under {
popup = popup.anchor(r).align(egui::RectAlign::BOTTOM_START).width(r.width());
}
let row_w = under.map_or(220.0, |r| r.width() - 12.0);
popup.show(|ui| {
// Arrows and Enter navigate the list, not the filter field's cursor. Capture them and
// strip them from the input so the TextEdit never sees them.
let (mut down, mut up, mut enter) = (false, false, false);
@@ -469,21 +492,24 @@ pub(crate) fn editable_combo(
});
});
}
let text_id = anchor.id.with(salt).with("__combo_text");
let te = ui.add(egui::TextEdit::singleline(&mut st.text).id(text_id).desired_width(220.0).hint_text("Type to filter…"));
if open_now && !te.has_focus() {
ui.ctx().memory_mut(|m| m.request_focus(text_id));
// Select all once focus lands so typing replaces the filter.
if let Some(mut s) = egui::text_edit::TextEditState::load(ui.ctx(), text_id) {
let len = st.text.chars().count();
s.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len))));
s.store(ui.ctx(), text_id);
if filter {
let text_id = anchor.id.with(salt).with("__combo_text");
let te = ui.add(egui::TextEdit::singleline(&mut st.text).id(text_id).desired_width(220.0).hint_text("Type to filter…"));
if open_now && !te.has_focus() {
ui.ctx().memory_mut(|m| m.request_focus(text_id));
// Select all once focus lands so typing replaces the filter.
if let Some(mut s) = egui::text_edit::TextEditState::load(ui.ctx(), text_id) {
let len = st.text.chars().count();
s.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len))));
s.store(ui.ctx(), text_id);
}
}
}
let lower = st.text.to_lowercase();
let filtered: Vec<usize> = items.iter().enumerate().filter(|(_, (_, d))| d.to_lowercase().contains(&lower)).map(|(i, _)| i).collect();
if !filtered.is_empty() {
let n = filtered.len() as i32;
st.sel = st.sel.clamp(0, n - 1);
if down {
st.sel = (st.sel + 1).rem_euclid(n);
}
@@ -491,27 +517,42 @@ pub(crate) fn editable_combo(
st.sel = (st.sel - 1).rem_euclid(n);
}
}
if enter {
if filtered.is_empty() {
let t = st.text.trim().to_string();
if !t.is_empty() {
result = ComboResult::Entered(t);
}
} else {
let idx = filtered[st.sel.clamp(0, filtered.len() as i32 - 1) as usize];
result = ComboResult::Picked(items[idx].0.to_string());
}
if enter && !filtered.is_empty() {
let idx = filtered[st.sel.clamp(0, filtered.len() as i32 - 1) as usize];
result = ComboResult::Picked(items[idx].0.to_string());
}
ui.separator();
egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| {
for i in &filtered {
if filter {
ui.separator();
}
let button_h = ui.spacing().interact_size.y.max(20.0);
let spacing = ui.spacing().item_spacing.y;
let n = filtered.len() as f32;
let content_h = n * button_h + (n - 1.0).max(0.0) * spacing;
let max_rows = 9.0_f32;
let max_h = max_rows * button_h + (max_rows - 1.0) * spacing;
let desired_h = content_h.min(max_h);
ui.set_min_height(desired_h);
egui::ScrollArea::vertical().max_height(desired_h).show(ui, |ui| {
for (pos, i) in filtered.iter().enumerate() {
let (value, disp) = &items[*i];
let row = ui.add(egui::Button::selectable(*i as i32 == st.sel, egui::RichText::new(disp)).min_size(vec2(220.0, 20.0)));
let row = ui.add(egui::Button::selectable(pos as i32 == st.sel, egui::RichText::new(disp)).min_size(vec2(row_w, 20.0)));
if (down || up) && pos as i32 == st.sel && !ui.clip_rect().contains_rect(row.rect) {
row.scroll_to_me(Some(if down { egui::Align::Max } else { egui::Align::Min }));
}
if row.clicked() {
result = ComboResult::Picked(value.to_string());
}
if row.hovered() {
st.sel = *i as i32;
st.sel = pos as i32;
}
}
});
@@ -1145,25 +1186,68 @@ pub const FONTS: &[&str] = &[
"Verdana",
];
/// Excel's size box: type a size and press Enter to apply exactly that number (the command refuses
/// sizes outside 1 to 409), Escape or clicking away reverts; the arrow opens the standard sizes.
fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
let t = Tokens::get(ui.ctx());
let size = st.font.size;
let label = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") };
let resp = ui.add_sized([52.0, 24.0], egui::Button::new(egui::RichText::new(&label).font(theme::ui_font(12.5))));
let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") };
let field = egui::Id::new("gridcraft.font_size_box");
let draft = field.with("draft");
let mut text = ui.data_mut(|d| d.get_temp::<String>(draft)).unwrap_or_else(|| current.clone());
// One frame like the Font name box beside it (same fill, border, height and corners), with the
// text field and the dropdown arrow inside it, as in Excel.
let (frame, _) = ui.allocate_exact_size(vec2(52.0, 24.0), egui::Sense::hover());
// Painted once the field's focus and hover are known, but beneath the field and the arrow.
let bg = ui.painter().add(egui::Shape::Noop);
let text_rect = Rect::from_min_max(frame.min, egui::pos2(frame.right() - 16.0, frame.bottom()));
let arrow_rect = Rect::from_min_max(egui::pos2(frame.right() - 16.0, frame.top() + 1.0), egui::pos2(frame.right() - 1.0, frame.bottom() - 1.0));
let mut field_ui =
ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(6.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
let out = egui::TextEdit::singleline(&mut text)
.id(field)
.font(theme::ui_font(12.5))
.frame(egui::Frame::NONE)
.desired_width(text_rect.width() - 12.0)
.show(&mut field_ui);
let resp = out.response;
control_keytip(app, ui, &resp, "home.fontSize");
let forced = invoke(app, "home.fontSize");
if invoke(app, "home.fontSize") {
ui.memory_mut(|m| m.request_focus(field));
}
if resp.gained_focus() {
// Typing replaces the size, as in Excel, whether the box was clicked or reached by keytip.
let mut state = out.state;
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()))));
state.store(ui.ctx(), field);
}
if resp.lost_focus() {
ui.data_mut(|d| d.remove::<String>(draft));
// Consume the Enter so the grid behind does not also move the active cell down.
if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter))
&& let Some(v) = text.trim().parse::<f64>().ok().filter(|v| v.is_finite())
{
act(app, "home.fontSize", json!({"size": v}));
}
} else if resp.has_focus() {
ui.data_mut(|d| d.insert_temp(draft, text));
} else {
ui.data_mut(|d| d.remove::<String>(draft));
}
let mut arrow_ui = ui.new_child(egui::UiBuilder::new().max_rect(arrow_rect));
let arrow = icon_button(&mut arrow_ui, Icon::Chevron, t.text_dim, "Font Size", arrow_rect.size());
let visuals = ui.style().visuals.clone();
let looks = if resp.has_focus() || ui.rect_contains_pointer(frame) { &visuals.widgets.hovered } else { &visuals.widgets.inactive };
let stroke = if resp.has_focus() { visuals.selection.stroke } else { looks.bg_stroke };
ui.painter().set(bg, egui::epaint::RectShape::new(frame, looks.corner_radius, looks.weak_bg_fill, stroke, egui::StrokeKind::Inside));
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Font Size"));
let sizes = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
let labels: Vec<String> = sizes.iter().map(|s| format!("{}", *s as i32)).collect();
let items: Vec<(&str, String)> = labels.iter().map(|s| (s.as_str(), s.clone())).collect();
let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") };
let mut apply = |s: &str| {
if let Ok(v) = s.parse::<f32>() {
act(app, "home.fontSize", json!({"size": v}));
}
};
match editable_combo(ui, &resp, "size", &current, &items, forced) {
ComboResult::Picked(value) => apply(&value),
ComboResult::Entered(text) => apply(&text),
_ => {}
if let ComboResult::Picked(value) = list_combo(ui, &arrow, frame, "size", &current, &items)
&& let Ok(v) = value.parse::<f32>()
{
act(app, "home.fontSize", json!({"size": v}));
}
}
+125
View File
@@ -0,0 +1,125 @@
//! Home ▸ Font Size is a box you type a size into, with the standard sizes behind its arrow (#932).
use egui::{Event, Key, Modifiers, accesskit::Role};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "A1:B2"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(4);
h
}
fn key(h: &mut Harness, key: Key) {
h.input_mut().events.extend([
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE },
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
]);
h.run_steps(2);
}
/// Alt, H, F, S focuses the size box, then `text` is typed and confirmed with Enter.
fn type_size(h: &mut Harness, text: &str) {
key(h, Key::F10);
for k in [Key::H, Key::F, Key::S] {
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Text(k.name().to_ascii_lowercase()));
h.run_steps(2);
}
h.run_steps(3);
for c in text.chars() {
h.input_mut().events.push(Event::Text(c.to_string()));
h.run_steps(1);
}
key(h, Key::Enter);
h.run_steps(3);
}
fn size(h: &Harness, cell: &str) -> f32 {
let d = h.state().session.active().unwrap();
let sh = d.wb.active().unwrap();
d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.size
}
#[test]
fn typed_size_is_applied_exactly_to_the_selection() {
for (typed, want) in [("6", 6.0), ("2", 2.0), ("10.5", 10.5)] {
let mut h = harness();
type_size(&mut h, typed);
assert!(h.state().message.is_none(), "typing {typed} must not raise an error");
for cell in ["A1", "B2"] {
assert_eq!(size(&h, cell), want, "typing {typed} + Enter sets {cell} to {want}");
}
assert_eq!(
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap()),
Value::Empty,
"the digits go to the box, not the cell"
);
}
}
#[test]
fn out_of_range_or_not_a_number_changes_nothing() {
let mut h = harness();
type_size(&mut h, "500");
let (_, text) = h.state().message.clone().expect("500 is refused with a message");
assert!(text.contains("between 1 and 409"), "{text}");
assert_eq!(size(&h, "A1"), 11.0);
let mut h = harness();
type_size(&mut h, "abc");
assert!(h.state().message.is_none());
assert_eq!(size(&h, "A1"), 11.0, "text that is not a number reverts");
}
#[test]
fn escape_cancels_the_typed_size() {
let mut h = harness();
key(&mut h, Key::F10);
for k in [Key::H, Key::F, Key::S] {
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Text(k.name().to_ascii_lowercase()));
h.run_steps(2);
}
h.run_steps(3);
h.input_mut().events.push(Event::Text("6".into()));
h.run_steps(1);
key(&mut h, Key::Escape);
h.run_steps(3);
assert_eq!(size(&h, "A1"), 11.0);
assert!(h.get_all_by_role(Role::TextInput).any(|n| n.value().as_deref() == Some("11")), "the box shows the cell's size again");
}
#[test]
fn the_arrow_opens_the_plain_list_of_sizes() {
let mut h = harness();
h.get_by_label("Font Size").click();
h.run_steps(3);
assert!(h.query_by_label("Type to filter…").is_none(), "the size list has no filter field");
// The grid's row headers carry the same numbers as labels: pick the list's button.
h.get_by_role_and_label(Role::Button, "16").click();
h.run_steps(3);
assert_eq!(size(&h, "B2"), 16.0, "clicking a size applies it");
let mut h = harness();
h.get_by_label("Font Size").click();
h.run_steps(3);
key(&mut h, Key::ArrowDown);
key(&mut h, Key::Enter);
h.run_steps(3);
assert_eq!(size(&h, "A1"), 12.0, "Down from 11 then Enter picks 12");
}
+22
View File
@@ -464,6 +464,28 @@ fn pivot_fields_pane_follows_the_interface_language() {
assert!(h.query_by_label("Remove Field").is_none());
}
#[test]
fn pivot_fields_pane_reuses_its_field_list_until_the_workbook_changes() {
// #2113: the pane ran `pivot.fields`, a scan of the whole source list, on every frame.
let mut h = harness(blank());
h.state_mut().run("range.setValues", json!({"range": "A1:B3", "values": [["Region", "Sales"], ["North", 10], ["South", 20]]})).unwrap();
h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"], "destination": "Sheet1!D1"})).unwrap();
h.run_steps(2);
assert!(h.query_by_label("Sales").is_some(), "the pane lists the source fields");
let (key, mut info) = h.state().grid.pivot_fields.clone().expect("the pane keeps its field list");
info["fields"][1]["name"] = json!("Cached");
h.state_mut().grid.pivot_fields = Some((key, info));
h.run_steps(2);
assert!(h.query_by_label("Cached").is_some(), "repaints reuse the kept list instead of scanning the source again");
h.state_mut().run("cell.set", json!({"cell": "B1", "input": "Revenue"})).unwrap();
h.run_steps(2);
assert!(h.query_by_label("Cached").is_none(), "an edit drops the kept list");
assert!(h.query_by_label("Revenue").is_some(), "the pane shows the edited source");
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert!(h.query_by_label("Sales").is_some(), "undo brings the previous field list back");
}
#[test]
fn format_cells_draws_its_labels_in_the_interface_language() {
let mut h = harness(blank());
+38 -1
View File
@@ -588,7 +588,10 @@ fn editable_combos_filter_by_typing_and_move_by_arrows() {
key(&mut h, Key::Enter);
h.run_steps(3);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "custom sizes must apply");
assert_eq!(h.query_all_by_label("13.5").count(), 1, "the button must show the custom size");
assert!(
h.get_all_by_role(egui::accesskit::Role::TextInput).any(|n| n.value().as_deref() == Some("13.5")),
"the size box must show the custom size"
);
// Closing without applying (Escape) and reopening must reset the filter to the full list.
let mut h = harness(blank());
@@ -643,3 +646,37 @@ fn every_sequence_terminates_without_panicking() {
}
assert_eq!(SEQUENCES.len(), SEQUENCES.len(), "every sequence ran");
}
/// The font list's highlight is a position in the filtered rows (#1365): with "ar" typed the rows
/// are Aptos Narrow, Arial, Candara, Garamond, and the highlight starts on the current font's
/// row (index 3, clamped to the filtered list). Down twice lands on the second row, Arial. Clearing
/// the filter brings the other fonts back.
#[test]
fn font_dropdown_highlight_follows_the_filtered_rows() {
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(3);
events(&mut h, [Event::Text("ar".into())]);
h.run_steps(3);
assert_eq!(h.query_all_by_label("Georgia").count(), 0, "typing must narrow the list");
key(&mut h, Key::ArrowDown);
key(&mut h, Key::ArrowDown);
key(&mut h, Key::Enter);
h.run_steps(3);
let d = h.state().session.active().unwrap();
let sh = d.wb.active().unwrap();
let name = d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).font.name.clone();
assert_eq!(name, "Arial", "the highlight is the second filtered row, not a full-list index");
// Clearing the filter shows the whole list again.
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(3);
events(&mut h, [Event::Text("ar".into())]);
h.run_steps(3);
assert_eq!(h.query_all_by_label("Georgia").count(), 0);
key(&mut h, Key::Backspace);
key(&mut h, Key::Backspace);
h.run_steps(3);
assert!(h.query_all_by_label("Georgia").count() >= 1, "an emptied filter lists every font again");
}
+58
View File
@@ -0,0 +1,58 @@
//! Pivot field buttons use the surface color for the active theme.
use egui::{Color32, RawInput, Rect, Shape, pos2, vec2};
use gridcraft_engine::Session;
use gridcraft_ui_egui::{SheetApp, theme};
use serde_json::json;
fn render_pivot_field_buttons(dark: bool) -> Vec<Shape> {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 100], ["West", 80]]})).unwrap();
session
.execute("insert.pivotTable", json!({"source": "Sheet1!A1:B3", "destination": "Sheet1!D3", "rows": ["Region"], "values": ["Sales"]}))
.unwrap();
session.execute("selection.set", json!({"cell": "D3"})).unwrap();
let mut app = SheetApp::new(session, Default::default());
app.ui.dark = dark;
let ctx = egui::Context::default();
let mut output = egui::FullOutput::default();
for _ in 0..6 {
output = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() }, |ui| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
});
output.textures_delta.clear();
}
output.shapes.into_iter().map(|shape| shape.shape).collect()
}
fn field_button_fill(shapes: &[Shape], label: &str) -> Color32 {
let text = shapes
.iter()
.find_map(|shape| match shape {
Shape::Text(text) if text.galley.text() == label => Some(text),
_ => None,
})
.unwrap_or_else(|| panic!("pivot field button {label:?} was not painted"));
let text_rect = text.galley.rect.translate(text.pos.to_vec2());
shapes
.iter()
.filter_map(|shape| match shape {
Shape::Rect(rect) if rect.rect.contains(text_rect.center()) => Some(rect),
_ => None,
})
.min_by(|a, b| a.rect.area().total_cmp(&b.rect.area()))
.unwrap_or_else(|| panic!("pivot field button {label:?} has no painted background"))
.fill
}
#[test]
fn pivot_field_button_surfaces_follow_the_active_theme() {
for (dark, expected) in [(false, theme::Tokens::light().ribbon), (true, theme::Tokens::dark().ribbon)] {
let shapes = render_pivot_field_buttons(dark);
assert_eq!(field_button_fill(&shapes, "Region ▾"), expected, "dark mode: {dark}");
}
}
+11 -4
View File
@@ -272,7 +272,7 @@ pub fn read_chart(root: &El) -> Option<Chart> {
y_title: y_ax.and_then(|a| a.child("title")).and_then(rich_title),
source: None,
by_rows: false,
pivot: root.path(&["pivotSource", "name"]).map(|n| n.text.trim().to_string()).filter(|n| !n.is_empty()),
pivot: root.path(&["pivotSource", "name"]).map(|n| strip_book(n.text.trim()).to_string()).filter(|n| !n.is_empty()),
})
}
@@ -732,17 +732,24 @@ fn axis_xml(a: &Ax<'_>) -> String {
s
}
/// A PivotChart link without the workbook in brackets: `[Book.xlsx]Sheet2!PT` → `Sheet2!PT`.
fn strip_book(link: &str) -> &str {
link.strip_prefix('[').and_then(|r| r.split_once(']')).map_or(link, |(_, rest)| rest)
}
/// Chart part XML for a model chart on sheet `si`.
pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart) -> String {
pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart, book_name: &str) -> String {
let mut s = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><c:roundedCorners val=\"0\"/>",
);
if (1..=48).contains(&ch.style) {
let _ = write!(s, "<c:style val=\"{}\"/>", ch.style);
}
// A PivotChart names its PivotTable (`Sheet2!PivotTable1`).
// A PivotChart names its PivotTable behind the workbook in brackets (`[Book.xlsx]Sheet2!PivotTable1`);
// without the brackets Excel shows an ordinary chart.
if let Some(link) = ch.pivot.as_deref().map(str::trim).filter(|l| !l.is_empty()) {
let _ = write!(s, "<c:pivotSource><c:name>{}</c:name><c:fmtId val=\"0\"/></c:pivotSource>", esc(link));
let link = strip_book(link);
let _ = write!(s, "<c:pivotSource><c:name>[{}]{}</c:name><c:fmtId val=\"0\"/></c:pivotSource>", esc(book_name), esc(link));
}
s.push_str("<c:chart>");
match &ch.title {
+6
View File
@@ -145,6 +145,12 @@ pub fn write_xlsx_as(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError>
write::write_xlsx(wb, kind)
}
/// Writes a workbook as the given member of the `.xlsx` family for the file `book_name` (a bare
/// file name, used for the workbook in a PivotChart's link to its PivotTable).
pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: &str) -> Result<Vec<u8>, IoError> {
write::write_xlsx_named(wb, kind, Some(book_name))
}
/// CSV import options.
#[derive(Clone, Debug, PartialEq)]
pub struct CsvOptions {
+15 -1
View File
@@ -40,6 +40,7 @@ const MAX_ITEMS: usize = 1_048_576;
const MAX_FIELDS: usize = 16_384;
/// The `x14:dataField` extension (Excel 2010 show-values-as additions such as rank).
const EXT_X14_DATA_FIELD: &str = "{E15A36E0-9728-4e99-A89B-3F7291B0FE68}";
const EXT_X14_PIVOT_TABLE: &str = "{962EF5D1-5CA2-4c93-8EF4-DBF5C05439D2}";
const NS_X14: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main";
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -847,7 +848,14 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
} else {
row_levels.max(1)
};
let _ = write!(s, "<location ref=\"{}\" firstHeaderRow=\"1\" firstDataRow=\"{first_data_row}\" firstDataCol=\"{first_data_col}\"", loc.a1());
// Two or more value fields in Columns alone: Excel 365 would add a `Values` caption row.
let hide_values_row = col_values.is_some() && col_idx.is_empty();
let first_header_row = usize::from(!hide_values_row);
let _ = write!(
s,
"<location ref=\"{}\" firstHeaderRow=\"{first_header_row}\" firstDataRow=\"{first_data_row}\" firstDataCol=\"{first_data_col}\"",
loc.a1()
);
if !pages.is_empty() {
let _ = write!(s, " rowPageCount=\"{}\" colPageCount=\"1\"", pages.len());
}
@@ -1004,6 +1012,12 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
let _ = write!(s, " name=\"{}\"", esc_attr(&pt.style));
}
s.push_str(" showRowHeaders=\"1\" showColHeaders=\"1\" showRowStripes=\"0\" showColStripes=\"0\" showLastColumn=\"1\"/>");
if hide_values_row {
let _ = write!(
s,
"<extLst><ext uri=\"{EXT_X14_PIVOT_TABLE}\" xmlns:x14=\"{NS_X14}\"><x14:pivotTableDefinition hideValuesRow=\"1\"/></ext></extLst>"
);
}
s.push_str("</pivotTableDefinition>");
s
}
+1 -1
View File
@@ -544,7 +544,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
drels.add_uri(crate::chartex::REL_CHARTEX, &format!("../charts/chartEx{n}.xml"))
} else {
let name = format!("xl/charts/chart{}.xml", out.charts);
out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch).into_bytes());
out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch, &out.book_name).into_bytes());
drels.add("chart", &format!("../charts/chart{}.xml", out.charts))
};
objs.push(Obj::Chart(ch, rid));
+70 -21
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap;
use std::fmt::Write as _;
use gridcraft_model::style::Protection;
use gridcraft_model::style::{DxfStated, Protection};
use gridcraft_model::{
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, Underline, VAlign, VertAlign, Workbook,
};
@@ -57,6 +57,9 @@ fn bool_prop(e: &El, name: &str) -> bool {
e.child(name).is_some_and(|c| c.flag("val", true))
}
const DEFAULT_FONT_FAMILY: u8 = 2;
const MAX_FONT_FAMILY: u8 = 14;
pub fn read_font(e: &El, palette: &[u32]) -> Font {
let mut f = Font::default();
if let Some(n) = e.child("name").or_else(|| e.child("rFont")).and_then(|n| n.attr("val")) {
@@ -84,6 +87,12 @@ pub fn read_font(e: &El, palette: &[u32]) -> Font {
_ => VertAlign::Baseline,
};
}
f.charset = e.child("charset").and_then(|c| c.attr_u32("val")).and_then(|v| u8::try_from(v).ok());
f.family = e
.child("family")
.and_then(|c| c.attr_u32("val"))
.and_then(|v| u8::try_from(v).ok())
.filter(|v| *v <= MAX_FONT_FAMILY && *v != DEFAULT_FONT_FAMILY);
f.color = opt_color(e.child("color"), palette);
// Text 1 (dk1) is what "automatic" font colour means in practice.
if f.color == Color::Theme(1, 0) {
@@ -348,10 +357,23 @@ pub fn read_styles(root: &El, checkboxes: &[bool]) -> StylesIn {
d.kids("dxf")
.map(|x| {
let mut s = Style::default();
if let Some(f) = x.child("font") {
let f = read_font(f, &palette);
// Differential fonts rarely name a face; keep the default face and size.
s.font = Font { name: s.font.name.clone(), size: s.font.size, ..f };
if let Some(e) = x.child("font") {
let f = read_font(e, &palette);
// Differential fonts rarely name a face; unless stated, keep the default face and size.
let has_size = e.child("sz").and_then(|n| n.attr_f64("val")).is_some();
let has_name = e.child("name").and_then(|n| n.attr("val")).is_some();
let name = if has_name { f.name.clone() } else { s.font.name.clone() };
let size = if has_size { f.size } else { s.font.size };
let def = Font::default();
let st = &mut s.dxf_stated;
st.bold = e.child("b").is_some() && !f.bold;
st.italic = e.child("i").is_some() && !f.italic;
st.strike = e.child("strike").is_some() && !f.strike;
st.underline = e.child("u").is_some() && f.underline == Underline::None;
st.size = has_size && size == def.size;
st.name = has_name && name == def.name;
st.scheme = e.child_val("scheme").map(str::to_string);
s.font = Font { name, size, ..f };
}
if let Some(n) = x.child("numFmt") {
if let Some(code) = n.attr("formatCode") {
@@ -371,6 +393,7 @@ pub fn read_styles(root: &El, checkboxes: &[bool]) -> StylesIn {
}
if let Some(p) = x.child("protection") {
s.protection = read_protection(p);
s.dxf_stated.protection = s.protection == Protection::default();
}
s
})
@@ -396,19 +419,20 @@ pub fn color_attrs(c: &Color) -> String {
}
}
/// `minor`: the font follows the theme's body font (`<scheme val="minor"/>`).
fn font_xml(f: &Font, dxf: bool, minor: bool) -> String {
/// `minor`: the font follows the theme's body font (`<scheme val="minor"/>`). `dxf`: a
/// differential format's font, which writes only what it states.
fn font_xml(f: &Font, dxf: Option<&DxfStated>, minor: bool) -> String {
let stated = dxf.cloned().unwrap_or_default();
let mut s = String::from("<font>");
if f.bold {
s.push_str("<b/>");
}
if f.italic {
s.push_str("<i/>");
}
if f.strike {
s.push_str("<strike/>");
for (on, said, tag) in [(f.bold, stated.bold, "b"), (f.italic, stated.italic, "i"), (f.strike, stated.strike, "strike")] {
if on {
let _ = write!(s, "<{tag}/>");
} else if said {
let _ = write!(s, "<{tag} val=\"0\"/>");
}
}
match f.underline {
Underline::None if stated.underline => s.push_str("<u val=\"none\"/>"),
Underline::None => {}
Underline::Single => s.push_str("<u/>"),
Underline::Double => s.push_str("<u val=\"double\"/>"),
@@ -420,19 +444,31 @@ fn font_xml(f: &Font, dxf: bool, minor: bool) -> String {
VertAlign::Superscript => s.push_str("<vertAlign val=\"superscript\"/>"),
VertAlign::Subscript => s.push_str("<vertAlign val=\"subscript\"/>"),
}
if !dxf {
let def = Font::default();
if dxf.is_none() || stated.size || f.size != def.size {
let _ = write!(s, "<sz val=\"{}\"/>", num(f.size as f64));
}
if f.color != Color::Auto {
let _ = write!(s, "<color {}/>", color_attrs(&f.color));
} else if !dxf {
} else if dxf.is_none() {
s.push_str("<color theme=\"1\"/>");
}
if !dxf {
let _ = write!(s, "<name val=\"{}\"/><family val=\"2\"/>", esc_attr(&f.name));
if dxf.is_none() {
let family = f.family.filter(|v| *v <= MAX_FONT_FAMILY).unwrap_or(DEFAULT_FONT_FAMILY);
let _ = write!(s, "<name val=\"{}\"/><family val=\"{family}\"/>", esc_attr(&f.name));
if let Some(charset) = f.charset {
let _ = write!(s, "<charset val=\"{charset}\"/>");
}
if minor {
s.push_str("<scheme val=\"minor\"/>");
}
} else {
if stated.name || f.name != def.name {
let _ = write!(s, "<name val=\"{}\"/>", esc_attr(&f.name));
}
if let Some(sc) = &stated.scheme {
let _ = write!(s, "<scheme val=\"{}\"/>", esc_attr(sc));
}
}
s.push_str("</font>");
s
@@ -664,14 +700,24 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St
for d in dxfs {
dx.push_str("<dxf>");
let def = Style::default();
let st = &d.dxf_stated;
if d.font.bold
|| d.font.italic
|| d.font.strike
|| d.font.underline != Underline::None
|| d.font.color != Color::Auto
|| d.font.vert != VertAlign::Baseline
|| d.font.size != def.font.size
|| d.font.name != def.font.name
|| st.bold
|| st.italic
|| st.strike
|| st.underline
|| st.size
|| st.name
|| st.scheme.is_some()
{
dx.push_str(&font_xml(&d.font, true, false));
dx.push_str(&font_xml(&d.font, Some(st), false));
}
if d.num_fmt != def.num_fmt {
let id = nf.id(d.num_fmt.as_str());
@@ -686,6 +732,9 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St
if d.borders != def.borders {
dx.push_str(&border_xml(&d.borders));
}
if st.protection || d.protection != def.protection {
let _ = write!(dx, "<protection locked=\"{}\" hidden=\"{}\"/>", d.protection.locked as u8, d.protection.hidden as u8);
}
dx.push_str("</dxf>");
}
for f in extra_fmts {
@@ -705,7 +754,7 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St
let _ = write!(s, "<fonts count=\"{}\">", fonts.len());
for (i, f) in fonts.iter().enumerate() {
// A Normal font in the theme's body face follows the theme, as Excel writes it (#805).
s.push_str(&font_xml(f, false, i == 0 && f.name == wb.theme.minor_font));
s.push_str(&font_xml(f, None, i == 0 && f.name == wb.theme.minor_font));
}
let _ = write!(s, "</fonts><fills count=\"{}\">", fills.len());
for f in &fills {
+61
View File
@@ -189,6 +189,67 @@ fn styles_theme_and_sizes() {
assert_eq!(s.print.fit_to, Some((1, 0)));
}
/// A differential format's face, size and scheme survive a save, and so do the parts it states at
/// their default value: a bold, italic or underline turned off, a protection (#1209). Read as
/// unset, a header dxf that turns bold off lost it and the table style made the header bold again.
#[test]
fn dxf_font_and_parts_stated_at_their_defaults_survive_save() {
let dxfs = concat!(
r#"<dxf><font><b val="0"/><i val="0"/><strike val="0"/><u val="none"/><sz val="11"/><name val="Calibri"/><scheme val="minor"/></font></dxf>"#,
r#"<dxf><protection locked="1" hidden="0"/></dxf>"#,
r#"<dxf><font><b/></font><protection locked="0" hidden="1"/></dxf>"#,
r#"<dxf><font><sz val="9"/><name val="Arial"/></font></dxf>"#,
);
let styles = format!(
r#"<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border/></borders>
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellXfs>
<dxfs count="4">{dxfs}</dxfs>
</styleSheet>"#
);
let rels = r#"<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#;
let body = r#"<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData>
<conditionalFormatting sqref="A1"><cfRule type="cellIs" dxfId="0" priority="1" operator="greaterThan"><formula>0</formula></cfRule></conditionalFormatting>
<conditionalFormatting sqref="A2"><cfRule type="cellIs" dxfId="1" priority="2" operator="greaterThan"><formula>0</formula></cfRule></conditionalFormatting>
<conditionalFormatting sqref="A3"><cfRule type="cellIs" dxfId="2" priority="3" operator="greaterThan"><formula>0</formula></cfRule></conditionalFormatting>
<conditionalFormatting sqref="A4"><cfRule type="cellIs" dxfId="3" priority="4" operator="greaterThan"><formula>0</formula></cfRule></conditionalFormatting>"#;
let bytes = minimal(body, &[("xl/styles.xml", &styles)], rels, "");
let (wb, _) = read_xlsx(&bytes).unwrap();
let out = crate::write_xlsx(&wb).unwrap();
let st = crate::package::Package::open(&out).unwrap().read("xl/styles.xml").unwrap().unwrap();
let st = String::from_utf8(st).unwrap();
let written = st.split_once("<dxfs").and_then(|(_, r)| r.split_once("</dxfs>")).map(|(d, _)| d);
assert_eq!(written, Some(format!(r#" count="4">{dxfs}"#).as_str()));
}
/// A dxf writes its parts in schema order: the border comes before the protection.
#[test]
fn dxf_border_is_written_before_protection() {
let dxfs = r#"<dxf><border><left style="thin"><color rgb="FF000000"/></left></border><protection locked="0" hidden="1"/></dxf>"#;
let styles = format!(
r#"<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border/></borders>
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellXfs>
<dxfs count="1">{dxfs}</dxfs>
</styleSheet>"#
);
let rels = r#"<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#;
let body = r#"<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData>
<conditionalFormatting sqref="A1"><cfRule type="cellIs" dxfId="0" priority="1" operator="greaterThan"><formula>0</formula></cfRule></conditionalFormatting>"#;
let bytes = minimal(body, &[("xl/styles.xml", &styles)], rels, "");
let (wb, _) = read_xlsx(&bytes).unwrap();
let out = crate::write_xlsx(&wb).unwrap();
let st = crate::package::Package::open(&out).unwrap().read("xl/styles.xml").unwrap().unwrap();
let st = String::from_utf8(st).unwrap();
let dx = st.split_once("<dxfs").and_then(|(_, r)| r.split_once("</dxfs>")).map(|(d, _)| d).unwrap_or_default();
let (b, p) = (dx.find("<border"), dx.find("<protection"));
assert!(b.is_some() && p.is_some() && b < p, "{dx}");
}
#[test]
fn strict_namespace_and_shared_strings() {
let ct = super::CT;
+50
View File
@@ -674,3 +674,53 @@ fn data_on_rows_with_one_value_field_reads_without_warning() {
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert!(back.sheet(0).unwrap().pivots[0].values_on_rows);
}
/// A one-sheet-of-data workbook with a pivot on "Report": `columns` are the source columns
/// (header, values), `pt` the pivot built on `Data!A1:<last>`.
fn small_pivot(columns: &[(&str, Vec<Value>)], pt: impl FnOnce(&mut PivotTable)) -> HashMap<String, String> {
let mut wb = Workbook::new();
let data = wb.sheet_mut(0).unwrap();
data.name = "Data".into();
let mut rows = 0;
for (c, (h, vals)) in columns.iter().enumerate() {
data.cells.set(CellRef::new(0, c as u32), Cell::value(Value::text(*h)));
for (r, v) in vals.iter().enumerate() {
if *v != Value::Empty {
data.cells.set(CellRef::new(r as u32 + 1, c as u32), Cell::value(v.clone()));
}
}
rows = rows.max(vals.len());
}
let last = format!("{}{}", gridcraft_core::col_to_letters(columns.len() as u32 - 1), rows + 1);
let mut table = PivotTable { id: 1, name: "PT".into(), source: format!("Data!$A$1:{last}"), anchor: at("A3"), ..PivotTable::default() };
pt(&mut table);
let mut report = Sheet::new("Report");
report.pivots = vec![table];
wb.sheets.push(Arc::new(report));
unzip(&write_xlsx(&wb).unwrap()).into_iter().map(|(k, v)| (k, String::from_utf8_lossy(&v).into_owned())).collect()
}
/// #1447: two value fields and no column field: Excel 365 adds a `Values` row unless told not to.
#[test]
fn two_value_fields_without_columns_hide_the_values_row() {
let cols = [
("Region", vec![Value::text("East"), Value::text("West"), Value::text("East")]),
("Sales", vec![Value::number(10.0), Value::number(20.0), Value::number(5.0)]),
("Qty", vec![Value::number(1.0), Value::number(2.0), Value::number(3.0)]),
];
let value = |c: &str| PivotValue { source_col: c.into(), name: format!("Sum of {c}"), ..PivotValue::default() };
let two = small_pivot(&cols, |p| {
p.rows = vec![field("Region")];
p.values = vec![value("Sales"), value("Qty")];
});
let t = &two["xl/pivotTables/pivotTable1.xml"];
assert!(t.contains("firstHeaderRow=\"0\" firstDataRow=\"1\""), "{t}");
assert!(t.contains("<x14:pivotTableDefinition hideValuesRow=\"1\"/>"), "{t}");
let one = small_pivot(&cols, |p| {
p.rows = vec![field("Region")];
p.values = vec![value("Sales")];
});
let t = &one["xl/pivotTables/pivotTable1.xml"];
assert!(t.contains("firstHeaderRow=\"1\""), "{t}");
assert!(!t.contains("hideValuesRow"), "{t}");
}
+58
View File
@@ -1245,6 +1245,64 @@ fn default_font_survives_a_round_trip() {
assert_eq!(font0(&saved), (Some("12".into()), Some("Calibri".into()), Some("2".into()), Some("minor".into())));
}
fn one_font_workbook(font: &str) -> Vec<u8> {
let styles = format!(
r#"<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="1"><font>{font}</font></fonts><fills count="1"><fill><patternFill patternType="none"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>"#
);
let rels = r#"<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#;
super::minimal("<sheetData/>", &[("xl/styles.xml", &styles)], rels, "")
}
#[test]
fn font_charset_and_family_roundtrip() {
let file = one_font_workbook(r#"<name val="Traditional Arabic"/><family val="1"/><charset val="178"/>"#);
let (wb, _) = read_xlsx(&file).unwrap();
let saved = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&saved).unwrap();
let xml = String::from_utf8(pkg.read("xl/styles.xml").unwrap().unwrap()).unwrap();
assert!(xml.contains("<name val=\"Traditional Arabic\"/><family val=\"1\"/><charset val=\"178\"/>"), "{xml}");
let (back, _) = read_xlsx(&saved).unwrap();
let font = &back.styles.get(StyleId::DEFAULT).font;
let back_saved = write_xlsx(&back).unwrap();
let mut pkg = Package::open(&back_saved).unwrap();
let xml = String::from_utf8(pkg.read("xl/styles.xml").unwrap().unwrap()).unwrap();
assert!(xml.contains("<name val=\"Traditional Arabic\"/><family val=\"1\"/><charset val=\"178\"/>"), "{xml}");
assert_eq!(font.name, "Traditional Arabic");
assert_eq!(font.family, Some(1));
assert_eq!(font.charset, Some(178));
}
#[test]
fn fonts_without_charset_write_unchanged() {
let file = one_font_workbook(r#"<name val="Arial"/>"#);
let saved = write_xlsx(&read_xlsx(&file).unwrap().0).unwrap();
let mut pkg = Package::open(&saved).unwrap();
let xml = String::from_utf8(pkg.read("xl/styles.xml").unwrap().unwrap()).unwrap();
assert!(xml.contains("<name val=\"Arial\"/><family val=\"2\"/>"), "{xml}");
assert!(!xml.contains("<charset"), "{xml}");
}
#[test]
fn out_of_range_family_is_ignored() {
let file = one_font_workbook(r#"<name val="Arial"/><family val="15"/>"#);
let (wb, _) = read_xlsx(&file).unwrap();
assert_eq!(wb.styles.get(StyleId::DEFAULT).font.family, None);
let saved = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&saved).unwrap();
let xml = String::from_utf8(pkg.read("xl/styles.xml").unwrap().unwrap()).unwrap();
assert!(xml.contains("<name val=\"Arial\"/><family val=\"2\"/>"), "{xml}");
let mut set_directly = wb.clone();
let wide = set_directly.styles.derive(StyleId::DEFAULT, |st| st.font.family = Some(200));
set_directly.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), Value::text("x"));
set_directly.sheet_mut(0).unwrap().set_style(CellRef::new(0, 0), wide);
let saved = write_xlsx(&set_directly).unwrap();
let mut pkg = Package::open(&saved).unwrap();
let xml = String::from_utf8(pkg.read("xl/styles.xml").unwrap().unwrap()).unwrap();
assert!(!xml.contains("<family val=\"200\"/>"), "{xml}");
}
#[test]
fn solid_data_bar_survives_with_sparklines_and_gradient_neighbours() {
let mut wb = Workbook::new();
+12
View File
@@ -113,6 +113,8 @@ pub struct Out {
pub sst: Sst,
pub images: u32,
pub charts: u32,
/// The workbook's file name, which a PivotChart's link to its PivotTable names in brackets.
pub book_name: String,
/// References of chartex parts, defined as hidden `_xlchart.v1.N` names.
pub chart_names: Vec<String>,
pub drawings: u32,
@@ -199,7 +201,16 @@ fn check_sheet_names(wb: &Workbook) -> Result<(), IoError> {
Ok(())
}
/// Workbook name written in a PivotChart's link when the file name isn't known.
const DEFAULT_BOOK_NAME: &str = "Workbook.xlsx";
pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
write_xlsx_named(wb, kind, None)
}
/// Writes the workbook for the file `book_name` (a bare file name). A PivotChart is bound to its
/// PivotTable in Excel only when its link starts with the workbook in brackets.
pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) -> Result<Vec<u8>, IoError> {
if wb.sheets.is_empty() {
return Err(IoError::Format("a workbook needs at least one sheet".into()));
}
@@ -215,6 +226,7 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
sst: Sst::default(),
images: 0,
charts: 0,
book_name: book_name.filter(|n| !n.is_empty() && !n.contains(['[', ']'])).unwrap_or(DEFAULT_BOOK_NAME).to_string(),
chart_names: vec![],
drawings: 0,
tables: 0,
+3
View File
@@ -113,6 +113,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `<rPh sb="…" eb="…">` with `<phoneticPr>`) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `<phoneticPr>` without runs is kept on its own, and when duplicate `<si>` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) |
| 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |
| 2026-10-20 | trivial | A workbook theme's major/minor East Asian (`<a:ea>`) and complex-script (`<a:cs>`) typefaces are read and written back, so a Japanese theme's fonts (e.g. 游ゴシック) survive open + save; they were deleted before because the model had nowhere to keep them. Rendering still resolves fonts without them (follow-up) |
| 2026-10-19 | minor | XLSX font charset and family values are preserved on save, retaining the character set and family used for substitute font selection |
| 2026-10-19 | minor | .xls import skips dialog sheets (WsBool `fDialog`), which share the worksheet BOF type and were read as empty worksheets |
| 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over |
| 2026-10-19 | trivial | XLSX text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`, ECMA-376 Part 1 §21.1.2.2.7): read from the first paragraph and written on every paragraph; output without it is unchanged |
@@ -124,6 +125,8 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| 2026-10-16 | trivial | XLSX: pictures in headers and footers (`&G`, the VML drawing `<legacyDrawingHF>` names and its images) are kept on save; they are still not shown (#1257) |
| 2026-10-16 | minor | XLSX column imports refuse more than 1,000,000 cumulative expanded assignments per sheet before expanding the next span; repeated overlaps cannot bypass the work limit (#1022) |
| 2026-10-16 | minor | XLSX: a note keeps its box's place and size (`<x:Anchor>` and the VML width and height) through open and save, moving with its cell; notes made here get the default box (#1608) |
| 2026-10-16 | minor | XLSX PivotTables: two value fields without a column field are written with `firstHeaderRow="0"` and `hideValuesRow`, so Excel 365 doesn't add a `Values` row that can turn the report into `#SPILL!` (#1447, item 6) |
| 2026-10-16 | minor | XLSX PivotTables: a PivotChart's link to its PivotTable is written behind the workbook in brackets (`[Q1 Report.xlsx]Sheet2!PivotTable1`; `Workbook.xlsx` when the file name is unknown), so Excel binds the chart to the PivotTable instead of showing an ordinary chart; the reader drops the prefix (#1447, item 7) |
| 2026-10-15 | minor | XLSX: formulas and PivotTable sources quote sheet names like Excel (a leading `.`, R1C1-like and cell-like names), so `'.x'!A1` no longer saves as `.x!A1`, which Excel could not open (#1458) |
| 2026-10-15 | minor | XLSX text preserves CR and CRLF across save/reopen; shared strings use `_x000D_` and other XML text uses `&#13;` (#1488) |
| 2026-10-15 | minor | XLSX save keeps legacy `INDEX`/`MATCH` formulas scalar instead of marking them as dynamic arrays (#1505) |
+1
View File
@@ -115,6 +115,7 @@ language (none so far) and of localized function names.
| 2026-10-20 | minor | Dutch (nl) interface catalog (`i18n/nl.rs`, 518 labels, ~24%): the shared key set every language must cover (ribbon, context menus, File view, dialog titles, Options page), matching the German, French and Czech catalogs; `nl-NL`, `nl-BE` locale tags resolve to Dutch |
| 2026-10-20 | minor | XLSX: a theme's per-script typefaces (`<a:font script="Arab">`, Hebr, Jpan…) are kept through open + save, so Arabic Excel's theme fonts for shapes, charts and theme-font cells survive a GridCraft save |
| 2026-10-20 | trivial | XLSX: a workbook theme's major/minor East Asian (`<a:ea>`) and complex-script (`<a:cs>`) typefaces are kept through open + save, so a Japanese workbook keeps its theme's East Asian font; rendering does not resolve them yet (follow-up) |
| 2026-10-19 | minor | XLSX fonts retain charset and family metadata so systems can select a substitute font with the intended script when the named font is unavailable |
| 2026-10-19 | minor | Defined-name length counts characters, not bytes: Arabic names up to 255 letters are accepted, and Create from Selection keeps long Arabic headers |
| 2026-10-19 | minor | Table names are limited to 255 Unicode characters |
| 2026-10-19 | minor | Right-to-left sheets in legacy `.xls` files (common in older Arabic workbooks) open right-to-left |
+4 -3
View File
@@ -27,7 +27,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) |
| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); New sheet (+) right after the last tab, the strip capped at 60% of the bar (#943); manual wheel/trackpad browsing preserved; next/previous skips Hidden and VeryHidden sheets and retains the current sheet and selection when no visible destination exists; rename focus bug on macOS (#145) |
| Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial |
| Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing |
| Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes; PivotTable source-field list scrollbar sits at the right edge of its pane | element-level Format pane for shapes and pictures missing |
| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing |
| Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) |
| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has |
@@ -195,15 +195,16 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| 2026-10-16 | trivial | Web: preferences (theme, interface size, language, editing options…) are kept in the browser's `localStorage` between visits, as the desktop app keeps `ui.json` and `prefs.json`; before, a reload brought back light mode and the defaults (#1562) |
| 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) |
| 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior |
| 2026-10-14 | minor | PivotTable source-field list fills the pane width so its scrollbar stays at the list's right edge |
| 2026-10-13 | minor | Dragging a sheet tab with Ctrl held (Cmd or Option on macOS) drops a copy of the sheet where the tab is released, with the copy cursor (#971) |
| 2026-10-13 | trivial | More Colors… and Recent Colors (#928) |
| 2026-10-13 | minor | Grid drags keep their own cursor over selection borders, fill handles and outside the grid; hover resumes on release (#936) |
| 2026-10-13 | minor | AutoFill month patterns retain the workbook's date system, preventing shifted dates in 1904 workbooks when filling forward or backward |
| 2026-10-13 | minor | Sheet tab bar: the tab strip shrinks to its tabs, up to 60% of the bar, so New sheet (+) sits right after the last tab and the horizontal scrollbar takes the rest (part of #943) |
| 2026-10-12 | minor | Next/previous sheet navigation no longer activates hidden worksheets at either workbook boundary; command tests cover Hidden, VeryHidden and selection retention |
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
| 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) |
| 2026-10-13 | minor | Formula-entry Command/Ctrl-click adds arguments and Shift-click extends references; ordinary replacement and dragging retain their behavior. Readiness estimates unchanged. |
| 2026-10-12 | minor | Next/previous sheet navigation no longer activates hidden worksheets at either workbook boundary; command tests cover Hidden, VeryHidden and selection retention |
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |
| 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) |
| 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) |