Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink survives XLSX saves) · **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.
Expand Down Expand Up @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | What landed |
|---|---|
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
| 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 |
Expand Down Expand Up @@ -179,6 +180,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
| 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 |
Expand Down
135 changes: 135 additions & 0 deletions crates/engine/tests/ink_roundtrip.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//! Native ink remains editable after XLSX saves, including a save after an unrelated cell edit.

use gridcraft_engine::Session;
use gridcraft_engine::cmd::draw::{ink_points, ink_width};
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::{Shape, ShapeKind};
use serde_json::json;

fn workbook() -> Session {
let mut session = Session::new();
session.new_workbook();
session
}

fn reopen(session: &mut Session) -> Session {
let saved = session.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.open", json!({"name": "ink.xlsx", "base64": saved["base64"]})).unwrap();
reopened
}

fn shapes(session: &Session) -> &[Shape] {
&session.doc().unwrap().wb.active().unwrap().shapes
}

fn assert_anchor(actual: &Shape, expected: &Shape) {
assert_eq!(actual.anchor.cell, expected.anchor.cell);
assert_eq!(actual.anchor.mode, expected.anchor.mode);
for (name, actual, expected) in [
("dx", actual.anchor.dx, expected.anchor.dx),
("dy", actual.anchor.dy, expected.anchor.dy),
("width", actual.anchor.width, expected.anchor.width),
("height", actual.anchor.height, expected.anchor.height),
] {
assert!((actual - expected).abs() < 0.001, "anchor {name}: {actual} != {expected}");
}
}

fn assert_ink(actual: &Shape, expected: &Shape) {
assert_eq!(actual.kind, ShapeKind::Ink);
let actual_points = ink_points(actual);
let expected_points = ink_points(expected);
assert_eq!(actual_points.len(), expected_points.len());
for (actual, expected) in actual_points.iter().zip(&expected_points) {
for axis in 0..2 {
// DrawingML quantizes coordinates to integer EMUs (9,525 per model unit).
assert!((actual[axis] - expected[axis]).abs() < 0.001, "point {actual:?} != {expected:?}");
}
}
assert_eq!(actual.line, expected.line);
assert_eq!(ink_width(actual), ink_width(expected));
assert_anchor(actual, expected);
}

#[test]
fn ink_geometry_and_style_survive_two_saves_and_a_cell_edit() {
let mut session = workbook();
session
.execute("draw.stroke", json!({"points": [[130.5, 45.25], [172.5, 86.25], [151.5, 112.25], [215.5, 59.25]], "color": "#A12BCD", "width": 3}))
.unwrap();
session
.execute("draw.stroke", json!({"points": [[300, 180], [360, 180], [360, 230], [300, 230], [300, 180]], "color": "#16843B", "width": 7}))
.unwrap();
let expected = shapes(&session).to_vec();
assert_eq!(expected.len(), 2);

for pass in 0..2 {
session = reopen(&mut session);
assert_eq!(shapes(&session).len(), expected.len());
for (actual, expected) in shapes(&session).iter().zip(&expected) {
assert_ink(actual, expected);
}
if pass == 0 {
session.execute("cell.set", json!({"cell": "Z30", "input": "42"})).unwrap();
}
}
assert_eq!(session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("Z30").unwrap()), Value::Number(42.0));
}

#[test]
fn reopened_ink_can_still_be_converted_to_a_shape() {
for (points, expected_kind, expected_name) in [
(vec![[100, 100], [200, 100], [200, 180], [100, 180], [100, 100]], ShapeKind::Rectangle, "Rectangle"),
(vec![[100, 100], [140, 110], [220, 130]], ShapeKind::Line, "Line"),
] {
let mut session = workbook();
session.execute("draw.stroke", json!({"points": points, "color": "#A12BCD", "width": 5})).unwrap();
let original = shapes(&session)[0].clone();
session = reopen(&mut session);
let id = shapes(&session)[0].id;
let converted = session.execute("draw.inkToShape", json!({"id": id})).unwrap();
assert_eq!(converted["kind"], expected_name);
let shape = &shapes(&session)[0];
assert_eq!(shape.kind, expected_kind);
assert_eq!(shape.line, original.line);
assert!(shape.text.is_empty());
assert_anchor(shape, &original);
}
}

#[test]
fn ordinary_shape_text_is_not_mistaken_for_ink_points() {
let mut session = workbook();
session
.execute(
"insert.shape",
json!({"kind": "rectangle", "at": "D6", "width": 120, "height": 60, "fill": "#112233", "line": "#A12BCD", "text": "0.0,0.0;50.0,20.0"}),
)
.unwrap();
let original = shapes(&session)[0].clone();
for _ in 0..2 {
session = reopen(&mut session);
assert_eq!(shapes(&session).len(), 1);
let shape = &shapes(&session)[0];
assert_eq!(shape.kind, ShapeKind::Rectangle);
assert_eq!(shape.text, original.text);
assert_eq!(shape.fill, original.fill);
assert_eq!(shape.line, original.line);
assert_anchor(shape, &original);
assert!(session.execute("draw.inkToShape", json!({})).is_err());
}
}

#[test]
fn horizontal_vertical_and_signed_highlighter_strokes_survive_reopening() {
for points in [vec![[25.1, 50.2], [140.3, 50.2]], vec![[40.2, 10.1], [40.2, 100.3]], vec![[-12.3, -4.2], [20.1, 10.2], [80.3, -1.4]]] {
let mut session = workbook();
session.execute("draw.stroke", json!({"points": points, "color": "#16843B", "width": 12})).unwrap();
let expected = shapes(&session)[0].clone();
for _ in 0..2 {
session = reopen(&mut session);
assert_ink(&shapes(&session)[0], &expected);
}
}
}
11 changes: 11 additions & 0 deletions crates/xlsx/src/drawing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ use crate::read::Ctx;
use crate::tables::EMU_PER_PX;
use crate::xml::{El, esc, esc_attr};

mod ink;

// ---------------------------------------------------------------- reading

fn emu_px(v: Option<i64>) -> f32 {
Expand Down Expand Up @@ -195,6 +197,9 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<(

fn read_shape(obj: &El, anchor: Anchor) -> Option<Shape> {
let sp = obj.child("spPr")?;
if let Some(shape) = ink::read(obj, anchor) {
return Some(shape);
}
let prst = sp.child("prstGeom").and_then(|g| g.attr("prst")).unwrap_or("rect");
let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false));
let ln = sp.child("ln");
Expand Down Expand Up @@ -358,6 +363,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
let (open, close) = anchor_open(sheet, &sh.anchor);
close_tag = close;
s.push_str(&open);
if let Some(xml) = ink::write(sh, theme, id) {
s.push_str(&xml);
s.push_str("<xdr:clientData/>");
s.push_str(close_tag);
continue;
}
let (prst, tx) = match sh.kind {
ShapeKind::Rectangle => ("rect", false),
ShapeKind::RoundedRectangle => ("roundRect", false),
Expand Down
Loading