mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Merge PR #1692: XLSX: keep Japanese phonetic runs on open and save
Conflicts: - crates/model/src/lib.rs, crates/xlsx/src/read.rs, crates/xlsx/src/write.rs: use lists unioned (ShowObjects and the phonetic types) - docs/file-format-parity.md: main's header and rows kept (#1692 collapsed the three duplicate .xlsx rows; they stay), phonetic-runs text added to each; Last updated 2026-10-20
This commit is contained in:
@@ -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, ShowObjects, Workbook};
|
||||
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, 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.
|
||||
|
||||
@@ -96,6 +96,47 @@ pub struct DocProps {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// One phonetic (furigana) run of a string item (`<rPh>`): 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 `<phoneticPr>` 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub alignment: Option<String>,
|
||||
}
|
||||
|
||||
/// The phonetic (furigana) data of one shared string: its `<rPh>` runs, in document order, and its
|
||||
/// `<phoneticPr>` 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 `<si>`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PhoneticString {
|
||||
pub text: String,
|
||||
pub runs: Vec<PhoneticRun>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub props: Option<PhoneticProps>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Workbook {
|
||||
pub sheets: Vec<Arc<Sheet>>,
|
||||
@@ -117,6 +158,17 @@ pub struct Workbook {
|
||||
pub custom_lists: Vec<Vec<String>>,
|
||||
/// 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
|
||||
/// `<si>` 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 `<si>` 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<PhoneticString>,
|
||||
/// 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. Shared, so a
|
||||
@@ -170,6 +222,7 @@ impl Workbook {
|
||||
structure_password: None,
|
||||
custom_lists: vec![],
|
||||
cell_styles: vec![],
|
||||
phonetics: vec![],
|
||||
vba_project: None,
|
||||
csv_delimiter: None,
|
||||
preserved: None,
|
||||
@@ -323,6 +376,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::<Workbook>(&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::<Workbook>(value).expect("older JSON").phonetics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_names() {
|
||||
let wb = Workbook::new();
|
||||
|
||||
+51
-3
@@ -3,7 +3,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{DateSystem, RangeRef};
|
||||
use gridcraft_model::{CalcMode, CellPicture, DefinedName, Sheet, ShowObjects, Style, StyleId, StyleTable, Visibility, Workbook};
|
||||
use gridcraft_model::{
|
||||
CalcMode, CellPicture, DefinedName, PhoneticString, Sheet, ShowObjects, Style, StyleId, StyleTable, Visibility, Workbook,
|
||||
};
|
||||
|
||||
use crate::package::{Package, Rel};
|
||||
use crate::styles::{StylesIn, read_styles};
|
||||
@@ -15,6 +17,8 @@ pub struct Ctx<'a> {
|
||||
pub pkg: Package<'a>,
|
||||
pub report: ReadReport,
|
||||
pub sst: Vec<Arc<str>>,
|
||||
/// Phonetic (furigana) data of the shared strings, one entry per base text.
|
||||
pub phonetics: Vec<PhoneticString>,
|
||||
/// Cell format index → model style.
|
||||
pub xf_map: Vec<StyleId>,
|
||||
pub dxfs: Vec<Style>,
|
||||
@@ -36,6 +40,34 @@ impl Ctx<'_> {
|
||||
pub fn warn(&mut self, m: impl Into<String>) {
|
||||
self.report.warn(m);
|
||||
}
|
||||
/// Stores the phonetic data read from `xl/sharedStrings.xml`.
|
||||
///
|
||||
/// One entry per base text: the writer rebuilds the shared string table from the values the
|
||||
/// sheets use and holds each text once (the format's `uniqueCount`), so only one reading can be
|
||||
/// written per text. A later `<si>` with a text that is already there is dropped — with a
|
||||
/// warning when its data differs, so nothing is lost silently.
|
||||
pub fn keep_phonetics(&mut self, list: Vec<PhoneticString>) {
|
||||
let mut kept: Vec<PhoneticString> = Vec::new();
|
||||
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut conflicts = 0usize;
|
||||
for p in list {
|
||||
match index.get(p.text.as_str()) {
|
||||
Some(&i) => {
|
||||
if kept.get(i).is_some_and(|k| *k != p) {
|
||||
conflicts += 1;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
index.insert(p.text.clone(), kept.len());
|
||||
kept.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if conflicts > 0 {
|
||||
self.warn(format!("shared strings: {conflicts} phonetic readings were dropped because another <si> with the same text already had one"));
|
||||
}
|
||||
self.phonetics = kept;
|
||||
}
|
||||
/// A fresh id for a chart, picture, shape or table.
|
||||
pub fn next_object_id(&mut self) -> u32 {
|
||||
let id = self.next_id;
|
||||
@@ -72,6 +104,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
pkg,
|
||||
report: ReadReport::default(),
|
||||
sst: vec![],
|
||||
phonetics: vec![],
|
||||
xf_map: vec![],
|
||||
dxfs: vec![],
|
||||
palette: crate::tables::INDEXED.to_vec(),
|
||||
@@ -140,19 +173,33 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
{
|
||||
wb.theme = crate::theme::read_theme(&x);
|
||||
}
|
||||
// Shared strings, streamed item by item.
|
||||
// Shared strings, streamed item by item. Phonetic (furigana) runs are kept next to the text
|
||||
// they annotate, so an open + save does not delete them.
|
||||
if let Some(r) = find_rel(&wb_rels, "sharedStrings").cloned() {
|
||||
match cx.pkg.read(&r.target)? {
|
||||
Some(b) => {
|
||||
let mut sst = Vec::new();
|
||||
let mut phonetics = Vec::new();
|
||||
let mut dropped = 0usize;
|
||||
let res = xml::parse_streaming(&b, &["si"], &mut |si| {
|
||||
sst.push(Arc::from(xml::rich_text(&si)));
|
||||
let text = xml::rich_text(&si);
|
||||
let raw = si.kids("rPh").count();
|
||||
let (runs, props) = xml::phonetic_data(&si, &text);
|
||||
dropped += raw.saturating_sub(runs.len());
|
||||
if !runs.is_empty() || props.is_some() {
|
||||
phonetics.push(PhoneticString { text: text.clone(), runs, props });
|
||||
}
|
||||
sst.push(Arc::from(text));
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = res {
|
||||
cx.warn(format!("shared strings are damaged ({e}); kept {} strings", sst.len()));
|
||||
}
|
||||
if dropped > 0 {
|
||||
cx.warn(format!("shared strings: {dropped} phonetic runs were dropped (bad sb/eb or no reading)"));
|
||||
}
|
||||
cx.sst = sst;
|
||||
cx.keep_phonetics(phonetics);
|
||||
}
|
||||
None => cx.warn(format!("missing part {}", r.target)),
|
||||
}
|
||||
@@ -348,6 +395,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
// Calls of LAMBDA names are spelled like the names (the parser upper-cases them).
|
||||
wb.apply_name_call_case();
|
||||
wb.styles = cx.styles;
|
||||
wb.phonetics = cx.phonetics;
|
||||
Ok((wb, cx.report))
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ mod literal_category_roundtrip;
|
||||
mod malformed;
|
||||
mod ods_tests;
|
||||
mod passthrough_tests;
|
||||
mod phonetic_tests;
|
||||
mod pivot_tests;
|
||||
mod richdata_tests;
|
||||
mod roundtrip;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Japanese phonetic runs (`rPh`, furigana) of shared strings survive an open + save.
|
||||
//!
|
||||
//! Behaviour reference: ISO/IEC 29500-1 §18.4.6 `rPh` (`sb`/`eb` are zero-based offsets into the
|
||||
//! base text, `sb < eb`, child `<t>`) and §18.4.7 `phoneticPr` (`fontId`, `type`, `alignment`).
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{Cell, PhoneticProps, PhoneticRun, PhoneticString, StyleId, Workbook};
|
||||
|
||||
use super::minimal;
|
||||
use crate::package::Package;
|
||||
use crate::{read_xlsx, write_xlsx};
|
||||
|
||||
/// A string item with two `<rPh>` runs and a `<phoneticPr>`, and one with no phonetic data.
|
||||
const SST: &str = r#"<?xml version="1.0"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="2" uniqueCount="2"><si><t>東京都</t><rPh sb="0" eb="2"><t>トウキョウ</t></rPh><rPh sb="2" eb="3"><t>ト & <x></t></rPh><phoneticPr fontId="1" type="noConversion" alignment="center"/></si><si><t>大阪</t></si></sst>"#;
|
||||
|
||||
const SHEET_BODY: &str = r#"<sheetData><row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row></sheetData>"#;
|
||||
|
||||
const SST_RELS: &str = r#"<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>"#;
|
||||
|
||||
fn at(a: &str) -> CellRef {
|
||||
CellRef::parse(a).unwrap()
|
||||
}
|
||||
|
||||
/// A one-sheet package whose shared string table is `sst`.
|
||||
fn package(sst: &str) -> Vec<u8> {
|
||||
minimal(SHEET_BODY, &[("xl/sharedStrings.xml", sst)], SST_RELS, "")
|
||||
}
|
||||
|
||||
/// A part of a written package, as text.
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut pkg = Package::open(bytes).unwrap();
|
||||
String::from_utf8(pkg.read(name).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn text_cell(sheet: &mut gridcraft_model::Sheet, a: &str, text: &str) {
|
||||
sheet.cells.set(at(a), Cell { value: Value::text(text), formula: None, style: StyleId::DEFAULT });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phonetic_runs_and_props_survive_open_and_save() {
|
||||
let (wb, report) = read_xlsx(&package(SST)).unwrap();
|
||||
assert!(!report.warnings.iter().any(|w| w.contains("phonetic")), "{:?}", report.warnings);
|
||||
let sheet = wb.sheet(0).unwrap();
|
||||
// A reading is a hint, not part of the text.
|
||||
assert_eq!(sheet.value(at("A1")), Value::text("東京都"));
|
||||
assert_eq!(sheet.value(at("B1")), Value::text("大阪"));
|
||||
assert_eq!(
|
||||
wb.phonetics,
|
||||
vec![PhoneticString {
|
||||
text: "東京都".into(),
|
||||
runs: vec![PhoneticRun { start: 0, end: 2, text: "トウキョウ".into() }, PhoneticRun { start: 2, end: 3, text: "ト & <x>".into() },],
|
||||
props: Some(PhoneticProps { font_id: 1, kind: Some("noConversion".into()), alignment: Some("center".into()) }),
|
||||
}]
|
||||
);
|
||||
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let sst = part(&bytes, "xl/sharedStrings.xml");
|
||||
assert!(
|
||||
sst.contains(
|
||||
r#"<si><t>東京都</t><rPh sb="0" eb="2"><t>トウキョウ</t></rPh><rPh sb="2" eb="3"><t>ト & <x></t></rPh><phoneticPr fontId="1" type="noConversion" alignment="center"/></si>"#
|
||||
),
|
||||
"{sst}"
|
||||
);
|
||||
assert!(sst.contains("<si><t>大阪</t></si>"), "a string without phonetic data stays plain: {sst}");
|
||||
|
||||
// A second open + save keeps the runs and reproduces the same part.
|
||||
let (again, report) = read_xlsx(&bytes).unwrap();
|
||||
assert_eq!(again.phonetics, wb.phonetics);
|
||||
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
|
||||
assert_eq!(part(&write_xlsx(&again).unwrap(), "xl/sharedStrings.xml"), sst);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_japanese_string_writes_the_plain_si() {
|
||||
let mut wb = Workbook::new();
|
||||
text_cell(wb.sheet_mut(0).unwrap(), "A1", "日本語");
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let sst = part(&bytes, "xl/sharedStrings.xml");
|
||||
assert!(sst.contains("<si><t>日本語</t></si>"), "{sst}");
|
||||
assert!(!sst.contains("<rPh") && !sst.contains("<phoneticPr"), "{sst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_only_shared_string_survives_open_and_save() {
|
||||
// `phoneticPr` without any `rPh` is valid (a shared string item permits it on its own), so
|
||||
// open + save must not delete it.
|
||||
let sst = r#"<?xml version="1.0"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1"><si><t>東京</t><phoneticPr fontId="2" type="Hiragana" alignment="left"/></si></sst>"#;
|
||||
let body = r#"<sheetData><row r="1"><c r="A1" t="s"><v>0</v></c></row></sheetData>"#;
|
||||
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#"<si><t>東京</t><phoneticPr fontId="2" type="Hiragana" alignment="left"/></si>"#), "{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
|
||||
// all, no `<t>`, an empty `<t>`, and junk `phoneticPr` attributes.
|
||||
let sst = r#"<?xml version="1.0"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1"><si junk="1"><t>課きく</t><rPh sb="1"><t>no eb</t></rPh><rPh eb="2"><t>no sb</t></rPh><rPh sb="3" eb="1"><t>reversed</t></rPh><rPh sb="0" eb="99"><t>clamped</t></rPh><rPh sb="junk" eb="1"><t>junk</t></rPh><rPh><t>no offsets</t></rPh><rPh sb="0" eb="1" junk="2"/><rPh sb="0" eb="1"><t/></rPh><phoneticPr fontId="junk" type="junk" alignment="junk"/></si></sst>"#;
|
||||
let (wb, report) = read_xlsx(&package(sst)).unwrap();
|
||||
// The base text is intact and only the one run that can be clamped is kept.
|
||||
assert_eq!(wb.sheet(0).unwrap().value(at("A1")), Value::text("課きく"));
|
||||
assert_eq!(
|
||||
wb.phonetics,
|
||||
vec![PhoneticString {
|
||||
text: "課きく".into(),
|
||||
runs: vec![PhoneticRun { start: 0, end: 3, text: "clamped".into() }],
|
||||
props: Some(PhoneticProps::default()),
|
||||
}]
|
||||
);
|
||||
assert!(report.warnings.iter().any(|w| w.contains("phonetic runs were dropped")), "{:?}", report.warnings);
|
||||
|
||||
// What is written back is valid: offsets within the text, a fontId on every `<phoneticPr>`.
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let out = part(&bytes, "xl/sharedStrings.xml");
|
||||
assert!(out.contains(r#"<si><t>課きく</t><rPh sb="0" eb="3"><t>clamped</t></rPh><phoneticPr fontId="0"/></si>"#), "{out}");
|
||||
assert_eq!(read_xlsx(&bytes).unwrap().0.phonetics, wb.phonetics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_text_with_different_readings_keeps_the_first_and_warns() {
|
||||
let sst = r#"<?xml version="1.0"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="2" uniqueCount="2"><si><t>東</t><rPh sb="0" eb="1"><t>ヒガシ</t></rPh></si><si><t>東</t><rPh sb="0" eb="1"><t>アズマ</t></rPh></si></sst>"#;
|
||||
let (wb, report) = read_xlsx(&package(sst)).unwrap();
|
||||
// The saved table holds each text once, so only one reading can be written: the first.
|
||||
assert_eq!(wb.phonetics.len(), 1);
|
||||
assert_eq!(wb.phonetics[0].runs.first().map(|r| r.text.as_str()), Some("ヒガシ"));
|
||||
assert!(report.warnings.iter().any(|w| w.contains("phonetic readings")), "{:?}", report.warnings);
|
||||
|
||||
// The same text twice with the same reading is not a conflict.
|
||||
let same = sst.replace("アズマ", "ヒガシ");
|
||||
let (wb, report) = read_xlsx(&package(&same)).unwrap();
|
||||
assert_eq!(wb.phonetics.len(), 1);
|
||||
assert!(!report.warnings.iter().any(|w| w.contains("phonetic readings")), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phonetic_data_never_used_by_a_cell_is_not_written() {
|
||||
let mut wb = Workbook::new();
|
||||
text_cell(wb.sheet_mut(0).unwrap(), "A1", "日本語");
|
||||
wb.phonetics =
|
||||
vec![PhoneticString { text: "別の文字列".into(), runs: vec![PhoneticRun { start: 0, end: 2, text: "ベツ".into() }], props: None }];
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let sst = part(&bytes, "xl/sharedStrings.xml");
|
||||
assert!(sst.contains("<si><t>日本語</t></si>"), "{sst}");
|
||||
assert!(!sst.contains("<rPh"), "{sst}");
|
||||
let (back, report) = read_xlsx(&bytes).unwrap();
|
||||
assert!(back.phonetics.is_empty());
|
||||
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_inline_strings_still_ignore_phonetic_runs() {
|
||||
let body = r#"<sheetData><row r="1"><c r="A1" t="inlineStr"><is><r><t>inline</t></r><rPh sb="0" eb="6"><t>インライン</t></rPh><phoneticPr fontId="1"/></is></c></row></sheetData>"#;
|
||||
let sheet_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments1.xml"/></Relationships>"#;
|
||||
let comments = r#"<comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>Ann</author></authors><commentList><comment ref="A1" authorId="0"><text><r><rPr><b/></rPr><t>Ann:</t></r><r><t>メモ</t></r><rPh sb="0" eb="2"><t>メモ</t></rPh><phoneticPr fontId="1"/></text></comment></commentList></comments>"#;
|
||||
let bytes = minimal(body, &[("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels), ("xl/comments1.xml", comments)], "", "");
|
||||
let (wb, _) = read_xlsx(&bytes).unwrap();
|
||||
let sheet = wb.sheet(0).unwrap();
|
||||
assert_eq!(sheet.value(at("A1")), Value::text("inline"));
|
||||
assert_eq!(sheet.comments.get(&at("A1")).map(|c| c.text.as_str()), Some("メモ"));
|
||||
// Neither path has a shared string table entry to key phonetic data by.
|
||||
assert!(wb.phonetics.is_empty());
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::RangeRef;
|
||||
use gridcraft_formula::quote_sheet;
|
||||
use gridcraft_model::{CalcMode, CalcSettings, ShowObjects, Style, Visibility, Workbook};
|
||||
use gridcraft_model::{CalcMode, CalcSettings, PhoneticString, ShowObjects, Style, Visibility, Workbook};
|
||||
|
||||
use crate::package::ZipOut;
|
||||
use crate::xml::{esc, esc_attr};
|
||||
@@ -396,9 +396,12 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
||||
crate::theme::write_theme(&wb.theme).into_bytes(),
|
||||
);
|
||||
wb_rels.add("theme", "theme/theme1.xml");
|
||||
// Phonetic (furigana) data is kept per string text: the table below holds each text once, so
|
||||
// the runs of the entry with that text are written next to it (`Workbook::phonetics`).
|
||||
let phonetics: HashMap<&str, &PhoneticString> = wb.phonetics.iter().map(|p| (p.text.as_str(), p)).collect();
|
||||
let mut sst = format!("{XML_DECL}<sst xmlns=\"{NS_MAIN}\" count=\"{}\" uniqueCount=\"{}\">", out.sst.count, out.sst.list.len());
|
||||
for t in &out.sst.list {
|
||||
let _ = write!(sst, "<si>{}</si>", crate::xml::t_el(t));
|
||||
let _ = write!(sst, "<si>{}</si>", crate::xml::si_body(t, phonetics.get(t.as_str()).copied()));
|
||||
}
|
||||
sst.push_str("</sst>");
|
||||
out.part("xl/sharedStrings.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"), sst.into_bytes());
|
||||
|
||||
+146
-1
@@ -1,6 +1,9 @@
|
||||
//! A small XML tree built on `quick-xml`, with streaming of repeated elements (worksheet rows,
|
||||
//! shared strings) so large parts never need a full tree, plus escaping helpers for writing.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_model::{PhoneticProps, PhoneticRun, PhoneticString};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
@@ -65,7 +68,8 @@ impl El {
|
||||
}
|
||||
|
||||
/// Text of a SpreadsheetML string item (`<si>`, `<is>`, comment `<text>`): `<t>` and rich text
|
||||
/// runs `<r><t>`, phonetic runs ignored. Decodes `_xHHHH_` escapes.
|
||||
/// runs `<r><t>`, phonetic runs ignored (read them with [`phonetic_data`] instead — they are hints,
|
||||
/// not part of the text). Decodes `_xHHHH_` escapes.
|
||||
pub fn rich_text(e: &El) -> String {
|
||||
let mut s = String::new();
|
||||
for c in &e.children {
|
||||
@@ -82,6 +86,59 @@ pub fn rich_text(e: &El) -> String {
|
||||
decode_xstring(&s)
|
||||
}
|
||||
|
||||
/// The phonetic (furigana) data of a string item: its `<rPh>` runs in document order, and its
|
||||
/// `<phoneticPr>` when present.
|
||||
///
|
||||
/// `base` is the item's base text ([`rich_text`]); the offsets are validated against it and never
|
||||
/// used to index anything. Behaviour reference: ISO/IEC 29500-1 §18.4.6 `rPh` (`sb` and `eb` are
|
||||
/// zero-based offsets into the base text with `sb < eb`, both within its length; child `<t>` holds
|
||||
/// the reading) and §18.4.7 `phoneticPr` — the `PhoneticRun` and `PhoneticProperties` classes on
|
||||
/// Microsoft Learn:
|
||||
/// <https://learn.microsoft.com/en-us/previous-versions/office/developer/office-2010/cc820083(v=office.14)>,
|
||||
/// <https://learn.microsoft.com/en-us/previous-versions/office/developer/office-2010/cc797016(v=office.14)>.
|
||||
/// Damaged runs are dropped rather than repaired: one without both offsets, with `sb >= eb`, wholly
|
||||
/// past the end of the base text, or without a reading; the caller reports how many.
|
||||
pub fn phonetic_data(e: &El, base: &str) -> (Vec<PhoneticRun>, Option<PhoneticProps>) {
|
||||
// The base text's length in UTF-16 code units: the widest reading of "the total length of the
|
||||
// base text" (§18.4.6), so a run is only dropped when it cannot refer to the text at all.
|
||||
let len = u32::try_from(base.encode_utf16().count()).unwrap_or(u32::MAX);
|
||||
let mut runs = Vec::new();
|
||||
for r in e.kids("rPh") {
|
||||
let (Some(sb), Some(eb)) = (r.attr_u32("sb"), r.attr_u32("eb")) else { continue };
|
||||
let start = sb.min(len);
|
||||
let end = eb.min(len);
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
let reading = r.child("t").map(|t| decode_xstring(&t.text)).unwrap_or_default();
|
||||
if reading.is_empty() {
|
||||
continue;
|
||||
}
|
||||
runs.push(PhoneticRun { start, end, text: reading });
|
||||
}
|
||||
(runs, e.child("phoneticPr").map(phonetic_props))
|
||||
}
|
||||
|
||||
/// `<phoneticPr>` attributes (ISO/IEC 29500-1 §18.4.7 `CT_PhoneticPr`: `fontId` is required,
|
||||
/// `type`, `alignment`). `type` and `alignment` are kept only when they are values §18.18.57
|
||||
/// `ST_PhoneticType` / §18.18.56 `ST_PhoneticAlignment` define, so a damaged file cannot make the
|
||||
/// saved file invalid; a missing or unusable `fontId` becomes 0, the Normal style's font, which an
|
||||
/// out-of-range index falls back to anyway.
|
||||
/// `ST_PhoneticType` (ISO/IEC 29500-1 §18.18.57), as `phoneticPr/@type` may write them.
|
||||
const PHONETIC_KINDS: &[&str] = &["halfwidthKatakana", "fullwidthKatakana", "Hiragana", "noConversion"];
|
||||
/// `ST_PhoneticAlignment` (ISO/IEC 29500-1 §18.18.56), as `phoneticPr/@alignment` may write them.
|
||||
const PHONETIC_ALIGNMENTS: &[&str] = &["noControl", "left", "center", "distributed"];
|
||||
|
||||
fn phonetic_props(e: &El) -> PhoneticProps {
|
||||
const KINDS: &[&str] = PHONETIC_KINDS;
|
||||
const ALIGNMENTS: &[&str] = PHONETIC_ALIGNMENTS;
|
||||
PhoneticProps {
|
||||
font_id: e.attr_u32("fontId").unwrap_or(0),
|
||||
kind: e.attr("type").map(str::trim).filter(|v| KINDS.contains(v)).map(str::to_string),
|
||||
alignment: e.attr("alignment").map(str::trim).filter(|v| ALIGNMENTS.contains(v)).map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts input bytes to UTF-8 XML text (handles BOMs and UTF-16).
|
||||
pub(crate) fn to_utf8(xml: &[u8]) -> std::borrow::Cow<'_, [u8]> {
|
||||
if let Some(rest) = xml.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||
@@ -337,6 +394,43 @@ pub fn t_el(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The contents of a shared string item (`<si>`): the base text, then — when the string has
|
||||
/// phonetic (furigana) data — its `<rPh sb="…" eb="…"><t>…</t></rPh>` runs in document order and
|
||||
/// its `<phoneticPr …/>`. 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: `<phoneticPr>` is a valid child of `<si>` 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 };
|
||||
// The model is public and restored from GridCraft JSON too, so the reader's checks are not the
|
||||
// only way data gets here: validate again and write only what the schema allows (§18.4.6: an
|
||||
// `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);
|
||||
for r in &p.runs {
|
||||
let start = r.start.min(len);
|
||||
let end = r.end.min(len);
|
||||
if start >= end || r.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let _ = write!(s, "<rPh sb=\"{start}\" eb=\"{end}\">{}</rPh>", t_el(&r.text));
|
||||
}
|
||||
if let Some(pr) = &p.props {
|
||||
let _ = write!(s, "<phoneticPr fontId=\"{}\"", pr.font_id);
|
||||
if let Some(kind) = pr.kind.as_deref().filter(|kind| PHONETIC_KINDS.contains(kind)) {
|
||||
let _ = write!(s, " type=\"{}\"", esc_attr(kind));
|
||||
}
|
||||
if let Some(alignment) = pr.alignment.as_deref().filter(|a| PHONETIC_ALIGNMENTS.contains(a)) {
|
||||
let _ = write!(s, " alignment=\"{}\"", esc_attr(alignment));
|
||||
}
|
||||
s.push_str("/>");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Formats a number for an XML attribute/value (shortest round-trip form).
|
||||
pub fn num(n: f64) -> String {
|
||||
if !n.is_finite() {
|
||||
@@ -352,6 +446,57 @@ pub fn num(n: f64) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn si_without_phonetics_is_written_exactly_as_before() {
|
||||
for s in ["plain", " leading ", "a & <b>", "line\nbreak", "日本語", "x_x0041_"] {
|
||||
assert_eq!(si_body(s, None), t_el(s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phonetic_data_validates_offsets() {
|
||||
let x = r#"<si><t>課きく</t><rPh sb="0" eb="1"><t>カ</t></rPh><rPh sb="2" eb="9"><t>ク</t></rPh><rPh sb="2" eb="2"><t>x</t></rPh><rPh sb="1"/></si>"#;
|
||||
let si = parse(x.as_bytes()).unwrap();
|
||||
let (runs, props) = phonetic_data(&si, &rich_text(&si));
|
||||
assert_eq!(runs, vec![PhoneticRun { start: 0, end: 1, text: "カ".into() }, PhoneticRun { start: 2, end: 3, text: "ク".into() }]);
|
||||
assert!(props.is_none());
|
||||
}
|
||||
|
||||
/// The write path validates too: a `PhoneticString` that came from JSON or an API caller (not
|
||||
/// from this reader) is written only where the schema allows it — an `sb` before `eb`, both
|
||||
/// inside the base text, a reading that isn't empty, and known `type`/`alignment` values.
|
||||
#[test]
|
||||
fn si_body_drops_damaged_phonetic_data() {
|
||||
// 課きく is three UTF-16 units.
|
||||
let text = "課きく";
|
||||
let damaged = PhoneticString {
|
||||
text: text.into(),
|
||||
runs: vec![
|
||||
PhoneticRun { start: 8, end: 9, text: "x".into() }, // past the text
|
||||
PhoneticRun { start: 2, end: 2, text: "x".into() }, // empty range
|
||||
PhoneticRun { start: 0, end: 1, text: String::new() }, // no reading
|
||||
PhoneticRun { start: 1, end: 3, text: "く".into() }, // valid, clamped end
|
||||
],
|
||||
props: Some(PhoneticProps { font_id: 7, kind: Some("bogus".into()), alignment: Some("center".into()) }),
|
||||
};
|
||||
let s = si_body(text, Some(&damaged));
|
||||
assert_eq!(s, "<t>課きく</t><rPh sb=\"1\" eb=\"3\"><t>く</t></rPh><phoneticPr fontId=\"7\" alignment=\"center\"/>");
|
||||
// With no run left, valid properties are still written: `<phoneticPr>` is a legal child of
|
||||
// `<si>` 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>課きく</t><phoneticPr fontId=\"7\" alignment=\"center\"/>");
|
||||
// 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(),
|
||||
runs: vec![PhoneticRun { start: 0, end: 1, text: "カ".into() }],
|
||||
props: Some(PhoneticProps { font_id: 1, kind: Some("Hiragana".into()), alignment: None }),
|
||||
};
|
||||
assert_eq!(si_body(text, Some(&known)), "<t>課きく</t><rPh sb=\"0\" eb=\"1\"><t>カ</t></rPh><phoneticPr fontId=\"1\" type=\"Hiragana\"/>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_basic() {
|
||||
let x = br#"<?xml version="1.0"?><a:root xmlns:a="x" k="v & w"><b>t < uA</b><c/><![CDATA[<raw>]]></a:root>"#;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; XLSX export keeps a picture shared by several sheets as one media part (#985); XLSX keeps the workbook's show-objects setting, `workbookPr showObjects`) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; Japanese phonetic runs (ふりがな) in XLSX shared strings survive open and save; HTML export keeps right-to-left; XLSX export keeps a picture shared by several sheets as one media part (#985); XLSX keeps the workbook's show-objects setting, `workbookPr showObjects`) · **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,9 +22,9 @@ 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, 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, show objects (`workbookPr showObjects`). **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) A picture used on several sheets is written once (#985). | `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, show objects (`workbookPr showObjects`). **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) A picture used on several sheets is written once (#985). | `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, 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, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings, show objects (`workbookPr showObjects`). **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) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.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: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` 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, show objects (`workbookPr showObjects`). **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) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.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: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` 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, 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, show objects (`workbookPr showObjects`). **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) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.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: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` 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, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings, show objects (`workbookPr showObjects`). **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) A picture used on several sheets is written once (#985). | `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, 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` |
|
||||
@@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `<rPh sb="…" eb="…">` with `<phoneticPr>`) 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 `<phoneticPr>` without runs is kept on its own, and when duplicate `<si>` 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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (defined-name length limit counts characters, so long Arabic names are accepted; Table names are limited to 255 characters; right-to-left sheets in `.xls` files open right-to-left; text boxes and shapes keep right-to-left paragraph direction through XLSX save; Save as Web Page and copied HTML keep right-to-left; French and Serbian `context|label` translations no longer show the context) · **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,
|
||||
@@ -56,7 +56,7 @@ separators, number/date formats, machine translation with agent review, and scri
|
||||
| French | fr | 0 (0%) | no | Latin: yes | no | none | 6–10 h |
|
||||
| Portuguese | pt-BR | 923 (~42%) | dialogs and tooltips (dialogs take effect with #669) | Latin: yes | no | menus, File view, dialogs | 2–4 h |
|
||||
| Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h |
|
||||
| Japanese | ja | 268 (~12%) | no | CJK system-font fallback; IME unverified; no furigana display | no | menus only | 8–12 h |
|
||||
| Japanese | ja | 268 (~12%) | no | CJK system-font fallback; IME unverified; no furigana display, but phonetic runs (ふりがな) survive an XLSX open + save | no | menus only | 8–12 h |
|
||||
| German | de | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h |
|
||||
| Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME unverified | no | menus only | 6–10 h |
|
||||
| Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics: unverified | no | none | 6–10 h |
|
||||
@@ -83,6 +83,7 @@ language (none so far) and of localized function names.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` 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 |
|
||||
|
||||
Reference in New Issue
Block a user