From e42ebf9311ae03d6618e2cc39f12d0dd9d762cec Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:35:13 +0900 Subject: [PATCH] XLSX: keep a phoneticPr that has no rPh runs, and document the limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit si_body returned as soon as no valid run was written, so a shared string with a and no — a shape §18.4.8 allows — lost its properties on save even though the reader keeps them. The runs and the properties are now written independently: valid runs as before, then the valid when the workbook has one; with neither, the is exactly the plain t_el output. property_only_shared_string_survives_open_and_save covers read, save and reopen, and the si_body damaged-data test now expects the property-only output. The write-time checks the reader also runs (sb/eb clamped into the UTF-16 length, empty readings skipped, type/alignment filtered through the shared PHONETIC_KINDS/PHONETIC_ALIGNMENTS) were already in the head; the tests above pin them, so no further change there. Docs: the .xlsx row and the revision history now state that the shared string table holds each text once, so duplicate items with the same text but different readings keep only the first reading (reported as a warning on open), and that a without runs is kept. Both parity docs' "Last updated" headers match their 2026-10-20 revision rows; the earlier rows keep their dates. --- crates/xlsx/src/tests/phonetic_tests.rs | 27 +++++++++++++++++++++++++ crates/xlsx/src/xml.rs | 17 +++++++++------- docs/file-format-parity.md | 6 +++--- docs/localization-parity.md | 4 ++-- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/xlsx/src/tests/phonetic_tests.rs b/crates/xlsx/src/tests/phonetic_tests.rs index 566057fcc..37f37ed2b 100644 --- a/crates/xlsx/src/tests/phonetic_tests.rs +++ b/crates/xlsx/src/tests/phonetic_tests.rs @@ -80,6 +80,33 @@ fn a_plain_japanese_string_writes_the_plain_si() { assert!(!sst.contains("東京"#; + let body = r#"0"#; + let bytes = minimal(body, &[("xl/sharedStrings.xml", sst)], SST_RELS, ""); + let (wb, report) = read_xlsx(&bytes).unwrap(); + assert!(!report.warnings.iter().any(|w| w.contains("phonetic")), "{:?}", report.warnings); + assert_eq!( + wb.phonetics, + vec![PhoneticString { + text: "東京".into(), + runs: vec![], + props: Some(PhoneticProps { font_id: 2, kind: Some("Hiragana".into()), alignment: Some("left".into()) }), + }] + ); + let written = write_xlsx(&wb).unwrap(); + let out = part(&written, "xl/sharedStrings.xml"); + assert!(out.contains(r#"東京"#), "{out}"); + // A second open + save reproduces the same part. + let (again, report) = read_xlsx(&written).unwrap(); + assert_eq!(again.phonetics, wb.phonetics); + assert!(!report.warnings.iter().any(|w| w.contains("phonetic")), "{:?}", report.warnings); + assert_eq!(part(&write_xlsx(&again).unwrap(), "xl/sharedStrings.xml"), out); +} + #[test] fn hostile_phonetic_runs_lose_only_the_runs() { // Missing `eb`, missing `sb`, `sb >= eb`, offsets past the text, junk offsets, no offsets at diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index 8f10f1bc0..c6880de31 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -398,6 +398,10 @@ pub fn t_el(s: &str) -> String { /// phonetic (furigana) data — its `…` runs in document order and /// its ``. Behaviour reference: ISO/IEC 29500-1 §18.4.8 `si` with §18.4.6 `rPh` and /// §18.4.7 `phoneticPr`. A string without phonetic data gives exactly [`t_el`]'s output. +/// +/// The runs and the properties are independent: `` is a valid child of `` on its +/// own (§18.4.8's `CT_SharedStringItem` allows it without any `rPh`), so a string whose runs were +/// all dropped still keeps its settings. pub fn si_body(text: &str, phonetic: Option<&PhoneticString>) -> String { let mut s = t_el(text); let Some(p) = phonetic else { return s }; @@ -406,7 +410,6 @@ pub fn si_body(text: &str, phonetic: Option<&PhoneticString>) -> String { // `sb` before `eb`, both inside the base text, a non-empty reading; §18.18.56/57: a known enum // value). Damaged data is dropped, never written back invalid. let len = u32::try_from(text.encode_utf16().count()).unwrap_or(u32::MAX); - let mut runs = 0usize; for r in &p.runs { let start = r.start.min(len); let end = r.end.min(len); @@ -414,10 +417,6 @@ pub fn si_body(text: &str, phonetic: Option<&PhoneticString>) -> String { continue; } let _ = write!(s, "{}", t_el(&r.text)); - runs += 1; - } - if runs == 0 { - return s; } if let Some(pr) = &p.props { let _ = write!(s, "課きくく"); - // Without a single valid run, no phonetic markup at all (a lone is pointless). + // With no run left, valid properties are still written: `` is a legal child of + // `` on its own, and Excel keeps the settings when the last reading is removed. let empty = PhoneticString { text: text.into(), runs: vec![], props: damaged.props.clone() }; - assert_eq!(si_body(text, Some(&empty)), t_el(text)); + assert_eq!(si_body(text, Some(&empty)), "課きく"); + // With neither runs nor properties there is nothing to write. + let none = PhoneticString { text: text.into(), runs: vec![], props: None }; + assert_eq!(si_body(text, Some(&none)), t_el(text)); // A known kind is written; the schema default ("none") is not invented for a missing one. let known = PhoneticString { text: text.into(), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 14333b7de..0909fa091 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** trivial (Japanese phonetic runs — ふりがな — in XLSX shared strings survive open + save: `` offsets and readings and `` are kept on the workbook and written back; previously trivial: formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** trivial (Japanese phonetic runs — ふりがな — in XLSX shared strings survive open + save: `` offsets and readings and `` are kept on the workbook and written back; previously trivial: formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened) · **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** @@ -22,7 +22,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, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string), 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`, `phonetic_tests.rs`, `pivot_tests.rs` | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, 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`, `phonetic_tests.rs`, `pivot_tests.rs` | | `.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`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | @@ -82,7 +82,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning (`phonetic_tests.rs`) | +| 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-19 | minor | .xls import skips dialog sheets (WsBool `fDialog`), which share the worksheet BOF type and were read as empty worksheets | | 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over | | 2026-10-19 | trivial | XLSX text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`, ECMA-376 Part 1 §21.1.2.2.7): read from the first paragraph and written on every paragraph; output without it is unchanged | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 14ac5a66f..dcf22c21a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Japanese phonetic runs (ふりがな) in XLSX shared strings are preserved through open + save; they are still not displayed) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (Japanese phonetic runs (ふりがな) in XLSX shared strings are preserved through open + save; they are still not displayed) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -83,7 +83,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| -| 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back. Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) | +| 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) | | 2026-10-19 | minor | Defined-name length counts characters, not bytes: Arabic names up to 255 letters are accepted, and Create from Selection keeps long Arabic headers | | 2026-10-19 | minor | Table names are limited to 255 Unicode characters | | 2026-10-19 | minor | Right-to-left sheets in legacy `.xls` files (common in older Arabic workbooks) open right-to-left |