Merge PR #1614: XLSX: a note keeps its box's place and size through open and save (#1608)

This commit is contained in:
aiongg
2026-10-22 02:35:24 +00:00
10 changed files with 277 additions and 33 deletions
+10 -1
View File
@@ -1250,7 +1250,16 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
edit(s, |cx| {
cx.sheet_mut(sheet)?.comments.insert(
at,
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
Comment {
author: author.clone(),
text: text.clone(),
replies: vec![],
threaded,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
Ok(json!({"cell": at.a1()}))
})
+1
View File
@@ -41,6 +41,7 @@ mod tests {
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()), external: false });
+28
View File
@@ -323,6 +323,34 @@ pub struct Comment {
/// read from the file (`dT`); empty when unknown, e.g. for comments made here.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dates: Vec<String>,
/// Where the note's box sits and how big it is, as read from the file; `None` (a note made
/// here, or a file without the shape) writes the default box next to the cell.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note_box: Option<NoteBox>,
}
/// A note box's place and size, from the note's VML shape: Excel's `<x:Anchor>` with its corners
/// stored relative to the note's cell, so the box moves with the note when rows or columns are
/// inserted or deleted, and the shape's width and height.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct NoteBox {
/// Top-left corner: columns and rows from the note's cell (negative = left of or above it),
/// then the pixel offset into that column and row.
pub from: NoteCorner,
/// Bottom-right corner, the same way.
pub to: NoteCorner,
/// The shape's width and height in points (the VML style's `width`/`height`), when given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_pt: Option<(f64, f64)>,
}
/// One corner of a [`NoteBox`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NoteCorner {
pub col: i64,
pub col_px: u32,
pub row: i64,
pub row_px: u32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+50 -19
View File
@@ -8,8 +8,8 @@ use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRe
use gridcraft_formula::Expr;
use gridcraft_model::{
AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, EditRange, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo,
Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, ValidationKind,
XmlColumnPr,
NoteBox, NoteCorner, Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation,
ValidationKind, XmlColumnPr,
};
use crate::IoError;
@@ -210,12 +210,13 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
read_threaded(cx, &x, &mut sheet);
}
}
// Which notes are displayed lives in the VML shapes (`<x:Visible/>`). Best effort: the VML is
// only read for that flag, so a part that doesn't parse is skipped without a warning.
// Which notes are displayed (`<x:Visible/>`) and where their boxes sit live in the VML shapes.
// Best effort: the VML is only read for those, so a part that doesn't parse is skipped without
// a warning.
if !sheet.comments.is_empty() {
for rel in rels.iter().filter(|r| r.kind == "vmlDrawing") {
if let Ok(Some(x)) = cx.pkg.read_xml(&rel.target) {
read_note_visibility(&x, &mut sheet, 0);
read_note_shapes(&x, &mut sheet, 0);
}
}
}
@@ -882,34 +883,63 @@ fn read_comments(x: &El, sheet: &mut Sheet) {
{
text = rest.strip_prefix('\n').unwrap_or(rest).to_string();
}
sheet.comments.insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![] });
sheet
.comments
.insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![], note_box: None });
}
}
/// Marks the notes whose VML shape carries `<x:Visible/>` as shown. Shapes may sit in groups.
fn read_note_visibility(x: &El, sheet: &mut Sheet, depth: usize) {
/// Reads the notes' VML shapes: a shape carrying `<x:Visible/>` marks its note as shown, and its
/// `<x:Anchor>` and style size become the note's box. Shapes may sit in groups.
fn read_note_shapes(x: &El, sheet: &mut Sheet, depth: usize) {
if depth > xml::MAX_DEPTH {
return;
}
for e in &x.children {
if e.name != "shape" {
read_note_visibility(e, sheet, depth + 1);
read_note_shapes(e, sheet, depth + 1);
continue;
}
let Some(cd) = e.child("ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue };
if cd.child("Visible").is_none() {
continue;
}
let num = |k: &str| cd.child(k).and_then(|v| v.text.trim().parse::<u32>().ok());
if let (Some(row), Some(col)) = (num("Row"), num("Column"))
&& let Some(c) = sheet.comments.get_mut(&CellRef::new(row, col))
&& !c.threaded
{
let (Some(row), Some(col)) = (num("Row"), num("Column")) else { continue };
let cell = CellRef::new(row, col);
let Some(c) = sheet.comments.get_mut(&cell) else { continue };
if cd.child("Visible").is_some() && !c.threaded {
c.visible = true;
}
if let Some(anchor) = cd.child("Anchor") {
c.note_box = note_box(&anchor.text, e.attr("style").unwrap_or(""), cell).filter(|b| *b != crate::sheet_write::DEFAULT_NOTE_BOX);
}
}
}
/// A note's box from its VML `<x:Anchor>` (`LeftColumn, LeftOffset, TopRow, TopOffset,
/// RightColumn, RightOffset, BottomRow, BottomOffset`; offsets in pixels) and its shape's style,
/// the corners made relative to the note's `cell`. `None` when the anchor isn't eight in-sheet
/// numbers.
fn note_box(anchor: &str, style: &str, cell: CellRef) -> Option<NoteBox> {
let v: Vec<u32> = anchor.split(',').map(|p| p.trim().parse::<u32>().ok()).collect::<Option<_>>()?;
let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = <[u32; 8]>::try_from(v).ok()?;
if c1.max(c2) >= MAX_COLS || r1.max(r2) >= MAX_ROWS {
return None;
}
let corner = |c: u32, dx: u32, r: u32, dy: u32| NoteCorner {
col: i64::from(c) - i64::from(cell.col),
col_px: dx,
row: i64::from(r) - i64::from(cell.row),
row_px: dy,
};
let pt = |key: &str| {
style
.split(';')
.filter_map(|d| d.split_once(':'))
.find(|(k, _)| k.trim().eq_ignore_ascii_case(key))
.and_then(|(_, v)| v.trim().strip_suffix("pt")?.trim().parse::<f64>().ok().filter(|n| n.is_finite() && *n > 0.0 && *n < 1e6))
};
Some(NoteBox { from: corner(c1, dx1, r1, dy1), to: corner(c2, dx2, r2, dy2), size_pt: pt("width").zip(pt("height")) })
}
fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) {
let mut by_id: HashMap<String, CellRef> = HashMap::new();
let mut replies: Vec<(String, String, String, String)> = vec![];
@@ -925,9 +955,10 @@ fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) {
by_id.insert(id.to_string(), cell);
}
let dates = if date.is_empty() { vec![] } else { vec![date] };
sheet
.comments
.insert(cell, Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates });
sheet.comments.insert(
cell,
Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates, note_box: None },
);
}
}
}
+25 -8
View File
@@ -3,10 +3,10 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::fmt::Write as _;
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{
Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, Orientation, Sheet, SparklineKind, Style, Table,
TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, NoteBox, NoteCorner, Orientation, Sheet,
SparklineKind, Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
};
use crate::drawing::{Obj, image_content_type, image_ext};
@@ -1260,6 +1260,24 @@ fn protected_ranges_xml(sheet: &Sheet) -> String {
if s.is_empty() { s } else { format!("<protectedRanges>{s}</protectedRanges>") }
}
/// The box a note made here gets: two columns right of its cell, four rows tall, 108 x 59.25 pt.
/// A box read from a file that equals it is kept as `None`, so a note's model survives a save.
pub(crate) const DEFAULT_NOTE_BOX: NoteBox = NoteBox {
from: NoteCorner { col: 1, col_px: 15, row: 0, row_px: 2 },
to: NoteCorner { col: 3, col_px: 15, row: 4, row_px: 16 },
size_pt: Some(DEFAULT_NOTE_SIZE_PT),
};
const DEFAULT_NOTE_SIZE_PT: (f64, f64) = (108.0, 59.25);
/// A note box's `<x:Anchor>` text and its width and height in points: the box read from the file
/// or [`DEFAULT_NOTE_BOX`], its corners moved with the note's `cell` and kept on the sheet.
fn note_anchor(cell: CellRef, b: Option<&NoteBox>) -> (String, (f64, f64)) {
let b = b.unwrap_or(&DEFAULT_NOTE_BOX);
let at = |base: u32, d: i64, max: u32| (i64::from(base) + d).clamp(0, i64::from(max) - 1);
let corner = |k: &NoteCorner| format!("{}, {}, {}, {}", at(cell.col, k.col, MAX_COLS), k.col_px, at(cell.row, k.row, MAX_ROWS), k.row_px);
(format!("{}, {}", corner(&b.from), corner(&b.to)), b.size_pt.unwrap_or(DEFAULT_NOTE_SIZE_PT))
}
/// Legacy VML shapes Excel uses to display notes.
fn vml_xml(sheet: &Sheet, n: u32) -> String {
let mut s = format!(
@@ -1268,14 +1286,13 @@ fn vml_xml(sheet: &Sheet, n: u32) -> String {
for (i, (cell, c)) in sheet.comments.iter().enumerate() {
let id = n as u64 * 1024 + 1 + i as u64;
let vis = if c.visible { "visible" } else { "hidden" };
let (anchor, (w, h)) = note_anchor(*cell, c.note_box.as_ref());
let _ = write!(
s,
"<v:shape id=\"_x0000_s{id}\" type=\"#_x0000_t202\" style=\"position:absolute;margin-left:59.25pt;margin-top:1.5pt;width:108pt;height:59.25pt;z-index:{};visibility:{vis}\" fillcolor=\"#ffffe1\" o:insetmode=\"auto\"><v:fill color2=\"#ffffe1\"/><v:shadow on=\"t\" color=\"black\" obscured=\"t\"/><v:path o:connecttype=\"none\"/><v:textbox style=\"mso-direction-alt:auto\"><div style=\"text-align:left\"></div></v:textbox><x:ClientData ObjectType=\"Note\"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>{}, 15, {}, 2, {}, 15, {}, 16</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>{}</x:Row><x:Column>{}</x:Column>{}</x:ClientData></v:shape>",
"<v:shape id=\"_x0000_s{id}\" type=\"#_x0000_t202\" style=\"position:absolute;margin-left:59.25pt;margin-top:1.5pt;width:{}pt;height:{}pt;z-index:{};visibility:{vis}\" fillcolor=\"#ffffe1\" o:insetmode=\"auto\"><v:fill color2=\"#ffffe1\"/><v:shadow on=\"t\" color=\"black\" obscured=\"t\"/><v:path o:connecttype=\"none\"/><v:textbox style=\"mso-direction-alt:auto\"><div style=\"text-align:left\"></div></v:textbox><x:ClientData ObjectType=\"Note\"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>{anchor}</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>{}</x:Row><x:Column>{}</x:Column>{}</x:ClientData></v:shape>",
num(w),
num(h),
i + 1,
cell.col + 1,
cell.row,
cell.col + 3,
cell.row + 4,
cell.row,
cell.col,
if c.visible { "<x:Visible/>" } else { "" }
+10 -1
View File
@@ -130,7 +130,16 @@ fn hostile_threaded_comments_persons_and_note_vml() {
use gridcraft_model::{Comment, Workbook};
let mut wb = Workbook::new();
let s = std::sync::Arc::make_mut(&mut wb.sheets[0]);
let c = |threaded| Comment { author: "A".into(), text: "t".into(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] };
let c = |threaded| Comment {
author: "A".into(),
text: "t".into(),
replies: vec![],
threaded,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
};
s.comments.insert(CellRef::new(0, 0), c(true));
s.comments.insert(CellRef::new(1, 1), c(false));
let good = write_xlsx(&wb).unwrap();
+1
View File
@@ -8,6 +8,7 @@ mod fixtures;
mod ink_roundtrip;
mod literal_category_roundtrip;
mod malformed;
mod note_box;
mod ods_tests;
mod passthrough_tests;
mod pivot_tests;
+117
View File
@@ -0,0 +1,117 @@
//! #1608: a note's box keeps the place and size the file gave it through open and save.
use std::io::Read;
use gridcraft_core::CellRef;
use gridcraft_model::{Comment, NoteBox, NoteCorner, Workbook};
use super::minimal;
use crate::{read_xlsx, write_xlsx};
const RELS: &str = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/></Relationships>"#;
const COMMENTS: &str = r#"<?xml version="1.0"?><comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>A</author></authors><commentList><comment ref="B2" authorId="0"><text><t>note</t></text></comment></commentList></comments>"#;
/// The issue's note on B2: Excel's box over B1, 224.25 x 89.25 pt.
const VML: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout><v:shape id="_x0000_s1025" type="#_x0000_t202" style="position:absolute;margin-left:66pt;margin-top:2.25pt;width:224.25pt;height:89.25pt;z-index:1;visibility:hidden" fillcolor="#ffffe1" o:insetmode="auto"><v:textbox><div>note</div></v:textbox><x:ClientData ObjectType="Note"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>
1, 10, 0, 2, 5, 32, 5, 8</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>1</x:Row><x:Column>1</x:Column></x:ClientData></v:shape></xml>"##;
fn package() -> Vec<u8> {
minimal(
r#"<sheetData><row r="2"><c r="B2"><v>1</v></c></row></sheetData><legacyDrawing r:id="rId2"/>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", RELS), ("xl/comments1.xml", COMMENTS), ("xl/drawings/vmlDrawing1.vml", VML)],
"",
"",
)
}
/// The written VML part (GridCraft writes one per sheet with notes).
fn written_vml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let name = (0..z.len()).map(|i| z.by_index(i).unwrap().name().to_string()).find(|n| n.ends_with(".vml")).expect("a VML part");
let mut s = String::new();
z.by_name(&name).unwrap().read_to_string(&mut s).unwrap();
s
}
fn b2() -> CellRef {
CellRef::new(1, 1)
}
#[test]
fn a_notes_box_is_read_relative_to_its_cell() {
let (wb, _) = read_xlsx(&package()).unwrap();
let c = &wb.sheets[0].comments[&b2()];
assert_eq!(
c.note_box,
Some(NoteBox {
from: NoteCorner { col: 0, col_px: 10, row: -1, row_px: 2 },
to: NoteCorner { col: 4, col_px: 32, row: 4, row_px: 8 },
size_pt: Some((224.25, 89.25)),
})
);
}
#[test]
fn an_unedited_notes_box_is_saved_where_and_as_big_as_it_was() {
let (wb, _) = read_xlsx(&package()).unwrap();
let vml = written_vml(&write_xlsx(&wb).unwrap());
assert!(vml.contains("<x:Anchor>1, 10, 0, 2, 5, 32, 5, 8</x:Anchor>"), "{vml}");
assert!(vml.contains("width:224.25pt;height:89.25pt"), "{vml}");
// and once more through GridCraft's own output
let (wb2, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(wb2.sheets[0].comments[&b2()].note_box, wb.sheets[0].comments[&b2()].note_box);
}
#[test]
fn a_notes_box_moves_with_its_note() {
// A row and a column inserted before the note move it to C3; its box follows it.
let (mut wb, _) = read_xlsx(&package()).unwrap();
let s = wb.sheet_mut(0).unwrap();
let c = s.comments.remove(&b2()).unwrap();
s.comments.insert(CellRef::new(2, 2), c);
let vml = written_vml(&write_xlsx(&wb).unwrap());
assert!(vml.contains("<x:Anchor>2, 10, 1, 2, 6, 32, 6, 8</x:Anchor>"), "{vml}");
}
#[test]
fn a_box_that_would_start_before_the_sheet_is_clamped_to_it() {
// The B2 note's box starts one row above it; moved to A1 that row doesn't exist.
let (mut wb, _) = read_xlsx(&package()).unwrap();
let s = wb.sheet_mut(0).unwrap();
let c = s.comments.remove(&b2()).unwrap();
s.comments.insert(CellRef::new(0, 0), c);
let vml = written_vml(&write_xlsx(&wb).unwrap());
assert!(vml.contains("<x:Anchor>0, 10, 0, 2, 4, 32, 4, 8</x:Anchor>"), "{vml}");
}
#[test]
fn a_new_note_gets_the_default_box() {
let mut wb = Workbook::new();
let note = Comment {
author: "A".into(),
text: "t".into(),
replies: vec![],
threaded: false,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
};
wb.sheet_mut(0).unwrap().comments.insert(b2(), note);
let vml = written_vml(&write_xlsx(&wb).unwrap());
assert!(vml.contains("<x:Anchor>2, 15, 1, 2, 4, 15, 5, 16</x:Anchor>") && vml.contains("width:108pt;height:59.25pt"), "{vml}");
}
#[test]
fn a_damaged_anchor_is_ignored() {
for anchor in ["1, 10, 0", "1, x, 0, 2, 5, 32, 5, 8", "-1, 10, 0, 2, 5, 32, 5, 8", "99999999999, 0, 0, 0, 0, 0, 0, 0"] {
let vml = VML.replace("\n 1, 10, 0, 2, 5, 32, 5, 8", anchor);
let bytes = minimal(
r#"<sheetData/><legacyDrawing r:id="rId2"/>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", RELS), ("xl/comments1.xml", COMMENTS), ("xl/drawings/vmlDrawing1.vml", &vml)],
"",
"",
);
let (wb, _) = read_xlsx(&bytes).unwrap();
assert_eq!(wb.sheets[0].comments[&b2()].note_box, None, "{anchor}");
}
}
+33 -3
View File
@@ -217,11 +217,21 @@ fn sample() -> Workbook {
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
s.comments.insert(
at("C3"),
Comment { author: "Bob".into(), text: "second".into(), replies: vec![], threaded: false, resolved: false, visible: true, dates: vec![] },
Comment {
author: "Bob".into(),
text: "second".into(),
replies: vec![],
threaded: false,
resolved: false,
visible: true,
dates: vec![],
note_box: None,
},
);
s.comments.insert(
at("D4"),
@@ -233,11 +243,21 @@ fn sample() -> Workbook {
resolved: true,
visible: false,
dates: vec!["2026-10-11T09:30:00.00".into(), String::new(), "2026-10-11T10:15:00.00".into()],
note_box: None,
},
);
s.comments.insert(
at("E5"),
Comment { author: "Cy".into(), text: "open thread".into(), replies: vec![], threaded: true, resolved: false, visible: false, dates: vec![] },
Comment {
author: "Cy".into(),
text: "open thread".into(),
replies: vec![],
threaded: true,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
s.autofilter = Some(AutoFilter {
range: rr("K1:N20"),
@@ -436,7 +456,16 @@ fn sample() -> Workbook {
s3.visibility = Visibility::VeryHidden;
s3.comments.insert(
at("A1"),
Comment { author: "Bob".into(), text: "other sheet".into(), replies: vec![], threaded: true, resolved: false, visible: false, dates: vec![] },
Comment {
author: "Bob".into(),
text: "other sheet".into(),
replies: vec![],
threaded: true,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
wb.sheets.push(Arc::new(s3));
wb.names.push(DefinedName { name: "Rate".into(), scope: None, formula: "0.07".into(), comment: "c".into(), hidden: false });
@@ -918,6 +947,7 @@ fn bad_comment_dates_are_left_out() {
resolved: false,
visible: false,
dates: vec!["\"/><evil".into()],
note_box: None,
};
s.comments.insert(at("A1"), c);
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
+2 -1
View File
@@ -23,7 +23,7 @@ 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, notes (with their shown/hidden state and their box's place and size, #1608) 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` 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`) |
@@ -91,6 +91,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| 2026-10-18 | trivial | `file.open {csvAsText: true}` keeps every CSV/TSV/TXT field as the text the file holds, so identifiers (GTINs, SKUs, ZIP codes) keep their digits and leading zeros (#1808; a preference and a CLI flag are separate work) |
| 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-16 | minor | XLSX: a note keeps its box's place and size (`<x:Anchor>` and the VML width and height) through open and save, moving with its cell; notes made here get the default box (#1608) |
| 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-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) |