From f82d86dc689cf0e5c4e7955b637754da96d4e3fa Mon Sep 17 00:00:00 2001 From: cloutier Date: Mon, 9 Nov 2026 13:49:36 -0500 Subject: [PATCH 1/2] #3665: preserve whole-object drawing hyperlinks --- Cargo.lock | 1 + ROADMAP.md | 4 +- crates/engine/src/cmd/draw.rs | 1 + crates/engine/src/cmd/equation.rs | 1 + crates/engine/src/cmd/insert.rs | 3 + crates/engine/src/cmd/insert/cond_format.rs | 1 + crates/engine/src/cmd/insert/objects.rs | 2 + crates/engine/src/cmd/print/tests.rs | 1 + crates/engine/src/cmd/sheet/tabs.rs | 5 + crates/engine/src/io/tests.rs | 3 + crates/engine/src/tests.rs | 43 ++++ crates/model/src/features.rs | 18 ++ crates/model/src/sheet.rs | 3 +- crates/model/src/workbook.rs | 6 +- crates/xlsx/Cargo.toml | 1 + crates/xlsx/src/drawing.rs | 79 ++++-- crates/xlsx/src/drawing/ink.rs | 5 +- crates/xlsx/src/package.rs | 4 +- crates/xlsx/src/read.rs | 12 + crates/xlsx/src/sheet_write.rs | 14 +- crates/xlsx/src/tests.rs | 1 + crates/xlsx/src/tests/drawing_hyperlinks.rs | 262 ++++++++++++++++++++ crates/xlsx/src/tests/equation_tests.rs | 2 + crates/xlsx/src/tests/ink_roundtrip.rs | 1 + crates/xlsx/src/tests/roundtrip.rs | 4 + crates/xlsx/src/tests/shape_text_rtl.rs | 1 + crates/xlsx/src/tests/shared_media.rs | 2 +- crates/xlsx/src/xlsb.rs | 8 +- docs/file-format-parity.md | 15 +- docs/gaps.md | 6 +- 30 files changed, 481 insertions(+), 28 deletions(-) create mode 100644 crates/xlsx/src/tests/drawing_hyperlinks.rs diff --git a/Cargo.lock b/Cargo.lock index 543cddfe1e..4b82467d54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2233,6 +2233,7 @@ dependencies = [ "gridcraft-math", "gridcraft-model", "quick-xml 0.41.0", + "serde_json", "sha1 0.11.0", "sha2 0.11.1", "thiserror 2.0.21", diff --git a/ROADMAP.md b/ROADMAP.md index 5906fda998..128072fb55 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-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-09 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves (#3665); earlier: form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **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. @@ -134,6 +134,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-11-09 | XLSX whole-object picture and shape click links retain targets, target modes, tooltips and actions through repeated saves (#3665); optional metadata supports older JSON objects; UI activation is separate. | | 2026-11-06 | A sheet keeps its own scroll position when sheets are inserted, deleted or moved before it: a new PivotTable or inserted sheet opens at the top instead of where its neighbour was scrolled; a closed window's positions are let go (#3485) | | 2026-11-02 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `` and VML), alongside the form controls GridCraft keeps unchanged | | 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors | @@ -264,6 +265,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | Preserve picture/shape click hyperlink metadata through XLSX saves (#3665). | | 2026-11-06 | trivial | Progress log: scroll positions follow their sheet through sheet inserts, deletes and moves (#3485) | | 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row | | 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs | diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index c8921718c2..0a896fb081 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -80,6 +80,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result { let text = pts.iter().map(|q| format!("{:.1},{:.1}", q[0] - minx, q[1] - miny)).collect::>().join(";"); let id = d.wb.next_object_id(); let shape = Shape { + hyperlink: None, math: Default::default(), id, kind: ShapeKind::Ink, diff --git a/crates/engine/src/cmd/equation.rs b/crates/engine/src/cmd/equation.rs index d485a3535c..f90c96cc61 100644 --- a/crates/engine/src/cmd/equation.rs +++ b/crates/engine/src/cmd/equation.rs @@ -87,6 +87,7 @@ fn insert_equation(s: &mut Session, p: &Json) -> Result { let id = d.wb.next_object_id(); let (width, height) = frame_for(&list, (size * PX_PER_PT) as f32); let mut shape = Shape { + hyperlink: None, id, kind: ShapeKind::Equation, anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width, height, mode: AnchorMode::MoveOnly }, diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index d832ecddfb..388a4546b1 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1116,6 +1116,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let scale = (640.0 / w.max(1) as f32).min(1.0); let img = Image { + hyperlink: None, id, anchor: Anchor { cell: at, @@ -1185,6 +1186,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { Color::Theme(4, -250) }); let shape = Shape { + hyperlink: None, math: Default::default(), id, kind, @@ -1827,6 +1829,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result { let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0)); let shape = Shape { + hyperlink: None, math: Default::default(), id, kind: ShapeKind::Icon, diff --git a/crates/engine/src/cmd/insert/cond_format.rs b/crates/engine/src/cmd/insert/cond_format.rs index 89bac49c95..a19c06119e 100644 --- a/crates/engine/src/cmd/insert/cond_format.rs +++ b/crates/engine/src/cmd/insert/cond_format.rs @@ -326,6 +326,7 @@ pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result { let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let color = crate::cmd::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0)); let shape = Shape { + hyperlink: None, id, kind: ShapeKind::Icon, anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() }, diff --git a/crates/engine/src/cmd/insert/objects.rs b/crates/engine/src/cmd/insert/objects.rs index f86915a3a6..779d6dae34 100644 --- a/crates/engine/src/cmd/insert/objects.rs +++ b/crates/engine/src/cmd/insert/objects.rs @@ -67,6 +67,7 @@ pub(super) fn insert_picture(s: &mut Session, p: &Json) -> Result { let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let scale = (640.0 / w.max(1) as f32).min(1.0); let img = Image { + hyperlink: None, id, anchor: Anchor { cell: at, @@ -139,6 +140,7 @@ pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result { Color::Theme(4, -250) }); let shape = Shape { + hyperlink: None, id, kind, anchor: Anchor { diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index 3230604702..5febc5f79c 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -513,6 +513,7 @@ fn charts_and_images_are_drawn() { let wb = std::sync::Arc::make_mut(&mut d.wb); let id = wb.next_object_id(); wb.sheet_mut(0).unwrap().images.push(Image { + hyperlink: None, id, anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize }, data: png.into(), diff --git a/crates/engine/src/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index 2c1657ef90..708c42a085 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -118,6 +118,11 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result { cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne))); } } + for link in sh.images.iter_mut().filter_map(|i| i.hyperlink.as_mut()).chain(sh.shapes.iter_mut().filter_map(|s| s.hyperlink.as_mut())) { + if link.internal_sheet.as_deref().is_some_and(|sheet| sheet.eq_ignore_ascii_case(&old)) { + link.internal_sheet = Some(name.clone()); + } + } for ch in sh.charts.iter_mut() { // A PivotChart follows its PivotTable's sheet. if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link) diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index ae5ea88323..38cc86fd55 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -91,6 +91,7 @@ fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() { fn html_export_embeds_images_as_data_uris() { let mut wb = Workbook::new(); wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image { + hyperlink: None, id: 1, anchor: gridcraft_model::Anchor { cell: CellRef::new(0, 0), dx: 0.0, dy: 0.0, width: 72.0, height: 36.0, mode: Default::default() }, data: vec![1, 2, 3].into(), @@ -113,6 +114,7 @@ fn html_export_places_images_at_their_anchor_using_sheet_geometry() { sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() }); sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() }); sh.images.push(gridcraft_model::Image { + hyperlink: None, id: 1, anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() }, data: vec![1].into(), @@ -132,6 +134,7 @@ fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook { let sh = wb.sheet_mut(0).unwrap(); sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0)); sh.images.push(gridcraft_model::Image { + hyperlink: None, id: 1, anchor: gridcraft_model::Anchor { cell: CellRef::parse(anchor).unwrap(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fcaf19c365..f3555af20f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -14,6 +14,48 @@ fn s() -> Session { s } +#[test] +fn drawing_part_links_follow_sheet_rename_reorder_and_json_roundtrip() { + use gridcraft_model::{DrawingHyperlink, Image}; + let mut session = s(); + let original = session.doc().unwrap().wb.sheet(0).unwrap().name.clone(); + session.execute("insert.shape", json!({"kind": "rectangle"})).unwrap(); + let link = DrawingHyperlink { target: "../worksheets/custom.xml#B2".into(), internal_sheet: Some(original.clone()), ..Default::default() }; + { + let sheet = std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + sheet.shapes[0].hyperlink = Some(link.clone()); + sheet.images.push(Image { + hyperlink: Some(link), + id: 100, + anchor: Default::default(), + data: b"original".to_vec().into(), + mime: "image/png".into(), + alt: String::new(), + }); + } + session.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap(); + let names = |s: &Session| { + let sheet = s.doc().unwrap().wb.sheets.iter().find(|s| !s.images.is_empty()).unwrap(); + (sheet.images[0].hyperlink.as_ref().unwrap().internal_sheet.clone(), sheet.shapes[0].hyperlink.as_ref().unwrap().internal_sheet.clone()) + }; + assert_eq!(names(&session), (Some("Renamed".into()), Some("Renamed".into()))); + session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(names(&session), (Some(original.clone()), Some(original))); + session.execute("edit.redo", json!({})).unwrap(); + session.execute("home.insertSheet", json!({"name": "Other"})).unwrap(); + session.execute("sheet.move", json!({"sheet": "Renamed", "to": 1})).unwrap(); + let mut wb: gridcraft_model::Workbook = serde_json::from_value(serde_json::to_value(&session.doc().unwrap().wb).unwrap()).unwrap(); + for _ in 0..2 { + wb = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap().0; + let sheet = wb.sheet(1).unwrap(); + assert_eq!(sheet.name, "Renamed"); + for link in [sheet.images[0].hyperlink.as_ref().unwrap(), sheet.shapes[0].hyperlink.as_ref().unwrap()] { + assert_eq!(link.internal_sheet.as_deref(), Some("Renamed")); + assert_eq!(link.target, "../worksheets/sheet2.xml#B2"); + } + } +} + fn v(s: &Session, a: &str) -> Value { s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) } @@ -3687,6 +3729,7 @@ fn add_image(s: &mut Session, cell: &str, mode: gridcraft_model::AnchorMode) -> let wb = std::sync::Arc::make_mut(&mut d.wb); let id = wb.next_object_id(); wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image { + hyperlink: None, id, anchor: gridcraft_model::Anchor { cell: CellRef::parse(cell).unwrap(), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode }, data: tiny_png().into(), diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 8ab74606a6..0529e48fb7 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -397,6 +397,20 @@ pub struct Hyperlink { // ---------------------------------------------------------------- drawing objects +/// A drawing object's imported click link. Retaining this metadata does not activate it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DrawingHyperlink { + /// Original relationship target, including any fragment or relative file path. + pub target: String, + #[serde(default)] + pub external: bool, + pub tooltip: Option, + pub action: Option, + /// Worksheet destination for an internal part target, remapped on save and sheet rename. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub internal_sheet: Option, +} + /// How a floating object is pinned to the grid when its cells move or resize. Mirrors Excel's /// Format ▸ Size and Properties ▸ Properties, stored in the file as the anchor's `editAs`: /// `twoCell` (absent `editAs`), `oneCell` and `absolute`. @@ -643,6 +657,8 @@ impl Chart { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Image { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hyperlink: Option, pub id: u32, pub anchor: Anchor, /// Encoded bytes (PNG/JPEG), base64 in JSON. Shared, so a copy of the sheet (an undo step) @@ -675,6 +691,8 @@ pub enum ShapeKind { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Shape { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hyperlink: Option, pub id: u32, pub kind: ShapeKind, pub anchor: Anchor, diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 409e93023c..18156de90d 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -938,8 +938,9 @@ mod tests { by_rows: false, pivot: None, }), - ObjKind::Image => s.images.push(Image { id, anchor, data: Arc::new(vec![]), mime: String::new(), alt: String::new() }), + ObjKind::Image => s.images.push(Image { hyperlink: None, id, anchor, data: Arc::new(vec![]), mime: String::new(), alt: String::new() }), ObjKind::Shape | ObjKind::Equation => s.shapes.push(Shape { + hyperlink: None, id, kind: if kind == ObjKind::Equation { ShapeKind::Equation } else { ShapeKind::default() }, anchor, diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 665ec323f5..d1128371e5 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -608,7 +608,8 @@ mod tests { #[test] fn object_ids_never_overflow_or_repeat() { let mut wb = Workbook::new(); - let image = |id| crate::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() }; + let image = + |id| crate::Image { hyperlink: None, id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() }; assert_eq!(wb.next_object_id(), 1); if let Some(sh) = wb.sheet_mut(0) { sh.images.push(image(7)); @@ -655,7 +656,8 @@ mod tests { #[test] fn free_object_ids_come_from_below_when_nothing_is_free_above() { let mut wb = Workbook::new(); - let image = |id| crate::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() }; + let image = + |id| crate::Image { hyperlink: None, id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() }; if let Some(sh) = wb.sheet_mut(0) { sh.images.push(image(u32::MAX)); sh.images.push(image(u32::MAX - 1)); diff --git a/crates/xlsx/Cargo.toml b/crates/xlsx/Cargo.toml index 60a9936800..24b06f691f 100644 --- a/crates/xlsx/Cargo.toml +++ b/crates/xlsx/Cargo.toml @@ -26,5 +26,6 @@ sha1 = { workspace = true } sha2 = { workspace = true } [dev-dependencies] +serde_json = { workspace = true } # Every registered function name, for the formula file-spelling test. gridcraft-functions = { workspace = true } diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index b702331c8e..462fddb36a 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -9,7 +9,9 @@ use std::fmt::Write as _; use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS}; -use gridcraft_model::{Anchor, AnchorMode, Color, Image, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, ShapeTextAnchor, Sheet, Theme}; +use gridcraft_model::{ + Anchor, AnchorMode, Color, DrawingHyperlink, Image, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, ShapeTextAnchor, Sheet, Theme, +}; use crate::IoError; use crate::read::Ctx; @@ -168,7 +170,14 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( }; let alt = obj.path(&["nvPicPr", "cNvPr"]).and_then(|c| c.attr("descr")).unwrap_or("").to_string(); let id = cx.next_object_id(); - sheet.images.push(Image { id, anchor, data: data.into(), mime: mime_for(&target).to_string(), alt }); + sheet.images.push(Image { + hyperlink: read_click(cx, obj, &rels), + id, + anchor, + data: data.into(), + mime: mime_for(&target).to_string(), + alt, + }); } "graphicFrame" => { let Some(gd) = obj.path(&["graphic", "graphicData"]) else { continue }; @@ -199,7 +208,8 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( } } "sp" | "cxnSp" => { - if let Some(s) = read_shape(obj, anchor) { + if let Some(mut s) = read_shape(obj, anchor) { + s.hyperlink = read_click(cx, obj, &rels); let id = cx.next_object_id(); sheet.shapes.push(Shape { id, ..s }); } @@ -212,6 +222,29 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( Ok(()) } +fn read_click(cx: &mut Ctx<'_>, obj: &El, rels: &[crate::package::Rel]) -> Option { + let props = obj.child("nvPicPr").or_else(|| obj.child("nvSpPr")).or_else(|| obj.child("nvCxnSpPr"))?.child("cNvPr")?; + let click = props.child("hlinkClick")?; + let id = click.attr("id")?; + let rel = rels.iter().find(|r| r.id == id && r.kind == "hyperlink" && !r.raw_target.is_empty())?; + let internal_sheet = if rel.external || rel.raw_target.starts_with('#') { + None + } else if let Some(name) = cx.drawing_link_sheets.get(&crate::package::normalize(&rel.target)) { + Some(name.clone()) + } else { + // Rewritten package parts cannot safely retain arbitrary relative destinations. + cx.warn("a drawing click link to an unsupported package part was skipped"); + return None; + }; + Some(DrawingHyperlink { + target: rel.raw_target.clone(), + external: rel.external, + tooltip: click.attr("tooltip").map(str::to_string), + action: click.attr("action").map(str::to_string), + internal_sheet, + }) +} + fn read_shape(obj: &El, anchor: Anchor) -> Option { let sp = obj.child("spPr")?; if let Some(shape) = ink::read(obj, anchor) { @@ -250,6 +283,7 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option { if let Some(text) = source { let ink = obj.child("txBody").and_then(|tb| first_run_color(tb, 0)).unwrap_or_default(); return Some(Shape { + hyperlink: None, id: 0, kind: ShapeKind::Equation, anchor, @@ -265,7 +299,7 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option { let text = obj.child("txBody").map(body_text).unwrap_or_default(); let mut format = read_text_format(obj.child("txBody")); format.no_line = ln.is_some_and(|l| l.child("noFill").is_some()); - Some(Shape { id: 0, kind, anchor, fill, line, text, format, math: Default::default() }) + Some(Shape { hyperlink: None, id: 0, kind, anchor, fill, line, text, format, math: Default::default() }) } /// A shape's text formatting: vertical anchor (`a:bodyPr anchor`), the first paragraph's @@ -334,7 +368,7 @@ fn first_run_color(e: &El, depth: usize) -> Option { /// An equation shape (inside the anchor): Office Math with a plain-text fallback when the source /// is in the subset OMML covers, else the plain-text box alone. Both carry the source. -fn write_equation(s: &mut String, sh: &Shape, id: usize, theme: &Theme) { +fn write_equation(s: &mut String, sh: &Shape, id: usize, theme: &Theme, click: &str) { // The font size the equation is drawn at (it scales with its frame), in points. let size_pt = sh .math @@ -360,7 +394,7 @@ fn write_equation(s: &mut String, sh: &Shape, id: usize, theme: &Theme) { let marker = |tx: &str| crate::xml::parse(tx.as_bytes()).map(|e| crate::omml::body_marker(&e)).unwrap_or_else(|_| "0".repeat(16)); let sp = |tx: &str, marker: &str| { format!( - "{}{tx}", + "{click}{}{tx}", id - 1, esc_attr(&format!("{LATEX_DESCR}{marker}:{}", sh.text)), xfrm(&sh.anchor, "a:xfrm") @@ -446,9 +480,21 @@ pub fn srgb(c: &Color, theme: &Theme) -> Option { /// One drawing object to write. pub enum Obj<'a> { - Image(&'a Image, String), + Image(&'a Image, String, Option), Chart(&'a gridcraft_model::Chart, String), - Shape(&'a Shape), + Shape(&'a Shape, Option), +} + +fn click_xml(link: Option<&DrawingHyperlink>, id: Option<&str>) -> String { + let (Some(link), Some(id)) = (link, id) else { return String::new() }; + let mut s = format!(""); + s } /// The drawing part for a sheet's objects (relationship ids already assigned). @@ -460,13 +506,14 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { let id = i + 2; let close_tag; match o { - Obj::Image(img, rid) => { + Obj::Image(img, rid, link_id) => { + let click = click_xml(img.hyperlink.as_ref(), link_id.as_deref()); let (open, close) = anchor_open(sheet, &img.anchor); close_tag = close; s.push_str(&open); let _ = write!( s, - "{}", + "{click}{}", id - 1, esc_attr(&img.alt), xfrm(&img.anchor, "a:xfrm") @@ -497,17 +544,19 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { xfrm(&ch.anchor, "xdr:xfrm") ); } - Obj::Shape(sh) if sh.kind == ShapeKind::Equation => { + Obj::Shape(sh, link_id) if sh.kind == ShapeKind::Equation => { let (open, close) = anchor_open(sheet, &sh.anchor); close_tag = close; s.push_str(&open); - write_equation(&mut s, sh, id, theme); + let click = click_xml(sh.hyperlink.as_ref(), link_id.as_deref()); + write_equation(&mut s, sh, id, theme, &click); } - Obj::Shape(sh) => { + Obj::Shape(sh, link_id) => { let (open, close) = anchor_open(sheet, &sh.anchor); close_tag = close; s.push_str(&open); - if let Some(xml) = ink::write(sh, theme, id) { + let click = click_xml(sh.hyperlink.as_ref(), link_id.as_deref()); + if let Some(xml) = ink::write(sh, theme, id, &click) { s.push_str(&xml); s.push_str(""); s.push_str(close_tag); @@ -525,7 +574,7 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { }; let _ = write!( s, - "{}", + "{click}{}", id - 1, if tx { " txBox=\"1\"" } else { "" }, xfrm(&sh.anchor, "a:xfrm") diff --git a/crates/xlsx/src/drawing/ink.rs b/crates/xlsx/src/drawing/ink.rs index 0bfc0911b8..11a4045710 100644 --- a/crates/xlsx/src/drawing/ink.rs +++ b/crates/xlsx/src/drawing/ink.rs @@ -180,6 +180,7 @@ pub(super) fn read(obj: &El, anchor: Anchor) -> Option { let (width, line) = validate_line(sp)?; let text = parse_points(path, &anchor, w, h)?; Some(Shape { + hyperlink: None, id: 0, kind: ShapeKind::Ink, anchor, @@ -193,7 +194,7 @@ pub(super) fn read(obj: &El, anchor: Anchor) -> Option { /// 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 { +pub(super) fn write(shape: &Shape, theme: &Theme, id: usize, click: &str) -> Option { if shape.kind != ShapeKind::Ink || shape.format.no_line { return None; } @@ -227,7 +228,7 @@ pub(super) fn write(shape: &Shape, theme: &Theme, id: usize) -> Option { } let [r, g, b] = shape.line.resolve(theme)?; let mut xml = format!( - "{}", + "{click}{}", xfrm(a, "a:xfrm") ); for (i, (x, y)) in points.iter().enumerate() { diff --git a/crates/xlsx/src/package.rs b/crates/xlsx/src/package.rs index 002a2bcedf..e81c14edb9 100644 --- a/crates/xlsx/src/package.rs +++ b/crates/xlsx/src/package.rs @@ -31,6 +31,8 @@ pub struct Rel { /// Resolved part name (no leading `/`), or the raw target for external relationships. pub target: String, pub external: bool, + /// Original target before part resolution removes fragments. + pub raw_target: String, } pub fn normalize(name: &str) -> String { @@ -145,7 +147,7 @@ impl<'a> Package<'a> { let target_raw = r.attr("Target")?; let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); let target = if external { target_raw.to_string() } else { resolve(dir, target_raw) }; - Some(Rel { id, kind, target, external }) + Some(Rel { id, kind, target, external, raw_target: target_raw.to_string() }) }) .collect()) } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 5bdc982163..36cab89587 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -15,6 +15,8 @@ use crate::{IoError, ReadReport}; /// State shared while reading one package. pub struct Ctx<'a> { + /// Source worksheet part → assigned model name for internal drawing click links. + pub drawing_link_sheets: std::collections::HashMap, pub pkg: Package<'a>, pub report: ReadReport, pub sst: Vec>, @@ -244,6 +246,7 @@ fn selected_workbook_view(workbook: &El) -> Option<&El> { pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> bool>) -> Result<(Workbook, ReadReport), IoError> { let pkg = Package::open(bytes)?; let mut cx = Ctx { + drawing_link_sheets: Default::default(), pkg, report: ReadReport::default(), sst: vec![], @@ -311,6 +314,15 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> let mut file_to_model: Vec> = vec![]; let mut pivot_caches = crate::pivot::CachesIn::new(&wb_xml, &wb_rels); let sheet_els: Vec = wb_xml.child("sheets").map(|s| s.kids("sheet").cloned().collect()).unwrap_or_default(); + let mut link_names = SheetNames::default(); + for (i, se) in sheet_els.iter().enumerate() { + if let Some(rel) = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).filter(|r| r.kind == "worksheet") { + let name = link_names.unique(se.attr("name").unwrap_or(""), i); + // Match the read loop's first relationship for duplicate ids; if malformed sheet + // entries reuse one part, keep the first model destination deterministically. + cx.drawing_link_sheets.entry(crate::package::normalize(&rel.target)).or_insert(name); + } + } let mut names = SheetNames::default(); for (i, se) in sheet_els.iter().enumerate() { let name = se.attr("name").unwrap_or("").to_string(); diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 4a0f4fd030..57af65a3f4 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -328,6 +328,16 @@ fn write_sheet_notes(s: &mut String, sheet: &Sheet, si: usize, kept: &crate::pas s.push_str(&kept.elements(&controls)); } +fn drawing_link(link: Option<&gridcraft_model::DrawingHyperlink>, wb: &Workbook, rels: &mut Rels) -> Option { + let link = link.filter(|l| !l.target.is_empty())?; + if let Some(name) = &link.internal_sheet { + let index = wb.sheets.iter().position(|s| s.name.eq_ignore_ascii_case(name))?; + let fragment = link.target.split_once('#').map(|(_, f)| format!("#{f}")).unwrap_or_default(); + return Some(rels.add("hyperlink", &format!("../worksheets/sheet{}.xml{fragment}", index + 1))); + } + Some(if link.external { rels.add_external("hyperlink", &link.target) } else { rels.add("hyperlink", &link.target) }) +} + fn write_sheet_drawing(s: &mut String, wb: &Workbook, si: usize, sheet: &Sheet, rels: &mut Rels, out: &mut Out) { // Drawing: pictures, charts, shapes. if !sheet.images.is_empty() || !sheet.charts.is_empty() || !sheet.shapes.is_empty() { @@ -343,7 +353,7 @@ fn write_sheet_drawing(s: &mut String, wb: &Workbook, si: usize, sheet: &Sheet, out.default_type(ext, image_content_type(ext)); let file = out.image_part(ext, &img.data); let rid = drels.add("image", &format!("../media/{file}")); - objs.push(Obj::Image(img, rid)); + objs.push(Obj::Image(img, rid, drawing_link(img.hyperlink.as_ref(), wb, &mut drels))); } for ch in &sheet.charts { out.charts += 1; @@ -367,7 +377,7 @@ fn write_sheet_drawing(s: &mut String, wb: &Workbook, si: usize, sheet: &Sheet, objs.push(Obj::Chart(ch, rid)); } for sh in &sheet.shapes { - objs.push(Obj::Shape(sh)); + objs.push(Obj::Shape(sh, drawing_link(sh.hyperlink.as_ref(), wb, &mut drels))); } out.part(&format!("xl/drawings/drawing{dn}.xml"), Some(CT_DRAWING), crate::drawing::write_drawing(sheet, &wb.theme, &objs).into_bytes()); if !drels.is_empty() { diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs index 94b78ff655..1f305bdb54 100644 --- a/crates/xlsx/src/tests.rs +++ b/crates/xlsx/src/tests.rs @@ -9,6 +9,7 @@ mod chart_data_table; mod chart_ref_offsets; mod controls_tests; mod csv_tests; +mod drawing_hyperlinks; mod encrypted_tests; mod equation_tests; mod fixtures; diff --git a/crates/xlsx/src/tests/drawing_hyperlinks.rs b/crates/xlsx/src/tests/drawing_hyperlinks.rs new file mode 100644 index 0000000000..4be6940892 --- /dev/null +++ b/crates/xlsx/src/tests/drawing_hyperlinks.rs @@ -0,0 +1,262 @@ +//! Contributor-original drawing packages, without vendor assets or network access. +use std::io::{Cursor, Read}; + +use super::{make_zip, minimal}; +use crate::{read_xlsx, write_xlsx}; + +fn fixture() -> Vec { + let mut objects = String::new(); + for (id, picture, linked) in [(2, true, true), (3, false, true), (4, true, false), (5, false, false)] { + let click = if !linked { + String::new() + } else if picture { + r#""#.into() + } else { + r#""#.into() + }; + let props = format!(r#"{click}"#); + let object = if picture { + format!( + r#"{props}"# + ) + } else { + format!(r#"{props}"#) + }; + objects.push_str(&format!(r#"{id}000{object}"#)); + } + let drawing = format!( + r#"{objects}"# + ); + let rels = r#""#; + let sheet_rels = r#""#; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels), ("xl/drawings/original.xml", &drawing), ("xl/drawings/_rels/original.xml.rels", rels)], + "", + "", + ); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut parts = Vec::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let mut data = Vec::new(); + f.read_to_end(&mut data).unwrap(); + parts.push((f.name().to_string(), data)); + } + // The reader preserves these original bytes; the test does not decode or render the picture. + parts.push(("xl/media/original.png".into(), b"\x89PNG\r\n\x1a\noriginal-test-payload".to_vec())); + make_zip(&parts.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect::>()) +} +fn xml(bytes: &[u8], part: &str) -> crate::xml::El { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut data = Vec::new(); + z.by_name(part).unwrap().read_to_end(&mut data).unwrap(); + crate::xml::parse(&data).unwrap() +} +#[test] +fn picture_and_shape_click_links_survive_two_saves() { + let mut bytes = fixture(); + for _ in 0..2 { + let (wb, _) = read_xlsx(&bytes).unwrap(); + assert_eq!(wb.sheet(0).unwrap().images.len(), 2); + assert_eq!(wb.sheet(0).unwrap().shapes.len(), 2); + bytes = write_xlsx(&wb).unwrap(); + let drawing = xml(&bytes, "xl/drawings/drawing1.xml"); + let rels = xml(&bytes, "xl/drawings/_rels/drawing1.xml.rels"); + let links: Vec<_> = drawing + .children + .iter() + .flat_map(|anchor| anchor.children.iter()) + .filter_map(|obj| obj.child("nvPicPr").or_else(|| obj.child("nvSpPr"))) + .filter_map(|nv| nv.child("cNvPr")) + .filter_map(|nv| nv.child("hlinkClick")) + .collect(); + assert_eq!(links.len(), 2, "both links must survive while the no-link controls remain unlinked"); + for click in links { + let rel = rels.kids("Relationship").find(|r| r.attr("Id") == click.attr("id")).unwrap(); + assert!(rel.attr("Type").unwrap().ends_with("/hyperlink")); + if click.attr("tooltip") == Some("Visit & inspect") { + assert_eq!(rel.attr("Target"), Some("https://example.invalid/?a=1&b=\"x\"")); + assert_eq!(rel.attr("TargetMode"), Some("External")); + assert_eq!(click.attr("action"), None); + } else { + assert_eq!(click.attr("tooltip"), Some("Jump & back")); + assert_eq!(click.attr("action"), Some("custom:jump")); + assert_eq!(rel.attr("Target"), Some("../worksheets/sheet1.xml#B2")); + assert_eq!(rel.attr("TargetMode"), None); + } + } + } +} + +fn replace_part(bytes: Vec, part: &str, from: &str, to: &str) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut parts = Vec::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut data = Vec::new(); + f.read_to_end(&mut data).unwrap(); + if name == part { + data = String::from_utf8(data).unwrap().replace(from, to).into_bytes(); + } + parts.push((name, data)); + } + make_zip(&parts.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect::>()) +} + +#[test] +fn missing_and_wrong_kind_relationships_do_not_create_click_links() { + let missing = replace_part(fixture(), "xl/drawings/original.xml", "r:id=\"url\"", "r:id=\"missing\""); + let (wb, _) = read_xlsx(&missing).unwrap(); + assert!(wb.sheet(0).unwrap().images.iter().all(|i| i.hyperlink.is_none())); + assert!(wb.sheet(0).unwrap().shapes[0].hyperlink.is_some()); + let wrong = replace_part(fixture(), "xl/drawings/_rels/original.xml.rels", "/relationships/hyperlink", "/relationships/image"); + let (wb, _) = read_xlsx(&wrong).unwrap(); + let sheet = wb.sheet(0).unwrap(); + assert!(sheet.images.iter().all(|i| i.hyperlink.is_none())); + assert!(sheet.shapes.iter().all(|s| s.hyperlink.is_none())); + let saved = write_xlsx(&wb).unwrap(); + let rels = xml(&saved, "xl/drawings/_rels/drawing1.xml.rels"); + assert!(rels.kids("Relationship").all(|r| !r.attr("Type").unwrap().ends_with("/hyperlink"))); +} + +#[test] +fn drawing_links_survive_json_and_old_objects_default_to_no_link() { + let (wb, _) = read_xlsx(&fixture()).unwrap(); + let sheet = wb.sheet(0).unwrap(); + let image = &sheet.images[0]; + let shape = &sheet.shapes[0]; + let mut image_json = serde_json::to_value(image).unwrap(); + let mut shape_json = serde_json::to_value(shape).unwrap(); + assert_eq!(serde_json::from_value::(image_json.clone()).unwrap().hyperlink, image.hyperlink); + assert_eq!(serde_json::from_value::(shape_json.clone()).unwrap().hyperlink, shape.hyperlink); + image_json.as_object_mut().unwrap().remove("hyperlink"); + shape_json.as_object_mut().unwrap().remove("hyperlink"); + assert!(serde_json::from_value::(image_json).unwrap().hyperlink.is_none()); + assert!(serde_json::from_value::(shape_json).unwrap().hyperlink.is_none()); + assert!(serde_json::to_value(&sheet.images[1]).unwrap().get("hyperlink").is_none()); + assert!(serde_json::to_value(&sheet.shapes[1]).unwrap().get("hyperlink").is_none()); +} + +#[test] +fn equation_and_ink_shapes_keep_their_click_metadata() { + use gridcraft_model::{Color, ShapeKind}; + for kind in [ShapeKind::Equation, ShapeKind::Ink] { + let (mut wb, _) = read_xlsx(&fixture()).unwrap(); + let shape = &mut wb.sheet_mut(0).unwrap().shapes[0]; + shape.kind = kind; + shape.text = if kind == ShapeKind::Equation { "x^2" } else { "0,0;50,40;90,10" }.into(); + shape.fill = Color::Rgb(3 << 16); + shape.line = Color::Rgb(0xA12BCD); + let expected = shape.hyperlink.clone(); + for _ in 0..2 { + let bytes = write_xlsx(&wb).unwrap(); + wb = read_xlsx(&bytes).unwrap().0; + let shape = &wb.sheet(0).unwrap().shapes[0]; + assert_eq!(shape.kind, kind); + assert_eq!(shape.hyperlink, expected); + } + } +} + +#[test] +fn relocated_part_links_map_to_the_written_worksheet_after_reordering() { + let mut z = zip::ZipArchive::new(Cursor::new(fixture())).unwrap(); + let mut parts = Vec::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut data = Vec::new(); + f.read_to_end(&mut data).unwrap(); + let written_name = match name.as_str() { + "xl/worksheets/sheet1.xml" => "xl/worksheets/unusual.xml", + "xl/worksheets/_rels/sheet1.xml.rels" => "xl/worksheets/_rels/unusual.xml.rels", + "xl/drawings/original.xml" => "xl/nested/drawings/original.xml", + "xl/drawings/_rels/original.xml.rels" => "xl/nested/drawings/_rels/original.xml.rels", + _ => &name, + }; + if name.ends_with(".rels") { + let mut text = String::from_utf8(data).unwrap(); + if name == "xl/_rels/workbook.xml.rels" { + text = text.replace("worksheets/sheet1.xml", "worksheets/unusual.xml"); + } else if name == "xl/worksheets/_rels/sheet1.xml.rels" { + text = text.replace("../drawings/original.xml", "../nested/drawings/original.xml"); + } else if name == "xl/drawings/_rels/original.xml.rels" { + text = text.replace("../media/", "../../media/").replace("../worksheets/sheet1.xml", "../../worksheets/unusual.xml"); + } + data = text.into_bytes(); + } + parts.push((written_name.to_string(), data)); + } + let bytes = make_zip(&parts.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect::>()); + let (mut wb, report) = read_xlsx(&bytes).unwrap(); + assert_eq!(report.warnings, vec!["no styles part".to_string()]); + assert_eq!(wb.sheet(0).unwrap().shapes[0].hyperlink.as_ref().unwrap().internal_sheet.as_deref(), Some("Data")); + wb.sheets.insert(0, std::sync::Arc::new(gridcraft_model::Sheet::new("Other"))); + // JSON must retain the logical destination without depending on skipped runtime ids. + wb = serde_json::from_value(serde_json::to_value(&wb).unwrap()).unwrap(); + for _ in 0..2 { + let bytes = write_xlsx(&wb).unwrap(); + let rels = xml(&bytes, "xl/drawings/_rels/drawing1.xml.rels"); + assert!(rels.kids("Relationship").any(|r| r.attr("Target") == Some("../worksheets/sheet2.xml#B2"))); + xml(&bytes, "xl/worksheets/sheet2.xml"); + wb = read_xlsx(&bytes).unwrap().0; + assert_eq!(wb.sheet(1).unwrap().shapes[0].hyperlink.as_ref().unwrap().internal_sheet.as_deref(), Some("Data")); + } +} + +#[test] +fn external_sheet_locations_are_unchanged_and_other_package_parts_warn() { + let bytes = replace_part( + fixture(), + "xl/drawings/_rels/original.xml.rels", + "Target=\"../worksheets/sheet1.xml#B2\"", + "Target=\"#'Data'!B2\" TargetMode=\"External\"", + ); + let (mut wb, _) = read_xlsx(&bytes).unwrap(); + for _ in 0..2 { + let link = wb.sheet(0).unwrap().shapes[0].hyperlink.as_ref().unwrap(); + assert_eq!(link.target, "#'Data'!B2"); + assert!(link.external); + assert!(link.internal_sheet.is_none()); + wb = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap().0; + } + let bytes = replace_part(fixture(), "xl/drawings/_rels/original.xml.rels", "../worksheets/sheet1.xml#B2", "../media/original.png#B2"); + let (wb, report) = read_xlsx(&bytes).unwrap(); + assert!(wb.sheet(0).unwrap().shapes[0].hyperlink.is_none()); + assert!(report.warnings.iter().any(|w| w.contains("drawing click link to an unsupported package part"))); +} + +#[test] +fn connector_shape_click_links_use_their_nonvisual_properties() { + let bytes = replace_part(fixture(), "xl/drawings/original.xml", "xdr:nvSpPr", "xdr:nvCxnSpPr"); + let bytes = replace_part(bytes, "xl/drawings/original.xml", "xdr:cNvSpPr", "xdr:cNvCxnSpPr"); + let bytes = replace_part(bytes, "xl/drawings/original.xml", "xdr:sp>", "xdr:cxnSp>"); + let (wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.sheet(0).unwrap().shapes[0].hyperlink.is_some()); + let wb = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap().0; + assert_eq!(wb.sheet(0).unwrap().shapes[0].hyperlink.as_ref().unwrap().tooltip.as_deref(), Some("Jump & back")); +} + +#[test] +fn internal_link_names_match_skipped_sheets_and_duplicate_relationship_ids() { + let bytes = replace_part( + fixture(), + "xl/workbook.xml", + "", + r#""#, + ); + let bytes = replace_part( + bytes, + "xl/_rels/workbook.xml.rels", + "", + r#""#, + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + assert_eq!(wb.sheets.len(), 1); + assert_eq!(wb.sheet(0).unwrap().name, "Data"); + assert_eq!(wb.sheet(0).unwrap().shapes[0].hyperlink.as_ref().unwrap().internal_sheet.as_deref(), Some("Data")); + let reopened = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap().0; + assert_eq!(reopened.sheet(0).unwrap().shapes[0].hyperlink.as_ref().unwrap().internal_sheet.as_deref(), Some("Data")); +} diff --git a/crates/xlsx/src/tests/equation_tests.rs b/crates/xlsx/src/tests/equation_tests.rs index 998364465e..9f4ecb1e10 100644 --- a/crates/xlsx/src/tests/equation_tests.rs +++ b/crates/xlsx/src/tests/equation_tests.rs @@ -12,6 +12,7 @@ use crate::{read_xlsx, write_xlsx}; fn equation(id: u32, cell: &str, latex: &str, ink: Color) -> Shape { Shape { + hyperlink: None, id, kind: ShapeKind::Equation, anchor: Anchor { cell: CellRef::parse(cell).unwrap(), width: 200.0, height: 60.0, mode: AnchorMode::MoveOnly, ..Default::default() }, @@ -153,6 +154,7 @@ fn a_text_box_stays_a_text_box_and_hostile_sources_survive() { let mut wb = book(vec![equation(1, "B2", &"{".repeat(40), Color::Auto), equation(2, "B6", "\u{0}\u{1F600} & ]]> ", Color::Auto)]); let mut sh = (*wb.sheets[0]).clone(); sh.shapes.push(Shape { + hyperlink: None, id: 3, kind: ShapeKind::TextBox, anchor: Anchor { cell: CellRef::parse("D2").unwrap(), width: 80.0, height: 30.0, ..Default::default() }, diff --git a/crates/xlsx/src/tests/ink_roundtrip.rs b/crates/xlsx/src/tests/ink_roundtrip.rs index 41cac955b7..1ba1f447ca 100644 --- a/crates/xlsx/src/tests/ink_roundtrip.rs +++ b/crates/xlsx/src/tests/ink_roundtrip.rs @@ -55,6 +55,7 @@ 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 { + hyperlink: None, id, kind: ShapeKind::Ink, anchor: Anchor { width: 100.0, height: 50.0, mode: AnchorMode::Absolute, ..Default::default() }, diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index eae22d8297..5d1a6f2828 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -352,6 +352,7 @@ fn sample() -> Workbook { }); let png = b"\x89PNG\r\n\x1a\n0000IHDRfake".to_vec(); s.images.push(Image { + hyperlink: None, id: 10, // A non-default anchor mode, to prove it survives an xlsx round-trip. anchor: Anchor { cell: at("C10"), dx: 5.0, dy: 4.0, width: 120.0, height: 80.0, mode: AnchorMode::MoveOnly }, @@ -360,6 +361,7 @@ fn sample() -> Workbook { alt: "Logo \"x\"".into(), }); s.shapes.push(Shape { + hyperlink: None, math: Default::default(), id: 11, kind: ShapeKind::Ellipse, @@ -370,6 +372,7 @@ fn sample() -> Workbook { format: Default::default(), }); s.shapes.push(Shape { + hyperlink: None, math: Default::default(), id: 12, kind: ShapeKind::Arrow, @@ -380,6 +383,7 @@ fn sample() -> Workbook { format: Default::default(), }); s.shapes.push(Shape { + hyperlink: None, math: Default::default(), id: 13, kind: ShapeKind::TextBox, diff --git a/crates/xlsx/src/tests/shape_text_rtl.rs b/crates/xlsx/src/tests/shape_text_rtl.rs index c60fd53f18..0a4dbf62fb 100644 --- a/crates/xlsx/src/tests/shape_text_rtl.rs +++ b/crates/xlsx/src/tests/shape_text_rtl.rs @@ -38,6 +38,7 @@ fn written_drawing(wb: &Workbook) -> String { fn text_box(text: &str, format: ShapeFormat) -> Workbook { let mut sheet = Sheet::new("Sheet1"); sheet.shapes.push(Shape { + hyperlink: None, id: 1, kind: ShapeKind::TextBox, anchor: Anchor { width: 120.0, height: 40.0, ..Default::default() }, diff --git a/crates/xlsx/src/tests/shared_media.rs b/crates/xlsx/src/tests/shared_media.rs index a1d7bc0c73..b634201b9e 100644 --- a/crates/xlsx/src/tests/shared_media.rs +++ b/crates/xlsx/src/tests/shared_media.rs @@ -18,7 +18,7 @@ fn sheet_with(name: &str, pictures: &[(u32, &[u8])]) -> Arc { let mut s = Sheet::new(name); for (id, data) in pictures { let anchor = Anchor { cell: CellRef::parse("B2").unwrap(), width: 40.0, height: 30.0, ..Default::default() }; - s.images.push(Image { id: *id, anchor, data: data.to_vec().into(), mime: "image/png".into(), alt: String::new() }); + s.images.push(Image { hyperlink: None, id: *id, anchor, data: data.to_vec().into(), mime: "image/png".into(), alt: String::new() }); } Arc::new(s) } diff --git a/crates/xlsx/src/xlsb.rs b/crates/xlsx/src/xlsb.rs index 39d9fd9f61..02680233d7 100644 --- a/crates/xlsx/src/xlsb.rs +++ b/crates/xlsx/src/xlsb.rs @@ -243,7 +243,13 @@ fn relationships(package: &mut Package<'_>, path: &str) -> Result, IoEr .unwrap_or_else(|| format!("unsupported:{ty}")); let target = e.attr("Target").ok_or_else(|| invalid("relationship has no target"))?; let external = e.attr("TargetMode") == Some("External"); - out.push(Rel { id: id.into(), kind, target: if external { target.into() } else { resolve(dir, target) }, external }); + out.push(Rel { + id: id.into(), + kind, + target: if external { target.into() } else { resolve(dir, target) }, + external, + raw_target: target.into(), + }); } Ok(out) } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bd3e26c89d..3d582a75e1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-09 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves (#3665); earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **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** @@ -16,6 +16,18 @@ shared formulas are expanded during import (`array_export.rs`, #1505). Cell text now uses shared Unicode bidi layout while retaining logical source indexes for editing, copy and XLSX. Format Cells exposes Context, Left to right and Right to left reading direction, independent of sheet geometry. OOXML Context uses the first non-whitespace character. Optional direction preserves absent versus explicit Context in differential styles. The renderer is pinned to a reviewed egui 0.36.2 backport with real-font, editing, accessibility and WASM checks. Physical screen-reader behavior remains unverified. +## Drawing click links + +Whole-object picture and shape click hyperlinks (`cNvPr/a:hlinkClick`) retain their drawing relationship targets, target modes, +tooltips and actions on save (#3665), including equation and ink shapes. Synthetic packages +cover repeated saves, malformed link references and older JSON objects. Internal worksheet +part destinations map from source parts to logical sheet names and back to the current output +parts, retaining fragments across relocated drawings, sheet rename and reordering. Other +internal package-part links are skipped with a warning rather than emitted as dangling targets. +External workbook locations such as `#'Data'!B2` retain their original text. This is metadata +preservation; drawing-link activation in the UI and native Excel interaction are separate. +Hyperlinks on individual text runs (`a:rPr/a:hlinkClick`) remain outside this object metadata. + ## Workbook window settings GridCraft keeps one modelled workbook window. On XLSX import it selects the first visible @@ -160,6 +172,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | Preserve DrawingML picture/shape click-link metadata and relationship references (#3665). | | 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) | | 2026-11-03 | minor | SYLK (`.slk`) import: values, R1C1 formulas as A1 (shared formulas too) with cached results, number formats and column widths; import only (#1481) | | 2026-11-03 | minor | Opening a web page (HTML) reads its first table as a sheet, whatever the file is called: a web app's "Export to Excel" saved as `.xls` no longer opens as lines of markup. Values read as in a CSV (or as text with `csvAsText`), `` cells are bold and centred, `` names the sheet, and a page not named `.htm`/`.html` opens with a warning (#3166) | diff --git a/docs/gaps.md b/docs/gaps.md index 8b5f088ad8..e486715f4e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (§17: equations; earlier: §13: Solver Sensitivity report; earlier: §4: chart resizing is no longer a gap; earlier: §13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-09 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves (#3665); earlier: §17: equations; earlier: §13: Solver Sensitivity report; earlier: §4: chart resizing is no longer a gap; earlier: §13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **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 @@ -34,6 +34,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Estimate:** 25–40 h. [target-app-parity.md](target-app-parity.md#performance), [hardware-parity.md](hardware-parity.md) ### 3. Some unmodelled XLSX content is dropped on save (FF) +- **Added:** whole-object picture and shape click links (`cNvPr/a:hlinkClick`) keep targets, target modes, tooltips and actions + through XLSX saves (#3665). This preserves metadata; drawing-link activation in the UI + and native Excel interaction remain unverified. Individual text-run links remain unmodelled. - **Missing:** slicers and their caches, timelines, the data model, query tables attached to tables, unmodelled `extLst` entries, chart details. Pass-through (#330) keeps custom XML, external links, connections, sheet query tables, printer settings, form and ActiveX controls, @@ -193,6 +196,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | Record picture/shape click-link preservation and its UI/native validation limits (#3665). | | 2026-11-02 | minor | §17: equations (Insert › Equation, LaTeX source, gallery, editor) | | 2026-11-02 | minor | §13: Solver Sensitivity report: shadow prices, reduced costs and allowable ranges for Simplex LP; Lagrange multipliers and reduced gradients for GRG Nonlinear | | 2026-11-02 | minor | §4: charts, like every floating object, move and resize from eight handles (#190), so resizing is no longer listed as missing; the evidence is one paragraph again, without the stale "PRs #158, #161 open" | From 3a0a4bee2b159a9041f18a9771d03b7f285ba28d Mon Sep 17 00:00:00 2001 From: Vincent Cloutier Date: Mon, 9 Nov 2026 16:05:02 -0500 Subject: [PATCH 2/2] GC-NAP001: remove drawing links when their destination sheet is deleted --- ROADMAP.md | 4 +- crates/engine/src/cmd/draw.rs | 1 + crates/engine/src/cmd/equation.rs | 1 + crates/engine/src/cmd/insert.rs | 3 ++ crates/engine/src/cmd/insert/cond_format.rs | 1 + crates/engine/src/cmd/insert/objects.rs | 2 + crates/engine/src/cmd/print/tests.rs | 1 + crates/engine/src/cmd/sheet/tabs.rs | 9 ++++ crates/engine/src/io/tests.rs | 3 ++ crates/engine/src/tests.rs | 47 +++++++++++++++++++++ crates/model/src/workbook.rs | 1 + crates/xlsx/src/drawing.rs | 4 ++ crates/xlsx/src/sheet_write.rs | 2 + crates/xlsx/src/tests/drawing_hyperlinks.rs | 11 +++++ crates/xlsx/src/tests/equation_tests.rs | 2 + crates/xlsx/src/tests/ink_roundtrip.rs | 1 + crates/xlsx/src/tests/roundtrip.rs | 1 + crates/xlsx/src/tests/shape_text_rtl.rs | 1 + crates/xlsx/src/tests/shared_media.rs | 1 + docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- 21 files changed, 96 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 128072fb55..5383abdcd5 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-11-01 · **Last updated:** 2026-11-09 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves (#3665); earlier: form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-09 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves and removed when their destination sheet is deleted (#3665); earlier: form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **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. @@ -265,7 +265,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| -| 2026-11-09 | minor | Preserve picture/shape click hyperlink metadata through XLSX saves (#3665). | +| 2026-11-09 | minor | Preserve picture/shape click hyperlink metadata through XLSX saves; deleting a destination removes its links so a recreated sheet cannot inherit them (#3665). | | 2026-11-06 | trivial | Progress log: scroll positions follow their sheet through sheet inserts, deletes and moves (#3485) | | 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row | | 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs | diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 0a896fb081..3e9c0facee 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -49,6 +49,7 @@ fn parse_points(t: &str) -> Vec<[f32; 2]> { t.split(';').filter_map(|pt| pt.split_once(',').and_then(|(x, y)| Some([x.parse().ok()?, y.parse().ok()?]))).collect() } +/// Insert an original ink stroke as an unlinked shape anchored to the selected cells. fn stroke(s: &mut Session, p: &Json) -> Result { let pts: Vec<[f64; 2]> = p .get("points") diff --git a/crates/engine/src/cmd/equation.rs b/crates/engine/src/cmd/equation.rs index f90c96cc61..7886ba1bda 100644 --- a/crates/engine/src/cmd/equation.rs +++ b/crates/engine/src/cmd/equation.rs @@ -60,6 +60,7 @@ pub fn describe(latex: &str, e: &MathError) -> String { } } +/// Insert an equation shape with its source and no initial click link. fn insert_equation(s: &mut Session, p: &Json) -> Result { const CMD: &str = "insert.equation"; let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source, such as \"x^2 + y^2 = r^2\""))?; diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 388a4546b1..c664097262 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1066,6 +1066,7 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result { }) } +/// Insert a picture at the requested anchor without an inherited click link. fn insert_picture(s: &mut Session, p: &Json) -> Result { let placement = str_param(p, "placement").unwrap_or("overCells"); if !matches!(placement, "cell" | "overCells") { @@ -1165,6 +1166,7 @@ fn validate_cell_picture(data: &[u8]) -> Result<&'static str> { Ok(mime) } +/// Insert the requested shape and initialize its formatting and anchor. fn insert_shape(s: &mut Session, p: &Json) -> Result { let kind = match str_param(p, "kind").unwrap_or("rectangle") { "roundedRectangle" => ShapeKind::RoundedRectangle, @@ -1821,6 +1823,7 @@ fn manage_cf(s: &mut Session, p: &Json) -> Result { }) } +/// Insert a generated icon shape without imported click metadata. fn insert_icon(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").unwrap_or("check").to_string(); let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32; diff --git a/crates/engine/src/cmd/insert/cond_format.rs b/crates/engine/src/cmd/insert/cond_format.rs index a19c06119e..9014b386f0 100644 --- a/crates/engine/src/cmd/insert/cond_format.rs +++ b/crates/engine/src/cmd/insert/cond_format.rs @@ -318,6 +318,7 @@ pub(super) fn manage_cf(s: &mut Session, p: &Json) -> Result { }) } +/// Insert a generated icon shape without imported click metadata. pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").unwrap_or("check").to_string(); let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32; diff --git a/crates/engine/src/cmd/insert/objects.rs b/crates/engine/src/cmd/insert/objects.rs index 779d6dae34..40ce28f700 100644 --- a/crates/engine/src/cmd/insert/objects.rs +++ b/crates/engine/src/cmd/insert/objects.rs @@ -21,6 +21,7 @@ pub(super) fn insert_sparkline(s: &mut Session, p: &Json) -> Result { }) } +/// Insert a picture at the requested anchor without an inherited click link. pub(super) fn insert_picture(s: &mut Session, p: &Json) -> Result { let placement = str_param(p, "placement").unwrap_or("overCells"); if !matches!(placement, "cell" | "overCells") { @@ -116,6 +117,7 @@ fn validate_cell_picture(data: &[u8]) -> Result<&'static str> { Ok(mime) } +/// Insert the requested shape and initialize its formatting and anchor. pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result { let kind = match str_param(p, "kind").unwrap_or("rectangle") { "roundedRectangle" => ShapeKind::RoundedRectangle, diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index 5febc5f79c..be14b26a28 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -499,6 +499,7 @@ fn rich_sheet_renders() { assert!(contains(&pdf, "(A long piece of text that overflows into neighbours) Tj")); } +/// Print rendering includes charts and pictures at their worksheet anchors. #[test] fn charts_and_images_are_drawn() { let mut s = s(); diff --git a/crates/engine/src/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index 708c42a085..6e2c3ec677 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -36,6 +36,7 @@ fn edit_doc(d: &mut crate::DocState, f: impl FnOnce(&mut Ctx) -> Result) - crate::cmd::commit(d, f) } +/// Delete a worksheet and invalidate references and drawing links to its old identity. pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result { let i = target_sheet(s, p)?; let d = s.doc()?; @@ -54,6 +55,13 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result { let keys: Vec<(CellRef, Arc)> = cx.wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); let Some(sh) = cx.wb.sheet_mut(si) else { continue }; + // Remove the complete link so neither its logical identity nor raw part path can + // accidentally point at a newly inserted sheet after this destination is deleted. + for link in sh.images.iter_mut().map(|i| &mut i.hyperlink).chain(sh.shapes.iter_mut().map(|s| &mut s.hyperlink)) { + if link.as_ref().and_then(|l| l.internal_sheet.as_deref()).is_some_and(|sheet| sheet.eq_ignore_ascii_case(&name)) { + *link = None; + } + } for (c, f) in keys { // Only formulas whose tree changes are rewritten; the others keep their text. let changed = f.with_expr(|e| Some(gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order)).filter(|ne| ne != e)); @@ -95,6 +103,7 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result { ok() } +/// Rename a worksheet and update formula, chart and drawing-link destinations in the same undo step. pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result { if s.doc()?.wb.protected_structure { return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 38cc86fd55..79287c8495 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -87,6 +87,7 @@ fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() { assert!(to_html(&ok, 0).is_ok()); } +/// HTML export embeds picture bytes in data URIs. #[test] fn html_export_embeds_images_as_data_uris() { let mut wb = Workbook::new(); @@ -107,6 +108,7 @@ fn html_export_embeds_images_as_data_uris() { assert!(!to_html(&Workbook::new(), 0).unwrap().contains(""); } +/// HTML picture placement follows the worksheet row and column geometry. #[test] fn html_export_places_images_at_their_anchor_using_sheet_geometry() { let mut wb = Workbook::new(); @@ -129,6 +131,7 @@ fn html_export_places_images_at_their_anchor_using_sheet_geometry() { assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}"); } +/// Build a synthetic workbook with one picture for export-placement tests. fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook { let mut wb = Workbook::new(); let sh = wb.sheet_mut(0).unwrap(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3555af20f..eca9bf3994 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -14,6 +14,7 @@ fn s() -> Session { s } +/// Drawing destinations follow rename, undo/redo, JSON serialization and reordered XLSX saves. #[test] fn drawing_part_links_follow_sheet_rename_reorder_and_json_roundtrip() { use gridcraft_model::{DrawingHyperlink, Image}; @@ -56,6 +57,51 @@ fn drawing_part_links_follow_sheet_rename_reorder_and_json_roundtrip() { } } +/// Deleting a destination removes its click link, even if the sheet name is reused later. +#[test] +fn drawing_part_links_do_not_retarget_a_recreated_sheet() { + use gridcraft_model::{DrawingHyperlink, Image}; + let mut session = s(); + session.execute("insert.shape", json!({"kind": "rectangle"})).unwrap(); + session.execute("home.insertSheet", json!({"name": "Destination"})).unwrap(); + { + let sheet = std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + sheet.shapes[0].hyperlink = + Some(DrawingHyperlink { target: "../worksheets/sheet2.xml#B2".into(), internal_sheet: Some("Destination".into()), ..Default::default() }); + sheet.images.push(Image { + hyperlink: sheet.shapes[0].hyperlink.clone(), + id: 100, + anchor: Default::default(), + data: b"original".to_vec().into(), + mime: "image/png".into(), + alt: String::new(), + }); + let mut external = sheet.shapes[0].clone(); + external.id = 101; + external.hyperlink = Some(DrawingHyperlink { target: "https://example.org/".into(), external: true, ..Default::default() }); + sheet.shapes.push(external); + } + let before = session.doc().unwrap().wb.clone(); + session.execute("home.deleteSheet", json!({"sheet": "Destination"})).unwrap(); + let check = |wb: &gridcraft_model::Workbook| { + let sheet = wb.sheet(0).unwrap(); + assert!(sheet.images[0].hyperlink.is_none()); + assert!(sheet.shapes[0].hyperlink.is_none()); + assert_eq!(sheet.shapes[1].hyperlink.as_ref().unwrap().target, "https://example.org/"); + }; + check(&session.doc().unwrap().wb); + session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(session.doc().unwrap().wb, before); + session.execute("edit.redo", json!({})).unwrap(); + check(&session.doc().unwrap().wb); + session.execute("home.insertSheet", json!({"name": "Destination"})).unwrap(); + let mut wb = session.doc().unwrap().wb.as_ref().clone(); + for _ in 0..2 { + wb = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap().0; + check(&wb); + } +} + fn v(s: &Session, a: &str) -> Value { s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) } @@ -3724,6 +3770,7 @@ fn tiny_png() -> Vec { png } +/// Add a synthetic image at the requested cell with the specified anchor movement mode. fn add_image(s: &mut Session, cell: &str, mode: gridcraft_model::AnchorMode) -> u32 { let d = s.doc_mut().unwrap(); let wb = std::sync::Arc::make_mut(&mut d.wb); diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index d1128371e5..b29a13ee57 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -605,6 +605,7 @@ mod tests { assert!(wb.check_sheet_name("Budget 2026", None).is_ok()); } + /// Object ID allocation remains unique at integer limits and with existing IDs. #[test] fn object_ids_never_overflow_or_repeat() { let mut wb = Workbook::new(); diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 462fddb36a..4a246606f8 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -128,6 +128,7 @@ fn mime_for(name: &str) -> &'static str { } } +/// Read supported floating objects and their click metadata from an original drawing part. pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<(), IoError> { let Some(x) = cx.optional_xml(part)? else { return Ok(()) }; let rels = cx.pkg.rels(part)?; @@ -222,6 +223,7 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( Ok(()) } +/// Read a valid click relationship, resolving supported package destinations to worksheet identities. fn read_click(cx: &mut Ctx<'_>, obj: &El, rels: &[crate::package::Rel]) -> Option { let props = obj.child("nvPicPr").or_else(|| obj.child("nvSpPr")).or_else(|| obj.child("nvCxnSpPr"))?.child("cNvPr")?; let click = props.child("hlinkClick")?; @@ -245,6 +247,7 @@ fn read_click(cx: &mut Ctx<'_>, obj: &El, rels: &[crate::package::Rel]) -> Optio }) } +/// Read supported shape geometry, formatting and whole-object click metadata. fn read_shape(obj: &El, anchor: Anchor) -> Option { let sp = obj.child("spPr")?; if let Some(shape) = ink::read(obj, anchor) { @@ -485,6 +488,7 @@ pub enum Obj<'a> { Shape(&'a Shape, Option), } +/// Emit a click element only when a retained link has an assigned relationship ID. fn click_xml(link: Option<&DrawingHyperlink>, id: Option<&str>) -> String { let (Some(link), Some(id)) = (link, id) else { return String::new() }; let mut s = format!(", wb: &Workbook, rels: &mut Rels) -> Option { let link = link.filter(|l| !l.target.is_empty())?; if let Some(name) = &link.internal_sheet { @@ -338,6 +339,7 @@ fn drawing_link(link: Option<&gridcraft_model::DrawingHyperlink>, wb: &Workbook, Some(if link.external { rels.add_external("hyperlink", &link.target) } else { rels.add("hyperlink", &link.target) }) } +/// Write drawing objects with shared media and individual click relationships. fn write_sheet_drawing(s: &mut String, wb: &Workbook, si: usize, sheet: &Sheet, rels: &mut Rels, out: &mut Out) { // Drawing: pictures, charts, shapes. if !sheet.images.is_empty() || !sheet.charts.is_empty() || !sheet.shapes.is_empty() { diff --git a/crates/xlsx/src/tests/drawing_hyperlinks.rs b/crates/xlsx/src/tests/drawing_hyperlinks.rs index 4be6940892..6ca7fa0df5 100644 --- a/crates/xlsx/src/tests/drawing_hyperlinks.rs +++ b/crates/xlsx/src/tests/drawing_hyperlinks.rs @@ -4,6 +4,7 @@ use std::io::{Cursor, Read}; use super::{make_zip, minimal}; use crate::{read_xlsx, write_xlsx}; +/// Build an original drawing package with linked and unlinked pictures and shapes. fn fixture() -> Vec { let mut objects = String::new(); for (id, picture, linked) in [(2, true, true), (3, false, true), (4, true, false), (5, false, false)] { @@ -47,12 +48,14 @@ fn fixture() -> Vec { parts.push(("xl/media/original.png".into(), b"\x89PNG\r\n\x1a\noriginal-test-payload".to_vec())); make_zip(&parts.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect::>()) } +/// Parse one asserted XML part from a generated package. fn xml(bytes: &[u8], part: &str) -> crate::xml::El { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); let mut data = Vec::new(); z.by_name(part).unwrap().read_to_end(&mut data).unwrap(); crate::xml::parse(&data).unwrap() } +/// Both click relationships and their metadata survive two saves while unlinked objects stay unlinked. #[test] fn picture_and_shape_click_links_survive_two_saves() { let mut bytes = fixture(); @@ -89,6 +92,7 @@ fn picture_and_shape_click_links_survive_two_saves() { } } +/// Rewrite one synthetic package part without changing the other entries. fn replace_part(bytes: Vec, part: &str, from: &str, to: &str) -> Vec { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); let mut parts = Vec::new(); @@ -105,6 +109,7 @@ fn replace_part(bytes: Vec, part: &str, from: &str, to: &str) -> Vec { make_zip(&parts.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect::>()) } +/// Missing or mismatched relationships cannot manufacture click links. #[test] fn missing_and_wrong_kind_relationships_do_not_create_click_links() { let missing = replace_part(fixture(), "xl/drawings/original.xml", "r:id=\"url\"", "r:id=\"missing\""); @@ -121,6 +126,7 @@ fn missing_and_wrong_kind_relationships_do_not_create_click_links() { assert!(rels.kids("Relationship").all(|r| !r.attr("Type").unwrap().ends_with("/hyperlink"))); } +/// JSON preserves click metadata and older object records still deserialize without links. #[test] fn drawing_links_survive_json_and_old_objects_default_to_no_link() { let (wb, _) = read_xlsx(&fixture()).unwrap(); @@ -139,6 +145,7 @@ fn drawing_links_survive_json_and_old_objects_default_to_no_link() { assert!(serde_json::to_value(&sheet.shapes[1]).unwrap().get("hyperlink").is_none()); } +/// Equation and ink shape serialization retains whole-object click metadata. #[test] fn equation_and_ink_shapes_keep_their_click_metadata() { use gridcraft_model::{Color, ShapeKind}; @@ -160,6 +167,7 @@ fn equation_and_ink_shapes_keep_their_click_metadata() { } } +/// Internal links follow imported worksheet identities after part relocation and sheet reordering. #[test] fn relocated_part_links_map_to_the_written_worksheet_after_reordering() { let mut z = zip::ZipArchive::new(Cursor::new(fixture())).unwrap(); @@ -206,6 +214,7 @@ fn relocated_part_links_map_to_the_written_worksheet_after_reordering() { } } +/// External and fragment destinations remain intact while unsupported package destinations warn. #[test] fn external_sheet_locations_are_unchanged_and_other_package_parts_warn() { let bytes = replace_part( @@ -228,6 +237,7 @@ fn external_sheet_locations_are_unchanged_and_other_package_parts_warn() { assert!(report.warnings.iter().any(|w| w.contains("drawing click link to an unsupported package part"))); } +/// Connector shapes read their click metadata from connector nonvisual properties. #[test] fn connector_shape_click_links_use_their_nonvisual_properties() { let bytes = replace_part(fixture(), "xl/drawings/original.xml", "xdr:nvSpPr", "xdr:nvCxnSpPr"); @@ -239,6 +249,7 @@ fn connector_shape_click_links_use_their_nonvisual_properties() { assert_eq!(wb.sheet(0).unwrap().shapes[0].hyperlink.as_ref().unwrap().tooltip.as_deref(), Some("Jump & back")); } +/// Skipped worksheets and duplicate relationship IDs do not misidentify the surviving destination. #[test] fn internal_link_names_match_skipped_sheets_and_duplicate_relationship_ids() { let bytes = replace_part( diff --git a/crates/xlsx/src/tests/equation_tests.rs b/crates/xlsx/src/tests/equation_tests.rs index 9f4ecb1e10..caa286a873 100644 --- a/crates/xlsx/src/tests/equation_tests.rs +++ b/crates/xlsx/src/tests/equation_tests.rs @@ -10,6 +10,7 @@ use gridcraft_model::*; use crate::{read_xlsx, write_xlsx}; +/// Build an original equation shape with the supplied source and text colour. fn equation(id: u32, cell: &str, latex: &str, ink: Color) -> Shape { Shape { hyperlink: None, @@ -149,6 +150,7 @@ fn office_math_without_a_source_is_converted_to_latex() { } } +/// Ordinary text boxes keep their kind and hostile equation text remains safe on roundtrip. #[test] fn a_text_box_stays_a_text_box_and_hostile_sources_survive() { let mut wb = book(vec![equation(1, "B2", &"{".repeat(40), Color::Auto), equation(2, "B6", "\u{0}\u{1F600} & ]]> ", Color::Auto)]); diff --git a/crates/xlsx/src/tests/ink_roundtrip.rs b/crates/xlsx/src/tests/ink_roundtrip.rs index 1ba1f447ca..98d9169453 100644 --- a/crates/xlsx/src/tests/ink_roundtrip.rs +++ b/crates/xlsx/src/tests/ink_roundtrip.rs @@ -50,6 +50,7 @@ fn has_caption(element: &El) -> bool { (element.name == "t" && !element.text.is_empty()) || element.children.iter().any(has_caption) } +/// Exported ink retains a visible unfilled path with its original stroke formatting. #[test] fn exported_ink_has_a_visible_unfilled_path_and_correct_stroke() { let mut sheet = Sheet::new("Ink"); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 5d1a6f2828..b6fd19ae12 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -17,6 +17,7 @@ fn rr(a: &str) -> RangeRef { RangeRef::parse(a).unwrap() } +/// Build an original workbook containing representative data, styles, objects and worksheet metadata. fn sample() -> Workbook { let mut wb = Workbook::new(); wb.date_system = DateSystem::D1904; diff --git a/crates/xlsx/src/tests/shape_text_rtl.rs b/crates/xlsx/src/tests/shape_text_rtl.rs index 0a4dbf62fb..d1520beead 100644 --- a/crates/xlsx/src/tests/shape_text_rtl.rs +++ b/crates/xlsx/src/tests/shape_text_rtl.rs @@ -35,6 +35,7 @@ fn written_drawing(wb: &Workbook) -> String { drawing } +/// Build a synthetic text-box workbook with the supplied text and shape formatting. fn text_box(text: &str, format: ShapeFormat) -> Workbook { let mut sheet = Sheet::new("Sheet1"); sheet.shapes.push(Shape { diff --git a/crates/xlsx/src/tests/shared_media.rs b/crates/xlsx/src/tests/shared_media.rs index b634201b9e..e3dc719ec3 100644 --- a/crates/xlsx/src/tests/shared_media.rs +++ b/crates/xlsx/src/tests/shared_media.rs @@ -14,6 +14,7 @@ fn png(fill: u8) -> Vec { v } +/// Build a worksheet containing the supplied synthetic picture IDs and media bytes. fn sheet_with(name: &str, pictures: &[(u32, &[u8])]) -> Arc { let mut s = Sheet::new(name); for (id, data) in pictures { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 3d582a75e1..4b88dfa19d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-09 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves (#3665); earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-09 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves and removed when their destination sheet is deleted (#3665); earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **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** diff --git a/docs/gaps.md b/docs/gaps.md index e486715f4e..efc590520b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-09 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves (#3665); earlier: §17: equations; earlier: §13: Solver Sensitivity report; earlier: §4: chart resizing is no longer a gap; earlier: §13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-09 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves and removed when their destination sheet is deleted (#3665); earlier: §17: equations; earlier: §13: Solver Sensitivity report; earlier: §4: chart resizing is no longer a gap; earlier: §13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **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