diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87d8954..83b5efa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/Cargo.lock b/Cargo.lock index 2dec069..1815ac7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -742,6 +742,22 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +dependencies = [ + "tempfile", +] + +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +dependencies = [ + "egui", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1480,9 +1496,10 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.5.0" +version = "0.6.1" dependencies = [ "arboard", + "craft-print", "eframe", "egui", "fmv-macos-events", @@ -1491,6 +1508,8 @@ dependencies = [ "image", "log", "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "pollster 1.0.1", "rfd", "serde_json", @@ -1499,7 +1518,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.5.0" +version = "0.6.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1511,7 +1530,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.5.0" +version = "0.6.1" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1522,7 +1541,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.5.0" +version = "0.6.1" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1534,7 +1553,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.5.0" +version = "0.6.1" dependencies = [ "js-sys", "serde", @@ -1544,7 +1563,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.5.0" +version = "0.6.1" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1565,7 +1584,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.5.0" +version = "0.6.1" dependencies = [ "gridcraft-core", "serde", @@ -1574,7 +1593,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.5.0" +version = "0.6.1" dependencies = [ "gridcraft-core", "regex", @@ -1582,7 +1601,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.5.0" +version = "0.6.1" dependencies = [ "gridcraft-engine", "log", @@ -1591,7 +1610,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.5.0" +version = "0.6.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1603,19 +1622,20 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.5.0" +version = "0.6.1" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.5.0" +version = "0.6.1" [[package]] name = "gridcraft-ui-egui" -version = "0.5.0" +version = "0.6.1" dependencies = [ + "craft-ui", "egui", "egui_kittest", "epaint_default_fonts", @@ -1635,7 +1655,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.5.0" +version = "0.6.1" dependencies = [ "eframe", "egui", @@ -1652,7 +1672,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.5.0" +version = "0.6.1" dependencies = [ "aes", "gridcraft-core", @@ -4506,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.5.0" +version = "0.6.1" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 11e4edf..0bfee24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.5.0" +version = "0.6.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/ROADMAP.md b/ROADMAP.md index fbe48d3..1f8cede 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (password-encrypted workbooks open, #11) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) | +| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | | 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | @@ -177,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index 9fc81e3..d514b1e 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -97,6 +97,9 @@ pub struct CalcReport { /// aren't results, so every formula counts as no oracle. pub stale_cache: bool, pub volatile: usize, + /// Matched cells whose producer saved GridCraft's TRUE or FALSE as the number 1 or 0 (no + /// `t="b"`, as Business Central's Excel exports do); counted in `matched` too. + pub bool_as_number: usize, /// The first `max_report` mismatches, primary ones first. pub mismatches: Vec, pub functions: BTreeMap, @@ -347,6 +350,13 @@ fn cell_name(wb: &Workbook, (sheet, c): Key) -> String { format!("{}!{}", quote_sheet(name), c.a1()) } +/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a +/// logical result `t="b"`; a writer that leaves the type out (`TRUE()1`, as +/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number. +pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool { + matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 }) +} + /// Whether GridCraft's `actual` agrees with the producer's `expected`. pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool { match (expected, actual) { @@ -458,7 +468,11 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo } None => vec!["(unparsed)".to_string()], }; - let ok = values_match(&cell.value, &actual, opts.tolerance); + let bool_as_number = bool_cached_as_number(&cell.value, &actual); + let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance); + if bool_as_number { + r.bool_as_number += 1; + } for n in &funcs { let st = r.functions.entry(n.clone()).or_default(); st.checked += 1; @@ -559,6 +573,14 @@ fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet, b: &BTreeSet Vec { + let mut cfs: Vec<_> = s.cond_formats.iter().collect(); + cfs.sort_by_key(|c| c.priority); + cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect() +} + fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) { if a != b { d.push(|| format!("{sheet}: {a} → {b}")); @@ -634,7 +656,12 @@ pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec>(); let (ca, cb) = (chart_list(sa), chart_list(sb)); @@ -901,6 +928,7 @@ struct Totals { mismatched: usize, no_oracle: usize, volatile: usize, + bool_as_number: usize, round_trip_failed: usize, round_trip_differing: usize, categories: BTreeMap<&'static str, usize>, @@ -922,6 +950,7 @@ fn totals(reports: &[FileReport]) -> Totals { t.mismatched += r.calc.mismatched; t.no_oracle += r.calc.no_oracle; t.volatile += r.calc.volatile; + t.bool_as_number += r.calc.bool_as_number; if r.round_trip.error.is_some() { t.round_trip_failed += 1; } @@ -982,7 +1011,8 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { "warnings": r.warnings, "calc": { "formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched, - "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "mismatches": mismatches, "functions": functions_json(&c.functions), + "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number, + "mismatches": mismatches, "functions": functions_json(&c.functions), "signatures": c.signatures, }, "roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories}, @@ -996,7 +1026,7 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { "files": files, "totals": { "files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked, - "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, + "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number, "roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories, }, "functions": functions_json(&t.functions), @@ -1035,6 +1065,9 @@ fn print_human(reports: &[FileReport], opts: &Options) { if c.stale_cache { outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)"); } + if c.bool_as_number > 0 { + outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number); + } for m in &c.mismatches { let tag = if m.primary { "mismatch" } else { " (downstream)" }; outln!( @@ -1131,6 +1164,9 @@ fn print_human(reports: &[FileReport], opts: &Options) { t.no_oracle, t.volatile ); + if t.bool_as_number > 0 { + outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number); + } let cats: Vec = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect(); outln!( "round trip: {} of {} files differ{}{}", @@ -1235,6 +1271,31 @@ mod tests { assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1); } + /// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`: + /// the cache holds the right value under a number type, so it matches and is counted apart. + #[test] + fn booleans_cached_as_numbers_match_and_are_counted_apart() { + let wb = book(&[ + ("A1", "=TRUE()", n(1.0)), + ("B1", "=FALSE()", n(0.0)), + ("C1", "=TRUE()", Value::Bool(true)), + ("D1", "=1=1", n(0.0)), + ("E1", "=TRUE()+0", n(1.0)), + ]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1)); + // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number. + assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1"); + assert_eq!(r.calc.signatures.get("number → bool"), Some(&1)); + assert_eq!(r.calc.bool_as_number, 2); + let v = report_json(std::slice::from_ref(&r), &Options::default()); + assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2))); + assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false))); + assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0))); + } + #[test] fn missing_cached_values_and_volatile_formulas_are_skipped() { let wb = @@ -1303,6 +1364,27 @@ mod tests { assert_eq!(get("tables").diffs, 0); } + #[test] + fn diff_detects_changed_cond_format_rule() { + use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat}; + let bar = |gradient: bool, priority: u32| CondFormat { + ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()], + rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true }, + priority, + stop_if_true: false, + }; + let with = |cf: CondFormat| { + let mut wb = book(&[("A1", "2", n(2.0))]); + wb.sheet_mut(0).unwrap().cond_formats.push(cf); + wb + }; + let diffs = + |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs; + assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0); + } + fn zip_of(parts: &[(&str, &str)]) -> Vec { let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); for (name, body) in parts { @@ -1362,7 +1444,19 @@ mod tests { assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"])); assert_eq!( keys(&f["calc"]), - set(&["formulaCells", "checked", "matched", "mismatched", "noOracle", "staleCache", "volatile", "mismatches", "functions", "signatures"]) + set(&[ + "formulaCells", + "checked", + "matched", + "mismatched", + "noOracle", + "staleCache", + "volatile", + "boolAsNumber", + "mismatches", + "functions", + "signatures" + ]) ); assert_eq!(f["calc"]["signatures"], json!({"number → number": 1})); assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"])); @@ -1401,6 +1495,7 @@ mod tests { "mismatched", "noOracle", "volatile", + "boolAsNumber", "roundTripFailed", "roundTripDiffering", "categoriesDiffering" diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 0f69e38..eac6c13 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] } diff --git a/apps/gridcraft-web/src/host.rs b/apps/gridcraft-web/src/host.rs new file mode 100644 index 0000000..2286023 --- /dev/null +++ b/apps/gridcraft-web/src/host.rs @@ -0,0 +1,166 @@ +//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed +//! GridCraft in an `