Integration: track t15

# Conflicts:
#	contributors/people.toml
#	crates/engine/src/tests.rs
#	docs/file-format-parity.md
#	docs/localization-parity.md
This commit is contained in:
aiongg
2026-10-22 03:05:39 +00:00
26 changed files with 1342 additions and 137 deletions
+115 -36
View File
@@ -139,9 +139,10 @@ impl RoundTrip {
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FileReport {
pub path: String,
/// GridCraft couldn't open the file: its message.
/// Opening the file or discovering the requested inputs failed: its message.
pub error: Option<String>,
pub warnings: usize,
/// Import warnings, in the order the importer produced them.
pub warnings: Vec<String>,
pub calc: CalcReport,
pub round_trip: RoundTrip,
}
@@ -204,67 +205,112 @@ pub fn main(args: &[String]) -> ExitCode {
Err(_) => return usage(format!("--max-report needs a whole number, not `{n}`")),
}
}
let files = collect_inputs(&a.positional);
let reports: Vec<FileReport> = files
.iter()
.map(|p| {
let path = p.to_string_lossy().into_owned();
match gridcraft_engine::io::read_file(&path) {
Ok(bytes) => check_bytes(&path, &bytes, &opts),
Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() },
}
})
.collect();
let Discovery { files, failures: mut reports } = collect_inputs(&a.positional);
reports.extend(files.iter().map(|p| {
let path = p.to_string_lossy().into_owned();
match gridcraft_engine::io::read_file(&path) {
Ok(bytes) => check_bytes(&path, &bytes, &opts),
Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() },
}
}));
if a.has("json") {
outln!("{}", pretty(&report_json(&reports, &opts)));
} else {
print_human(&reports, &opts);
}
if reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE }
if !files.is_empty() && reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE }
}
#[derive(Default)]
struct Discovery {
files: Vec<PathBuf>,
failures: Vec<FileReport>,
}
impl Discovery {
fn fail(&mut self, path: &Path, error: String) {
self.failures.push(FileReport { path: path.to_string_lossy().into_owned(), error: Some(error), ..Default::default() });
}
/// False means the file budget is exhausted: the remaining scan is incomplete.
fn add_file(&mut self, path: PathBuf) -> bool {
if self.files.len() >= MAX_FILES {
self.fail(&path, format!("file limit of {MAX_FILES} reached; this and remaining inputs were not checked"));
return false;
}
self.files.push(path);
true
}
}
/// Files named on the command line are checked whatever their extension; directories contribute
/// their `.xlsx`/`.xlsm` files, recursively, in name order. Hidden files and Excel lock files
/// (`~$…`) are skipped.
fn collect_inputs(inputs: &[String]) -> Vec<PathBuf> {
let mut out = Vec::new();
fn collect_inputs(inputs: &[String]) -> Discovery {
let mut out = Discovery::default();
for i in inputs {
let p = PathBuf::from(i);
if p.is_dir() {
walk(&p, 0, &mut out);
} else {
// A missing path is reported as a file that failed to open.
out.push(p);
}
if out.len() >= MAX_FILES {
out.truncate(MAX_FILES);
if !walk(&p, 0, &mut out) {
break;
}
} else if !out.add_file(p) {
break;
}
}
if out.files.is_empty()
&& out.failures.is_empty()
&& let Some(input) = inputs.first()
{
out.fail(Path::new(input), "no workbook files were found in the requested inputs".into());
}
out
}
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth > MAX_DEPTH || out.len() >= MAX_FILES {
return;
/// Returns false only when the global file budget stops traversal.
fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
if depth > MAX_DEPTH {
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
return true;
}
let rd = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) => {
out.fail(dir, format!("cannot read directory: {e}"));
return true;
}
};
let mut entries = Vec::new();
for entry in rd {
match entry {
Ok(entry) => entries.push(entry.path()),
Err(e) => out.fail(dir, format!("cannot read directory entry: {e}")),
}
}
let Ok(rd) = std::fs::read_dir(dir) else { return };
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for p in entries {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if name.starts_with('.') || name.starts_with("~$") {
continue;
}
if p.is_dir() {
walk(&p, depth + 1, out);
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) {
out.push(p);
}
if out.len() >= MAX_FILES {
return;
let metadata = match p.metadata() {
Ok(metadata) => metadata,
Err(e) => {
out.fail(&p, format!("cannot inspect input: {e}"));
continue;
}
};
if metadata.is_dir() {
if !walk(&p, depth + 1, out) {
return false;
}
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
&& !out.add_file(p)
{
return false;
}
}
true
}
/// Checks one file's bytes. Never fails: problems are recorded in the report. A panic escaping
@@ -287,7 +333,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
return report;
}
};
report.warnings = warnings.len();
report.warnings = warnings;
let mut fresh = cached.clone();
let mut calc = Calc::new();
calc.recalc_all(&mut fresh);
@@ -1270,6 +1316,30 @@ mod tests {
use super::*;
#[test]
fn discovery_reports_a_directory_read_error() {
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
std::fs::write(&path, "not a directory").unwrap();
let mut discovery = Discovery::default();
assert!(walk(&path, 0, &mut discovery));
assert!(discovery.files.is_empty());
assert_eq!(discovery.failures.len(), 1);
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory"));
let _ = std::fs::remove_file(path);
}
#[test]
fn discovery_reports_omissions_only_after_the_file_limit() {
let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() };
assert!(discovery.add_file(PathBuf::from("last.xlsx")));
assert!(discovery.failures.is_empty());
assert!(!discovery.add_file(PathBuf::from("omitted.xlsx")));
assert_eq!(discovery.files.len(), MAX_FILES);
assert_eq!(discovery.failures[0].path, "omitted.xlsx");
assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
}
fn at(a1: &str) -> CellRef {
CellRef::parse(a1).unwrap()
}
@@ -1600,6 +1670,15 @@ mod tests {
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn json_lists_warning_messages() {
let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() };
let without = FileReport { path: "b.xlsx".into(), ..Default::default() };
let v = report_json(&[with, without], &Options::default());
assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"]));
assert_eq!(v["files"][1]["warnings"], json!([]));
}
#[test]
fn unopenable_files_are_reported() {
let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
+32
View File
@@ -174,3 +174,35 @@ fn check_exit_codes_and_json() {
assert_eq!(run(&["check", book_s, "--tolerance", "x"]).status.code(), Some(2));
assert_eq!(run(&["check", book_s, "--bogus"]).status.code(), Some(2));
}
#[test]
fn check_rejects_an_empty_scan() {
let dir = tmpdir("check-empty");
std::fs::write(dir.join("notes.txt"), "not a workbook").unwrap();
std::fs::write(dir.join(".hidden.xlsx"), "ignored").unwrap();
std::fs::write(dir.join("~$locked.xlsx"), "ignored").unwrap();
let out = run(&["check", dir.to_str().unwrap(), "--json"]);
assert_eq!(out.status.code(), Some(1));
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(report["files"][0]["error"].as_str().unwrap().contains("no workbook files"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn check_reports_a_depth_limited_scan_while_checking_readable_files() {
let dir = tmpdir("check-depth");
let book = dir.join("readable.xlsx");
ok(&["run", "--out", book.to_str().unwrap(), "--quiet"]);
let mut deep = dir.clone();
for _ in 0..34 {
deep.push("d");
}
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("skipped.xlsx"), "not reached").unwrap();
let out = run(&["check", dir.to_str().unwrap(), "--json"]);
assert_eq!(out.status.code(), Some(1));
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(report["totals"]["opened"], 1);
assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit"))));
let _ = std::fs::remove_dir_all(dir);
}
+12 -3
View File
@@ -119,14 +119,23 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
Line::Reject(e) => e,
Line::Request { id, method, params } => {
let (req, rx) = ControlRequest::new(method, params);
let state = std::sync::Arc::clone(&req.state);
if tx.send(req).is_err() {
break;
}
// Wake the UI thread: egui only runs frames when something happens.
ctx.request_repaint();
let mut reply = rx
.recv_timeout(Duration::from_secs(60))
.unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"}));
let mut reply = rx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| {
// Give up on the request as well as on its reply. While it is still
// queued this keeps it from running late -- the client was told it had
// failed, and an agent that retried after the error ran it twice (#1914);
// if the app had already started it, its outcome is unknown instead.
if ControlRequest::abandon(&state) {
json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"})
} else {
json!({"ok": false, "error": "timed out waiting for the UI, which had already started the request, so it may still finish; its outcome is unknown"})
}
});
if let Some(o) = reply.as_object_mut() {
o.insert("id".into(), id);
}
+3
View File
@@ -90,3 +90,6 @@ sync_github_name = false
[people.Harrison-Booth]
real_name = "Harrison Booth"
sync_github_name = false
[people.bilalmughal]
sync_github_name = true
+11
View File
@@ -359,6 +359,10 @@ pub fn number_to_text(n: f64) -> String {
if !(1e-9..1e15).contains(&a) && a.round() != a || a >= 1e15 {
return sci_text(n, 15);
}
// A value just below 1e15 can round up to 1e15 at 15 significant digits.
if format!("{:.14e}", a).parse::<f64>().is_ok_and(|r| r >= 1e15) {
return sci_text(n, 15);
}
// 15 significant digits, trailing zeros trimmed.
let mag = a.log10().floor() as i32;
let decimals = (14 - mag).clamp(0, 30) as usize;
@@ -402,6 +406,13 @@ mod tests {
assert_eq!(number_to_text(1.0 / 3.0), "0.333333333333333");
}
#[test]
fn number_text_rounds_up_to_scientific_boundary() {
assert_eq!(number_to_text(999999999999999.9), "1E+15");
assert_eq!(number_to_text(-999999999999999.9), "-1E+15");
assert_eq!(number_to_text(999999999999999.0), "999999999999999");
}
#[test]
fn coercion() {
assert_eq!(Value::from("12").to_number(), Ok(12.0));
+201
View File
@@ -0,0 +1,201 @@
//! Charts as inline SVG for Save as Web Page: the chart crate's drawing primitives, text included.
use std::fmt::Write as _;
use gridcraft_chart::{HAlign, Prim, Rgba, VAlign};
use gridcraft_model::{Chart, Workbook};
use crate::io::esc;
/// Widest or tallest chart written; larger anchors are clamped.
const MAX_SIDE: f32 = 16_384.0;
struct HelveticaMeasure;
impl gridcraft_chart::Measure for HelveticaMeasure {
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
gridcraft_pdf::text_width(if bold { gridcraft_pdf::Font::HelveticaBold } else { gridcraft_pdf::Font::Helvetica }, size, text)
}
}
/// `chart` of sheet `sheet` as an `<svg>` element placed by `style`, or `None` for a degenerate size.
pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str) -> Option<String> {
let (w, h) = (chart.anchor.width.min(MAX_SIDE), chart.anchor.height.min(MAX_SIDE));
if !(w >= 1.0 && h >= 1.0) {
return None;
}
let data = gridcraft_chart::resolve(wb, sheet, chart);
let prims = gridcraft_chart::render(chart, &data, w, h, &HelveticaMeasure);
Some(prims_svg(&prims, w, h, style))
}
fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String {
let mut out = String::new();
let _ = write!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"Helvetica,Arial,sans-serif\" style=\"{style}\">"
);
for p in prims {
match p {
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
let _ = write!(out, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", num(*x), num(*y), num(*w), num(*h));
if *radius > 0.0 {
let _ = write!(out, " rx=\"{}\"", num(*radius));
}
paint(&mut out, *fill, *stroke);
out.push_str("/>");
}
Prim::Line { pts, color, width, dash } => {
let _ = write!(out, "<polyline points=\"{}\"", points(pts));
paint(&mut out, None, Some((*color, *width)));
if *dash {
out.push_str(" stroke-dasharray=\"3 2\"");
}
out.push_str("/>");
}
Prim::Polygon { pts, fill, stroke } => {
let _ = write!(out, "<polygon points=\"{}\"", points(pts));
paint(&mut out, Some(*fill), *stroke);
out.push_str("/>");
}
Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => {
let _ = write!(out, "<path d=\"{}\"", wedge_path(*cx, *cy, *r_outer, *r_inner, *a0, *a1));
paint(&mut out, Some(*fill), *stroke);
out.push_str("/>");
}
Prim::Circle { cx, cy, r, fill, stroke } => {
let _ = write!(out, "<circle cx=\"{}\" cy=\"{}\" r=\"{}\"", num(*cx), num(*cy), num(*r));
paint(&mut out, *fill, *stroke);
out.push_str("/>");
}
Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {
// Baseline offsets as in the PDF export.
let dy = match valign {
VAlign::Top => size * 0.78,
VAlign::Middle => size * 0.35,
VAlign::Bottom => -size * 0.21,
};
let anchor = match align {
HAlign::Left => "start",
HAlign::Center => "middle",
HAlign::Right => "end",
};
let _ = write!(out, "<text x=\"{}\" y=\"{}\" font-size=\"{}\" text-anchor=\"{anchor}\"", num(*x), num(y + dy), num(*size));
if *bold {
out.push_str(" font-weight=\"bold\"");
}
if rotation.is_finite() && *rotation != 0.0 {
let _ = write!(out, " transform=\"rotate({} {} {})\"", num(rotation.to_degrees()), num(*x), num(*y));
}
let _ = write!(out, " fill=\"{}\"{}>{}</text>", hex(*color), opacity("fill", *color), esc(text));
}
}
}
out.push_str("</svg>");
out
}
/// Fill and stroke attributes of an open shape element; the caller closes it.
fn paint(out: &mut String, fill: Option<Rgba>, stroke: Option<(Rgba, f32)>) {
match fill {
Some(c) => {
let _ = write!(out, " fill=\"{}\"{}", hex(c), opacity("fill", c));
}
None => out.push_str(" fill=\"none\""),
}
if let Some((c, w)) = stroke {
let _ = write!(out, " stroke=\"{}\" stroke-width=\"{}\"{}", hex(c), num(w), opacity("stroke", c));
}
}
/// A pie or doughnut sector. Angles run clockwise from 12 o'clock, like the chart crate's.
fn wedge_path(cx: f32, cy: f32, r_outer: f32, r_inner: f32, a0: f32, a1: f32) -> String {
let span = (a1 - a0).clamp(-std::f32::consts::TAU + 0.001, std::f32::consts::TAU - 0.001);
let a1 = a0 + span;
let at = |a: f32, r: f32| format!("{} {}", num(cx + r * a.sin()), num(cy - r * a.cos()));
let large = u8::from(span.abs() > std::f32::consts::PI);
let sweep = u8::from(span >= 0.0);
let mut d = format!("M{} A{r} {r} 0 {large} {sweep} {}", at(a0, r_outer), at(a1, r_outer), r = num(r_outer));
if r_inner > 0.0 {
let _ = write!(d, " L{} A{r} {r} 0 {large} {} {} Z", at(a1, r_inner), 1 - sweep, at(a0, r_inner), r = num(r_inner));
} else {
let _ = write!(d, " L{} {} Z", num(cx), num(cy));
}
d
}
fn points(pts: &[[f32; 2]]) -> String {
pts.iter().map(|p| format!("{},{}", num(p[0]), num(p[1]))).collect::<Vec<_>>().join(" ")
}
fn hex(c: Rgba) -> String {
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
fn opacity(attr: &str, c: Rgba) -> String {
if c[3] == 255 { String::new() } else { format!(" {attr}-opacity=\"{:.2}\"", f32::from(c[3]) / 255.0) }
}
/// A coordinate with two decimals; a non-finite value is 0 so the SVG stays well-formed.
fn num(v: f32) -> String {
if !v.is_finite() {
return "0".into();
}
let s = format!("{v:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_primitive_becomes_an_svg_element() {
let red = [255, 0, 0, 255];
let prims = vec![
Prim::Rect { x: 1.0, y: 2.0, w: 3.0, h: 4.0, fill: Some(red), stroke: None, radius: 0.0 },
Prim::Line { pts: vec![[0.0, 0.0], [5.0, 5.5]], color: red, width: 1.0, dash: true },
Prim::Polygon { pts: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], fill: red, stroke: Some(([0, 0, 0, 255], 1.0)) },
Prim::Wedge { cx: 10.0, cy: 10.0, r_outer: 5.0, r_inner: 2.0, a0: 0.0, a1: 1.0, fill: red, stroke: None },
Prim::Circle { cx: 1.0, cy: 1.0, r: 2.0, fill: Some([0, 0, 255, 128]), stroke: None },
Prim::Text {
x: 5.0,
y: 6.0,
text: "A<B".into(),
size: 10.0,
color: red,
bold: true,
align: HAlign::Center,
valign: VAlign::Top,
rotation: -1.5,
},
];
let svg = prims_svg(&prims, 100.0, 50.0, "left:1px");
for part in [
"<rect x=\"1\" y=\"2\" width=\"3\" height=\"4\" fill=\"#ff0000\"/>",
"<polyline points=\"0,0 5,5.5\" fill=\"none\" stroke=\"#ff0000\" stroke-width=\"1\" stroke-dasharray=\"3 2\"/>",
"<polygon ",
"<path d=\"M10 5 A5 5 0 0 1 ",
"fill-opacity=\"0.50\"",
"text-anchor=\"middle\"",
"font-weight=\"bold\"",
"transform=\"rotate(-85.94 5 6)\"",
">A&lt;B</text>",
] {
assert!(svg.contains(part), "{part} in {svg}");
}
assert!(svg.starts_with("<svg ") && svg.ends_with("</svg>") && svg.contains("style=\"left:1px\""), "{svg}");
}
#[test]
fn non_finite_numbers_do_not_break_the_markup() {
let prims = [Prim::Circle { cx: f32::NAN, cy: f32::INFINITY, r: 1.0, fill: None, stroke: None }];
assert!(prims_svg(&prims, 10.0, 10.0, "").contains("<circle cx=\"0\" cy=\"0\" r=\"1\" fill=\"none\"/>"));
}
#[test]
fn a_full_circle_wedge_keeps_a_drawable_arc() {
let d = wedge_path(0.0, 0.0, 10.0, 0.0, 0.0, std::f32::consts::TAU);
assert!(d.contains("A10 10 0 1 1 ") && d.ends_with("L0 0 Z"), "{d}");
}
}
+6 -4
View File
@@ -651,12 +651,13 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.undo.pop() else { break };
let cur_wb = d.wb.clone();
// Redo goes back to the undone edit's cells, not wherever the user clicked before undoing (#1855).
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: e.selection.clone() };
d.redo.push(cur);
let cur_selection = e.selection.clone();
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
@@ -671,11 +672,12 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.redo.pop() else { break };
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
d.undo.push(cur);
let cur_wb = d.wb.clone();
let cur_selection = d.selection.clone();
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
+23 -3
View File
@@ -133,9 +133,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
number_format
),
cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, |s, p| set_fmt(
s, p, ACCOUNTING
)),
cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, accounting),
cmd!("home.percent", "Percent Style", ["Home", "Number"], Some("Ctrl+Shift+%"), "{range?}", has_doc, |s, p| set_fmt(s, p, "0%")),
cmd!("home.comma", "Comma Style", ["Home", "Number"], None, "{range?}", has_doc, |s, p| set_fmt(s, p, COMMA)),
cmd!("home.increaseDecimal", "Increase Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, 1)),
@@ -208,6 +206,23 @@ pub fn specs() -> Vec<CommandSpec> {
const ACCOUNTING: &str = "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)";
const COMMA: &str = "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)";
/// Accounting Number Format with the currency symbol to use (Excel's own button takes the system
/// currency; `symbol` is how the control channel picks another one, e.g. `"L"` for lempiras).
fn accounting(s: &mut Session, p: &Json) -> Result<Json> {
let symbol = str_param(p, "symbol").unwrap_or("$");
// The symbol goes into a quoted literal: a quote or a backslash would end it, and a control
// character would reach the format's text as-is.
if symbol.contains(['"', '\\']) || symbol.chars().any(char::is_control) {
return Err(bad("home.accounting", "the symbol can't contain a quote, a backslash or a control character"));
}
set_fmt(s, p, &accounting_format(symbol))
}
fn accounting_format(symbol: &str) -> String {
let symbol = format!("\"{symbol}\"");
format!("_({symbol}* #,##0.00_);_({symbol}* \\(#,##0.00\\);_({symbol}* \"-\"??_);_(@_)")
}
fn with(p: &Json, key: &str, v: Json) -> Json {
let mut m = p.as_object().cloned().unwrap_or_default();
m.insert(key.into(), v);
@@ -1228,6 +1243,11 @@ mod tests {
assert_eq!(v(&mut s, "D1"), json!(4.0));
}
#[test]
fn the_default_symbol_reproduces_the_accounting_format() {
assert_eq!(accounting_format("$"), ACCOUNTING);
}
#[test]
fn decimal_steps() {
let v = gridcraft_core::Value::Number(1.5);
+154 -9
View File
@@ -294,7 +294,7 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
.map_err(|e| EngineError::Other(e.to_string()))
}
fn esc(s: &str) -> String {
pub(crate) fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
@@ -448,31 +448,56 @@ impl std::fmt::Write for ClipboardHtml {
/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells.
const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
/// A sheet as a standalone HTML table (Save as Web Page).
/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's
/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor.
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) };
let fixed = !(sh.images.is_empty() && sh.charts.is_empty());
let used = sh.used_range();
let mut out = String::new();
let _ = write!(
out,
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>table{{border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt}}td{{border:1px solid #d4d4d4;padding:2px 4px;white-space:nowrap}}</style></head><body><table{}>",
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>table{{border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt{}}}td{{border:1px solid #d4d4d4;padding:{};white-space:nowrap{}}}</style></head><body>",
esc(&sh.name),
table_dir(sh)
if fixed { ";table-layout:fixed" } else { "" },
if fixed { "0 4px" } else { "2px 4px" },
if fixed { ";overflow:hidden;line-height:1" } else { "" }
);
if let Some(r) = sh.used_range() {
if fixed {
out.push_str("<div style=\"position:relative\">");
}
let _ = write!(out, "<table{}", table_dir(sh));
if let (true, Some(r)) = (fixed, used) {
let width: f32 = (r.start.col..=r.end.col).map(|c| sh.col_width(c)).sum();
let _ = write!(out, " style=\"width:{width}px\"");
}
out.push('>');
if let Some(r) = used {
if r.height() > HTML_EXPORT_MAX_ROWS {
return Err(EngineError::Other(format!(
"the sheet is too tall for a web page ({} rows, at most {HTML_EXPORT_MAX_ROWS}). Save as .xlsx or CSV instead.",
r.height()
)));
}
if fixed {
out.push_str("<colgroup>");
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
let _ = write!(out, "<col style=\"width:{}px\">", sh.col_width(col));
}
out.push_str("</colgroup>");
}
for row in r.start.row..=r.end.row {
if sh.is_row_hidden(row) {
continue;
}
out.push_str("<tr>");
if fixed {
let _ = write!(out, "<tr style=\"height:{}px\">", sh.row_height(row));
} else {
out.push_str("<tr>");
}
for col in r.start.col..=r.end.col {
let c = CellRef::new(row, col);
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) {
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) || (fixed && sh.is_col_hidden(col)) {
continue;
}
let text = crate::display::cell_text(wb, sh, c);
@@ -482,14 +507,52 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
.merges
.iter()
.find(|m| m.start == c)
.map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width()))
.map(|m| {
if fixed {
let rows = (m.start.row..=m.end.row).filter(|&y| !sh.is_row_hidden(y)).count();
let cols = (m.start.col..=m.end.col).filter(|&x| !sh.is_col_hidden(x)).count();
format!(" rowspan=\"{rows}\" colspan=\"{cols}\"")
} else {
format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())
}
})
.unwrap_or_default();
let _ = write!(out, "<td style=\"{css}\"{}{span}>{}</td>", dir.attr(), esc(&text));
}
out.push_str("</tr>");
}
}
out.push_str("</table></body></html>");
out.push_str("</table>");
if fixed {
// Sheet units are CSS pixels; the table starts at the used range, not at A1.
let (left, top) = used.map_or((0.0, 0.0), |r| (sh.col_left(r.start.col), sh.row_top(r.start.row)));
let place = |a: &gridcraft_model::Anchor| {
format!(
"position:absolute;left:{}px;top:{}px;width:{}px;height:{}px",
sh.col_left(a.cell.col) - left + f64::from(a.dx),
sh.row_top(a.cell.row) - top + f64::from(a.dy),
a.width.max(0.0),
a.height.max(0.0)
)
};
for im in &sh.images {
let _ = write!(
out,
"<img src=\"data:{};base64,{}\" alt=\"{}\" style=\"{}\">",
esc(&im.mime),
base64_encode(&im.data),
esc(&im.alt),
place(&im.anchor)
);
}
for ch in &sh.charts {
if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor)) {
out.push_str(&svg);
}
}
out.push_str("</div>");
}
out.push_str("</body></html>");
Ok(out)
}
@@ -612,6 +675,88 @@ mod tests {
range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap()
}
#[test]
fn html_export_embeds_images_as_data_uris() {
let mut wb = Workbook::new();
wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image {
id: 1,
anchor: gridcraft_model::Anchor { cell: CellRef::new(0, 0), dx: 0.0, dy: 0.0, width: 72.0, height: 36.0, mode: Default::default() },
data: vec![1, 2, 3],
mime: "image/png".into(),
alt: "a \"logo\"".into(),
});
let html = to_html(&wb, 0).unwrap();
assert!(
html.contains("<img src=\"data:image/png;base64,AQID\" alt=\"a &quot;logo&quot;\" style=\"position:absolute;left:0px;top:0px;width:72px;height:36px\"></div></body>"),
"{html}"
);
assert!(html.contains("<div style=\"position:relative\"><table"), "{html}");
assert!(!to_html(&Workbook::new(), 0).unwrap().contains("<img"), "no images, no <img>");
}
#[test]
fn html_export_places_images_at_their_anchor_using_sheet_geometry() {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() });
sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() });
sh.images.push(gridcraft_model::Image {
id: 1,
anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() },
data: vec![1],
mime: "image/png".into(),
alt: String::new(),
});
sh.set_value(CellRef::new(1, 1), gridcraft_core::Value::Number(1.0));
let html = to_html(&wb, 0).unwrap();
// The table starts at B2; the picture is at C4: one 100 px column and two rows (30 + 20) further on.
assert!(html.contains("<col style=\"width:100px\">"), "{html}");
assert!(html.contains("<tr style=\"height:30px\">"), "{html}");
assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}");
}
fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0));
sh.images.push(gridcraft_model::Image {
id: 1,
anchor: gridcraft_model::Anchor {
cell: CellRef::parse(anchor).unwrap(),
dx: 0.0,
dy: 0.0,
width: 10.0,
height: 10.0,
mode: Default::default(),
},
data: vec![1],
mime: "image/png".into(),
alt: String::new(),
});
wb
}
#[test]
fn html_export_places_images_past_hidden_columns_and_rows() {
let mut wb = book_with_picture("C3", (0, 0));
let sh = wb.sheet_mut(0).unwrap();
sh.set_value(CellRef::new(2, 2), gridcraft_core::Value::Number(2.0));
sh.cols.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
sh.rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
let html = to_html(&wb, 0).unwrap();
// Column B and row 2 take no room: C3 is one default column and row from A1.
assert_eq!(html.matches("<col ").count(), 2, "{html}");
assert_eq!(html.matches("<tr ").count(), 2, "{html}");
assert!(html.contains("left:64px;top:20px"), "{html}");
}
#[test]
fn html_export_places_images_above_and_left_of_the_table() {
let html = to_html(&book_with_picture("A1", (2, 2)), 0).unwrap();
// The table starts at C3, so a picture at A1 sits before it instead of failing.
assert!(html.contains("left:-128px;top:-40px"), "{html}");
}
#[test]
fn html_export_marks_right_to_left_sheets() {
use gridcraft_core::Value;
+84 -8
View File
@@ -8,6 +8,7 @@
pub mod catalog;
pub mod cf;
mod chart_svg;
mod clip_html;
pub mod cmd;
pub mod display;
@@ -61,6 +62,8 @@ pub struct HistoryEntry {
pub label: String,
pub wb: Arc<Workbook>,
pub selection: Selection,
/// Estimate of storage this snapshot retains beyond the next workbook version.
pub retained_bytes: usize,
}
/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference.
@@ -116,6 +119,50 @@ pub struct DocState {
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
const UNDO_LIMIT: usize = 100;
/// Past this many entries the journal drops its oldest half.
const JOURNAL_MAX_ENTRIES: usize = 10_000;
/// Past this many bytes (entries serialized as JSON) the journal drops its oldest entries until
/// it is under half. Entries keep their full params so they replay (recorded scripts,
/// `engine.journal`), and a `file.open` holds the whole file as base64: without a size cap a
/// few opens of a large workbook keep tens of MB alive.
pub(crate) const JOURNAL_MAX_BYTES: usize = 64 * 1024 * 1024;
const UNDO_BYTES_LIMIT: usize = 128 * 1024 * 1024;
fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
let mut bytes = std::mem::size_of::<Workbook>();
for (index, sheet) in older.sheets.iter().enumerate() {
// Moving or inserting sheets must not charge unchanged sheets at a new index.
if newer.sheets.iter().any(|next| Arc::ptr_eq(sheet, next)) {
continue;
}
bytes = bytes.saturating_add(std::mem::size_of::<gridcraft_model::Sheet>());
let empty = gridcraft_model::CellStore::new();
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
}
bytes
}
fn push_history(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
push_history_with_budget(history, label, wb, next, selection, UNDO_BYTES_LIMIT);
}
fn push_history_with_budget(
history: &mut Vec<HistoryEntry>,
label: String,
wb: Arc<Workbook>,
next: &Workbook,
selection: Selection,
byte_budget: usize,
) {
let retained_bytes = history_retained_bytes(&wb, next);
history.push(HistoryEntry { label, wb, selection, retained_bytes });
let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes));
// Always preserve the latest action, even if that one edit exceeds the budget.
while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) {
total = total.saturating_sub(history.remove(0).retained_bytes);
}
}
impl DocState {
pub fn new(mut wb: Workbook, path: Option<String>, title: String) -> DocState {
@@ -243,6 +290,9 @@ pub struct Session {
pub mode: Mode,
/// Commands run so far, without their passwords ([`cmd::journal_params`]).
pub journal: Vec<(String, Json)>,
/// Serialized size of each journal entry, measured once when it is pushed, and their sum.
journal_sizes: Vec<usize>,
journal_bytes: usize,
pub ui_requests: Vec<UiRequest>,
/// Format painter source style (sticky when double-clicked).
pub format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
@@ -319,6 +369,38 @@ impl Session {
self.add_document(DocState::new(wb, None, format!("Book{n}")))
}
/// Journals a command, then trims the oldest entries past either cap. A recording in progress
/// keeps pointing at the same entry (the steps the trim dropped are lost).
fn push_journal(&mut self, id: &str, params: Json) {
let size = |e: &(String, Json)| serde_json::to_vec(e).map_or(0, |v| v.len());
// `journal` is public: if it was changed from outside, measure it again.
if self.journal_sizes.len() != self.journal.len() {
self.journal_sizes = self.journal.iter().map(size).collect();
self.journal_bytes = self.journal_sizes.iter().sum();
}
let entry = (id.to_string(), params);
let entry_size = size(&entry);
self.journal.push(entry);
self.journal_sizes.push(entry_size);
self.journal_bytes = self.journal_bytes.saturating_add(entry_size);
let len = self.journal.len();
let mut n = if len > JOURNAL_MAX_ENTRIES { len - JOURNAL_MAX_ENTRIES / 2 } else { 0 };
let mut kept = self.journal_bytes.saturating_sub(self.journal_sizes.iter().take(n).sum());
if self.journal_bytes > JOURNAL_MAX_BYTES {
// The newest entry stays even when it is larger than the cap on its own.
while kept > JOURNAL_MAX_BYTES / 2 && n + 1 < len {
kept = kept.saturating_sub(self.journal_sizes.get(n).copied().unwrap_or(0));
n += 1;
}
}
if n > 0 {
self.journal.drain(..n);
self.journal_sizes.drain(..n);
self.journal_bytes = kept;
self.recording = self.recording.map(|r| r.saturating_sub(n));
}
}
pub fn commands(&self) -> Vec<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
}
@@ -350,20 +432,14 @@ impl Session {
}
};
if spec.journal && result.is_ok() {
self.journal.push((id.to_string(), cmd::journal_params(&params)));
if self.journal.len() > 10_000 {
self.journal.drain(..5_000);
}
self.push_journal(id, cmd::journal_params(&params));
}
if spec.undoable
&& let Some((uid, wb, sel)) = before
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid)
&& !Arc::ptr_eq(&d.wb, &wb)
{
d.undo.push(HistoryEntry { label: spec.label.trim_end_matches('…').to_string(), wb, selection: sel });
if d.undo.len() > UNDO_LIMIT {
d.undo.remove(0);
}
push_history(&mut d.undo, spec.label.trim_end_matches('…').to_string(), wb, &d.wb, sel);
d.redo.clear();
d.revision += 1;
}
+132
View File
@@ -341,6 +341,50 @@ fn redo_selects_the_redone_cell() {
assert_eq!(s.doc().unwrap().selection.a1(), "A3");
}
#[test]
fn bulk_formula_history_is_bounded_and_latest_edit_still_undoes() {
use std::sync::Arc;
use gridcraft_model::{Cell, Formula, Workbook};
let mut current = Arc::new(Workbook::new());
let mut history = Vec::new();
let budget = 64 * 1024;
for version in 0..5 {
let previous = current.clone();
let mut next = (*previous).clone();
let sheet = next.sheet_mut(0).unwrap();
for row in 0..128 {
sheet.cells.set(CellRef::new(row, 0), Cell::formula(Formula::new(&format!("=B{}+{}", row + 1, version))));
}
current = Arc::new(next);
crate::push_history_with_budget(&mut history, format!("edit {version}"), previous, &current, crate::Selection::default(), budget);
}
assert!(history.len() < 5, "bulk edits should evict old steps before reaching the count limit");
assert_eq!(history.last().unwrap().label, "edit 4");
assert!(history.len() == 1 || history.iter().map(|e| e.retained_bytes).sum::<usize>() <= budget);
let mut session = s();
let doc = session.doc_mut().unwrap();
doc.wb = current;
doc.undo = history;
session.execute("edit.undo", json!({})).unwrap();
assert_eq!(session.doc().unwrap().wb.active().unwrap().cells.get(CellRef::new(0, 0)).unwrap().formula.as_ref().unwrap().text, "B1+3");
session.execute("edit.redo", json!({})).unwrap();
assert_eq!(session.doc().unwrap().wb.active().unwrap().cells.get(CellRef::new(0, 0)).unwrap().formula.as_ref().unwrap().text, "B1+4");
}
#[test]
fn small_edits_keep_the_full_undo_depth() {
let mut session = s();
for number in 0..100 {
session.execute("cell.set", json!({"cell": "A1", "input": number.to_string()})).unwrap();
}
assert_eq!(session.doc().unwrap().undo.len(), 100);
session.execute("edit.undo", json!({"steps": 100})).unwrap();
assert_eq!(v(&session, "A1"), Value::Empty);
}
#[test]
fn shape_text_edit_undo_redo_and_noop() {
let mut s = s();
@@ -1639,6 +1683,28 @@ fn slicer_filters_table() {
assert!(s.execute("slicer.toggle", json!({"value": "Nowhere"})).is_err());
}
#[test]
fn accounting_format_takes_the_symbol_it_is_given() {
// The command documents `symbol`; it used to be dropped, so only `$` was ever reachable
// from the control channel (asking for lempiras, #1893, changed nothing).
let text = |s: &Session, a: &str| {
let d = s.doc().unwrap();
crate::display::cell_text(&d.wb, d.wb.active().unwrap(), CellRef::parse(a).unwrap())
};
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "1234.5"})).unwrap();
s.execute("home.accounting", json!({"range": "A1", "symbol": "L"})).unwrap();
assert_eq!(text(&s, "A1"), " L 1,234.50 ");
// Without a symbol it stays the default `$` format, character for character.
s.execute("home.accounting", json!({"range": "A1"})).unwrap();
assert_eq!(text(&s, "A1"), " $ 1,234.50 ");
// A quote or a backslash would end the format's quoted text, and a control character would
// reach it as-is, so those symbols are refused.
for bad in ["\"", "\\", "\n", "L\t"] {
assert!(s.execute("home.accounting", json!({"range": "A1", "symbol": bad})).is_err(), "{bad:?}");
}
}
#[test]
fn table_header_formatted_as_accounting_keeps_its_text_as_the_column_name() {
// Found by the command-sequence fuzzer: the accounting format pads text (` 172.4 `), and that
@@ -4327,3 +4393,69 @@ fn box_whisker_quartile_methods_survive_xlsx() {
s.execute("chart.set", json!({"quartiles": "exclusive"})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().charts[0].series.iter().all(|s| s.quartiles == QuartileMethod::Exclusive));
}
#[test]
fn journal_is_capped_by_serialized_size() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
// Large entries (like a `file.open` with its base64): the oldest go once the cap is passed.
let pad = "x".repeat(crate::JOURNAL_MAX_BYTES / 8);
for i in 0..12 {
s.execute("selection.set", json!({"range": "B2", "pad": pad, "n": i})).unwrap();
}
let bytes: usize = s.journal.iter().map(|e| serde_json::to_vec(e).unwrap().len()).sum();
assert!(bytes <= crate::JOURNAL_MAX_BYTES, "{bytes}");
assert!(s.journal.len() < 12, "{}", s.journal.len());
assert_eq!(s.journal.last().unwrap().1["n"], 11, "the newest entries stay");
// Small entries are kept as they were.
s.execute("cell.set", json!({"cell": "A2", "input": "=SUM(A1)"})).unwrap();
assert_eq!(s.journal.last().unwrap(), &("cell.set".to_string(), json!({"cell": "A2", "input": "=SUM(A1)"})));
}
#[test]
fn recording_keeps_its_steps_when_the_journal_is_trimmed() {
let mut s = s();
for _ in 0..9_990 {
s.execute("selection.set", json!({"range": "A1"})).unwrap();
}
s.execute("automate.recordActions", json!({})).unwrap();
// The 10th entry past the start overflows the 10,000-entry cap and drops the oldest half.
for i in 0..20 {
s.execute("cell.set", json!({"cell": "B1", "input": format!("step{i}")})).unwrap();
}
let r = s.execute("automate.recordActions", json!({"stop": true, "name": "Steps"})).unwrap();
assert_eq!(r["steps"], 20, "{r}");
let script = s.doc().unwrap().wb.names.iter().find(|n| n.name == "_sc_scripts").map(|n| n.formula.clone()).unwrap();
assert!(script.contains("step0") && script.contains("step19") && !script.contains("selection.set"), "{script}");
}
#[test]
fn html_export_draws_charts_as_inline_svg_with_their_text() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Month", "Sales"], ["Jan", 5], ["Feb", 7], ["Mar", 9]]})).unwrap();
s.execute("insert.chart", json!({"range": "A1:B4", "type": "column", "title": "Quarterly <Sales>", "at": "D2", "width": 300, "height": 200}))
.unwrap();
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
assert!(html.contains("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"300\" height=\"200\""), "{html}");
assert!(html.contains(">Quarterly &lt;Sales&gt;</text>"), "the title is drawn as escaped text: {html}");
assert!(html.contains("position:absolute;left:"), "{html}");
assert!(html.contains("<rect "), "{html}");
}
#[test]
fn html_export_draws_pie_wedges_as_svg_paths() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Share"], ["North", 60], ["South", 40]]})).unwrap();
s.execute("insert.chart", json!({"range": "A1:B3", "type": "pie", "title": "Share"})).unwrap();
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
assert!(html.matches("<path d=\"M").count() >= 2, "one wedge per slice: {html}");
assert!(html.contains(">North</text>") && html.contains(">South</text>"), "the legend names the slices: {html}");
}
#[test]
fn html_export_without_pictures_or_charts_has_no_fixed_geometry() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["a", "b"]]})).unwrap();
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
assert!(!html.contains("<colgroup>") && !html.contains("table-layout") && !html.contains("<svg") && !html.contains("<img"), "{html}");
}
+49
View File
@@ -217,6 +217,55 @@ impl CellStore {
}
out
}
/// Approximate heap storage kept alive by this snapshot's bands that `newer` no longer
/// shares. This is for bounding Undo history, not for reporting process memory: BTreeMap
/// nodes and allocator overhead vary by platform. Shared formulas and values are counted
/// only when the newer band no longer holds the same Arc.
pub fn retained_bytes_against(&self, newer: &CellStore) -> usize {
use std::mem::size_of;
let mut bytes = 0usize;
for (key, band) in &self.bands {
let next = newer.bands.get(key);
if next.is_some_and(|b| Arc::ptr_eq(b, band)) {
continue;
}
bytes = bytes.saturating_add(size_of::<Band>());
for (row, cols) in &band.rows {
bytes = bytes.saturating_add(size_of::<u32>() + 3 * size_of::<usize>());
for (col, cell) in cols {
// Cells and BTreeMap nodes are duplicated with a cloned band even when
// their formula/value Arcs are still shared with the next snapshot.
bytes = bytes.saturating_add(size_of::<u32>() + size_of::<Cell>() + 3 * size_of::<usize>());
let next_cell = next.and_then(|b| b.rows.get(row)).and_then(|r| r.get(col));
if let Some(formula) = &cell.formula
&& !next_cell.and_then(|c| c.formula.as_ref()).is_some_and(|f| Arc::ptr_eq(f, formula))
{
bytes = bytes.saturating_add(size_of::<crate::cell::Formula>() + formula.text.len());
if let Some(expr) = &formula.expr {
expr.walk(&mut |_| bytes = bytes.saturating_add(size_of::<gridcraft_formula::Expr>()));
}
}
match &cell.value {
gridcraft_core::Value::Text(text)
if !next_cell.and_then(|c| c.value.as_text()).is_some_and(|t| std::ptr::eq(t, text.as_ref())) =>
{
bytes = bytes.saturating_add(text.len());
}
gridcraft_core::Value::Array(array)
if !next_cell.is_some_and(|c| matches!(&c.value, gridcraft_core::Value::Array(a) if Arc::ptr_eq(a, array))) =>
{
bytes = bytes.saturating_add(
size_of::<gridcraft_core::Array>() + array.data.len().saturating_mul(size_of::<gridcraft_core::Value>()),
);
}
_ => {}
}
}
}
}
bytes
}
/// Removes every cell in `r` and returns them.
pub fn take_range(&mut self, r: RangeRef) -> Vec<(CellRef, Cell)> {
let keys: Vec<CellRef> = self.iter_range(r).map(|(c, _)| c).collect();
+16 -9
View File
@@ -466,20 +466,26 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
let has_q = |idx: &[usize]| idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('?')));
let has_whole = !whole_idx.is_empty();
let (mut whole, f) = if has_whole { (x.floor(), x - x.floor()) } else { (0.0, x) };
let (mut num, den) = match fixed_den {
Some(d) => ((f * d as f64).round() as u64, d),
// Without a whole part the numerator carries the whole value and can pass u64::MAX, so it
// stays an f64 (a float-to-u64 cast saturates).
let (mut num, den): (f64, u64) = match fixed_den {
Some(d) => ((f * d as f64).round(), d),
None if f >= 1e15 => (f.round(), 1),
None => {
let digits = den_idx.len().clamp(1, 7) as u32;
approximate(f, 10u64.pow(digits) - 1)
let (n, d) = approximate(f, 10u64.pow(digits) - 1);
(n as f64, d)
}
};
if has_whole && num >= den && den > 0 {
whole += (num / den) as f64;
num %= den;
if has_whole && num >= den as f64 && den > 0 {
// The fractional part is below 1, so the numerator is at most the denominator here.
let n = num as u64;
whole += (n / den) as f64;
num = (n % den) as f64;
}
let whole_s = if whole >= 1.0 { fixed(whole, 0).0 } else { String::new() };
let num_zero_ph = num_idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('0')));
if has_whole && num == 0 && !num_zero_ph {
if has_whole && num == 0.0 && !num_zero_ph {
fill_int(toks, &whole_idx, &whole_s, sec.thousands, true, pieces);
let spaces = has_q(&whole_idx) || has_q(&num_idx) || has_q(&den_idx);
blank_range(toks, sep_start..den_end, spaces, pieces, lits);
@@ -488,7 +494,7 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
if has_whole {
// A zero value shows `0` in a `?` or `0` units place, nothing in a `#` one.
let units_hash = whole_idx.last().is_some_and(|&i| toks.get(i) == Some(&Tok::Digit('#')));
let force = whole_s.is_empty() && num == 0 && !units_hash;
let force = whole_s.is_empty() && num == 0.0 && !units_hash;
fill_int(toks, &whole_idx, &whole_s, sec.thousands, force, pieces);
let whole_shown = whole_idx.iter().any(|&i| pieces.get(i).is_some_and(|p| p.chars().any(|c| c.is_ascii_digit())));
if !whole_shown {
@@ -496,7 +502,8 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
blank_range(toks, sep_start..num_start, spaces, pieces, lits);
}
}
fill_int(toks, &num_idx, &num.to_string(), false, true, pieces);
let num_s = if num < 1e15 { (num as u64).to_string() } else { fixed(num, 0).0 };
fill_int(toks, &num_idx, &num_s, false, true, pieces);
if let Some(p) = pieces.get_mut(slash) {
*p = "/".into();
}
+12
View File
@@ -158,6 +158,18 @@ fn fractions() {
assert_eq!(f("# ???/???", 0.5), " 1/2 ");
}
/// A fraction without a whole part puts the whole value in the numerator; large values must
/// not saturate at u64::MAX.
#[test]
fn fraction_numerator_beyond_u64() {
assert_eq!(f("?/1000000000", 2e10), "20000000000000000000/1000000000");
assert_eq!(f("?/?", 1e20), "100000000000000000000/1");
assert_eq!(f("?/100", 1e18), "100000000000000000000/100");
assert_eq!(f("??/??", 1e25), "10000000000000000000000000/1 ");
assert!(!f("?/10", 1e300).contains("18446744073709551615"));
assert_eq!(f("?/1000000000", 2.5), "2500000000/1000000000");
}
/// Fraction sections with literal text, as Excel renders them (observed `TEXT()` results).
#[test]
fn fractions_with_literals() {
+99 -21
View File
@@ -29,12 +29,35 @@ pub struct ControlRequest {
pub method: String,
pub params: Json,
pub reply: Sender<ControlResponse>,
/// Who has the request: [`PENDING`], [`TAKEN`] (the app runs it) or [`ABANDONED`] (the sender
/// gave up waiting, so the app must not run it late: a client told its request timed out had
/// it applied anyway on the next UI pass, and an agent that retried ran it twice (#1914).
pub state: std::sync::Arc<std::sync::atomic::AtomicU8>,
}
/// Queued, not yet run.
pub const PENDING: u8 = 0;
/// The app has started it: it will answer.
pub const TAKEN: u8 = 1;
/// Its sender gave up waiting (the reply timeout expired): it is dropped unrun.
pub const ABANDONED: u8 = 2;
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Json) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx }, rx)
(Self { method: method.into(), params, reply: tx, state: Default::default() }, rx)
}
/// Claim the request for running; false when its sender already gave up on it.
pub fn take(&self) -> bool {
use std::sync::atomic::Ordering;
self.state.compare_exchange(PENDING, TAKEN, Ordering::SeqCst, Ordering::SeqCst).is_ok() || self.state.load(Ordering::SeqCst) == TAKEN
}
/// Give up on the request; false when the app has already started it (it will answer).
pub fn abandon(state: &std::sync::atomic::AtomicU8) -> bool {
use std::sync::atomic::Ordering;
state.compare_exchange(PENDING, ABANDONED, Ordering::SeqCst, Ordering::SeqCst).is_ok() || state.load(Ordering::SeqCst) == ABANDONED
}
}
@@ -143,24 +166,42 @@ fn cell_center(app: &SheetApp, c: CellRef) -> Option<egui::Pos2> {
Some(geo.screen_cell_rect(sh, c).center())
}
fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) {
/// Most synthetic pointer events a control client may keep queued at once (the queue drains one batch per frame).
const MAX_QUEUED_POINTER_EVENTS: usize = 10_000;
/// Refuses a request that would push the queued pointer events past [`MAX_QUEUED_POINTER_EVENTS`]; the queue is left unchanged.
fn pointer_budget(app: &SheetApp, extra: usize) -> Result<(), String> {
let queued: usize = app.synthetic.iter().map(Vec::len).sum();
if queued.saturating_add(extra) > MAX_QUEUED_POINTER_EVENTS {
return Err(format!("too many pointer events (limit {MAX_QUEUED_POINTER_EVENTS} queued at once)"));
}
Ok(())
}
fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) -> Result<(), String> {
let count = count.max(1) as usize;
pointer_budget(app, count.saturating_mul(2).saturating_add(1))?;
app.synthetic.push_back(vec![egui::Event::PointerMoved(pos)]);
let mut batch = Vec::new();
for _ in 0..count.max(1) {
let mut batch = Vec::with_capacity(count * 2);
for _ in 0..count {
batch.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m });
batch.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
}
app.synthetic.push_back(batch);
Ok(())
}
fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) {
fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) -> Result<(), String> {
let steps = steps.max(1);
pointer_budget(app, steps.saturating_add(3))?;
app.synthetic.push_back(vec![egui::Event::PointerMoved(a)]);
app.synthetic.push_back(vec![egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }]);
for i in 1..=steps.max(1) {
let t = i as f32 / steps.max(1) as f32;
for i in 1..=steps {
let t = i as f32 / steps as f32;
app.synthetic.push_back(vec![egui::Event::PointerMoved(a + (b - a) * t)]);
}
app.synthetic.push_back(vec![egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }]);
Ok(())
}
/// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all.
@@ -334,33 +375,38 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
}
"ui.click" => {
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
push_click(app, egui::pos2(f("x"), f("y")), button, p.get("count").and_then(Json::as_u64).unwrap_or(1) as u32, modifiers(p));
ok(Json::Null)
let count = p.get("count").and_then(Json::as_u64).unwrap_or(1).min(u64::from(u32::MAX)) as u32;
match push_click(app, egui::pos2(f("x"), f("y")), button, count, modifiers(p)) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
}
}
"ui.drag" => {
push_drag(
app,
egui::pos2(f("x"), f("y")),
egui::pos2(f("toX"), f("toY")),
p.get("steps").and_then(Json::as_u64).unwrap_or(8) as usize,
modifiers(p),
);
ok(Json::Null)
let steps = p.get("steps").and_then(Json::as_u64).unwrap_or(8).min(u64::from(u32::MAX)) as usize;
match push_drag(app, egui::pos2(f("x"), f("y")), egui::pos2(f("toX"), f("toY")), steps, modifiers(p)) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
}
}
"ui.cell.click" => {
let Some(c) = s("cell").and_then(CellRef::parse) else { return err("missing `cell`") };
let Some(pos) = cell_center(app, c) else { return err("no grid yet") };
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
push_click(app, pos, button, p.get("count").and_then(Json::as_u64).unwrap_or(1) as u32, modifiers(p));
ok(json!({"x": pos.x, "y": pos.y}))
let count = p.get("count").and_then(Json::as_u64).unwrap_or(1).min(u64::from(u32::MAX)) as u32;
match push_click(app, pos, button, count, modifiers(p)) {
Ok(()) => ok(json!({"x": pos.x, "y": pos.y})),
Err(e) => err(e),
}
}
"ui.cell.drag" => {
let (Some(a), Some(b)) = (s("from").and_then(CellRef::parse), s("to").and_then(CellRef::parse)) else {
return err("missing `from`/`to`");
};
let (Some(pa), Some(pb)) = (cell_center(app, a), cell_center(app, b)) else { return err("no grid yet") };
push_drag(app, pa, pb, 8, modifiers(p));
ok(Json::Null)
match push_drag(app, pa, pb, 8, modifiers(p)) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
}
}
"ui.resize" => {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(f("width").max(400.0), f("height").max(300.0))));
@@ -404,6 +450,11 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
pub fn poll(app: &mut SheetApp, ctx: &egui::Context) {
let Some(rx) = app.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
// A request whose sender stopped waiting is dropped unrun: running it would apply a
// command the client was told had failed, and its reply would go nowhere (#1914).
if !req.take() {
continue;
}
let outcome = handle(app, ctx, &req.method, &req.params);
match outcome {
Outcome::Done(v) => {
@@ -519,6 +570,33 @@ mod tests {
assert_eq!(filter_commands(sample(), None).len(), 3);
}
fn test_app() -> SheetApp {
let mut session = gridcraft_engine::Session::new();
session.new_workbook();
SheetApp::new(session, crate::Services::default())
}
#[test]
fn oversized_pointer_requests_are_refused_and_leave_the_queue_alone() {
let mut app = test_app();
let p = egui::pos2(20.0, 20.0);
let m = egui::Modifiers::NONE;
let btn = egui::PointerButton::Primary;
assert!(push_click(&mut app, p, btn, 2, m).is_ok());
assert_eq!(app.synthetic.iter().map(Vec::len).sum::<usize>(), 5);
assert!(push_click(&mut app, p, btn, u32::MAX, m).is_err());
assert!(push_drag(&mut app, p, p, usize::MAX, m).is_err());
assert!(push_drag(&mut app, p, p, MAX_QUEUED_POINTER_EVENTS, m).is_err());
assert_eq!(app.synthetic.len(), 2);
assert_eq!(app.synthetic.iter().map(Vec::len).sum::<usize>(), 5);
// Already queued input counts toward the budget.
assert!(push_click(&mut app, p, btn, (MAX_QUEUED_POINTER_EVENTS / 2) as u32, m).is_err());
assert_eq!(app.synthetic.len(), 2);
// Normal requests still queue in full.
assert!(push_drag(&mut app, p, p + egui::vec2(40.0, 40.0), 3, m).is_ok());
assert_eq!(app.synthetic.len(), 8);
}
#[test]
fn print_command_discovery_documents_settings_and_submission() {
let mut session = gridcraft_engine::Session::new();
+11 -1
View File
@@ -1300,7 +1300,17 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
d.values.insert("path".into(), json!(path.clone()));
ok_cancel(ui, &mut confirm, &mut open);
if confirm {
let target = if path.is_empty() { app.services.pick_save.as_ref().and_then(|f| f(&format!("Copy.{fmt}"))) } else { Some(path) };
// A bare name has no folder: written as typed it would land in the
// process's working directory (`/` from the Finder, the install folder on
// Windows), never where the user looks. The picker chooses the folder.
let pick = app.services.pick_save.as_ref();
let target = if path.is_empty() {
pick.and_then(|f| f(&format!("Copy.{fmt}")))
} else if let Some(f) = pick.filter(|_| std::path::Path::new(&path).is_relative()) {
f(&if path.ends_with(&format!(".{fmt}")) { path } else { format!("{path}.{fmt}") })
} else {
Some(path)
};
if let Some(t) = target {
let id = match fmt.as_str() {
"csv" => "file.exportCsv",
+47 -24
View File
@@ -564,6 +564,21 @@ fn combo_popup(
result
}
/// The Accounting Number Format dropdown: label and the code Excel applies for that symbol.
const ACCOUNTING_SYMBOLS: &[(&str, &str)] = &[
("$ English (United States)", "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"),
("£ English (United Kingdom)", "_-[$£-809]* #,##0.00_-;-[$£-809]* #,##0.00_-;_-[$£-809]* \"-\"??_-;_-@_-"),
("€ Euro", "_-[$€-x-euro2] * #,##0.00_-;-[$€-x-euro2] * #,##0.00_-;_-[$€-x-euro2] * \"-\"??_-;_-@_-"),
("¥ Japanese", "_-[$¥-411]* #,##0_-;-[$¥-411]* #,##0_-;_-[$¥-411]* \"-\"_-;_-@_-"),
("¥ Chinese (PRC)", "_-[$¥-804]* #,##0.00_-;-[$¥-804]* #,##0.00_-;_-[$¥-804]* \"-\"??_-;_-@_-"),
("₹ English (India)", "_-[$₹-4009]* #,##0.00_-;-[$₹-4009]* #,##0.00_-;_-[$₹-4009]* \"-\"??_-;_-@_-"),
("CHF German (Switzerland)", "_-[$CHF-807]* #,##0.00_-;-[$CHF-807]* #,##0.00_-;_-[$CHF-807]* \"-\"??_-;_-@_-"),
("Rp Indonesian", "_-[$Rp-421]* #,##0.00_-;-[$Rp-421]* #,##0.00_-;_-[$Rp-421]* \"-\"??_-;_-@_-"),
("₩ Korean", "_-[$₩-412]* #,##0_-;-[$₩-412]* #,##0_-;_-[$₩-412]* \"-\"_-;_-@_-"),
("R$ Portuguese (Brazil)", "_-[$R$-416]* #,##0.00_-;-[$R$-416]* #,##0.00_-;_-[$R$-416]* \"-\"??_-;_-@_-"),
("₽ Russian", "_-[$₽-419]* #,##0.00_-;-[$₽-419]* #,##0.00_-;_-[$₽-419]* \"-\"??_-;_-@_-"),
];
fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) {
for (label, id, p) in items {
if *label == "-" {
@@ -886,30 +901,10 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
act(app, "home.accounting", json!({}));
}
egui::Popup::menu(&ca).show(|ui| {
menu_items(
app,
ui,
&[
(
"$ English (United States)",
"home.numberFormat",
json!({"code": "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"}),
),
(
"£ English (United Kingdom)",
"home.numberFormat",
json!({"code": "_-[$£-809]* #,##0.00_-;-[$£-809]* #,##0.00_-;_-[$£-809]* \"-\"??_-;_-@_-"}),
),
(
"€ Euro",
"home.numberFormat",
json!({"code": "_-[$€-x-euro2] * #,##0.00_-;-[$€-x-euro2] * #,##0.00_-;_-[$€-x-euro2] * \"-\"??_-;_-@_-"}),
),
("¥ Japanese", "home.numberFormat", json!({"code": "_-[$¥-411]* #,##0_-;-[$¥-411]* #,##0_-;_-[$¥-411]* \"-\"_-;_-@_-"})),
("-", "", json!(null)),
("More Accounting Formats…", "dialog:formatCells", json!({"tab": "Number"})),
],
);
let mut items: Vec<_> = ACCOUNTING_SYMBOLS.iter().map(|(label, code)| (*label, "home.numberFormat", json!({"code": code}))).collect();
items.push(("-", "", json!(null)));
items.push(("More Accounting Formats…", "dialog:formatCells", json!({"tab": "Number"})));
menu_items(app, ui, &items);
});
let __k = icon_button(ui, Icon::Percent, t.text, "Percent Style", vec2(24.0, 23.0));
control_keytip(app, ui, &__k, "home.percent");
@@ -2798,3 +2793,31 @@ fn whole_lines(app: &SheetApp) -> Option<bool> {
None
}
}
#[cfg(test)]
mod accounting_symbol_tests {
use super::*;
use gridcraft_engine::core::{DateSystem, Value};
use gridcraft_engine::numfmt::{NumberFormat, format_value};
#[test]
fn accounting_dropdown_offers_the_issue_currencies_with_excel_codes() {
// #1063: label, Excel's [$symbol-LCID] tag, and 1234.5 as Excel shows it (fill and the
// `_-` padding aside). The yen and won have no minor unit, so their codes show no decimals.
for (label, tag, shown) in [
("Rp Indonesian", "[$Rp-421]", "Rp1,234.50"),
("₹ English (India)", "[$₹-4009]", "₹1,234.50"),
("₩ Korean", "[$₩-412]", "₩1,235"),
("¥ Chinese (PRC)", "[$¥-804]", "¥1,234.50"),
("¥ Japanese", "[$¥-411]", "¥1,235"),
] {
let code = ACCOUNTING_SYMBOLS.iter().find(|(l, _)| *l == label).map(|(_, c)| *c);
let Some(code) = code else { panic!("{label} is not in the Accounting dropdown") };
assert!(code.split(';').take(3).all(|section| section.contains(tag)), "{label}: {code}");
let out = format_value(&Value::Number(1234.5), &NumberFormat::parse(code), DateSystem::D1900);
assert_eq!(out.text.trim(), shown, "{label}");
let symbol = tag.trim_start_matches("[$").split('-').next().unwrap_or_default();
assert_eq!(out.fill.map(|(c, at)| (c, out.text[..at].trim_start())), Some((' ', symbol)), "{label}");
}
}
}
+61
View File
@@ -0,0 +1,61 @@
//! A control request whose sender stopped waiting is dropped unrun (#1914).
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_ui_egui::{ControlRequest, SheetApp};
use serde_json::json;
fn app() -> (SheetApp, egui::Context) {
let mut s = Session::new();
s.new_workbook();
(SheetApp::new(s, Default::default()), egui::Context::default())
}
fn a1(app: &SheetApp) -> String {
let d = app.session.active().unwrap();
d.wb.active().unwrap().cell(CellRef::parse("A1").unwrap()).map(|c| format!("{:?}", c.value)).unwrap_or_else(|| "empty".into())
}
/// `set` writes A1, so a request that did run is visible in the workbook.
fn set_a1(text: &str) -> serde_json::Value {
json!({"command": "cell.set", "params": {"cell": "A1", "input": text}})
}
#[test]
fn a_request_its_sender_gave_up_on_does_not_run_later() {
let (mut app, ctx) = app();
let (tx, rx) = std::sync::mpsc::channel();
app.control_rx = Some(rx);
// One request the sender is still waiting for, and one it gave up on after its reply timed
// out. Before the fix both ran on the next UI pass: the client was told its command had
// failed and the command was applied anyway, so an agent that retried ran it twice.
let (kept, kept_rx) = ControlRequest::new("engine.execute", set_a1("kept"));
tx.send(kept).unwrap();
gridcraft_ui_egui::control::poll(&mut app, &ctx);
assert!(kept_rx.try_recv().is_ok(), "a request still waited for is answered");
assert_eq!(a1(&app), "Text(\"kept\")");
let (dropped, dropped_rx) = ControlRequest::new("engine.execute", set_a1("gave up"));
assert!(ControlRequest::abandon(&dropped.state), "the sender gives up while it is queued");
tx.send(dropped).unwrap();
gridcraft_ui_egui::control::poll(&mut app, &ctx);
assert_eq!(a1(&app), "Text(\"kept\")", "the abandoned request did not run");
assert!(dropped_rx.try_recv().is_err(), "and nothing was replied to it");
}
/// Which of the two sides wins the race is settled once: a request the app has started is not
/// abandoned (the app will answer, so the sender reports an unknown outcome instead of a failure),
/// and one already abandoned is never started.
#[test]
fn taking_and_abandoning_a_request_settle_once() {
let (taken, _rx) = ControlRequest::new("engine.execute", json!({}));
assert!(taken.take(), "a pending request can be taken");
assert!(taken.take(), "and taking it again is the same answer");
assert!(!ControlRequest::abandon(&taken.state), "it cannot then be abandoned");
let (given_up, _rx) = ControlRequest::new("engine.execute", json!({}));
assert!(ControlRequest::abandon(&given_up.state), "a pending request can be abandoned");
assert!(ControlRequest::abandon(&given_up.state), "and again is the same answer");
assert!(!given_up.take(), "it cannot then be taken");
}
+43
View File
@@ -334,3 +334,46 @@ fn a_cancelled_export_pdf_picker_writes_nothing() {
assert!(h.state().message.is_none());
assert!(h.state_mut().session.take_ui_requests().is_empty());
}
/// Share › Export as Web Page (and the native File menu's Save as Web Page) asks for a name in its
/// own dialog. A bare name has no folder: written as typed it lands in the process's working
/// directory, where nobody looks for it (#1212). The save picker chooses the folder instead.
#[test]
fn save_a_copy_with_a_bare_name_asks_where_to_save_it() {
let dir = std::env::temp_dir().join(format!("gridcraft-save-copy-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let asked = Arc::new(Mutex::new(Vec::<String>::new()));
let (capture, target) = (asked.clone(), dir.clone());
let mut h = harness(Services {
pick_save: Some(Box::new(move |suggested| {
capture.lock().unwrap().push(suggested.to_string());
Some(target.join("Chosen.html").to_string_lossy().into_owned())
})),
..Default::default()
});
h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();
let save_copy = |h: &mut egui_kittest::Harness<'static, SheetApp>, path: &str| {
h.state_mut().open_dialog("saveCopy", serde_json::json!({"format": "html"}));
h.run_steps(3);
h.state_mut().dialog.as_mut().unwrap().values.insert("path".into(), serde_json::json!(path));
h.run_steps(2);
h.get_by_label(" OK ").click();
h.run_steps(3);
};
let name = format!("gridcraft-save-copy-{}", std::process::id());
save_copy(&mut h, &name);
let stray = std::path::PathBuf::from(format!("{name}.html"));
let written_in_cwd = stray.exists();
let _ = std::fs::remove_file(&stray);
assert!(!written_in_cwd, "a bare name is not written to the working directory");
assert_eq!(*asked.lock().unwrap(), vec![format!("{name}.html")], "the picker is offered the typed name");
assert!(std::fs::read_to_string(dir.join("Chosen.html")).unwrap().contains("42"), "the page is written where the picker says");
assert!(h.state().message.is_none());
// A full path skips the picker; a write that fails is reported, not swallowed.
save_copy(&mut h, &dir.join("Named.html").to_string_lossy());
assert!(dir.join("Named.html").exists());
save_copy(&mut h, &dir.join("missing").join("Lost.html").to_string_lossy());
assert!(h.state().message.is_some(), "a failed write is reported");
assert_eq!(asked.lock().unwrap().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
+114 -8
View File
@@ -17,7 +17,107 @@ const CP1252: [u32; 32] = [
0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
];
/// Decodes CSV bytes: UTF-8/UTF-16 BOMs, else UTF-8, else Windows-1252.
/// Windows-1256 code points for bytes 0x80–0xFF (unicode.org `CP1256.TXT`).
pub(crate) const CP1256: [u32; 128] = [
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
0x200F, 0x06D2,
];
/// Evidence that bytes which aren't UTF-8 are Windows-1256 Arabic rather than Windows-1252.
#[derive(Default)]
struct Cp1256Evidence {
/// High bytes that decode to different characters in the two code pages, apart from invisible
/// marks and ordinals after a digit.
differing: usize,
/// Of those, Arabic letters and Arabic punctuation (، ؛ ؟) in Windows-1256.
arabic: usize,
/// Arabic bytes next to an ASCII letter: Western accents sit inside Latin words, Arabic doesn't.
arabic_touching_latin: usize,
/// Accented Latin bytes the two code pages share, next to another high byte: Hebrew
/// Windows-1255 letters land there, Arabic and Western text rarely do.
shared_letters_in_runs: usize,
}
impl Cp1256Evidence {
fn of(bytes: &[u8]) -> Self {
let mut e = Self::default();
for (i, &b) in bytes.iter().enumerate() {
if b < 0x80 {
continue;
}
let before = i.checked_sub(1).and_then(|j| bytes.get(j)).copied();
let after = bytes.get(i + 1).copied();
let next_to = |pred: fn(u8) -> bool| before.is_some_and(pred) || after.is_some_and(pred);
if same_in_cp1252_and_cp1256(b) {
if is_shared_latin_letter(b) && next_to(|x| x >= 0x80) {
e.shared_letters_in_runs += 1;
}
continue;
}
if is_invisible_mark(b) || is_ordinal_after_digit(b, before) {
continue;
}
e.differing += 1;
if is_cp1256_arabic(b) {
e.arabic += 1;
if next_to(|x| x.is_ascii_alphabetic()) {
e.arabic_touching_latin += 1;
}
}
}
e
}
/// Thresholds chosen against month, day and language names from 95 glibc locales (Western in
/// Windows-1252, Arabic, Persian and Urdu in 1256, Hebrew and Yiddish in 1255) plus CSV samples.
fn is_arabic(&self) -> bool {
self.arabic >= 2
&& self.arabic * 10 >= self.differing * 9
&& self.arabic_touching_latin * 10 <= self.arabic * 3
&& self.shared_letters_in_runs * 20 <= self.arabic
}
}
fn same_in_cp1252_and_cp1256(b: u8) -> bool {
matches!(
b,
0x80 | 0x82..=0x89 | 0x8B..=0x8C | 0x91..=0x97 | 0x99 | 0x9B..=0x9C | 0xA0 | 0xA2..=0xA9 | 0xAB..=0xB9 | 0xBB..=0xBE | 0xD7 | 0xE0 | 0xE2 | 0xE7..=0xEB
| 0xEE..=0xEF | 0xF4 | 0xF7 | 0xF9 | 0xFB..=0xFC
)
}
/// ZWNJ, ZWJ, LRM and RLM: common inside Persian words and around Arabic cells, but no evidence
/// either way.
fn is_invisible_mark(b: u8) -> bool {
matches!(b, 0x9D | 0x9E | 0xFD | 0xFE)
}
/// `º` and `ª` after a digit (`1º`, `2ª`) are Western ordinals, though Windows-1256 reads the bytes
/// as `؛` and `ھ`; either way they are no evidence.
fn is_ordinal_after_digit(b: u8, before: Option<u8>) -> bool {
matches!(b, 0xAA | 0xBA) && before.is_some_and(|x| x.is_ascii_digit())
}
fn is_shared_latin_letter(b: u8) -> bool {
matches!(b, 0xE0 | 0xE2 | 0xE7..=0xEB | 0xEE..=0xEF | 0xF4 | 0xF9 | 0xFB..=0xFC)
}
fn is_cp1256_arabic(b: u8) -> bool {
matches!(
b,
0x81 | 0x8A | 0x8D..=0x90 | 0x98 | 0x9A | 0x9F | 0xA1 | 0xAA | 0xBA | 0xBF..=0xD6 | 0xD8..=0xDB | 0xDD..=0xDF | 0xE1 | 0xE3..=0xE6 | 0xEC..=0xED | 0xFF
)
}
/// Decodes CSV bytes with BOM-aware UTF-8 and UTF-16 handling. Without a BOM, valid UTF-8 is kept.
/// Other bytes decode as Windows-1256 when they read as Arabic (see [`Cp1256Evidence`]), else as
/// Windows-1252.
pub fn decode_text(bytes: &[u8]) -> String {
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return String::from_utf8_lossy(rest).into_owned();
@@ -34,13 +134,19 @@ pub fn decode_text(bytes: &[u8]) -> String {
}
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => bytes
.iter()
.map(|&b| match b {
0x80..=0x9F => char::from_u32(CP1252.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}'),
_ => b as char,
})
.collect(),
Err(_) => {
let use_windows_1256 = Cp1256Evidence::of(bytes).is_arabic();
bytes
.iter()
.map(|&b| match b {
0x80..=0xFF if use_windows_1256 => {
char::from_u32(CP1256.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}')
}
0x80..=0x9F => char::from_u32(CP1252.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}'),
_ => b as char,
})
.collect()
}
}
}
+95
View File
@@ -68,6 +68,101 @@ fn encodings() {
assert_eq!(v(&wb, 0, 1), Value::Number(1.0));
}
#[test]
fn windows_1256_table_matches_the_unicode_mapping() {
let at = |b: u8| crate::csv::CP1256.get(usize::from(b - 0x80)).and_then(|&c| char::from_u32(c));
let expected = [
(0x80, '€'),
(0x81, 'پ'),
(0x8D, 'چ'),
(0x98, 'ک'),
(0x9D, '\u{200C}'),
(0xA1, '،'),
(0xBA, '؛'),
(0xBF, '؟'),
(0xC7, 'ا'),
(0xC8, 'ب'),
(0xDC, 'ـ'),
(0xE1, 'ل'),
(0xE3, 'م'),
(0xE9, 'é'),
(0xFD, '\u{200E}'),
(0xFE, '\u{200F}'),
(0xFF, 'ے'),
];
for (b, c) in expected {
assert_eq!(at(b), Some(c), "0x{b:02X}");
}
}
#[test]
fn arabic_csv_in_windows_1256_reads_as_arabic() {
let bytes = [
0xC7, 0xE1, 0xC7, 0xD3, 0xE3, b',', 0xC7, 0xE1, 0xDE, 0xED, 0xE3, 0xC9, b'\n', 0xE3, 0xD1, 0xCD, 0xC8, 0xC7, b' ', b'1', b'2', b'3', 0xA1,
b' ', b'4', b'2', 0xBF, b',', 0xC8, 0xED, 0xC7, 0xE4, 0xC7, 0xCA, b' ', b'7', b'\n',
];
let wb = read_csv(&bytes, &CsvOptions::default()).unwrap();
assert_eq!(v(&wb, 0, 0), Value::text("الاسم"));
assert_eq!(v(&wb, 0, 1), Value::text("القيمة"));
assert_eq!(v(&wb, 1, 0), Value::text("مرحبا 123، 42؟"));
assert_eq!(v(&wb, 1, 1), Value::text("بيانات 7"));
}
fn reads_as_arabic(bytes: &[u8]) -> bool {
decode_text(bytes).chars().any(|c| ('\u{0600}'..='\u{06FF}').contains(&c))
}
#[test]
fn western_1252_text_stays_latin() {
let french = [
b'C', b'r', 0xE8, b'm', b'e', b' ', b'b', b'r', 0xFB, b'l', 0xE9, b'e', b',', b' ', 0xE0, b' ', b'c', 0xF4, b't', 0xE9, b',', b' ', 0xE9,
b't', 0xE9,
];
let german = [
b'G', b'r', 0xF6, 0xDF, b'e', b',', b' ', 0xDC, b'b', b'e', b'r', b'm', b'a', 0xDF, b',', b' ', b'F', b'u', 0xDF, b'g', 0xE4, b'n', b'g',
b'e', b'r',
];
assert_eq!(decode_text(&french), "Crème brûlée, à côté, été");
assert_eq!(decode_text(&german), "Größe, Übermaß, Fußgänger");
let portuguese = [
b'A', 0xC7, 0xC3, b'O', b',', b' ', b'i', b'n', b'f', b'o', b'r', b'm', b'a', 0xE7, 0xE3, b'o', b',', b' ', b's', b'i', b't', b'u', b'a',
0xE7, 0xE3, b'o', b',', b' ', b'P', 0xE3, b'o',
];
assert_eq!(decode_text(&portuguese), "AÇÃO, informação, situação, Pão");
let nordic = [
0xC6, b'r', 0xF8, b',', 0xD8, b's', b't', b',', 0xC5, b's', b'e', b',', 0xDE, 0xF3, b'r', 0xF0, b'u', b'r', b',', b'G', b'u', 0xF0, b'r',
0xFA, b'n', b',', 0xC4, 0xD6, 0xDC,
];
assert_eq!(decode_text(&nordic), "Ærø,Øst,Åse,Þórður,Guðrún,ÄÖÜ");
let ordinals = [b'1', 0xBA, b',', b'2', 0xBA, b',', b'3', 0xBA, b'\n', b'1', 0xAA, b',', b'2', 0xAA];
assert_eq!(decode_text(&ordinals), "1º,2º,3º\n1ª,2ª");
let inside_words = [b'K', 0xE4, b's', b'e', b',', b'B', 0xE4, b'c', b'k', b'e', b'r'];
assert_eq!(decode_text(&inside_words), "Käse,Bäcker");
}
#[test]
fn short_arabic_words_read_as_arabic() {
assert_eq!(decode_text(&[0xDD, 0xED, b',', 0xE3, 0xE4, b',', 0xDA, 0xE4, b'\n']), "في,من,عن\n");
assert_eq!(decode_text(&[0xE4, 0xDA, 0xE3, b',', 0xE1, 0xC7, b'\n', 0xE1, 0xC7, b',', 0xE4, 0xDA, 0xE3, b'\n']), "نعم,لا\nلا,نعم\n");
assert_eq!(decode_text(&[0x98, 0xCA, 0xC7, 0xC8, b' ', 0x81, 0xC7, 0xD1, 0xD3, 0xED]), "کتاب پارسي");
let with_marks = [
0xDF, 0xCA, 0xC7, 0xC8, 0x9D, 0xE5, 0xC7, b',', 0xCE, 0xC7, 0xE4, 0xE5, 0x9D, 0xE5, 0xC7, b',', 0xE3, 0xCD, 0xE3, 0xCF, 0xFE, b',', 0xDA,
0xE1, 0xED, 0xFE,
];
assert_eq!(decode_text(&with_marks), "كتاب\u{200C}ها,خانه\u{200C}ها,محمد\u{200F},علي\u{200F}");
}
#[test]
fn hebrew_1255_bytes_are_not_read_as_arabic() {
let plain = [0xF9, 0xEC, 0xE5, 0xED, b',', 0xF2, 0xE5, 0xEC, 0xED];
let pointed = [0xF9, 0xC8, 0xD1, 0xEC, 0xE5, 0xC9, 0xED, b' ', 0xF2, 0xE5, 0xC9, 0xEC, 0xC8, 0xED];
let maqaf = [0xFA, 0xEC, 0xCE, 0xE0, 0xE1, 0xE9, 0xE1, b',', 0xE1, 0xEF, 0xCE, 0xE2, 0xE5, 0xF8, 0xE9, 0xE5, 0xEF];
let gershayim = [0xF6, 0xE4, 0xD8, 0xEC, b',', 0xE3, 0xD8, 0xF8];
for bytes in [&plain[..], &pointed, &maqaf, &gershayim] {
assert!(!reads_as_arabic(bytes), "{:?}", decode_text(bytes));
}
}
#[test]
fn delimiter_sniffing() {
assert_eq!(sniff_delimiter("a;b;c\n1;2;3\n", '"'), b';');
+5 -1
View File
@@ -1,6 +1,6 @@
# Architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code on main; supersedes the local-only plan/architecture.md for committed docs) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (bound Undo's retained cell storage) · **Target:** Microsoft Excel (Microsoft 365)
How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md).
@@ -35,6 +35,9 @@ including tests, 2026-10-10.
`data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`).
`Session::execute` catches escaped panics and restores the workbook (never-crash guard).
2. **Edits** change the model through copy-on-write bands; undo keeps the previous snapshot.
Undo retains at most 100 steps and trims older steps when its estimated retained cell storage
exceeds 128 MiB. The newest step remains undoable even if that step alone exceeds the budget.
The estimate is a history policy, not a measure or hard cap of process memory.
3. **Recalc** marks dependents of changed cells dirty through the dependency graph (with range
nodes for range dependents), evaluates in topological order, spills dynamic arrays and reports
`#SPILL!`/cycles. Volatile functions are always dirty.
@@ -68,4 +71,5 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee |
| 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps |
+5 -1
View File
@@ -102,7 +102,11 @@ sheet's rules in priority order, priority numbers aside), `validations`, `charts
`hyperlinks`, `pivots`. `#SPILL!`/`#CALC!` compare equal to `#VALUE!` and `#CIRC!` to 0, as
that's what the file format stores.
Files that fail to open are reported with GridCraft's error and don't stop the run. Exit code: 0 when every file
Files that fail to open are reported with GridCraft's error and don't stop the run. Unreadable directories,
directory-entry/metadata errors, and subtrees or files omitted by the depth (32) and file (100,000) limits appear
as failed path reports too, in both human and JSON output. Intentional hidden/lock-file exclusions remain ignored.
JSON `totals.files` includes failed discovery paths; `totals.opened` counts only workbooks that opened successfully.
An empty scan fails with a diagnostic. Exit code: 0 when at least one file was checked, discovery was complete, and every file
opened with no mismatch and no round-trip difference, 1 otherwise, 2 on a usage error. `--max-report` caps the
examples listed per file and category (counts are always complete).
+3 -3
View File
@@ -25,13 +25,13 @@ connection; nothing sent after it runs. Blank lines are skipped.
|---|---|---|
| `engine.execute` | `{command, params}` | Runs any engine command (see `engine.commands` or `gridcraft-cli commands`). `edit.goTo`, `edit.goToSpecial`, `selection.set` and `selection.move` scroll the active cell into view, as Go To does in the window |
| `engine.commands` | `{search?}` | Every command: id, label, ribbon path, shortcut, params doc, enabled; `search` keeps those whose id, label or ribbon path contains it (case-insensitive) |
| `engine.journal` | | Commands run so far (replayable). Passwords (`password`, `…Password` params) are left out, so a replayed Protect Sheet has no password |
| `engine.journal` | | Commands run so far (replayable). Passwords (`password`, `…Password` params) are left out, so a replayed Protect Sheet has no password. Capped at 10,000 entries and 64 MB (serialized); past either cap the oldest entries go |
| `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels |
| `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range |
| `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link |
| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf |
| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points |
| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing) |
| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 pointer events queued |
| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same 10,000-event queue limit |
| `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing) |
| `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor |
| `ui.ribbon` | `{tab}` | Switch ribbon tab |
+8 -6
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; Japanese phonetic runs (ふりがな) in XLSX shared strings survive open and save; HTML export keeps right-to-left; XLSX export keeps a picture shared by several sheets as one media part (#985); XLSX keeps the workbook's show-objects setting, `workbookPr showObjects`) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -66,13 +66,13 @@ values) is still dropped.
| Format | Excel | GridCraft read | GridCraft write | Notes |
|---|---|---|---|---|
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes | Text Import Wizard options partly via Text to Columns |
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes (displayed values) | Text Import Wizard options partly via Text to Columns |
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes | Text Import Wizard options partly via Text to Columns |
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes (displayed values) | Text Import Wizard options partly via Text to Columns |
| `.prn` formatted text (space-delimited) | R/W | no | no | |
| `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy |
| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported |
| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `<img>` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported |
| `.mht` / `.mhtml` single-file web page | R/W (Windows) | no | no | |
| `.json` | via Power Query | GridCraft's own workbook JSON (debug) and JSON import | GridCraft JSON | Not Power Query's JSON shaping; notes and hyperlinks break export (#126) |
| `.xml` with XML maps | Developer ▸ Import/Export | no | no | |
@@ -110,10 +110,12 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI `<img>`) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged |
| 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 | CSV and text import decodes likely Windows-1256 Arabic; other invalid UTF-8 retains the Windows-1252 fallback |
| 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 |
+1
View File
@@ -116,6 +116,7 @@ language (none so far) and of localized function names.
| 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 | Arabic CSV and text imports decode Windows-1256 text; Arabic shaping and bidirectional UI remain unsupported |
| 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 |