From 40803e6acaaddd3d116a7df075b6c64401fe3d39 Mon Sep 17 00:00:00 2001
From: midasdf <205497066+midasdf@users.noreply.github.com>
Date: Sat, 17 Oct 2026 01:58:52 +0900
Subject: [PATCH 1/2] =?UTF-8?q?XLSX:=20keep=20Japanese=20phonetic=20runs?=
=?UTF-8?q?=20(=E3=81=B5=E3=82=8A=E3=81=8C=E3=81=AA)=20on=20open=20and=20s?=
=?UTF-8?q?ave?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
xl/sharedStrings.xml can carry phonetic runs (ふりがな)
and a beside a string item. GridCraft read only the text and wrote every
string back as …, so a plain open + save deleted a Japanese workbook's
furigana.
Both are now read — offsets validated against the base text, runs with bad or missing
offsets, sb >= eb or no reading dropped with a warning, and never used to index — and
kept on the workbook as Workbook::phonetics, one entry per string text. The writer
emits them next to the of the same text. The saved table holds each text once, so
two identical texts cannot keep two different readings: the reader keeps the first and
warns. Strings without phonetic data are written byte for byte as before.
Behaviour reference: ISO/IEC 29500-1 §18.4.6 rPh and §18.4.7 phoneticPr (Microsoft
Learn PhoneticRun/PhoneticProperties). Tests: crates/xlsx/src/tests/phonetic_tests.rs
(round trip, plain string, hostile offsets, shared text, unused data, comments and
inline strings). Docs: file-format-parity.md, localization-parity.md.
---
crates/model/src/lib.rs | 2 +-
crates/model/src/workbook.rs | 70 +++++++++++
crates/xlsx/src/read.rs | 52 +++++++-
crates/xlsx/src/tests/mod.rs | 1 +
crates/xlsx/src/tests/phonetic_tests.rs | 151 ++++++++++++++++++++++++
crates/xlsx/src/write.rs | 7 +-
crates/xlsx/src/xml.rs | 144 +++++++++++++++++++++-
docs/file-format-parity.md | 8 +-
docs/localization-parity.md | 5 +-
9 files changed, 426 insertions(+), 14 deletions(-)
create mode 100644 crates/xlsx/src/tests/phonetic_tests.rs
diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs
index a76f30fc3..c236d5b2e 100644
--- a/crates/model/src/lib.rs
+++ b/crates/model/src/lib.rs
@@ -25,7 +25,7 @@ pub use style::{
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
VAlign, VertAlign,
};
-pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, Workbook};
+pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, Workbook};
/// Font used for new workbooks. Only a name: renderers substitute an available open font
/// (e.g. the metric-compatible Carlito) when it isn't installed.
diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs
index 828ca692e..12e4bb6af 100644
--- a/crates/model/src/workbook.rs
+++ b/crates/model/src/workbook.rs
@@ -85,6 +85,47 @@ pub struct DocProps {
pub description: String,
}
+/// One phonetic (furigana) run of a string item (``): the reading `text` and the range of the
+/// base text it annotates.
+///
+/// `start` and `end` are zero-based offsets into the base text (`sb` and `eb` in the file) with
+/// `start < end`; reading keeps them within the text's length in UTF-16 code units. Behaviour
+/// reference: ISO/IEC 29500-1 §18.4.6 `rPh` and the Microsoft Learn `PhoneticRun` class.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PhoneticRun {
+ pub start: u32,
+ pub end: u32,
+ pub text: String,
+}
+
+/// The `` of a string item: how its phonetic runs are displayed. `kind` (`type`) and
+/// `alignment` only ever hold values ISO/IEC 29500-1 §18.18.57 `ST_PhoneticType` / §18.18.56
+/// `ST_PhoneticAlignment` define, so damaged files cannot be written back invalid.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PhoneticProps {
+ /// `fontId`: a required attribute, an index into the style sheet's fonts. A file without one
+ /// gets 0 — the Normal style's font, which an out-of-range index falls back to anyway.
+ #[serde(default)]
+ pub font_id: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub kind: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub alignment: Option,
+}
+
+/// The phonetic (furigana) data of one shared string: its `` runs, in document order, and its
+/// `` when the file had one.
+///
+/// `text` is the string's base text — the only handle a cell keeps on its shared string — and is
+/// the key the writer uses to re-emit the runs next to the right ``.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PhoneticString {
+ pub text: String,
+ pub runs: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub props: Option,
+}
+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Workbook {
pub sheets: Vec>,
@@ -104,6 +145,17 @@ pub struct Workbook {
pub custom_lists: Vec>,
/// Named cell styles (Cell Styles gallery): name → style.
pub cell_styles: Vec<(String, crate::style::Style)>,
+ /// Phonetic (furigana) data of shared strings (`xl/sharedStrings.xml`), one entry per string
+ /// text, so that an open + save keeps a Japanese workbook's readings. Written back next to the
+ /// `` holding the same text.
+ ///
+ /// Keyed by text because that is all a cell keeps of its shared string: the saved table holds
+ /// each text once (the format's `uniqueCount`), so two `` items that share a base text can
+ /// only keep one reading — the reader keeps the first and warns. Empty for workbooks without
+ /// phonetic data; not present in older JSON. Modelled data (unlike [`Workbook::preserved`],
+ /// which holds unmodelled parts), so it is saved to JSON as well as to XLSX.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub phonetics: Vec,
/// VBA project (`xl/vbaProject.bin`) carried through unchanged. GridCraft does not run
/// macros; the blob is preserved so that saving a macro-enabled workbook never destroys the
/// macros it arrived with. base64 in JSON. `None` = the workbook has no macros.
@@ -144,6 +196,7 @@ impl Workbook {
structure_password: None,
custom_lists: vec![],
cell_styles: vec![],
+ phonetics: vec![],
vba_project: None,
csv_delimiter: None,
preserved: None,
@@ -296,6 +349,23 @@ fn spell_calls(e: Expr, lambdas: &[String]) -> Expr {
mod tests {
use super::*;
+ #[test]
+ fn phonetics_are_additive_in_json() {
+ let mut wb = Workbook::new();
+ assert!(wb.phonetics.is_empty(), "new workbooks have no phonetic data");
+ wb.phonetics.push(PhoneticString {
+ text: "東".into(),
+ runs: vec![PhoneticRun { start: 0, end: 1, text: "ヒガシ".into() }],
+ props: Some(PhoneticProps { font_id: 3, kind: Some("Hiragana".into()), alignment: None }),
+ });
+ let json = serde_json::to_string(&wb).expect("serialize");
+ assert_eq!(serde_json::from_str::(&json).expect("deserialize").phonetics, wb.phonetics);
+ // JSON written before the field existed still loads.
+ let mut value: serde_json::Value = serde_json::from_str(&json).expect("value");
+ value.as_object_mut().expect("an object").remove("phonetics");
+ assert!(serde_json::from_value::(value).expect("older JSON").phonetics.is_empty());
+ }
+
#[test]
fn sheet_names() {
let wb = Workbook::new();
diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs
index 1a479081c..56858cb86 100644
--- a/crates/xlsx/src/read.rs
+++ b/crates/xlsx/src/read.rs
@@ -3,7 +3,7 @@
use std::sync::Arc;
use gridcraft_core::{DateSystem, RangeRef};
-use gridcraft_model::{CalcMode, CellPicture, DefinedName, Sheet, Style, StyleId, StyleTable, Visibility, Workbook};
+use gridcraft_model::{CalcMode, CellPicture, DefinedName, PhoneticString, Sheet, Style, StyleId, StyleTable, Visibility, Workbook};
use crate::package::{Package, Rel};
use crate::styles::{StylesIn, read_styles};
@@ -15,6 +15,8 @@ pub struct Ctx<'a> {
pub pkg: Package<'a>,
pub report: ReadReport,
pub sst: Vec>,
+ /// Phonetic (furigana) data of the shared strings, one entry per base text.
+ pub phonetics: Vec,
/// Cell format index → model style.
pub xf_map: Vec,
pub dxfs: Vec