#551: preserve editable ink through XLSX round trips

This commit is contained in:
jonathanwxh-cell 2026-10-12 23:54:44 +08:00
parent 85bfcdfcfd
commit d4874373a4
8 changed files with 564 additions and 3 deletions

View File

@ -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.
@ -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 | 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) |
@ -178,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 | 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 |

View File

@ -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);
}
}
}

View File

@ -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 {
@ -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");
@ -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),

View File

@ -0,0 +1,207 @@
//! The open, solid polylines the model can represent as editable ink. This is DrawingML
//! geometry, not Office InkML. Unsupported geometry keeps the existing shape-import fallback.
use super::*;
const MAX_POINTS: usize = 20_000;
const MAX_COORD: f64 = 1e7;
fn bounded(v: f64) -> bool {
v.is_finite() && v.abs() <= MAX_COORD
}
fn only_children(e: &El, names: &[&str]) -> bool {
e.children.iter().all(|c| names.contains(&c.name.as_str()))
}
fn only_attrs(e: &El, names: &[&str]) -> bool {
e.attrs.iter().all(|(k, _)| names.contains(&k.as_str()))
}
fn one<'a>(e: &'a El, name: &str) -> Option<&'a El> {
let mut found = e.children.iter().filter(|c| c.name == name);
let first = found.next()?;
found.next().is_none().then_some(first)
}
/// Do not partially import a path: losing a curve, subpath, transform or line effect changes
/// the drawing. Accept only the explicit subset emitted below, including its line styling.
pub(super) fn read(obj: &El, anchor: Anchor) -> Option<Shape> {
if obj.name != "sp"
|| !only_children(obj, &["nvSpPr", "spPr"])
|| !only_attrs(obj, &["macro", "textlink"])
|| obj.attrs.iter().any(|(_, v)| !v.is_empty())
|| obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|e| e.flag("txBox", false))
{
return None;
}
let sp = one(obj, "spPr")?;
if !only_children(sp, &["xfrm", "custGeom", "noFill", "ln"]) || !sp.attrs.is_empty() {
return None;
}
let no_fill = one(sp, "noFill")?;
if !no_fill.attrs.is_empty() || !no_fill.children.is_empty() {
return None;
}
let xf = one(sp, "xfrm")?;
if !xf.attrs.is_empty() || !only_children(xf, &["off", "ext"]) {
return None;
}
let off = one(xf, "off")?;
if off.attr_i64("x")? != 0 || off.attr_i64("y")? != 0 {
return None;
}
let ext = one(xf, "ext")?;
let ew = ext.attr_i64("cx")?;
let eh = ext.attr_i64("cy")?;
if ew <= 0 || eh <= 0 || anchor.width <= 0.0 || anchor.height <= 0.0 || !bounded(anchor.width as f64) || !bounded(anchor.height as f64) {
return None;
}
let same_extent =
|emu: i64, extent: f32| (emu as f64 / EMU_PER_PX - extent as f64).abs() <= 0.001_f64.max(extent as f64 * f32::EPSILON as f64 * 2.0);
if !same_extent(ew, anchor.width) || !same_extent(eh, anchor.height) {
return None;
}
let geom = one(sp, "custGeom")?;
if !geom.attrs.is_empty() || !only_children(geom, &["avLst", "gdLst", "ahLst", "cxnLst", "rect", "pathLst"]) {
return None;
}
// Text bounds have no effect on this unfilled geometry, which has no caption.
if geom.children.iter().any(|c| !matches!(c.name.as_str(), "pathLst" | "rect") && (!c.children.is_empty() || !c.attrs.is_empty())) {
return None;
}
if geom.kids("rect").count() > 1 || geom.kids("rect").any(|r| !r.children.is_empty() || !only_attrs(r, &["l", "t", "r", "b"])) {
return None;
}
let paths = one(geom, "pathLst")?;
if paths.children.len() != 1 {
return None;
}
let path = one(paths, "path")?;
let w = path.attr_i64("w")?;
let h = path.attr_i64("h")?;
if w <= 0
|| h <= 0
|| path.attr("fill") != Some("none")
|| !only_attrs(path, &["w", "h", "fill", "stroke", "extrusionOk"])
|| path.attr("stroke").is_some_and(|_| path.attr_bool("stroke") != Some(true))
|| path.attr("extrusionOk").is_some_and(|_| path.attr_bool("extrusionOk") != Some(false))
|| !(2..=MAX_POINTS).contains(&path.children.len())
{
return None;
}
let ln = one(sp, "ln")?;
if !only_attrs(ln, &["w", "cap", "cmpd", "algn"])
|| ln.attr("cap") != Some("flat")
|| ln.attr("cmpd").is_some_and(|v| v != "sng")
|| ln.attr("algn").is_some_and(|v| v != "ctr")
|| !only_children(ln, &["solidFill", "miter"])
{
return None;
}
let width_emu = ln.attr_i64("w")?;
let unit = EMU_PER_PX as i64;
if width_emu % unit != 0 || !(1..=40).contains(&(width_emu / unit)) {
return None;
}
let width = width_emu / unit;
let join = one(ln, "miter")?;
if !join.attrs.is_empty() || !join.children.is_empty() {
return None;
}
let fill = one(ln, "solidFill")?;
if fill.children.len() != 1 {
return None;
}
let color = one(fill, "srgbClr")?;
if !only_attrs(color, &["val"]) || !only_children(color, &["alpha"]) {
return None;
}
let expected_alpha = if width > 8 { 45_000 } else { 100_000 };
if color.children.is_empty() {
if expected_alpha != 100_000 {
return None;
}
} else {
let alpha = one(color, "alpha")?;
if !only_attrs(alpha, &["val"]) || !alpha.children.is_empty() || alpha.attr_i64("val")? != expected_alpha {
return None;
}
}
let line = Color::from_hex(color.attr("val")?)?;
let mut text = String::new();
for (i, command) in path.children.iter().enumerate() {
if command.name != if i == 0 { "moveTo" } else { "lnTo" } || !command.attrs.is_empty() || command.children.len() != 1 {
return None;
}
let pt = one(command, "pt")?;
if !only_attrs(pt, &["x", "y"]) || !pt.children.is_empty() {
return None;
}
let x = pt.attr_i64("x")? as f64 * anchor.width as f64 / w as f64;
let y = pt.attr_i64("y")? as f64 * anchor.height as f64 / h as f64;
if !bounded(x) || !bounded(y) {
return None;
}
if i != 0 {
text.push(';');
}
let _ = write!(text, "{},{}", x as f32, y as f32);
}
Some(Shape { id: 0, kind: ShapeKind::Ink, anchor, fill: Color::Rgb((width as u32) << 16), line, text, format: Default::default() })
}
/// Invalid/native out-of-range data uses the existing rectangle fallback. A successful
/// result is complete before it is appended, so malformed points never yield half a stroke.
pub(super) fn write(shape: &Shape, theme: &Theme, id: usize) -> Option<String> {
if shape.kind != ShapeKind::Ink || shape.format.no_line {
return None;
}
let width = match shape.fill {
Color::Rgb(rgb) => ((rgb >> 16) & 0xff).max(1),
_ => 2,
};
if !(1..=40).contains(&width) {
return None;
}
let a = &shape.anchor;
if !bounded(a.width as f64) || !bounded(a.height as f64) || a.width <= 0.0 || a.height <= 0.0 || !a.dx.is_finite() || !a.dy.is_finite() {
return None;
}
let w = (a.width as f64 * EMU_PER_PX).round().max(1.0) as i64;
let h = (a.height as f64 * EMU_PER_PX).round().max(1.0) as i64;
let mut points = Vec::new();
for point in shape.text.split(';') {
if points.len() == MAX_POINTS {
return None;
}
let (x, y) = point.split_once(',')?;
let (x, y) = (x.parse::<f64>().ok()?, y.parse::<f64>().ok()?);
if !bounded(x) || !bounded(y) {
return None;
}
points.push(((x * EMU_PER_PX).round() as i64, (y * EMU_PER_PX).round() as i64));
}
if points.len() < 2 {
return None;
}
let [r, g, b] = shape.line.resolve(theme)?;
let mut xml = format!(
"<xdr:sp macro=\"\" textlink=\"\"><xdr:nvSpPr><xdr:cNvPr id=\"{id}\" name=\"Ink {id}\"/><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr>{}<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:pathLst><a:path w=\"{w}\" h=\"{h}\" fill=\"none\">",
xfrm(a, "a:xfrm")
);
for (i, (x, y)) in points.iter().enumerate() {
let tag = if i == 0 { "moveTo" } else { "lnTo" };
let _ = write!(xml, "<a:{tag}><a:pt x=\"{x}\" y=\"{y}\"/></a:{tag}>");
}
let _ = write!(
xml,
"</a:path></a:pathLst></a:custGeom><a:noFill/><a:ln w=\"{}\" cap=\"flat\"><a:solidFill><a:srgbClr val=\"{r:02X}{g:02X}{b:02X}\">",
width as i64 * EMU_PER_PX as i64
);
if width > 8 {
xml.push_str("<a:alpha val=\"45000\"/>");
}
xml.push_str("</a:srgbClr></a:solidFill><a:miter/></a:ln></xdr:spPr></xdr:sp>");
Some(xml)
}

