diff --git a/ROADMAP.md b/ROADMAP.md index 13ca5dc8c..dfcf8c311 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -143,6 +143,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-11 | View › Interface size: a saved 75–200 % scale for the whole window (#230) | | 2026-10-11 | View › Formula Bar and Zoom 100% reachable from the CLI and MCP (#289) | | 2026-10-11 | Freeze panes by dragging the freeze handles on the header edges (#195) | +| 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 | @@ -160,6 +161,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-11 | minor | Worksheet functions 502 / 523 (TRIMRANGE); progress log row | | 2026-10-11 | minor | Selected objects can be deleted from the keyboard (#213) | | 2026-10-11 | minor | Catalog 258 → 260 / 290: View › Formula Bar and Zoom 100% (#289) | +| 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 | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 3fc6cff9d..cab5df756 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1036,9 +1036,10 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result { 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()})) }) } diff --git a/crates/model/src/cellmap.rs b/crates/model/src/cellmap.rs index 6bfd1dea1..0f2c685a7 100644 --- a/crates/model/src/cellmap.rs +++ b/crates/model/src/cellmap.rs @@ -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()) }); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 2d2518d16..e0219b4b1 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -291,6 +291,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, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 065558e6b..59796ae50 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -198,6 +198,15 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result`). 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 `` 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::().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 = 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)); } } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 313839f91..1a841ac6e 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -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, ""); } - // 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")); @@ -979,31 +988,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> { + let mut seq: u64 = 0; + let mut ids = BTreeMap::new(); + for (cell, c) in sheet.comments.iter().filter(|(_, c)| c.threaded) { + let v: Vec = (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>) -> 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 = 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}"); + let mut s = if ids.is_empty() { + format!("{XML_DECL}") + } else { + format!( + "{XML_DECL}" + ) + }; for a in &authors { let _ = write!(s, "{}", esc(a)); } s.push_str(""); 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, "{}", 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, "{}", cell.a1(), crate::xml::t_el(&text)); } s.push_str(""); 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| 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>, persons: &mut Vec) -> 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}"); + 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, + "{}", + cell.a1(), + person(author), + esc(text) + ); + } + } + s.push_str(""); + s +} + /// Legacy VML shapes Excel uses to display notes. fn vml_xml(sheet: &Sheet, n: u32) -> String { let mut s = format!( diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index dc3368345..859197124 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -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)> = 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 = "".repeat(300) + &"".repeat(300); + let threaded = format!( + r#"rootorphanreply to orphanoff sheetno ref"# + ); + let vml = format!( + r#"4294967295-111{deep}"# + ); + 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" = 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); +} diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index f8d695b4b..a627c5de4 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -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 & \nline 2".into(), replies: vec![], threaded: false, resolved: false, visible: false }, + Comment { + author: "Ann".into(), + text: "Note & \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 \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); @@ -671,3 +702,45 @@ fn implicit_intersection_is_written_as_single() { assert_eq!(sh.cell(at("D3")).and_then(|c| c.formula.as_ref()).map(|f| f.text.as_str()), Some("@A1:A3*2")); assert_eq!(sh.cell(at("D3")).and_then(|c| c.formula.as_ref()).and_then(|f| f.array), None); } + +#[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("tc={{{id}}}")) && 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!["\"/>, 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, 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, IoError> { ); wb_rels.add("sheetMetadata", "metadata.xml"); } + if !out.persons.is_empty() { + let mut x = format!("{XML_DECL}"); + for (i, name) in out.persons.iter().enumerate() { + let n = esc_attr(name); + let _ = write!(x, "", person_guid(i)); + } + x.push_str(""); + 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()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index eeb2dd9b4..30596bf7d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 |