diff --git a/Cargo.lock b/Cargo.lock index 395518b46c..9b5769d8e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2355,6 +2355,7 @@ dependencies = [ "gridcraft-math", "gridcraft-model", "quick-xml 0.41.0", + "serde_json", "sha2 0.11.1", "thiserror 2.0.21", "zip 3.0.0", diff --git a/ROADMAP.md b/ROADMAP.md index 5ff15b4221..b4f13704dc 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-05 · **Change:** minor (CJK fallback text shares the Latin baseline; 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-10 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves and removed when their destination sheet is deleted (#3665); earlier: CJK fallback text shares the Latin baseline; 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 | @@ -270,6 +271,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Retain drawing click-link preservation and destination-deletion handling alongside current CJK baseline, legacy-array and regional-format updates; estimates unchanged. | +| 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-05 | minor | Reconcile upstream form-control progress with the CJK baseline update | | 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row | diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index c8921718c2..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") @@ -80,6 +81,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..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\""))?; @@ -87,6 +88,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 4bd564db37..05431c1b12 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") { @@ -1116,6 +1117,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, @@ -1164,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, @@ -1185,6 +1188,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { Color::Theme(4, -250) }); let shape = Shape { + hyperlink: None, math: Default::default(), id, kind, @@ -1825,6 +1829,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; @@ -1833,6 +1838,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..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; @@ -326,6 +327,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..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") { @@ -67,6 +68,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, @@ -115,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, @@ -139,6 +142,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 5ebd733018..839946dd33 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(); @@ -513,6 +514,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..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())); @@ -118,6 +127,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/cmd/wordart.rs b/crates/engine/src/cmd/wordart.rs index 3577591a6a..d543708c2f 100644 --- a/crates/engine/src/cmd/wordart.rs +++ b/crates/engine/src/cmd/wordart.rs @@ -194,6 +194,7 @@ fn insert_word_art(s: &mut Session, p: &Json) -> Result { text: text.clone(), format: ShapeFormat { no_line: true, align: Some(ShapeTextAlign::Center), anchor: Some(ShapeTextAnchor::Middle), ..Default::default() }, math: Default::default(), + hyperlink: None, }; apply("insert.wordArt", &mut shape, Some(style), p)?; // Default box: the longest line at the font size (about 0.62 em per character) plus padding. diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index ad8ca40f25..7d27d96974 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -106,10 +106,12 @@ 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(); 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(), @@ -125,6 +127,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(); @@ -132,6 +135,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(), @@ -146,11 +150,13 @@ 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(); 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 f5c17ba169..b84f09a06b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -14,6 +14,94 @@ 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}; + 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"); + } + } +} + +/// 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()) } @@ -3881,11 +3969,13 @@ 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); 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 5c9f961d7c..05b1e30a28 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) @@ -678,6 +694,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 ab401828da..5e93d989f4 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -611,10 +611,12 @@ 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(); - 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)); @@ -661,7 +663,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 7825b2d239..2335358df7 100644 --- a/crates/xlsx/Cargo.toml +++ b/crates/xlsx/Cargo.toml @@ -29,5 +29,6 @@ zip = { 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 6ef49f1757..a043a8881b 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; @@ -126,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)?; @@ -168,7 +171,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 +209,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 +223,31 @@ 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")?; + 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, + }) +} + +/// 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) { @@ -259,6 +295,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, @@ -274,7 +311,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 @@ -344,7 +381,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 @@ -370,7 +407,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") @@ -456,9 +493,22 @@ 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), +} + +/// 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!(""); + s } /// The drawing part for a sheet's objects (relationship ids already assigned). @@ -470,13 +520,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") @@ -507,17 +558,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); @@ -535,7 +588,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 d4de20c60c..2275797a99 100644 --- a/crates/xlsx/src/package.rs +++ b/crates/xlsx/src/package.rs @@ -30,6 +30,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, } /// GridCraft's wording of a package error. @@ -93,7 +95,7 @@ impl<'a> Package<'a> { .into_iter() .map(|r| { let kind = r.kind().to_string(); - Rel { id: r.id, kind, target: r.target, external: r.external } + Rel { id: r.id, kind, target: r.target, external: r.external, raw_target: r.target_raw } }) .collect()) } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 5ae2a782b3..ab8f50e63b 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>, @@ -249,6 +251,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], opts: crate::ReadOptions<'a>) -> Result<(W let known_function = opts.known_function; let pkg = Package::open(bytes)?; let mut cx = Ctx { + drawing_link_sheets: Default::default(), pkg, report: ReadReport::default(), sst: vec![], @@ -319,6 +322,15 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], opts: crate::ReadOptions<'a>) -> Result<(W let mut legacy_formulas: Vec<(usize, 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 c405667b1d..f9f3c484ff 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -328,6 +328,18 @@ fn write_sheet_notes(s: &mut String, sheet: &Sheet, si: usize, kept: &crate::pas s.push_str(&kept.elements(&controls)); } +/// Create a click relationship, remapping internal worksheet identities to current output part paths. +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) }) +} + +/// 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() { @@ -343,7 +355,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 +379,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 72eec23263..8c4214ba72 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..6ca7fa0df5 --- /dev/null +++ b/crates/xlsx/src/tests/drawing_hyperlinks.rs @@ -0,0 +1,273 @@ +//! 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}; + +/// 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)] { + 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::>()) +} +/// 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(); + 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); + } + } + } +} + +/// 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(); + 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::>()) +} + +/// 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\""); + 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"))); +} + +/// 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(); + 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()); +} + +/// 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}; + 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); + } + } +} + +/// 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(); + 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")); + } +} + +/// 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( + 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"))); +} + +/// 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"); + 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")); +} + +/// 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( + 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..caa286a873 100644 --- a/crates/xlsx/src/tests/equation_tests.rs +++ b/crates/xlsx/src/tests/equation_tests.rs @@ -10,8 +10,10 @@ 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, id, kind: ShapeKind::Equation, anchor: Anchor { cell: CellRef::parse(cell).unwrap(), width: 200.0, height: 60.0, mode: AnchorMode::MoveOnly, ..Default::default() }, @@ -148,11 +150,13 @@ 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)]); 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..98d9169453 100644 --- a/crates/xlsx/src/tests/ink_roundtrip.rs +++ b/crates/xlsx/src/tests/ink_roundtrip.rs @@ -50,11 +50,13 @@ 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"); 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 da88df3d91..36c58c9565 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; @@ -352,6 +353,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 +362,7 @@ fn sample() -> Workbook { alt: "Logo \"x\"".into(), }); s.shapes.push(Shape { + hyperlink: None, math: Default::default(), id: 11, kind: ShapeKind::Ellipse, @@ -370,6 +373,7 @@ fn sample() -> Workbook { format: Default::default(), }); s.shapes.push(Shape { + hyperlink: None, math: Default::default(), id: 12, kind: ShapeKind::Arrow, @@ -380,6 +384,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 43a3f699fc..7111a702d9 100644 --- a/crates/xlsx/src/tests/shape_text_rtl.rs +++ b/crates/xlsx/src/tests/shape_text_rtl.rs @@ -35,9 +35,11 @@ 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 { + 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 0c37e584bc..819be121b3 100644 --- a/crates/xlsx/src/tests/shared_media.rs +++ b/crates/xlsx/src/tests/shared_media.rs @@ -14,11 +14,12 @@ 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 { 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 0847714a3a..f98cab0797 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-12 · **Change:** minor (.xls data validation is read; earlier: .xls is read by the shared craft-xls crate: the workbook opens on the sheet it was saved on, and rich-text (RString) cells are read; 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-12 · **Change:** minor (picture/shape click hyperlinks retained through XLSX saves and removed when their destination sheet is deleted (#3665); earlier: .xls data validation is read; earlier: .xls is read by the shared craft-xls crate: the workbook opens on the sheet it was saved on, and rich-text (RString) cells are read; 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 @@ -164,6 +176,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-11-12 | minor | `.xls` files open with their data validation (drop-down lists, input messages, error alerts and the rules), read by craft-xls and mapped as the `.xlsx` reader maps the same rules. A rule Excel would refuse, or one past 65,536 in a workbook, is skipped with a warning (#3634, from #3636) | | 2026-11-12 | minor | .xls import reads the file with craft-xls (storytold/craft-libs), the reader that moved there from this repository; GridCraft keeps the mapping onto its model. The workbook now opens on the sheet it was saved on (WINDOW1 `itabCur`), falling back to the first visible sheet as before (from #3551), and cells saved as RString records (text with formatting runs) are read as text instead of skipped | | 2026-11-10 | minor | `.xls` import: every formula except a Ctrl+Shift+Enter array is read as a legacy one with Excel's implicit `@` (a title row `=Other!A1:G1` shows its first cell instead of `#SPILL!`), one-cell Ctrl+Shift+Enter arrays stay arrays, and `@` of a 2-D range meets the formula's row and column (#410, PR #1367) | +| 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 d2c89d86af..da9ab6764d 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 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 @@ -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" |