mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-01 14:46:51 -06:00
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:
co-authored by
Claude Opus 5.5
parent
9f896c1249
commit
8e5b1ac8e3
+3
-1
@@ -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 |
|
||||
|
||||
@@ -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()}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()) });
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 & 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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user