View File

@ -0,0 +1,194 @@
//! Ink uses visible DrawingML geometry, and only representable polylines become editable ink.
use std::io::Read;
use std::sync::Arc;
use gridcraft_model::{Anchor, AnchorMode, Color, Shape, ShapeKind, Sheet, Workbook};
use super::minimal;
use crate::xml::{self, El};
use crate::{read_xlsx, write_xlsx};
const POINTS: &str = r#"<a:moveTo><a:pt x="-20" y="10"/></a:moveTo><a:lnTo><a:pt x="100" y="80"/></a:lnTo><a:lnTo><a:pt x="180" y="20"/></a:lnTo>"#;
const PATH: &str = r#"<a:path w="200" h="100" fill="none">"#;
fn drawing() -> String {
format!(
r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<xdr:oneCellAnchor><xdr:from><xdr:col>0</xdr:col><xdr:colOff>190500</xdr:colOff><xdr:row>0</xdr:row><xdr:rowOff>285750</xdr:rowOff></xdr:from><xdr:ext cx="952500" cy="476250"/>
<xdr:sp><xdr:nvSpPr><xdr:cNvPr id="1" name="Independent polyline"/><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr>
<a:xfrm><a:off x="0" y="0"/><a:ext cx="952500" cy="476250"/></a:xfrm>
<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="0" t="0" r="r" b="b"/><a:pathLst>{PATH}{POINTS}</a:path></a:pathLst></a:custGeom>
<a:noFill/><a:ln w="28575" cap="flat"><a:solidFill><a:srgbClr val="A12BCD"/></a:solidFill><a:miter/></a:ln>
</xdr:spPr></xdr:sp><xdr:clientData/></xdr:oneCellAnchor></xdr:wsDr>"#
)
}
fn read_drawing(drawing: &str) -> Workbook {
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>"#;
let bytes = minimal(
r#"<sheetData/><drawing r:id="rId1"/>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/drawing1.xml", drawing)],
"",
"",
);
read_xlsx(&bytes).unwrap().0
}
fn points(shape: &Shape) -> Vec<[f64; 2]> {
shape
.text
.split(';')
.map(|pair| {
let (x, y) = pair.split_once(',').unwrap();
[x.parse().unwrap(), y.parse().unwrap()]
})
.collect()
}
fn has_caption(element: &El) -> bool {
(element.name == "t" && !element.text.is_empty()) || element.children.iter().any(has_caption)
}
#[test]
fn exported_ink_has_a_visible_unfilled_path_and_correct_stroke() {
let mut sheet = Sheet::new("Ink");
for (id, width) in [(1, 3), (2, 12)] {
sheet.shapes.push(Shape {
id,
kind: ShapeKind::Ink,
anchor: Anchor { width: 100.0, height: 50.0, mode: AnchorMode::Absolute, ..Default::default() },
fill: Color::Rgb(width << 16),
line: Color::Rgb(0xA12BCD),
text: "-10.0,5.0;50.0,40.0;90.0,10.0".into(),
format: Default::default(),
});
}
let wb = Workbook { sheets: vec![Arc::new(sheet)], ..Default::default() };
let bytes = write_xlsx(&wb).unwrap();
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut drawing = String::new();
archive.by_name("xl/drawings/drawing1.xml").unwrap().read_to_string(&mut drawing).unwrap();
let root = xml::parse(drawing.as_bytes()).unwrap();
let shapes: Vec<_> = root.children.iter().filter_map(|anchor| anchor.child("sp")).collect();
assert_eq!(shapes.len(), 2);
for (shape, width) in shapes.iter().zip([3, 12]) {
assert!(!has_caption(shape), "stroke coordinates must not be visible text");
let properties = shape.child("spPr").unwrap();
assert!(properties.child("prstGeom").is_none());
assert!(properties.child("noFill").is_some());
let paths: Vec<_> = properties.path(&["custGeom", "pathLst"]).unwrap().kids("path").collect();
assert_eq!(paths.len(), 1);
let path = paths[0];
assert_eq!(path.attr("fill"), Some("none"));
let w = path.attr_i64("w").unwrap();
let h = path.attr_i64("h").unwrap();
assert!(w > 0 && h > 0);
assert_eq!(path.children.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["moveTo", "lnTo", "lnTo"]);
for (command, [x, y]) in path.children.iter().zip([[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]) {
let point = command.child("pt").unwrap();
let actual_x = point.attr_i64("x").unwrap() as f64 * 100.0 / w as f64;
let actual_y = point.attr_i64("y").unwrap() as f64 * 50.0 / h as f64;
assert!((actual_x - x).abs() < 0.001 && (actual_y - y).abs() < 0.001);
}
let line = properties.child("ln").unwrap();
assert_eq!(line.attr_i64("w"), Some(9525 * width));
assert_eq!(line.attr("cap"), Some("flat"));
assert!(line.child("miter").is_some());
let color = line.path(&["solidFill", "srgbClr"]).unwrap();
assert_eq!(color.attr("val"), Some("A12BCD"));
if width > 8 {
assert_eq!(color.child_val("alpha"), Some("45000"));
} else {
assert!(color.child("alpha").is_none_or(|a| a.attr("val") == Some("100000")));
}
}
}
#[test]
fn numeric_polyline_from_an_independent_package_is_scaled_into_editable_ink() {
for (width, alpha) in [(3, ""), (12, r#"<a:alpha val="45000"/>"#)] {
let xml = drawing()
.replace("w=\"28575\"", &format!("w=\"{}\"", width * 9525))
.replace(r#"<a:srgbClr val="A12BCD"/>"#, &format!(r#"<a:srgbClr val="A12BCD">{alpha}</a:srgbClr>"#));
let wb = read_drawing(&xml);
let shapes = &wb.sheet(0).unwrap().shapes;
assert_eq!(shapes.len(), 1);
let shape = &shapes[0];
assert_eq!(shape.kind, ShapeKind::Ink);
assert_eq!(points(shape), [[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]);
assert_eq!(shape.line, Color::Rgb(0xA12BCD));
assert_eq!(shape.fill, Color::Rgb(width << 16));
assert_eq!((shape.anchor.dx, shape.anchor.dy), (20.0, 30.0));
assert_eq!((shape.anchor.width, shape.anchor.height), (100.0, 50.0));
}
}
#[test]
fn imported_points_are_not_snapped_to_native_tenth_units() {
let xml = drawing().replace("w=\"200\"", "w=\"2000000\"").replace(
POINTS,
r#"<a:moveTo><a:pt x="2001" y="10"/></a:moveTo><a:lnTo><a:pt x="1000000" y="80"/></a:lnTo><a:lnTo><a:pt x="1800000" y="20"/></a:lnTo>"#,
);
let wb = read_drawing(&xml);
let shape = &wb.sheet(0).unwrap().shapes[0];
assert_eq!(shape.kind, ShapeKind::Ink);
let original = points(shape);
assert!((original[0][0] - 0.10005).abs() < 0.00000001, "an imported coordinate is not necessarily a native tenth-unit point");
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
let after = &reopened.sheet(0).unwrap().shapes[0];
assert_eq!(after.kind, ShapeKind::Ink);
for (actual, expected) in points(after).iter().zip(original) {
for axis in 0..2 {
// A saved DrawingML coordinate is quantized to one EMU, plus f32 model precision.
assert!((actual[axis] - expected[axis]).abs() < 0.00006);
}
}
}
#[test]
fn unsupported_geometry_or_appearance_is_not_reclassified_as_editable_ink() {
let original = drawing();
let cases = [
("formula coordinate", original.replace("x=\"-20\"", "x=\"wd2\"")),
("nonfinite coordinate", original.replace("x=\"-20\"", "x=\"NaN\"")),
("overflow coordinate", original.replace("x=\"-20\"", "x=\"9223372036854775808\"")),
("zero path width", original.replace("w=\"200\"", "w=\"0\"")),
("missing point", original.replace(r#"<a:pt x="100" y="80"/>"#, "")),
(
"curve",
original
.replace(r#"<a:lnTo><a:pt x="100" y="80"/></a:lnTo>"#, r#"<a:quadBezTo><a:pt x="40" y="20"/><a:pt x="100" y="80"/></a:quadBezTo>"#),
),
("closed path", original.replace("</a:path>", "<a:close/></a:path>")),
("multiple paths", original.replace("</a:pathLst>", &format!("{PATH}{POINTS}</a:path></a:pathLst>"))),
("filled path", original.replace("fill=\"none\"", "fill=\"norm\"")),
("filled shape", original.replace("<a:noFill/>", "<a:solidFill><a:srgbClr val=\"112233\"/></a:solidFill>")),
("rotation", original.replace("<a:xfrm>", "<a:xfrm rot=\"60000\">")),
("reflection", original.replace("<a:xfrm>", "<a:xfrm flipH=\"1\">")),
("square caps", original.replace("cap=\"flat\"", "cap=\"sq\"")),
("round join", original.replace("<a:miter/>", "<a:round/>")),
("dash", original.replace("</a:ln>", "<a:prstDash val=\"dash\"/></a:ln>")),
("arrow", original.replace("</a:ln>", "<a:headEnd type=\"triangle\"/></a:ln>")),
(
"caption",
original.replace("</xdr:sp>", "<xdr:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Caption</a:t></a:r></a:p></xdr:txBody></xdr:sp>"),
),
("fractional width", original.replace("w=\"28575\"", "w=\"33338\"")),
("unsupported opacity", original.replace(r#"<a:srgbClr val="A12BCD"/>"#, r#"<a:srgbClr val="A12BCD"><a:alpha val="50000"/></a:srgbClr>"#)),
];
for (name, xml) in cases {
let wb = read_drawing(&xml);
assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink), "{name} was silently converted to editable ink");
}
}
#[test]
fn oversized_polyline_is_not_truncated_into_different_ink() {
let mut commands = String::from(r#"<a:moveTo><a:pt x="0" y="0"/></a:moveTo>"#);
for _ in 0..20_000 {
commands.push_str(r#"<a:lnTo><a:pt x="100" y="80"/></a:lnTo>"#);
}
let wb = read_drawing(&drawing().replace(POINTS, &commands));
assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink));
}

View File

@ -4,6 +4,7 @@ mod array_export;
mod csv_tests;
mod encrypted_tests;
mod fixtures;
mod ink_roundtrip;
mod malformed;
mod ods_tests;
mod passthrough_tests;

View File

@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink round-trips as DrawingML geometry) · **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**
@ -13,6 +13,13 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and
## Workbook formats
Ink created with Draw is written as standard, unfilled DrawingML polylines, without visible point
captions or private metadata. Supported numeric polylines reopen as editable ink, retaining points,
colour, integer width and anchor. Curves, filled paths, transforms and unsupported line styles are
not interpreted as ink. This does not implement Office InkML or fix inserted icons (#551).
Command and package tests cover repeated saves and unrelated cell edits; external Excel rendering
and repair-free opening remain unverified.
| Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests |
|---|---|---|---|---|---|
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
@ -78,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Ink paths and editing survive XLSX save/reopen; strict polyline import and malformed-input controls |
| 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) |
| 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` |
| 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types |

View File

@ -1,6 +1,6 @@
# Gaps
> **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 (ink XLSX preservation; icons still pending) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open
@ -125,6 +125,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
conversion; cloud storage requested (#86). **Estimate:** 35–55 h (co-authoring scope needs the owner).
### 17. Insert objects (F)
- Ink strokes now survive XLSX save/reopen as editable polylines. Inserted icons still lose their
identity/artwork on save (#551); native Office InkML import is not implemented.
- SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot; 8 shape kinds vs
~170; pictures in cells (#61, PR #105). **Estimate:** 25–40 h.
@ -160,6 +162,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | §17: ink geometry survives XLSX saves; the icon portion of #551 remains |
| 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 | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) |
| 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate |