diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..b444df236 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 78e1afaae..f961baac3 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -498,12 +498,10 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if img.data.is_empty() { continue; } - out.images += 1; let ext = image_ext(&img.mime, &img.data); - let name = format!("xl/media/image{}.{ext}", out.images); out.default_type(ext, image_content_type(ext)); - out.part(&name, None, img.data.clone()); - let rid = drels.add("image", &format!("../media/image{}.{ext}", out.images)); + let file = out.image_part(ext, &img.data); + let rid = drels.add("image", &format!("../media/{file}")); objs.push(Obj::Image(img, rid)); } for ch in &sheet.charts { diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index bdbd66e05..7697ae952 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -14,6 +14,7 @@ mod pivot_tests; mod richdata_tests; mod roundtrip; mod shape_text_rtl; +mod shared_media; mod sheet_names; mod smooth_roundtrip; mod trendline_roundtrip; diff --git a/crates/xlsx/src/tests/shared_media.rs b/crates/xlsx/src/tests/shared_media.rs new file mode 100644 index 000000000..1422a2663 --- /dev/null +++ b/crates/xlsx/src/tests/shared_media.rs @@ -0,0 +1,87 @@ +//! A picture used on several sheets is written as one media part that every drawing points to. + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_core::CellRef; +use gridcraft_model::{Anchor, Image, Sheet, Workbook}; + +use crate::{read_xlsx, write_xlsx}; + +fn png(fill: u8) -> Vec { + let mut v = b"\x89PNG\r\n\x1a\n".to_vec(); + v.extend(std::iter::repeat_n(fill, 4096)); + v +} + +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(), mime: "image/png".into(), alt: String::new() }); + } + Arc::new(s) +} + +fn names(bytes: &[u8]) -> Vec { + let z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap(); + z.file_names().map(str::to_string).collect() +} + +fn text(bytes: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap(); + let mut s = String::new(); + z.by_name(name).unwrap().read_to_string(&mut s).unwrap(); + s +} + +#[test] +fn a_picture_on_several_sheets_is_one_media_part() { + let (a, b) = (png(1), png(2)); + // `a` on three sheets (and twice on one of them), `b` on one: two media parts, not five. + let wb = Workbook { + sheets: vec![sheet_with("One", &[(1, &a)]), sheet_with("Two", &[(1, &a), (2, &b)]), sheet_with("Three", &[(1, &a), (2, &a)])], + ..Default::default() + }; + let bytes = write_xlsx(&wb).unwrap(); + let media: Vec = names(&bytes).into_iter().filter(|n| n.starts_with("xl/media/")).collect(); + assert_eq!(media.len(), 2, "{media:?}"); + // Every drawing relationship points to an existing part, and the three sheets that show `a` + // point to the same one. + let mut targets = vec![]; + for d in 1..=3 { + let rels = text(&bytes, &format!("xl/drawings/_rels/drawing{d}.xml.rels")); + for t in rels.split("Target=\"").skip(1) { + let t = t.split('"').next().unwrap(); + let part = format!("xl/media/{}", t.trim_start_matches("../media/")); + assert!(media.contains(&part), "drawing{d}: dangling {t}"); + targets.push((d, part)); + } + } + assert_eq!(targets.len(), 5, "{targets:?}"); + let distinct: std::collections::BTreeSet<&String> = targets.iter().map(|(_, p)| p).collect(); + assert_eq!(distinct.len(), 2, "{targets:?}"); + let first_of = |d: usize| targets.iter().find(|(x, _)| *x == d).map(|(_, p)| p.clone()); + assert_eq!(first_of(1), first_of(2), "sheets One and Two show the same picture"); + assert_eq!(first_of(1), first_of(3)); + // They read back with the right bytes on the right sheets. + let back = read_xlsx(&bytes).unwrap().0; + let data = |i: usize| back.sheets[i].images.iter().map(|im| im.data.clone()).collect::>(); + assert_eq!(data(0), vec![a.clone()]); + assert_eq!(data(1), vec![a.clone(), b.clone()]); + assert_eq!(data(2), vec![a.clone(), a.clone()]); + // A second save of what was read back is stable. + let again = write_xlsx(&back).unwrap(); + assert_eq!(names(&again).iter().filter(|n| n.starts_with("xl/media/")).count(), 2); +} + +#[test] +fn different_pictures_with_the_same_length_stay_separate() { + let (a, b) = (png(1), png(2)); + assert_eq!(a.len(), b.len()); + let wb = Workbook { sheets: vec![sheet_with("One", &[(1, &a), (2, &b)])], ..Default::default() }; + let bytes = write_xlsx(&wb).unwrap(); + assert_eq!(names(&bytes).iter().filter(|n| n.starts_with("xl/media/")).count(), 2); + let back = read_xlsx(&bytes).unwrap().0; + assert_eq!(back.sheets[0].images.iter().map(|i| i.data.clone()).collect::>(), vec![a, b]); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 06761f01d..7c63e59b4 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -135,6 +135,20 @@ impl Out { } self.parts.push((name.to_string(), data)); } + /// The `xl/media` file name (`imageN.ext`) holding `data`. A picture used on several sheets, + /// or twice on one, is written once and every drawing points to that part, as in Excel. + pub fn image_part(&mut self, ext: &str, data: &[u8]) -> String { + let existing = self.parts.iter().find(|(name, bytes)| { + name.strip_prefix("xl/media/image").is_some_and(|rest| rest.ends_with(&format!(".{ext}"))) && bytes.as_slice() == data + }); + if let Some((name, _)) = existing { + return name.trim_start_matches("xl/media/").to_string(); + } + self.images += 1; + let file = format!("image{}.{ext}", self.images); + self.part(&format!("xl/media/{file}"), None, data.to_vec()); + file + } pub fn default_type(&mut self, ext: &str, ct: &str) { self.defaults.entry(ext.to_string()).or_insert_with(|| ct.to_string()); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5a7588986..bf84454d1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (.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) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (.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; XLSX export keeps a picture shared by several sheets as one media part (#985)) · **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** @@ -22,9 +22,9 @@ and repair-free opening remain unverified. | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures | | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | @@ -92,6 +92,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-10-18 | trivial | XLSX PivotTables: `rowGrandTotals` is the Grand Total row and `colGrandTotals` the Grand Total column. Both were read and written the other way round, which a GridCraft round trip hid, so an Excel file with only one grand total opened showing the other | | 2026-10-19 | minor | HTML (Save as Web Page and clipboard) marks right-to-left sheets and right-to-left cell text with `dir` | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | +| 2026-10-13 | trivial | XLSX export keeps a picture shared by several sheets as one media part (#985) | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) |