From 675d4b9a3c305445a6d88b8ffd9e47a67667d404 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:44:33 -0500 Subject: [PATCH] XLSX: form controls that only the VML drawing lists are kept on save when no element is left, as when the last listed control is modelled --- crates/xlsx/src/controls.rs | 50 +++++-- crates/xlsx/src/passthrough/read.rs | 21 +-- crates/xlsx/src/sheet_read.rs | 8 +- crates/xlsx/src/tests/controls_tests.rs | 151 +++++++++++++++++++++ crates/xlsx/src/tests/passthrough_tests.rs | 6 +- docs/file-format-parity.md | 2 +- 6 files changed, 213 insertions(+), 25 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index 6f56894cd..bf6d9858c 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -398,11 +398,33 @@ fn number_of(shape: &El) -> Option { shape.attr("id").and_then(shape_number).or_else(|| shape.attr("spid").and_then(shape_number)) } +/// What [`read`] took from a sheet's form controls, for pass-through. +#[derive(Debug, Default)] +pub struct ReadControls { + /// The VML shape numbers of the controls read into the model: GridCraft writes those, so they + /// are left out of the kept content. + pub modelled: HashSet, + /// The VML drawing also holds a control that the sheet doesn't list and the model doesn't + /// hold (a check box only the drawing has): the drawing is kept for it even when no + /// `` element is left to keep. + pub vml_only_unmodelled: bool, +} + +/// Whether a VML shape the sheet doesn't list is a control that only pass-through can keep: it +/// has client data that is neither a note (GridCraft writes notes) nor an embedded object's +/// picture (`Pict`, which means nothing without its sheet entry). +fn unlisted_kept(shape: &El) -> bool { + shape + .child("ClientData") + .and_then(|d| d.attr("ObjectType")) + .is_some_and(|t| !t.trim().eq_ignore_ascii_case("note") && !t.trim().eq_ignore_ascii_case("pict")) +} + /// Reads a sheet's buttons, scroll bars and spin buttons: each `` with its properties /// part, else its VML shape, and VML shapes the sheet doesn't list (files that only have VML). /// The shapes come from the VML drawing with relationship id `vml_rid` (``). /// Returns the shape numbers of the controls it read, so pass-through keeps only the others. -pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels: &[Rel], sheet: &mut Sheet) -> HashSet { +pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels: &[Rel], sheet: &mut Sheet) -> ReadControls { let mut read = HashSet::new(); let vml = match vml_rid.and_then(|id| rels.iter().find(|r| r.id == id && r.kind == "vmlDrawing")) { Some(r) => cx.pkg.read_xml(&r.target).ok().flatten(), @@ -477,19 +499,27 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels sheet.controls.push(build(p, kind, anchor, ctl.attr("name"), id)); read.insert(n); } - // Controls known only from the VML. + // Controls known only from the VML. The ones not read (a check box, or past the limit) stay + // in the kept drawing. + let mut vml_only_unmodelled = false; for s in &shapes { - let Some(n) = number_of(s) else { continue }; - if listed.contains(&n) || sheet.controls.len() >= MAX_CONTROLS { + let n = number_of(s); + if n.is_some_and(|n| listed.contains(&n)) { continue; } - let Some(p) = props_from_vml(s) else { continue }; - let (Some(kind), Some(anchor)) = (p.kind, vml_anchor(s, sheet)) else { continue }; - let id = cx.next_object_id(); - sheet.controls.push(build(p, kind, anchor, None, id)); - read.insert(n); + let modelled = n.filter(|_| sheet.controls.len() < MAX_CONTROLS).and_then(|n| { + let p = props_from_vml(s)?; + let (kind, anchor) = (p.kind?, vml_anchor(s, sheet)?); + let id = cx.next_object_id(); + sheet.controls.push(build(p, kind, anchor, None, id)); + read.insert(n); + Some(()) + }); + if modelled.is_none() && unlisted_kept(s) { + vml_only_unmodelled = true; + } } - read + ReadControls { modelled: read, vml_only_unmodelled } } fn anchor_from_markers(a: &El, sheet: &Sheet) -> Option { diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs index 4e2264e6a..73c176136 100644 --- a/crates/xlsx/src/passthrough/read.rs +++ b/crates/xlsx/src/passthrough/read.rs @@ -283,9 +283,10 @@ impl KeptReader { (!p.is_empty()).then(|| Arc::new(p)) } - /// Sheet-level content of the sheet part `part`. `read_controls` are the VML shape numbers of - /// the form controls the sheet reader modelled; they are left out of the kept content. - pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, read_controls: &HashSet) -> Option> { + /// Sheet-level content of the sheet part `part`. `controls` says which form controls the + /// sheet reader modelled (their VML shapes are left out of the kept content) and whether the + /// VML drawing holds others that only it lists. + pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, controls: &crate::controls::ReadControls) -> Option> { if !self.on { return None; } @@ -306,8 +307,8 @@ impl KeptReader { // Notes have one VML drawing per sheet; another one holds the header and footer pictures. let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count(); let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count(); - if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes { - self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen, read_controls); + if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes || controls.vml_only_unmodelled { + self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen, controls); } (!p.is_empty()).then(|| Arc::new(p)) } @@ -321,8 +322,9 @@ impl KeptReader { rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet, - read_controls: &HashSet, + controls: &crate::controls::ReadControls, ) { + let read_controls = &controls.modelled; let scan = match cx.pkg.read(part) { Ok(Some(bytes)) => scan_sheet(&bytes), _ => None, @@ -361,8 +363,9 @@ impl KeptReader { .into_iter() .filter_map(|(slot, x)| if slot == "controls" { without_controls(&x, read_controls).map(|x| (slot, x)) } else { Some((slot, x)) }) .collect(); - // Controls and OLE objects are drawn from shapes in the legacy VML drawing. - if frags.iter().any(|(slot, _)| slot != "picture") { + // Controls and OLE objects are drawn from shapes in the legacy VML drawing. Controls only + // the drawing lists need it too, whether or not a `` element is left. + if frags.iter().any(|(slot, _)| slot != "picture") || controls.vml_only_unmodelled { match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen, read_controls) { Ok((vml, parts)) => { p.parts.extend(parts); @@ -392,7 +395,7 @@ impl KeptReader { Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")), } } - if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") { + if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") || (controls.vml_only_unmodelled && p.vml.is_some()) { cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving")); } else { p.vml = None; diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index df4a4bf4e..f2047b6ee 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -1,6 +1,6 @@ //! Reading worksheet parts. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::date::{serial_from_ymd, time_fraction}; @@ -159,7 +159,7 @@ fn read_sheet_format(e: &El, sheet: &mut Sheet) { /// Reads the sheet part `part`, with the VML shape numbers of the form controls read into the /// model (pass-through keeps the other controls). -pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, HashSet), IoError> { +pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, crate::controls::ReadControls), IoError> { let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?; let rels = cx.pkg.rels(part)?; let mut sheet = Sheet::new(name); @@ -340,10 +340,10 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, Ha /// `mc:AlternateContent`) with its properties parts, else the shapes of the VML drawing that /// `` names (not the header and footer drawing of ``). /// Returns the VML shape numbers read, so pass-through keeps only the others. -fn read_form_controls(cx: &mut Ctx<'_>, root: &El, rels: &[Rel], sheet: &mut Sheet) -> HashSet { +fn read_form_controls(cx: &mut Ctx<'_>, root: &El, rels: &[Rel], sheet: &mut Sheet) -> crate::controls::ReadControls { let controls = root.children.iter().filter_map(crate::controls::controls_of).next_back(); let vml = root.child("legacyDrawing").and_then(|e| e.attr("id")); - if controls.is_some() || vml.is_some() { crate::controls::read(cx, controls, vml, rels, sheet) } else { HashSet::new() } + if controls.is_some() || vml.is_some() { crate::controls::read(cx, controls, vml, rels, sheet) } else { Default::default() } } /// A protection password hash: the iterated attributes (`algorithmName`, `hashValue`, diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index a3e2c214d..4fbb65218 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -574,3 +574,154 @@ fn controls_naming_one_properties_part_read_it_once() { })); assert_eq!(sh.comments.len(), 1, "a part read after the controls still reads: {:?}", report.warnings); } + +/// A legacy VML drawing with a button (`1025`, modelled), a check box (`1026`) and an option +/// button (`1027`, with a picture through the drawing's own relationship) that only the drawing +/// lists, plus `note` (a note shape for A1, with its comments part) when set. `listed`: the sheet's +/// `` lists the button. +fn vml_only_others(listed: bool, button: bool, note: bool) -> Vec { + const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + let mut rels = format!(r#""#); + if listed { + rels.push_str(&format!(r#""#)); + } + if note { + rels.push_str(&format!(r#""#)); + } + let rels = + format!(r#"{rels}"#); + let vml_rels = format!( + r#""# + ); + let prop_a = r#""#; + let mut shapes = String::new(); + if note { + shapes.push_str(r##"1, 15, 0, 2, 3, 15, 4, 400"##); + } + if button { + shapes.push_str(r##"
Go
2, 0, 3, 0, 4, 0, 5, 0[0]!Go
"##); + } + shapes.push_str(r##"
Paid
2, 0, 7, 0, 4, 0, 8, 0$B$71
"##); + shapes.push_str(r##"2, 0, 9, 0, 4, 0, 10, 0$B$9"##); + let vml = format!( + r#"{shapes}"# + ); + let controls = if listed { + r#"20304050"# + } else { + "" + }; + let notes = r#"Anncheck the totals"#; + let mut parts: Vec<(&str, &str)> = vec![ + ("xl/worksheets/_rels/sheet1.xml.rels", &rels), + ("xl/drawings/vmlDrawing1.vml", &vml), + ("xl/drawings/_rels/vmlDrawing1.vml.rels", &vml_rels), + ("xl/media/image1.png", "not really a png"), + ]; + if listed { + parts.push(("xl/ctrlProps/a.xml", prop_a)); + } + if note { + parts.push(("xl/comments1.xml", notes)); + } + // As `minimal` builds it, with content types for the picture and the drawing. + let ct = super::CT.replace( + "", + r#""#, + ); + let base = minimal(&format!(r#"{controls}"#), &parts, "", ""); + let mut zip = Package::open(&base).unwrap(); + let mut files: Vec<(String, Vec)> = vec![("[Content_Types].xml".into(), ct.into_bytes())]; + for n in zip.names() { + if n != "[Content_Types].xml" { + let data = zip.read(&n).unwrap().unwrap(); + files.push((n, data)); + } + } + super::make_zip(&files.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect::>()) +} + +/// Checks a saved package keeps the check box and option button shapes once each, in the sheet's +/// one VML drawing, with that drawing's one relationship to its one picture; returns the drawing. +fn kept_vml_only_others(out: &[u8]) -> String { + let mut pkg = Package::open(out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + assert_eq!(sheet.matches(" = names.iter().filter(|n| n.ends_with(".vml")).collect(); + assert_eq!(vmls.len(), 1, "{names:?}"); + let vml_name = vmls[0].clone(); + let vml = text(&mut pkg, &vml_name); + assert_eq!(vml.matches("ObjectType=\"Checkbox\"").count(), 1, "the check box is kept once: {vml}"); + assert_eq!(vml.matches("ObjectType=\"Radio\"").count(), 1, "the option button is kept once: {vml}"); + let (dir, file) = vml_name.rsplit_once('/').unwrap(); + let vml_rels = text(&mut pkg, &format!("{dir}/_rels/{file}.rels")); + assert_eq!(vml_rels.matches(" = names.iter().filter(|n| n.starts_with("xl/media/")).collect(); + assert_eq!(media.len(), 1, "the option button's picture once: {names:?}"); + vml +} + +/// The sheet's `` lists only a button, which is modelled; a check box and an option +/// button in the same VML drawing have no worksheet entry. With the button's entry gone from the +/// kept content no `` element is left, but the drawing still holds the two, so it is +/// kept: the save writes them beside the regenerated button, and they survive a second round trip. +#[test] +fn vml_only_controls_survive_when_the_last_listed_control_is_modelled() { + let bytes = vml_only_others(true, true, false); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1, "the button is modelled"); + assert_eq!(sh.controls[0].kind, ControlKind::Button); + assert!(sh.preserved.as_ref().is_some_and(|p| p.vml.is_some()), "the drawing is kept: {:?}", report.warnings); + assert!(report.warnings.iter().any(|w| w.contains("form controls are not shown")), "{:?}", report.warnings); + let out = write_xlsx(&wb).unwrap(); + let vml = kept_vml_only_others(&out); + assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "the button once, written from the model: {vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1); + assert_eq!(sh.controls[0].text, "Go"); + // Saved again: still once each. + let again = write_xlsx(&back).unwrap(); + let vml = kept_vml_only_others(&again); + assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "{vml}"); +} + +/// A sheet whose only controls are a check box and an option button that only the VML drawing +/// lists (no ``, no notes, nothing modelled): the drawing is kept on its own. +#[test] +fn a_sheet_whose_only_controls_are_vml_only_and_unmodelled_keeps_them() { + let bytes = vml_only_others(false, false, false); + let (wb, report) = read_xlsx(&bytes).unwrap(); + assert!(wb.sheet(0).unwrap().controls.is_empty()); + let out = write_xlsx(&wb).unwrap(); + let vml = kept_vml_only_others(&out); + assert!(!vml.contains("ObjectType=\"Button\""), "{vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + let again = write_xlsx(&back).unwrap(); + kept_vml_only_others(&again); + assert!(report.warnings.iter().any(|w| w.contains("form controls are not shown")), "{:?}", report.warnings); +} + +/// The same with a note in the drawing (one VML drawing for one comments part) and a VML-only +/// button that is modelled: the note and the button are written from the model, the check box and +/// option button stay, all in the one drawing. +#[test] +fn vml_only_controls_beside_a_note_are_kept() { + let bytes = vml_only_others(false, true, true); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!((sh.controls.len(), sh.comments.len()), (1, 1), "{:?}", report.warnings); + let out = write_xlsx(&wb).unwrap(); + let vml = kept_vml_only_others(&out); + assert_eq!(vml.matches("ObjectType=\"Note\"").count(), 1, "{vml}"); + assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "{vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!((sh.controls.len(), sh.comments.len()), (1, 1)); + let vml = kept_vml_only_others(&write_xlsx(&back).unwrap()); + assert_eq!(vml.matches("ObjectType=\"Note\"").count(), 1, "{vml}"); +} diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 80e4c1edc..a71b34422 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -849,6 +849,10 @@ const LOGO: &[u8] = b"\x89PNG\r\n\x1a\n letterhead logo"; const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n footer signature"; const HF_VML: &str = r##""##; +/// The VML drawing of one note and nothing else. [`VML`] also holds a check box that only the +/// drawing lists, which is kept with the drawing (and its source shape block). +const NOTE_VML: &str = r##"
old note
00
"##; + /// #1257: a letterhead in the header and a picture in the footer (`&G`), drawn from a VML part /// that `` names, next to a note whose VML drawing GridCraft rewrites. fn header_picture_package() -> Vec { @@ -875,7 +879,7 @@ fn header_picture_package() -> Vec { ("xl/worksheets/sheet1.xml", sheet.as_bytes()), ("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels.as_bytes()), ("xl/comments1.xml", COMMENTS.as_bytes()), - ("xl/drawings/vmlDrawing1.vml", VML.as_bytes()), + ("xl/drawings/vmlDrawing1.vml", NOTE_VML.as_bytes()), ("xl/drawings/vmlDrawing2.vml", HF_VML.as_bytes()), ("xl/drawings/_rels/vmlDrawing2.vml.rels", hf_rels.as_bytes()), ("xl/media/image1.png", LOGO), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 18b03f5cb..a3bc169bd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -76,7 +76,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), form controls (buttons, scroll bars and spin buttons: control properties parts, `` and VML `x:ClientData`; a VML-only control is read too; a button's command, fixed input range or output cell travels in a hidden defined name and comes back only onto the same button while the file still assigns its macro, or none for a command), protection, calc settings, show objects (`workbookPr showObjects`). **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), other form controls (check boxes, list boxes, drop-downs; written in the same `` element and VML drawing as the modelled ones), 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, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), form controls (buttons, scroll bars and spin buttons: control properties parts, `` and VML `x:ClientData`; a VML-only control is read too; a button's command, fixed input range or output cell travels in a hidden defined name and comes back only onto the same button while the file still assigns its macro, or none for a command), protection, calc settings, show objects (`workbookPr showObjects`). **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), other form controls (check boxes, list boxes, drop-downs; written in the same `` element and VML drawing as the modelled ones, also those only the VML drawing lists, with no `` entry), 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, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), 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, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **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, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), 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, show objects (`workbookPr showObjects`). **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 (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), 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), pictures in headers and footers (`&G`, #1257: their VML drawing and images are written back unchanged; they are not shown, and can't be inserted), 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` |