diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..f40210f 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-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 | diff --git a/crates/engine/tests/ink_roundtrip.rs b/crates/engine/tests/ink_roundtrip.rs new file mode 100644 index 0000000..bff1b61 --- /dev/null +++ b/crates/engine/tests/ink_roundtrip.rs @@ -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); + } + } +} diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 076148a..6dbd17e 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -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) -> 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 { 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(""); + s.push_str(close_tag); + continue; + } let (prst, tx) = match sh.kind { ShapeKind::Rectangle => ("rect", false), ShapeKind::RoundedRectangle => ("roundRect", false), diff --git a/crates/xlsx/src/drawing/ink.rs b/crates/xlsx/src/drawing/ink.rs new file mode 100644 index 0000000..b48d99e --- /dev/null +++ b/crates/xlsx/src/drawing/ink.rs @@ -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 { + 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 { + 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::().ok()?, y.parse::().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!( + "{}", + xfrm(a, "a:xfrm") + ); + for (i, (x, y)) in points.iter().enumerate() { + let tag = if i == 0 { "moveTo" } else { "lnTo" }; + let _ = write!(xml, ""); + } + let _ = write!( + xml, + "", + width as i64 * EMU_PER_PX as i64 + ); + if width > 8 { + xml.push_str(""); + } + xml.push_str(""); + Some(xml) +} diff --git a/crates/xlsx/src/tests/ink_roundtrip.rs b/crates/xlsx/src/tests/ink_roundtrip.rs new file mode 100644 index 0000000..2882765 --- /dev/null +++ b/crates/xlsx/src/tests/ink_roundtrip.rs @@ -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#""#; +const PATH: &str = r#""#; + +fn drawing() -> String { + format!( + r#" + 01905000285750 + + + {PATH}{POINTS} + + "# + ) +} + +fn read_drawing(drawing: &str) -> Workbook { + let rels = r#""#; + let bytes = minimal( + r#""#, + &[("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::>(), ["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#""#)] { + let xml = drawing() + .replace("w=\"28575\"", &format!("w=\"{}\"", width * 9525)) + .replace(r#""#, &format!(r#"{alpha}"#)); + 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#""#, + ); + 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#""#, "")), + ( + "curve", + original + .replace(r#""#, r#""#), + ), + ("closed path", original.replace("", "")), + ("multiple paths", original.replace("", &format!("{PATH}{POINTS}"))), + ("filled path", original.replace("fill=\"none\"", "fill=\"norm\"")), + ("filled shape", original.replace("", "")), + ("rotation", original.replace("", "")), + ("reflection", original.replace("", "")), + ("square caps", original.replace("cap=\"flat\"", "cap=\"sq\"")), + ("round join", original.replace("", "")), + ("dash", original.replace("", "")), + ("arrow", original.replace("", "")), + ( + "caption", + original.replace("", "Caption"), + ), + ("fractional width", original.replace("w=\"28575\"", "w=\"33338\"")), + ("unsupported opacity", original.replace(r#""#, r#""#)), + ]; + 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#""#); + for _ in 0..20_000 { + commands.push_str(r#""#); + } + let wb = read_drawing(&drawing().replace(POINTS, &commands)); + assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink)); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index d5d2f2f..e1d86fe 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -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; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8510659..cf5c6d8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 | diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b8..fdd7492 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 |