Merge pull request #712 from jonathanwxh-cell:fix/xlsx-ink-roundtrip
This commit is contained in:
commit
a8cb5feafd
|
|
@ -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 | 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 |
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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,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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ mod chart_bool_default;
|
|||
mod csv_tests;
|
||||
mod encrypted_tests;
|
||||
mod fixtures;
|
||||
mod ink_roundtrip;
|
||||
mod literal_category_roundtrip;
|
||||
mod malformed;
|
||||
mod ods_tests;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# File-format parity
|
||||
|
||||
> **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
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` array formulas fill their range on recalculation) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (macOS Open With offers GridCraft for `.xlsb` and `.ods`) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
|
|
@ -14,6 +15,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` |
|
||||
|
|
@ -79,6 +87,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 | `.xls` import: the other cells of an array formula ({=FREQUENCY(...)}, {=TREND(...)}) drop their cached results, as `.xlsx` import does, so the formula fills its range on recalculation instead of showing `#SPILL!` against its own old values; 79 fewer mismatching formulas and 16 fewer round-trip differences over 4,224 real-world files |
|
||||
| 2026-10-12 | minor | macOS: the app bundle declares `.xlsb` and `.ods`, so Finder's Open With offers GridCraft for them, as Linux and Windows already did (#321) |
|
||||
| 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) |
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (ink XLSX preservation; icons still pending) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (§8: PivotTable values on rows) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
|
||||
|
|
@ -130,6 +131,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.
|
||||
|
||||
|
|
@ -165,6 +168,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 | §8: values on rows (Σ Values in Rows, `dataOnRows`) laid out, moved and round-tripped |
|
||||
| 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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue