XLSX: write threaded comments and persons parts, read note visibility (#300)

- Writer: one xl/threadedComments/threadedCommentN.xml per sheet with
  comments (thread, replies via parentId, done for resolved, dT when known)
  and xl/persons/person.xml (one person per author), with the [MS-XLSX]
  relationship and content types. Each thread keeps its legacy note, linked
  by the tc={id} author and xr:uid; its body is the text plus replies.
  Ids are GUID-shaped and deterministic.
- Reader: keeps dT timestamps (Comment.dates, JSON-compatible), drops
  replies whose thread is missing, and reads note visibility from the VML
  <x:Visible/> (best effort, broken VML skipped silently).
- Invalid dates are never written. Round-trip and hostile-input tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 12:15:45 +02:00
co-authored by Claude Opus 5.5
parent 9f896c1249
commit 8e5b1ac8e3
10 changed files with 331 additions and 24 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Excel for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (XLSX threaded comments round trip, #300) · **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.
@@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-11 | XLSX: threaded comments (replies, resolved, timestamps) and note visibility survive a save (#300) |
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
@@ -142,6 +143,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Progress log: XLSX threaded comments round trip (#300) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
| 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha |
+4 -3
View File
@@ -1036,9 +1036,10 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
cx.sheet_mut(sheet)?
.comments
.insert(at, Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false });
cx.sheet_mut(sheet)?.comments.insert(
at,
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
);
Ok(json!({"cell": at.a1()}))
})
}
+1
View File
@@ -40,6 +40,7 @@ mod tests {
threaded: true,
resolved: false,
visible: false,
dates: vec![],
},
);
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()) });
+4
View File
@@ -279,6 +279,10 @@ pub struct Comment {
pub threaded: bool,
pub resolved: bool,
pub visible: bool,
/// Threaded comments: when the thread (first entry) and each reply were written, ISO 8601 as
/// 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>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+49 -5
View File
@@ -198,6 +198,15 @@ 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.
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);
}
}
}
Ok(sheet)
}
@@ -785,33 +794,68 @@ 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 });
sheet.comments.insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![] });
}
}
/// 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) {
if depth > xml::MAX_DEPTH {
return;
}
for e in &x.children {
if e.name != "shape" {
read_note_visibility(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
{
c.visible = true;
}
}
}
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)> = vec![];
let mut replies: Vec<(String, String, String, String)> = vec![];
for t in x.kids("threadedComment") {
let Some(cell) = t.attr("ref").and_then(CellRef::parse) else { continue };
let author = t.attr("personId").and_then(|p| cx.persons.get(p)).cloned().unwrap_or_default();
let text = t.child("text").map(|e| e.text.clone()).unwrap_or_default();
let date = t.attr("dT").unwrap_or("").to_string();
match t.attr("parentId") {
Some(p) => replies.push((p.to_string(), author, text)),
Some(p) => replies.push((p.to_string(), author, text, date)),
None => {
if let Some(id) = t.attr("id") {
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 });
.insert(cell, Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates });
}
}
}
for (parent, author, text) in replies {
// Replies in file order; ones whose thread is missing are dropped.
for (parent, author, text, date) in replies {
if let Some(c) = by_id.get(&parent)
&& let Some(cm) = sheet.comments.get_mut(c)
{
if !date.is_empty() {
// `dates[0]` is the thread, `dates[i + 1]` reply i; unknown dates stay empty.
let at = cm.replies.len() + 1;
if cm.dates.len() < at {
cm.dates.resize(at, String::new());
}
cm.dates.push(date);
}
cm.replies.push((author, text));
}
}
+109 -10
View File
@@ -13,7 +13,9 @@ use crate::drawing::{Obj, image_content_type, image_ext};
use crate::fmla::text_to_file;
use crate::styles::{color_attrs, dxf_id};
use crate::tables::{paper_id, px_to_col_width, px_to_pt};
use crate::write::{CT_CHART, CT_COMMENTS, CT_DRAWING, CT_TABLE, NS_MAIN, NS_REL, Out, Rels, XML_DECL};
use crate::write::{
CT_CHART, CT_COMMENTS, CT_DRAWING, CT_TABLE, CT_THREADED_COMMENTS, NS_MAIN, NS_REL, NS_THREADED, Out, REL_THREADED_COMMENT, Rels, XML_DECL,
};
use crate::xml::{encode_xstring, esc, esc_attr, num};
fn sqref(ranges: &[RangeRef]) -> String {
@@ -500,11 +502,18 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
let rid = rels.add("drawing", &format!("../drawings/drawing{dn}.xml"));
let _ = write!(s, "<drawing r:id=\"{rid}\"/>");
}
// Notes: comments part + VML so Excel shows them.
// Notes: comments part + VML so Excel shows them. Threaded comments also get a legacy note
// (its text with the replies as lines, for readers without threaded comments) linked by id.
if !sheet.comments.is_empty() {
out.comments += 1;
let n = out.comments;
out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet).into_bytes());
let ids = thread_ids(sheet, si);
out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes());
if !ids.is_empty() {
let x = threaded_xml(sheet, &ids, &mut out.persons);
out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes());
rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml"));
}
out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing");
out.part(&format!("xl/drawings/vmlDrawing{n}.vml"), None, vml_xml(sheet, n).into_bytes());
rels.add("comments", &format!("../comments{n}.xml"));
@@ -971,31 +980,121 @@ fn table_xml(t: &Table, cols: &[String], name: &str, id: u32) -> String {
s
}
fn comments_xml(sheet: &Sheet) -> String {
/// Ids of the threaded comments of sheet `si`: the thread's id, then one per reply. GUID-shaped
/// and deterministic; they only need to be unique within the file.
fn thread_ids(sheet: &Sheet, si: usize) -> BTreeMap<CellRef, Vec<String>> {
let mut seq: u64 = 0;
let mut ids = BTreeMap::new();
for (cell, c) in sheet.comments.iter().filter(|(_, c)| c.threaded) {
let v: Vec<String> = (0..=c.replies.len())
.map(|_| {
seq += 1;
format!("{{{:08X}-0000-4000-8001-{:012X}}}", si as u64 & 0xFFFF_FFFF, seq & 0xFFFF_FFFF_FFFF)
})
.collect();
ids.insert(*cell, v);
}
ids
}
fn comments_xml(sheet: &Sheet, ids: &BTreeMap<CellRef, Vec<String>>) -> String {
// A threaded comment's legacy note is authored by `tc={thread id}`.
let author_of = |cell: &CellRef, c: &gridcraft_model::Comment| match ids.get(cell).and_then(|v| v.first()) {
Some(id) => format!("tc={id}"),
None => c.author.clone(),
};
let mut authors: Vec<String> = vec![];
for c in sheet.comments.values() {
if !authors.contains(&c.author) {
authors.push(c.author.clone());
for (cell, c) in &sheet.comments {
let a = author_of(cell, c);
if !authors.contains(&a) {
authors.push(a);
}
}
let mut s = format!("{XML_DECL}<comments xmlns=\"{NS_MAIN}\"><authors>");
let mut s = if ids.is_empty() {
format!("{XML_DECL}<comments xmlns=\"{NS_MAIN}\"><authors>")
} else {
format!(
"{XML_DECL}<comments xmlns=\"{NS_MAIN}\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\" mc:Ignorable=\"xr\" xmlns:xr=\"http://schemas.microsoft.com/office/spreadsheetml/2014/revision\"><authors>"
)
};
for a in &authors {
let _ = write!(s, "<author>{}</author>", esc(a));
}
s.push_str("</authors><commentList>");
for (cell, c) in &sheet.comments {
let aid = authors.iter().position(|a| *a == c.author).unwrap_or(0);
let a = author_of(cell, c);
let aid = authors.iter().position(|x| *x == a).unwrap_or(0);
let mut text = c.text.clone();
// Threaded replies are kept as text lines.
for (who, reply) in &c.replies {
let _ = write!(text, "\n{who}: {reply}");
}
let _ = write!(s, "<comment ref=\"{}\" authorId=\"{aid}\"><text><r>{}</r></text></comment>", cell.a1(), crate::xml::t_el(&text));
let link = match ids.get(cell).and_then(|v| v.first()) {
Some(id) => format!(" shapeId=\"0\" xr:uid=\"{id}\""),
None => String::new(),
};
let _ = write!(s, "<comment ref=\"{}\" authorId=\"{aid}\"{link}><text><r>{}</r></text></comment>", cell.a1(), crate::xml::t_el(&text));
}
s.push_str("</commentList></comments>");
s
}
/// `2024-05-01T09:30:00` with optional fraction and zone: what `dT` (xsd:dateTime) accepts.
/// Anything else is left out rather than written into the file.
fn iso_datetime(d: &str) -> bool {
let b = d.as_bytes();
let digits = |r: std::ops::Range<usize>| b.get(r).is_some_and(|s| s.iter().all(u8::is_ascii_digit));
d.len() <= 40
&& digits(0..4)
&& b.get(4) == Some(&b'-')
&& digits(5..7)
&& b.get(7) == Some(&b'-')
&& digits(8..10)
&& b.get(10) == Some(&b'T')
&& digits(11..13)
&& b.get(13) == Some(&b':')
&& digits(14..16)
&& b.get(16) == Some(&b':')
&& digits(17..19)
&& b.get(19..).is_some_and(|rest| rest.iter().all(|c| c.is_ascii_digit() || b".:+-Z".contains(c)))
}
/// `xl/threadedComments/threadedCommentN.xml`: each thread, then its replies (`parentId`).
fn threaded_xml(sheet: &Sheet, ids: &BTreeMap<CellRef, Vec<String>>, persons: &mut Vec<String>) -> String {
let mut person = |name: &str| {
let i = persons.iter().position(|p| p == name).unwrap_or_else(|| {
persons.push(name.to_string());
persons.len() - 1
});
crate::write::person_guid(i)
};
let mut s = format!("{XML_DECL}<ThreadedComments xmlns=\"{NS_THREADED}\" xmlns:x=\"{NS_MAIN}\">");
for (cell, id) in ids {
let Some(c) = sheet.comments.get(cell) else { continue };
let entries = std::iter::once((&c.author, &c.text)).chain(c.replies.iter().map(|(a, t)| (a, t)));
for (k, ((author, text), own)) in entries.zip(id).enumerate() {
let date = match c.dates.get(k).filter(|d| iso_datetime(d)) {
Some(d) => format!(" dT=\"{}\"", esc_attr(d)),
None => String::new(),
};
let tail = match (k, id.first()) {
(0, _) if c.resolved => " done=\"1\"".to_string(),
(0, _) | (_, None) => String::new(),
(_, Some(parent)) => format!(" parentId=\"{parent}\""),
};
let _ = write!(
s,
"<threadedComment ref=\"{}\"{date} personId=\"{}\" id=\"{own}\"{tail}><text>{}</text></threadedComment>",
cell.a1(),
person(author),
esc(text)
);
}
}
s.push_str("</ThreadedComments>");
s
}
/// Legacy VML shapes Excel uses to display notes.
fn vml_xml(sheet: &Sheet, n: u32) -> String {
let mut s = format!(
+57
View File
@@ -121,3 +121,60 @@ fn zip_bomb_is_rejected() {
assert!(bytes.len() < 10 << 20);
assert!(matches!(read_xlsx(&bytes), Err(IoError::TooLarge(_))));
}
/// Threaded comments, persons and note VML from a hostile file: odd links and values are dropped,
/// broken parts are skipped, nothing panics.
#[test]
fn hostile_threaded_comments_persons_and_note_vml() {
use gridcraft_core::CellRef;
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![] };
s.comments.insert(CellRef::new(0, 0), c(true));
s.comments.insert(CellRef::new(1, 1), c(false));
let good = write_xlsx(&wb).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(good)).unwrap();
let mut parts: Vec<(String, Vec<u8>)> = vec![];
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut d = vec![];
std::io::Read::read_to_end(&mut f, &mut d).unwrap();
parts.push((f.name().to_string(), d));
}
let ns = r#"xmlns="http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments""#;
let deep = "<v:group>".repeat(300) + &"</v:group>".repeat(300);
let threaded = format!(
r#"<ThreadedComments {ns}><threadedComment ref="A1" dT="not a date" personId="{{nobody}}" id="{{T}}"><text>root</text></threadedComment><threadedComment ref="A1" personId="x" id="{{R}}" parentId="{{missing}}"><text>orphan</text></threadedComment><threadedComment ref="A1" personId="x" id="{{RR}}" parentId="{{R}}"><text>reply to orphan</text></threadedComment><threadedComment ref="ZZZZZ99999999" personId="x" id="{{B}}"><text>off sheet</text></threadedComment><threadedComment personId="x" id="{{N}}"><text>no ref</text></threadedComment></ThreadedComments>"#
);
let vml = format!(
r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:schemas-microsoft-com:office:excel"><v:shape><x:ClientData ObjectType="Note"><x:Row>4294967295</x:Row><x:Column>-1</x:Column><x:Visible/></x:ClientData></v:shape><v:shape><x:ClientData ObjectType="Note"><x:Row>1</x:Row><x:Column>1</x:Column><x:Visible/></x:ClientData></v:shape>{deep}</xml>"#
);
for (name, bytes) in &mut parts {
if name.starts_with("xl/threadedComments/") {
*bytes = threaded.clone().into_bytes();
} else if name.starts_with("xl/drawings/vmlDrawing") {
*bytes = vml.clone().into_bytes();
} else if name == "xl/persons/person.xml" {
*bytes = b"<personList><person".to_vec();
}
}
let refs: Vec<(&str, &[u8])> = parts.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect();
let (back, _) = read_xlsx(&super::make_zip(&refs)).unwrap();
let sheet = &back.sheets[0];
let a1 = &sheet.comments[&CellRef::new(0, 0)];
assert!(a1.threaded && a1.replies.is_empty() && a1.author.is_empty());
assert_eq!(a1.text, "root");
assert_eq!(sheet.comments.len(), 2);
// Too deep for the parser: the VML is skipped, so the note keeps its default.
assert!(!sheet.comments[&CellRef::new(1, 1)].visible);
// A VML that parses shows the note.
for (name, bytes) in &mut parts {
if name.starts_with("xl/drawings/vmlDrawing") {
*bytes = vml.replace(&deep, "").into_bytes();
}
}
let refs: Vec<(&str, &[u8])> = parts.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect();
let (back, _) = read_xlsx(&super::make_zip(&refs)).unwrap();
assert!(back.sheets[0].comments[&CellRef::new(1, 1)].visible);
}
+76 -3
View File
@@ -192,10 +192,36 @@ fn sample() -> Workbook {
s.hyperlinks.insert(at("A4"), Hyperlink { target: "mailto:me@example.com".into(), tooltip: None });
s.comments.insert(
at("B2"),
Comment { author: "Ann".into(), text: "Note & <stuff>\nline 2".into(), replies: vec![], threaded: false, resolved: false, visible: false },
Comment {
author: "Ann".into(),
text: "Note & <stuff>\nline 2".into(),
replies: vec![],
threaded: false,
resolved: false,
visible: false,
dates: vec![],
},
);
s.comments.insert(
at("C3"),
Comment { author: "Bob".into(), text: "second".into(), replies: vec![], threaded: false, resolved: false, visible: true, dates: vec![] },
);
s.comments.insert(
at("D4"),
Comment {
author: "Ann & Co".into(),
text: "Threaded <one>\nsecond line".into(),
replies: vec![("Bob".into(), "first reply".into()), ("Ann & Co".into(), "second reply".into())],
threaded: true,
resolved: true,
visible: false,
dates: vec!["2026-10-11T09:30:00.00".into(), String::new(), "2026-10-11T10:15:00.00".into()],
},
);
s.comments.insert(
at("E5"),
Comment { author: "Cy".into(), text: "open thread".into(), replies: vec![], threaded: true, resolved: false, visible: false, dates: vec![] },
);
s.comments
.insert(at("C3"), Comment { author: "Bob".into(), text: "second".into(), replies: vec![], threaded: false, resolved: false, visible: false });
s.autofilter = Some(AutoFilter {
range: rr("K1:N20"),
criteria: vec![
@@ -340,6 +366,10 @@ fn sample() -> Workbook {
wb.sheets.push(Arc::new(s2));
let mut s3 = Sheet::new("2024");
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![] },
);
wb.sheets.push(Arc::new(s3));
wb.names.push(DefinedName { name: "Rate".into(), scope: None, formula: "0.07".into(), comment: "c".into(), hidden: false });
wb.names.push(DefinedName { name: "Local".into(), scope: Some(1), formula: "Hidden!$A$1".into(), comment: String::new(), hidden: true });
@@ -373,6 +403,7 @@ fn full_roundtrip() {
assert_eq!(back.sheets[1].visibility, Visibility::Hidden);
assert_eq!(back.sheets[2].visibility, Visibility::VeryHidden);
assert_eq!(back.sheets[2].name, "2024");
assert_eq!(back.sheets[2].comments, wb.sheets[2].comments);
let (a, b) = (wb.sheet(0).unwrap(), back.sheet(0).unwrap());
assert_eq!(b.name, a.name);
@@ -600,3 +631,45 @@ fn spill_and_calc_errors_are_cached_as_value_errors() {
assert_eq!(cell.child("v").unwrap().text, value);
}
}
#[test]
fn threaded_comments_write_persons_and_linked_legacy_notes() {
let wb = sample();
let bytes = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
let text = |pkg: &mut Package, part: &str| String::from_utf8(pkg.read(part).unwrap().unwrap()).unwrap();
// One person per author across the workbook, ids unique.
let persons = text(&mut pkg, "xl/persons/person.xml");
assert_eq!(persons.matches("<person ").count(), 3, "{persons}");
assert!(persons.contains("displayName=\"Ann &amp; Co\""));
let ct = text(&mut pkg, "[Content_Types].xml");
assert!(ct.contains("application/vnd.ms-excel.threadedcomments+xml") && ct.contains("application/vnd.ms-excel.person+xml"));
let tc = text(&mut pkg, "xl/threadedComments/threadedComment1.xml");
assert_eq!(tc.matches("<threadedComment ").count(), 4, "{tc}");
assert_eq!(tc.matches("parentId=").count(), 2);
assert_eq!(tc.matches("done=\"1\"").count(), 1);
// The legacy note of each thread points at it.
let legacy = text(&mut pkg, "xl/comments1.xml");
for id in tc.split("id=\"{").skip(1).filter_map(|s| s.split('}').next()).take(1) {
assert!(legacy.contains(&format!("<author>tc={{{id}}}</author>")) && legacy.contains(&format!("xr:uid=\"{{{id}}}\"")), "{legacy}");
}
}
#[test]
fn bad_comment_dates_are_left_out() {
let mut wb = Workbook::new();
let s = Arc::make_mut(&mut wb.sheets[0]);
let c = Comment {
author: "A".into(),
text: "t".into(),
replies: vec![],
threaded: true,
resolved: false,
visible: false,
dates: vec!["\"/><evil".into()],
};
s.comments.insert(at("A1"), c);
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
let c = &back.sheets[0].comments[&at("A1")];
assert!(c.threaded && c.dates.is_empty());
}
+25
View File
@@ -20,6 +20,12 @@ pub const CT_DRAWING: &str = "application/vnd.openxmlformats-officedocument.draw
pub const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
pub const CT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml";
pub const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml";
pub const CT_THREADED_COMMENTS: &str = "application/vnd.ms-excel.threadedcomments+xml";
pub const CT_PERSONS: &str = "application/vnd.ms-excel.person+xml";
pub const REL_THREADED_COMMENT: &str = "http://schemas.microsoft.com/office/2017/10/relationships/threadedComment";
pub const REL_PERSON: &str = "http://schemas.microsoft.com/office/2017/10/relationships/person";
/// Namespace of the threaded comments and persons parts ([MS-XLSX]).
pub const NS_THREADED: &str = "http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments";
/// A part's relationships.
#[derive(Default)]
@@ -102,6 +108,8 @@ pub struct Out {
pub drawings: u32,
pub tables: u32,
pub comments: u32,
/// Authors of threaded comments, in first-use order; written to `xl/persons/person.xml`.
pub persons: Vec<String>,
pub table_id: u32,
pub dynamic_arrays: bool,
pub needs_calc: bool,
@@ -120,6 +128,12 @@ impl Out {
}
}
/// Id of the i-th person of `xl/persons/person.xml`: a GUID-shaped, deterministic id (the
/// file only needs ids that are unique within it).
pub fn person_guid(i: usize) -> String {
format!("{{00000000-0000-4000-8002-{:012X}}}", i as u64 & 0xFFFF_FFFF_FFFF)
}
/// `$A$1:$D$20` (or `$1:$3` / `$A:$C`).
pub fn abs_range(r: &RangeRef) -> String {
let c = |col: u32| gridcraft_core::col_to_letters(col);
@@ -154,6 +168,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
drawings: 0,
tables: 0,
comments: 0,
persons: vec![],
table_id: 0,
dynamic_arrays: false,
needs_calc: false,
@@ -297,6 +312,16 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
);
wb_rels.add("sheetMetadata", "metadata.xml");
}
if !out.persons.is_empty() {
let mut x = format!("{XML_DECL}<personList xmlns=\"{NS_THREADED}\" xmlns:x=\"{NS_MAIN}\">");
for (i, name) in out.persons.iter().enumerate() {
let n = esc_attr(name);
let _ = write!(x, "<person displayName=\"{n}\" id=\"{}\" userId=\"{n}\" providerId=\"None\"/>", person_guid(i));
}
x.push_str("</personList>");
out.part("xl/persons/person.xml", Some(CT_PERSONS), x.into_bytes());
wb_rels.add_uri(REL_PERSON, "persons/person.xml");
}
out.part("xl/workbook.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), w.into_bytes());
out.part("xl/_rels/workbook.xml.rels", None, wb_rels.xml().into_bytes());
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; every format Excel reads or writes, against the code on main) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (XLSX threaded comments and persons written; note visibility read, #300) · **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**
@@ -15,7 +15,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and
| 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. **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 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` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures |
| `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, **without macros** | VBA project dropped with a warning; saving as `.xlsm` loses the macros (#6) | — |
| `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the workbook content type, not the template one; "new from template" behaviour missing | — |
@@ -74,5 +74,6 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | XLSX: threaded comments and persons parts written (they came back as notes before); note visibility read from VML (#300) |
| 2026-10-10 | minor | Encrypted and `.xls` files are now recognised and refused with a message (#17) |
| 2026-10-10 | major | Created: every Excel format with our read/write support; XLSB and ODS values-only imports recorded; `.xls`, encryption and large files flagged as beta gates |