From 71e2135494c1b4f8052f4d873d4318d169580bf3 Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 17:44:40 +0900 Subject: [PATCH 01/12] feat(io): carry phonetic guides through the xlsb shared-string table - decode the BrtSSTItem phonetic tail in src/io/xlsb/reader.cpp: the kana is stored once and each PhRun names where its slice starts, so a run's reading ends where the next run's begins and the last runs to the end - treat an empty run array with a non-empty reading as the whole-string case Excel writes when it elides the runs, the same shape takes when it spans the entire surface text - emit that tail from src/io/xlsb/sst_writer.cpp behind the RichStr phonetic flag bit, writing the concatenated kana followed by a running ichFirst offset per run - key SstBuilder::intern on the guide as well as the text, so two cells that read the same kanji differently no longer collapse onto a single SST entry - pass cell.phonetic_runs into EmitLiteralCellRecord in cell_writer.cpp so a Text payload carries its guide into the table - add tests/unit/io/xlsb_phonetic_test.cpp over the new xlsb_phonetic fixtures, plus sst_writer cases for the interner key and the tail --- src/io/xlsb/cell_writer.cpp | 15 +- src/io/xlsb/reader.cpp | 180 +++++++++++++++++++---- src/io/xlsb/sst_writer.cpp | 99 +++++++++++-- src/io/xlsb/sst_writer.h | 67 ++++++--- tests/CMakeLists.txt | 1 + tests/fixtures/excel/xlsb_phonetic.xlsb | Bin 0 -> 8085 bytes tests/fixtures/excel/xlsb_phonetic.xlsx | Bin 0 -> 8769 bytes tests/unit/io/xlsb/sst_writer_test.cpp | 142 +++++++++++++++--- tests/unit/io/xlsb_phonetic_test.cpp | 182 ++++++++++++++++++++++++ 9 files changed, 608 insertions(+), 78 deletions(-) create mode 100644 tests/fixtures/excel/xlsb_phonetic.xlsb create mode 100644 tests/fixtures/excel/xlsb_phonetic.xlsx create mode 100644 tests/unit/io/xlsb_phonetic_test.cpp diff --git a/src/io/xlsb/cell_writer.cpp b/src/io/xlsb/cell_writer.cpp index defbebad..bfce0041 100644 --- a/src/io/xlsb/cell_writer.cpp +++ b/src/io/xlsb/cell_writer.cpp @@ -17,6 +17,7 @@ #include "io/xlsb/sst_writer.h" #include "parser/ast.h" #include "parser/parser.h" +#include "phonetic.h" #include "utils/arena.h" #include "utils/structured_log.h" #include "value.h" @@ -171,8 +172,12 @@ void EmitArrayFormulaRecord(std::vector& dst, std::uint32_t rw_fir } /// Emits a literal record for `cached`. Used for plain literal cells. +/// +/// `phonetic` travels with a Text payload into the shared-string table: +/// the guide belongs to the string entry rather than the cell record, so +/// two cells reading the same kanji differently need distinct entries. void EmitLiteralCellRecord(std::vector& dst, std::uint32_t col, std::uint32_t xf_index, - const Value& cached, SstBuilder& sst) { + const Value& cached, const std::vector& phonetic, SstBuilder& sst) { switch (cached.kind()) { case ValueKind::Blank: { std::vector p; @@ -208,7 +213,7 @@ void EmitLiteralCellRecord(std::vector& dst, std::uint32_t col, st return; } case ValueKind::Text: { - const std::uint32_t idx = sst.intern(cached.as_text()); + const std::uint32_t idx = sst.intern(cached.as_text(), phonetic); std::vector p; EmitCellHeader(p, col, xf_index); emit_u32(p, idx); @@ -253,14 +258,14 @@ Expected emit_cell(std::vector& dst, const Cell& cell if (downgraded_formula_count != nullptr) { ++*downgraded_formula_count; } - EmitLiteralCellRecord(dst, col, cell.xf_index, cell.cached_value, sst); + EmitLiteralCellRecord(dst, col, cell.xf_index, cell.cached_value, cell.phonetic_runs, sst); return Expected::Ok(); } EmitFormulaCellRecord(dst, col, cell.xf_index, cell.cached_value, formula_or.value()); return Expected::Ok(); } - EmitLiteralCellRecord(dst, col, cell.xf_index, cell.cached_value, sst); + EmitLiteralCellRecord(dst, col, cell.xf_index, cell.cached_value, cell.phonetic_runs, sst); return Expected::Ok(); } @@ -283,7 +288,7 @@ Expected emit_array_anchor(std::vector& dst, const Ce if (downgraded_to_literal != nullptr) { *downgraded_to_literal = true; } - EmitLiteralCellRecord(dst, col, cell.xf_index, anchor_value, sst); + EmitLiteralCellRecord(dst, col, cell.xf_index, anchor_value, cell.phonetic_runs, sst); return Expected::Ok(); } // The anchor's own cell record is a PtgExp shell typed by the spilled diff --git a/src/io/xlsb/reader.cpp b/src/io/xlsb/reader.cpp index c2676c9b..b9f70b40 100644 --- a/src/io/xlsb/reader.cpp +++ b/src/io/xlsb/reader.cpp @@ -14,6 +14,7 @@ #include "io/xlsb/reader.h" #include +#include #include #include #include @@ -29,6 +30,8 @@ #include #include +#include "eval/text_ops.h" +#include "eval/utf8_length.h" #include "io/array_anchor_budget.h" #include "io/default_content_type.h" #include "io/ooxml/package_validator.h" @@ -46,6 +49,7 @@ #include "io/zip_reader.h" #include "parser/ast.h" #include "parser/ast_format.h" +#include "phonetic.h" #include "pivot/pivot_cache.h" #include "pivot/pivot_index.h" #include "pivot/pivot_table.h" @@ -733,12 +737,101 @@ Expected, Error> DecodeExternSheet(const std::vector return ranges; } +/// `RichStr` flag bits ([MS-XLSB] §2.5.87): rich-text runs follow the +/// string when the first is set, a phonetic guide when the second is. +constexpr std::uint8_t kRichStrRichRuns = 0x01U; +constexpr std::uint8_t kRichStrPhonetic = 0x02U; + +/// One `StrRun` ([MS-XLSB] §2.5.94) is `(u16 ich, u16 ifnt)`. +constexpr std::size_t kStrRunSize = 4U; + +/// Decodes the phonetic tail a `RichStr` carries when `fExtStr` is set, +/// appending one `PhoneticRun` per `PhRun` to `out`. +/// +/// The binary shape stores the kana once, concatenated across every run, +/// and each `PhRun` names where its slice starts rather than carrying the +/// slice: `(u16 ichFirst, u16 ichMom, u16 cchMom)` is the start of this +/// run's kana inside the concatenation, the surface-text offset it reads, +/// and how many surface characters it covers. A run's kana therefore ends +/// where the next run's begins, and the last run's runs to the end. All +/// offsets are UTF-16 code units, which is what `PhoneticRun` uses too. +/// +/// Excel elides the runs entirely for a whole-string reading (the +/// concatenation is then simply the whole annotation), so an empty run +/// array with a non-empty phonetic string is the single-run case rather +/// than an absent one -- the same shape `` takes in +/// OOXML. +/// +/// The trailing `(u16 ifnt, u16 flags)` -- phonetic font, plus the +/// annotation type and alignment `` carries in OOXML -- is +/// read past but not modelled: `PhoneticRun` holds the reading, not how +/// Excel renders it. The OOXML reader drops the same element. +Expected DecodePhoneticTail(ByteSpan& cursor, std::string_view surface, std::vector& out) { + auto phonetic_or = read_xlwidestring(cursor); + if (!phonetic_or) { + return phonetic_or.error(); + } + const std::string phonetic = std::move(phonetic_or.value()); + auto count_or = read_u32(cursor); + if (!count_or) { + return count_or.error(); + } + const std::uint32_t run_count = count_or.value(); + if (run_count == 0U) { + if (!phonetic.empty()) { + out.push_back(PhoneticRun{0U, eval::utf16_units_in(surface), phonetic}); + } + return {}; + } + + // Each PhRun is three `u16`s; bound the count against the remaining + // payload before reserving so a corrupt count cannot drive a large + // allocation. + constexpr std::size_t kPhRunSize = 6U; + if (static_cast(run_count) > cursor.size / kPhRunSize) { + std::string ctx("context=xlsb.sst run_count="); + ctx.append(std::to_string(run_count)); + ctx.append(" cursor_size=").append(std::to_string(cursor.size)); + return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, "xlsb phonetic run array truncated", std::move(ctx)); + } + std::vector> runs; + runs.reserve(run_count); + for (std::uint32_t i = 0; i < run_count; ++i) { + std::array fields{}; + for (std::uint16_t& field : fields) { + auto field_or = read_u16(cursor); + if (!field_or) { + return field_or.error(); + } + field = field_or.value(); + } + runs.push_back(fields); + } + + const std::uint32_t phonetic_units = eval::utf16_units_in(phonetic); + for (std::size_t i = 0; i < runs.size(); ++i) { + const std::uint32_t kana_start = runs[i][0]; + const std::uint32_t surface_start = runs[i][1]; + const std::uint32_t surface_length = runs[i][2]; + const std::uint32_t kana_end = (i + 1U < runs.size()) ? runs[i + 1U][0] : phonetic_units; + // A backwards or out-of-range slice yields an empty reading rather + // than an error: the surrounding cell is still usable, and the run + // boundaries are Excel's own bookkeeping rather than user data. + const std::uint32_t kana_length = kana_end > kana_start ? kana_end - kana_start : 0U; + out.push_back(PhoneticRun{surface_start, surface_start + surface_length, + eval::utf16_substring(phonetic, kana_start, kana_length)}); + } + return {}; +} + /// Decodes `xl/sharedStrings.bin` into an in-order list of string /// payloads, appending each one into `text_storage` so cells can take -/// non-owning views. Returns the list of `string_view`s parallel to the -/// SST index. -Expected, Error> DecodeSharedStringsBin(const std::vector& body, - std::deque& text_storage) { +/// non-owning views. `out_phonetic` is filled in parallel with `entries` +/// -- one (possibly empty) run list per SST index, exactly as the OOXML +/// reader's `phonetic_for_entries` is. +Expected, Error> DecodeSharedStringsBin( + const std::vector& body, std::deque& text_storage, + std::vector>& out_phonetic) { std::vector entries; ByteSpan cursor{body.data(), body.size()}; while (cursor.size > 0) { @@ -750,22 +843,48 @@ Expected, Error> DecodeSharedStringsBin(const std: if (rec.type != static_cast(XlsbRecordType::BrtSSTItem)) { continue; } - // BrtSSTItem ([MS-XLSB] §2.4.293): - // richStr : RichStr (we only need the string — first byte is a - // flags byte; rich-format runs follow when fRichStr is - // set, but those are not decoded here). + // BrtSSTItem ([MS-XLSB] §2.4.293) is a `RichStr`: a flags byte, the + // string, then the optional rich-format runs and phonetic guide the + // flags announce. ByteSpan p = rec.payload; auto flags_or = read_u8(p); if (!flags_or) { return flags_or.error(); } - (void)flags_or.value(); + const std::uint8_t flags = flags_or.value(); auto str_or = read_xlwidestring(p); if (!str_or) { return str_or.error(); } text_storage.push_back(std::move(str_or.value())); entries.push_back(text_storage.back()); + out_phonetic.emplace_back(); + + if ((flags & kRichStrPhonetic) == 0U) { + continue; + } + // The rich-format runs sit between the string and the phonetic tail, + // so they have to be stepped over even though the reader models + // plain text only. + if ((flags & kRichStrRichRuns) != 0U) { + auto run_count_or = read_u32(p); + if (!run_count_or) { + return run_count_or.error(); + } + const std::uint32_t rich_runs = run_count_or.value(); + if (static_cast(rich_runs) > p.size / kStrRunSize) { + std::string ctx("context=xlsb.sst rich_runs="); + ctx.append(std::to_string(rich_runs)); + ctx.append(" cursor_size=").append(std::to_string(p.size)); + return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, "xlsb rich-text run array truncated", + std::move(ctx)); + } + p.data += static_cast(rich_runs) * kStrRunSize; + p.size -= static_cast(rich_runs) * kStrRunSize; + } + if (auto decoded = DecodePhoneticTail(p, entries.back(), out_phonetic.back()); !decoded) { + return decoded.error(); + } } return entries; } @@ -1876,9 +1995,10 @@ Expected RegisterArraySpills(Workbook& wb, std::size_t sheet_index, Expected DispatchSheetRecord( const XlsbRecord& rec, XlsbRecordType type, const std::uint8_t* framed, std::size_t framed_size, SheetDecodeState& state, std::size_t sheet_index, Workbook& wb, const std::vector& sst_entries, - std::deque& text_storage, const std::vector& sheet_names, - const std::vector& name_table, const std::vector& sheet_ranges, - const XlsbExternalBooks& external_books, std::uint32_t* undecoded_formula_count) { + const std::vector>& sst_phonetic, std::deque& text_storage, + const std::vector& sheet_names, const std::vector& name_table, + const std::vector& sheet_ranges, const XlsbExternalBooks& external_books, + std::uint32_t* undecoded_formula_count) { switch (type) { case XlsbRecordType::BrtBeginWsView: { // BrtBeginWsView ([MS-XLSB] §2.4.141) stores the SheetView fields @@ -2301,6 +2421,14 @@ Expected DispatchSheetRecord( wb.sheet(sheet_index) .set_cell_cached_value_borrowed(state.current_row, col_or.value().col, Value::text(sst_entries[idx_or.value()])); + // Attached after the value, mirroring the OOXML reader: every + // value-mutating setter clears the annotation, so the order is + // load-bearing. Skipped when the entry carries no guide so an + // unannotated cell keeps its default-constructed run vector. + if (idx_or.value() < sst_phonetic.size() && !sst_phonetic[idx_or.value()].empty()) { + wb.sheet(sheet_index) + .set_cell_phonetic_runs(state.current_row, col_or.value().col, sst_phonetic[idx_or.value()]); + } if (auto r = ApplyXfIndex(wb, sheet_index, state.current_row, col_or.value().col, col_or.value().xf_index); !r) { return r.error(); } @@ -2541,14 +2669,12 @@ Expected DispatchSheetRecord( /// `wb.sheet(sheet_index)`. SST indices are resolved against /// `sst_entries`; out-of-range indices are returned as /// `kIoXlsbCorrupt`. -Expected DecodeSheetBin(const std::vector& body, std::size_t sheet_index, - Workbook& wb, const std::vector& sst_entries, - std::deque& text_storage, - const std::vector& sheet_names, - const std::vector& name_table, - const std::vector& sheet_ranges, - const XlsbExternalBooks& external_books, - std::uint32_t* undecoded_formula_count) { +Expected DecodeSheetBin( + const std::vector& body, std::size_t sheet_index, Workbook& wb, + const std::vector& sst_entries, const std::vector>& sst_phonetic, + std::deque& text_storage, const std::vector& sheet_names, + const std::vector& name_table, const std::vector& sheet_ranges, + const XlsbExternalBooks& external_books, std::uint32_t* undecoded_formula_count) { SheetDecodeState state; ByteSpan cursor{body.data(), body.size()}; while (cursor.size > 0) { @@ -2565,9 +2691,9 @@ Expected DecodeSheetBin(const std::vector } // Every record resolves to a disposition; the result is consumed rather // than discarded so that no record can pass through unclassified. - auto disposition_or = - DispatchSheetRecord(rec, type, framed, framed_size, state, sheet_index, wb, sst_entries, text_storage, - sheet_names, name_table, sheet_ranges, external_books, undecoded_formula_count); + auto disposition_or = DispatchSheetRecord(rec, type, framed, framed_size, state, sheet_index, wb, sst_entries, + sst_phonetic, text_storage, sheet_names, name_table, sheet_ranges, + external_books, undecoded_formula_count); if (!disposition_or) { return disposition_or.error(); } @@ -2755,12 +2881,14 @@ Expected read_xlsb(ByteSpan bytes) { // after the caller moves the workbook out of the read result. std::deque& text_storage = wb.mutable_text_storage(); std::vector sst_entries; + // Parallel to `sst_entries`, one (possibly empty) run list per index. + std::vector> sst_phonetic; if (!wb_rels.sst_path.empty() && zip.has_entry(wb_rels.sst_path)) { auto sst_bytes_or = zip.read_entry(wb_rels.sst_path); if (!sst_bytes_or) { return sst_bytes_or.error(); } - auto sst_or = DecodeSharedStringsBin(sst_bytes_or.value(), text_storage); + auto sst_or = DecodeSharedStringsBin(sst_bytes_or.value(), text_storage, sst_phonetic); if (!sst_or) { return sst_or.error(); } @@ -2811,8 +2939,8 @@ Expected read_xlsb(ByteSpan bytes) { if (!sheet_bytes_or) { return sheet_bytes_or.error(); } - auto state_or = DecodeSheetBin(sheet_bytes_or.value(), i, wb, sst_entries, text_storage, sheet_names, name_table, - sheet_ranges, external_books, &undecoded_formula_count); + auto state_or = DecodeSheetBin(sheet_bytes_or.value(), i, wb, sst_entries, sst_phonetic, text_storage, sheet_names, + name_table, sheet_ranges, external_books, &undecoded_formula_count); if (!state_or) { return state_or.error(); } diff --git a/src/io/xlsb/sst_writer.cpp b/src/io/xlsb/sst_writer.cpp index b2179e9c..56586429 100644 --- a/src/io/xlsb/sst_writer.cpp +++ b/src/io/xlsb/sst_writer.cpp @@ -4,31 +4,104 @@ #include "io/xlsb/sst_writer.h" #include +#include #include #include #include #include +#include "eval/utf8_length.h" #include "io/xlsb/record.h" #include "io/xlsb/record_writer.h" +#include "phonetic.h" #include "utils/error.h" #include "utils/expected.h" namespace formulon { namespace io { namespace xlsb { +namespace { -std::uint32_t SstBuilder::intern(std::string_view text) { - // Hashing keys directly off the `string_view` requires either a - // custom hash type or temporarily materialising a `std::string`. We - // pick the latter for simplicity — interning is O(text-cells) at - // workbook write time, which is dwarfed by the rest of the writer. - std::string key(text); +/// `RichStr` flag bit announcing the phonetic tail ([MS-XLSB] §2.5.87). +constexpr std::uint8_t kRichStrPhonetic = 0x02U; + +/// The phonetic tail's closing `(u16 ifnt, u16 flags)`. +/// +/// `ifnt = 0` names the workbook's default font, and the flags word +/// packs the annotation type in bits 0-1 and its alignment in bits 2-3 +/// over a constant `0x30`. `0x0030` is therefore +/// `halfwidthKatakana` / `noControl` — the pair Excel itself writes for +/// a guide that arrived without a ``, which is exactly what +/// the OOXML writer produces. +constexpr std::uint16_t kPhoneticDefaultFont = 0U; +constexpr std::uint16_t kPhoneticDefaultFlags = 0x0030U; + +/// Builds the interner key for one payload. +/// +/// Length-prefixes every field for the same reason the OOXML shared +/// strings writer does: without it, adjacent fields could be re-cut into +/// the same byte sequence and collide two distinct annotations onto one +/// entry. +std::string key_for(std::string_view text, const std::vector& phonetic) { + std::string key; + key.reserve(text.size() + 32U + phonetic.size() * 16U); + key.append(std::to_string(text.size())); + key.push_back(':'); + key.append(text); + for (const PhoneticRun& run : phonetic) { + key.push_back(';'); + key.append(std::to_string(run.sb)); + key.push_back(','); + key.append(std::to_string(run.eb)); + key.push_back(','); + key.append(std::to_string(run.text.size())); + key.push_back(':'); + key.append(run.text); + } + return key; +} + +/// Narrows a UTF-16 offset to the `u16` the record format stores. +/// +/// Excel's own string ceiling is 32767 characters, so a well-formed +/// annotation always fits; clamping rather than truncating keeps a +/// hostile in-memory workbook from wrapping an offset around into a +/// different, valid-looking span. +std::uint16_t narrow_offset(std::uint32_t units) { + constexpr std::uint32_t kMax = std::numeric_limits::max(); + return static_cast(units < kMax ? units : kMax); +} + +/// Appends the phonetic tail for `runs` to `payload`. +void emit_phonetic_tail(std::vector& payload, const std::vector& runs) { + std::string kana; + for (const PhoneticRun& run : runs) { + kana.append(run.text); + } + emit_xlwidestring(payload, kana); + emit_u32(payload, static_cast(runs.size())); + // `ichFirst` is a running offset into the concatenation above, so the + // slice boundaries are recovered on read from consecutive runs. + std::uint32_t kana_offset = 0; + for (const PhoneticRun& run : runs) { + emit_u16(payload, narrow_offset(kana_offset)); + emit_u16(payload, narrow_offset(run.sb)); + emit_u16(payload, narrow_offset(run.eb > run.sb ? run.eb - run.sb : 0U)); + kana_offset += eval::utf16_units_in(run.text); + } + emit_u16(payload, kPhoneticDefaultFont); + emit_u16(payload, kPhoneticDefaultFlags); +} + +} // namespace + +std::uint32_t SstBuilder::intern(std::string_view text, const std::vector& phonetic) { + std::string key = key_for(text, phonetic); if (auto it = index_.find(key); it != index_.end()) { return it->second; } const std::uint32_t idx = static_cast(entries_.size()); - entries_.push_back(key); + entries_.push_back(SstEntry{std::string(text), phonetic}); index_.emplace(std::move(key), idx); return idx; } @@ -52,11 +125,15 @@ Expected, Error> emit_sst(const SstBuilder& sst) { emit_record(body, static_cast(XlsbRecordType::BrtBeginSst), p); } - // One BrtSSTItem per entry: (u8 flags=0, XLWideString). - for (const std::string& s : sst.entries()) { + // One BrtSSTItem per entry: (u8 flags, XLWideString[, phonetic tail]). + for (const SstEntry& entry : sst.entries()) { std::vector p; - emit_u8(p, 0); // flags: not rich, no phonetic guide. - emit_xlwidestring(p, s); + const bool has_phonetic = !entry.phonetic.empty(); + emit_u8(p, has_phonetic ? kRichStrPhonetic : 0U); + emit_xlwidestring(p, entry.text); + if (has_phonetic) { + emit_phonetic_tail(p, entry.phonetic); + } emit_record(body, static_cast(XlsbRecordType::BrtSSTItem), p); } diff --git a/src/io/xlsb/sst_writer.h b/src/io/xlsb/sst_writer.h index b00aabba..426cc9fc 100644 --- a/src/io/xlsb/sst_writer.h +++ b/src/io/xlsb/sst_writer.h @@ -6,12 +6,19 @@ // pointing at the SST index. `emit_sst` packages the interned strings // as a sequence of `BrtBeginSst | BrtSSTItem* | BrtEndSst` records. // -// The writer emits plain (non-rich) entries only — every `BrtSSTItem` -// is `flags=0x00` followed by an `XLWideString`. This is the minimum -// shape `read_sst` recognises, and matches the reader's expectations. +// Interning is keyed on the text AND its phonetic guide, matching the +// OOXML shared-strings writer: two cells reading the same kanji with +// different furigana are different `` entries, and merging them +// would move one cell's reading onto the other. +// +// The writer emits plain (non-rich) entries only: `flags` carries the +// phonetic bit when the entry has a guide and is otherwise `0x00`, and +// rich-format runs are never produced. Both shapes are what `read_sst` +// recognises. // // Design references: -// * [MS-XLSB] §2.4.293 (BrtSSTItem) and §2.4.290 (BrtBeginSst) +// * [MS-XLSB] §2.4.293 (BrtSSTItem), §2.4.290 (BrtBeginSst), +// §2.5.87 (RichStr) #ifndef FORMULON_IO_XLSB_SST_WRITER_H_ #define FORMULON_IO_XLSB_SST_WRITER_H_ @@ -22,6 +29,7 @@ #include #include +#include "phonetic.h" #include "utils/error.h" #include "utils/expected.h" @@ -29,33 +37,42 @@ namespace formulon { namespace io { namespace xlsb { +/// One interned shared string: the text plus the phonetic guide that +/// travels with it. +struct SstEntry { + std::string text; + std::vector phonetic; +}; + /// Interns text payloads for `xl/sharedStrings.bin`. /// -/// Identical strings dedupe to the same index. The hash table only -/// holds keys (the strings themselves live in `entries_`), so callers -/// can observe insertion order via `entries()`. +/// Entries with identical text and identical phonetic guides dedupe to +/// the same index. The hash table only holds keys (the payloads +/// themselves live in `entries_`), so callers can observe insertion +/// order via `entries()`. class SstBuilder { public: - /// Interns `text` and returns the assigned 0-based index. The first - /// time a string is seen the index equals the prior `size()`. - std::uint32_t intern(std::string_view text); + /// Interns `text` carrying `phonetic` and returns the assigned 0-based + /// index. The first time a payload is seen the index equals the prior + /// `size()`. + std::uint32_t intern(std::string_view text, const std::vector& phonetic); - /// Number of distinct strings interned so far. Equals + /// Number of distinct payloads interned so far. Equals /// `entries().size()`. std::uint32_t size() const noexcept { return static_cast(entries_.size()); } - /// Returns `true` when no string has been interned yet. Callers + /// Returns `true` when nothing has been interned yet. Callers /// (notably `write_xlsb`) gate emission of the SST part on this /// predicate so a workbook with no text cells produces no SST /// part / Override. bool empty() const noexcept { return entries_.empty(); } - /// Read-only access to the interned strings in insertion order. + /// Read-only access to the interned payloads in insertion order. /// Stable across subsequent `intern` calls (entries are append-only). - const std::vector& entries() const noexcept { return entries_; } + const std::vector& entries() const noexcept { return entries_; } private: - std::vector entries_; + std::vector entries_; std::unordered_map index_; }; @@ -65,16 +82,26 @@ class SstBuilder { /// * `BrtBeginSst` payload — `(u32 cstTotal, u32 cstUnique)`. We /// emit `cstTotal == cstUnique == sst.size()` because the writer /// does not track multiplicity (every cell that referenced the -/// same string was redirected to the same SST index by the +/// same payload was redirected to the same SST index by the /// interner). -/// * One `BrtSSTItem` per entry: `(u8 flags=0x00, XLWideString)`. +/// * One `BrtSSTItem` per entry: `(u8 flags, XLWideString)`, followed +/// by the phonetic tail when the entry carries a guide. /// * `BrtEndSst` (empty payload). /// +/// The phonetic tail stores the kana once, concatenated across every +/// run, then `(u32 count)` and one `(u16 ichFirst, u16 ichMom, u16 +/// cchMom)` per run: where this run's kana starts inside the +/// concatenation, which surface-text offset it reads, and how many +/// surface characters it covers. A run's kana ends where the next one's +/// starts. The closing `(u16 ifnt, u16 flags)` is the phonetic font and +/// the annotation type / alignment — emitted as font 0 with Excel's own +/// defaults, since `PhoneticRun` models the reading rather than how it +/// is rendered, and the OOXML writer likewise emits no ``. +/// /// The returned bytes are a complete XLSB part body ready to be /// stored as `xl/sharedStrings.bin`. Returns no errors today; the -/// `Expected` shape is preserved for forward compatibility (rich- -/// text + phonetic-guide emission may surface allocation failures -/// in the future). +/// `Expected` shape is preserved for forward compatibility (rich-text +/// emission may surface allocation failures in the future). Expected, Error> emit_sst(const SstBuilder& sst); } // namespace xlsb diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c31ec3b5..bba0319a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -88,6 +88,7 @@ set(FORMULON_UNIT_TEST_SOURCES unit/io/xlsb/sst_writer_test.cpp unit/io/xlsb/writer_test.cpp unit/io/xlsb_fidelity_test.cpp + unit/io/xlsb_phonetic_test.cpp unit/io/external_link_fixture_test.cpp unit/io/xlsb_pivot_fixture_test.cpp unit/io/xlsb_reader_contract_test.cpp diff --git a/tests/fixtures/excel/xlsb_phonetic.xlsb b/tests/fixtures/excel/xlsb_phonetic.xlsb new file mode 100644 index 0000000000000000000000000000000000000000..fba4fb7c162690c6ecfb6f47254f95d6f1e4138a GIT binary patch literal 8085 zcmeHMg-q(M*`i2n%Dx59mU~P>oY)Pt2Usy>jLv;< z)Z0aUpy|2P+AC#;7Zl_%aT=0JVV5ezSE{X;*@t zwlOeoU+wi8ZpY>0eX19gI93YA?X6uoMBVE%9VXgin|kzNqZMmzE~PQONyiH>N(k$fJ zd3_B8sQkiQN#E7ZQ&>1Ez=(+kW3GWc#L9t#{oD7yjQt;`=|3JlJa!1iSoE$v*#^nA zi%&MP$XV6(RJrrm%vekGHe}TiIdtID6BC94-WrO_y`!OwhWUZ9f+j&JmQr6QRbQi5 zotl#x`y&md>EKSO!C}To7D0P|z2{zUpKr#fQHC}`RUrgKEFT{n4h5^8im0-7RQAO` zY?ERN%92!mmKpiHgTCy8G zu`!8OWRZG`-{oZ%s1-3srzoezsg)FH_!5-IyU;7a!~{wsNN^YzMjb=eb+Y5l8WFq%RiVn=y?K+1U*VTgE_7amS{);$Z#+R{*`O~>BhhF4jfD=z#RR*eUzxR ze&b!^x0P~_cL$7|l~=DITQBj&JGxe}n!nJfa-^Ao+1F{*Tb?5!ovr!wa0R-Nv)2Jf zTiwdSSV4a48J^WP8A92_*Z>gOXy^5=?>z)sT&v#gVknRE%Q4({&Qvj)B20Gu?Kv+@ z$WLBYaAqpU33uo__?E}`@w?Aw>aKrj40pg}91`ptWDW+oqh*r@<$e`&DcJt7#GiCZ zdDkF1y2Ch;l8Tvv%ml$e(B1NGci&+#{kl-6F9Q^%w~$b)cS``PGnO)vdbfxAu;;yU z*cva>73!MJeV#Q(sf#wQwArsZ8#}f@cj6W9z*UdKW;kGRLkt^#5YNFJ z0&#TU_&#ubW1vU$5N0bFyQ_KHCOSW6!R~1`15yZ5fh*wwxx7SM&*D-{s@}G2145d0 zP~QE`VOz7!ivdSxxhd7vY=Xcy2fcoAcLP`{y$+M`62_J0SmYx+qd$cRi$RY=Q!B_H z`6FcMP!|aIXXW$K%r$y@wY;C9NhxbJL9FuMD{r^f#mMboju1=LlozbQ7mRmdTb@!| zTEmbNtg%A5cemH{tL+PA_TUm4gu$U1h70o4IS~SygL6ExC{12qdilqeE<4ONn78{bLhTBiC8rsD8vpT1v`NfbV%#m8Eu>^QJTKS zXV!zG$SAdnMC)3i>|k4|KA+8rJ{+3z?G8rxYK{#D?#@BzL|Iwc)zjxtv(uhuzHk9r zMy=~DFY;*%Daq7OL7*dg-!j*z{6V2?Yod*psexF7%Ph{=yr+kCW&P@J~FfkroepVM%p~Kz4N- zboME83>gto-$z1jrBctaam4Bak0WL>&olFYx1XHKMuwKi@TDXv_3T51!aAEY!-mNc zFyW|t@KJCdAr_)5vZbeC>E@m*5p?)!m>n&(jRC z%ib@i2F+iW!xm=`8iKv92W%IvFEehdVd+>Khq`#3om{#sHeIaRUPGZogga^YilO^l zA)GEs`vFI>(r>^J)Ht#9T$ecFpkQKL##{4*6F?l&mNr(RwOZVSoV-fWv6)<7v9r>oEtGr9n!+VLMCok zZJ4q;sg$TXTXb%fZeU;oR={aw-{bTqC z8|bD<4k`U%_#bra&K+)r4178=+{7^|3I1GJsUMu(5l9gu$Z90EZib!R zEd*&p6f@nxj5%FG!ryZ`we-bsSxiON7x!gRo4FPI6_Tv&@dclU*7F{V@V%)7i0~5= zlSw7Hl93TT_V8iRwxnz{-u5XsXkmbOB7NY1KyF4rv<7B5+naQ1o`>tt!-bsH4JuP; zqL2k7oc)QcurgLB9pk@cDBfX&fJqOOQpVhv6j}G$Rgu{URmo z_9mW@(&RYzxZ6ZcYFM1@@w=m+tdse5v7OL&FM;~NMjX8!!~Dil5h(m{JTe_Z6?rq~JeQr*YVZ_K#AF`cQ?PhL0cYX1}bWz{a-29}P0t+JoEuu?OGLE9mBQ?-; ztR3u58%2Dq8YO%da*}@0kCI1Qo_!WvoO2s9im#FyT~5epH{{5nh0b4IYvVCI@U3i2 z>{GQnp5ju1sH*Url1(h}tgiXbTGRZW3i7M9Kb*%^P8{J%*;-X{YFgXs?TwiVcjpsy z9bL6kr{}F(=tiU$bIM2cAeaphw$Q_iu5h7NP3ospEsr)YL9(TyRvr|^3`+=*eo$mk zZ}A=<%yx<@;sV;QT5z`1xQ{&8`vw2maq@ATGjyiDH~IG1*hTmgDF8X!r}&m-kKL_= zwK^9!dne&>o8>2d{`R4mEOGu7;THR@$ja?nj=dwZ!?nd_I%J8Y$GJ@79@vXyq!AlI znsbwrRBzMB%ys+md7arGMtpNo&7}tj5B2%(wG-B(upd}T`LL-}%E$*wa zp9crctT1nzUrR|Xjc3tW)Z1UayKP<)8+>Qd_w|J_JyAEtsWB~*zCdI6rh^E9bTg4m zZXT2^*ENl`q?!5P!Y_|b$G868^5?eXVaPeIc<=;i)A4S&o$K=@%SS`V4Oo3V(|4|F zm>(ouhH>Poqi;Qxio=G`=OqpB=@Pt1jjZdlR;lg#fR4zPL|5z|7f|v>I)`+fw@mM4 z`$C0#WptG;wWUpHe#}FY>8e*;#(_>0Qx)r%MIJsAGab?6q4fHjyF00~+6?utWvDsu z9`Db2o)%vsSfuozTf`pNP$t|5k@1DSf94~gQm+`#n0tAyy^}D1NMo3WiMXe6E^Y&| zFf?znVU5q5=sQ}ex8`>P_T*q$@5X+5JI@;kB8e9fQjE7{u&U{0XPOL=xzOd$!@a+n z=(-y{4VlHTO3U$F{bV-pEg{dP)nG4f`PK~8)Rn17z>2*DD1yb@$fUTeD=~oH=)ztS zNoZZwx&E@^^8{R(LN~OiwJJGp$zn2e|6ELZcs5eq?3Dw@a1*|krw@x+a%|0oeA$P> z6JPDJH9ku}wQ4n}+<^8gwNK*~1YRmfjh+H$!>J?kK<%bRkSN!NkNj)#TvlW(9HhCY5s4`s9G(*d_BMPRhdW*vBO@ zoKnWO93ql(q5{rx?Q&i@$>WuZs8OP!4JrIc`J;VLA|uJNd>e)CNEQ| zgYjnf8gwFpYM=KyUi)&j5V*X#d2!rcm05f7Z?lheAHE( zz-n(-Fk6KJRp7JS54_PIG!To?Ye-A@0o*LBVE>Nx5sLpWMkW%h@= z&QiUT)C!v+hXoAnUw8ejDElF?|Ekh{3hnPTn$=LN1*|qfT)N2Id+cbCdd%4s{9wv3 z+H#8$Tp8cNn-Bq!J3N6}?@S#eo{42$RRw6wGloT3Wm;;hbnpugRr>7pO>To2nYuA2 zyG9@PZVgo#71C>Y;A#*M0_B;uBrVI)5MJE5vr?CAgsExKU#VnXCKxALvy(Cy zg@~@4PnzZbQJRPBT~HTnY~tTe4UZbwMPq@DGG4QH(J_snd0N~ zrhImJJZ1+I(7(So!k9-NSz%8u2|Fo&^8k!8hW2jvZA>7p-%E|r*v9~P1gZphu4wN- zRIzA*+stj<0G~RrCo}dC!b&k%BtGO@_4@n&L^YpHa>J&;Fk0fkieM94V-H*lWq05FVTnl#GYubX7Gcs@Sj(|&)4r#8(X+ZJP{}@-9Kk{^MRKagoNTrzkxRcL@U|FX<;Cz5B~zh17H-I+7pjM9%MUDThwf#P+`3W}fpguvJR14} zOaFfeG@NN3p%hr4VZ#`Y{m(!%w6ps+&R{P4aiqq!+RSpI2krsSF#~raQfn9k!4J8r zSe|K?1Ma(Tng|o-%LaCk_CyXqY{ypF-BTL6&B?ST<{+~*Pu>MINjWnPC)iRVRvQiAdIUWG20s#2}GpTQXtVeQxASwkbFN`A;MA? zFX1kWMzAH7(yClwQ3c|jubWm5gImnzB(8veQ^#S$7i&hu_geDTmfDD82YH+8HHMF0 zi}FTPA_m(Q-hLfI9S~Y^kMqL=S*Mey#=}fPHV^vWfsb(6ter~N1U1lrn6_^Ir7iU8 zhngx_$n)nLkBvu*^0dMNCHiA8yU-f2r-@{zdZEr#8n)|CH@bB>tJB}*yzXJTa)$zCC zx3Lb!l7DP`+%&w|`~7LUg81Kt)Zd-po5sKPJ%5_QmRi`}>HpmYy@_+P5B3vD2lc-< z@ngyUxme#sx!FVdi2_0Uj&if3bQ9pFu>1)S1M5lsA~SEA-V{eaO_2%yGQBC4ZX(yn~ Yk-t^s5n!PQ0HDJ@6tL(uBLDX5KX+V*#Q*>R literal 0 HcmV?d00001 diff --git a/tests/fixtures/excel/xlsb_phonetic.xlsx b/tests/fixtures/excel/xlsb_phonetic.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..b1a919d4bb2a6ddf188e6006708ca22af0f40df0 GIT binary patch literal 8769 zcmeHMgFSVoUiO`>EB_Z-^&D`qwfFj_zzxzqS)T2ZQMYaW4VjR zOZOF~${*nfY(cv~Y^vhT?Qc5^O!U&Ntgh2vp97_`$gG7vf(EBNd3XCQ%kAx|pdp?0 zYWQLP-OZW?)IwYxU5L9)w%@7Osd>95U4v~ylTd{f?>@VJz#|*pUIYsVjkgLL= zqt0rqR_k<@0B&@?SHm~@+ys4w8y4L_I6W|nRo^H?tHe)L{vq_B0MZ$4!`|ej@8!{S zFEZ>v<*ZJS%+m0_Uiu?{2I$u@fQn7nC-P!m`$6w^39@X(-knzZ>GQkuBsA@_)TVXm zQPHMPDel;D%;k2aODjJRVuSkRcf)Iho0YdrXuRz6hOn0~WlpY1kBHan_M4@E@MZ=k z@8GSu?sZX5#Fu5gfbHG*bzj~qECAs88Uvv6H?yqN`yy zas012{s-IOUmm?EMoGDi8y|+0y9n++9-WQ`N`c)Zec&dM2mEA~w1iju!Zf!a$^=j8$&Mu!OT0FR|H}o#JI*6wS92yN(`?9wj|d zaKGo;5XDwl^F9jCPCRl%S1Dsc#j-%BqDj zH{K4W_Wg)clbdkyn|1=?q(kPLO${D_NKYF3*ojEcJIKUVRY|jP!Z~%Mv@uLY!Uh+ zETK$@2sOR3<#KayvN3jWu=x?Uax|Vfq;QjZXMDPTews%0HkMmM?WKIa%tW5`rAHAB zcT2&?B)eo6&Fk|LdAa13TpQw(0KBf%DNn@PVa@wP7(!Z>aYi1b#_gIVcv<9>jg4pI zOcc6Z8Z9|mVr@}V3&Xps$}C{J?N!JuBS1y4EzVg!Y2X=R{NNtvs|^XO}P>Mkky7ym)|}l?j<<*`;SsOq&rawU<)) zlGrBMu;?f*hC<$a+MR)hi&qT#!CmxV^_neh{=*CkoZ@axiS7loJUEvWT`HRN|M0khS%Ao1}RgtJVZdBKuURf2}uQN|<+)c3oD%y-tMOl|Yg5pS1NCk4@M| zU8zG+mkNM`hNA8Ny7#}5_fJ=khRVE9NSTZ$_g@wf}u9fam4 z5f1zl>m)M+pU=T8HCx>?oo`?+3qyQF$eZ}a()*)mQ(C@N_5?D`U2Fs?98&n@1+N_lOvN`yW7=C%$? z1g9maBi4^RFQ$%lt-3U-!Yx*K@MVW+c2?%#+}h*QBc(4hwMjbbxPna}SJ9SM*Ku3+Lg-ca zwHV(8OIC<-#|y-*`}+#-S{c}kvpCmy;qiDqJzm`zNYK%CsmQL^IT3xzw_E%jGm^+64+DRa zL_eI!ukb>$?L0AIG#zwVj%)hYu(_o<(%pd# z0Fd4JvE}{SmA4lklD$c!k5*oy-S&8+wr)_#md_o&{4hNR(tsMBCcC2Au69gyS(&V;9R#wao|)e&^7Rb#pae z(yGlC*(NmZ$_VPp!)w_Lw7GTCbpA!PZN1A+4^f2ie{bV9oE07)hLH{%Ov7!QCy+DL{ZM~gLSkyYA>OH=0C#U=(xKGQ z$N6&4;k&l>cdlzF;o2RwBsQ=;9ZOjGl$-CxsISj4-T|`dYQDbIXREW9_v$dC$?Nji z*C)e6$2y4B;Bsa2`>faHMs?H0_l!m-OC+ur1g31M$Ru*-)*5TnJX!>+?bVXT)Kg=S zk+u9Bj*F;9l!hrFhVItdY@bP%&C%Xadk_*Lv!6IGJ%2+!MCwe)M>b?}T|9fF ze3K#VCg-Vs*5JJjlMQ}&U^>KK17~ZP;-cJ%7*kuX>Va3}S!!*%hunZ269--&%@RwX zuDMr8!p$zuI1-wQ@)n}9P<hqq^9ixM z(;f-hYxh3gkE;6b~+>er*x-mLlv~a#ZuC z)>`^ao$$z2uhu*ZvpM1*a^OOTRV)k0B^K)W3B36tuy`UDnJa3b-MTkBS;V80z-3?0 zDG8f`V1dr{Ce5Kq0+PhsB#J}+?9*TgP%IWJ|GNzb;9|D8tdwFem3=*RA=_K#g)fyJ_0GVgt$0(U`z7C1(t2Uw zHaH&{>A28vOh7ItH1+ZT*3_lVoaj9q!_`b&k z>E@`)7t2ZsSm{ep?Hg(xwEm-g^}_aFHZm$6X4XBY4|gvV@=QJ(K!Y zO$&N_xFRgU7S+)U6{hnSqajVAI^ey#HRnvp&ob4kNR@d4b+XAUkpu4o7X#>>55rsC z$C%%e?Yd?2E|1TOEV1qJm|F$z$EXdgP)?D?65MvK{wTT5>1)otR)wp4tFE=tZQR?r z_**>thDrXtWG%>$*>IYYgh3qFN4A3Z9c;bRLeBOUX$X3iG_!?E8{gLuo+KG1zL|kG z5v$c~!Ahbr;@ARZ=iq5UnO3XUZuc+g-T16u2I&aJGw<}zyPw*oWHkCM@dT>3-|c-` zuZ!-MSE(2kw7bQcQM?Ccu8@9vsxOqQ%jT=F5;DM9Z?LvOv){J(nff_yrqUoDyDj4y z;>cF=8NzAEw#7cMLhwko$r{gr>04J3wNXXo+Ph-T?<^a2+CqFb!Dh;KU&}s{>f7lN zM_%86eS09<*YCu&lmw1*n{W(@hq^XooJSaV0|l|jnx-i#Ob- zj3?;Dj-(@P2Ey<0VUiVUM8W+E^_rWSSVqniN12vFT@8Z|UF@4BeT8YFsEu+T0Q9Gk z0&ERFb}Cj?Pc9v4!Z#k%HM^hI23Av~IXmk$=guF_HT`P}S)9$1Foa4W(@~pcg8z;B z&Mxk@X3jsi%9>#NNp9fut+m@Y+hhu%%VV&d%t%K1M-y0bH(kKsSfxO^fShNwGNr1S zLTi*BnW@>2;gQREmSOF9p2AGCpOf5a@>e5BE!)DEA!foNxID%MBV^iP_`WslP-59{ zs+y`w0GK4EU@i8FF7M3tj%p5r81ZS#b@#r8$j7-RFJhvimFTu}_ zJ50!-e?dX`I!Aw=(CAL@HQJkzsZwpp56ht<;;YblyRe`)94y*+xddr=nG@0&kax^f z$rJ+SbUBCOuNmE~K8;q2qUpceOinvhDJ_QqvCR`68|nuQV~pDus63>Vb!V`*hl2H*cAY0fj~s2aG{RZkXI zBG=Zur&ux!6I3d z$Cw=s9#0Lh0blezr@p z(c1+*+@IwQ=NfQdBl-wS^ZaQroO8mpAJ!7v|xkVI-6EEPrU^RL7mVF zQ7u&dR%3(rHOTOjpjewBXTHVaxEV2awxh^Rtb7jKPPhv>Q$X0dtyXxO@aK~WYGy8q zyIY7}^;bD0?9b;CpOuU5Rbv~%Ji{V=3RN?wSH&ZP=Tn$&1}yN0LtCOFp+h_TxS>p; z7HusDysR;_$4g0!EKI5+1YFAENy3~-bJnwk-oa-EmLQJn7|d}uD9x45p5ec8e@}wz zSO=90=c4#e`Um$djhxI()m@yd>@9wpz^51bF85_NK~TlJ&f5xTNt#yn|E@IvlFzsayQQ}zpJ z^9H6=iePHUvaRsq&JWGcbP{4t04YhbmgCH_jB%{n9y zr@HP?$EN&x*C?GnZ8^3*S=Cz2pX%^TPysgqC|$s!gp&e1V3ODxI=a*8zaq?ivTwh) zd&XbkfW|A_EsDRk2#k;Sv1taqIO-uIDxnY+JqUgfB6b=e#O7t=e=Wtk(#tEhv6NYu zGaj`XmEyBvVFHS*Dq7z?02^jCl1fpqiOLis$-&W?%f!LS?2l&Ce@h;yrHg&@Ou3Do6oyo~AR;)v zAG=;29e>x_bs0or!9T;0YS%7>n>l@PSZ-gqzt{=hYZMb(RZ$YkgU_^JPft+A-H@cz z8E3Q87VyyN+r#aVBGr1GSE-VHACrH1)gsPCv5I!vYqR+Y0RxzcI?+V{(zQm>DOu7I zqLKqsnlC?;ZFf0fM+AD2u{Xy?^9TZiK&o@Mx!o{`Z$~Q-${nwy&}K5hu<*wy9Cc$) z3aMLCc27WHesrtRpOnL*v9vi*HJpcceoaoad&a%8jg=8Rx|#UYQ*Dw4^mK617e`wf2Ep{qvL;>hFY>e zMrusckJ#UNjP8x?@2U8J0NR>KK`I}BPhj2r)DYpEjy@kd3BBztmk1%Lxh+NSzfI^X~ z36WxR$JX|s%K$GCW+WxnHkySh#+NeZzVC)1%{dFx?wJ z3g`-k))}40Af-8NNVHTM2QH@9X0Cir<~%&a;S$RiiP_094u-nFGB`B!`>2TZ@6Gx@ z75L};FRgnO@ZSaey{-5s@W-5hvXZ|v8Gi@<-fH;;{fx>de`~z_4*q+=`xg`daK`-! z{(s8fzsvc(3i?Y@9^U``iGS2Yf0y!m`RkXIeN +#include #include #include #include +#include #include +#include "eval/text_ops.h" +#include "eval/utf8_length.h" #include "gtest/gtest.h" #include "io/xlsb/record.h" #include "io/zip_reader.h" +#include "phonetic.h" namespace formulon { namespace io { namespace xlsb { namespace { +const std::vector kNoPhonetic; + ByteSpan SpanOf(const std::vector& v) { return ByteSpan{v.data(), v.size()}; } @@ -58,6 +66,78 @@ std::vector DecodeSstStream(const std::vector& body) return out; } +/// One `BrtSSTItem` decoded far enough to see the phonetic tail. +struct DecodedSstItem { + std::string text; + std::vector phonetic; +}; + +// Walks the emitted stream the way `read_xlsb` does, but decodes the +// phonetic tail as well: the kana concatenation, the run count, and each +// `(ichFirst, ichMom, cchMom)` triple, closed by `(ifnt, flags)`. +std::vector DecodeSstItems(const std::vector& body) { + std::vector out; + ByteSpan cursor = SpanOf(body); + while (cursor.size > 0) { + auto rec = read_record(cursor); + if (!rec) { + ADD_FAILURE() << "read_record failed: " << rec.error().message; + return out; + } + if (rec.value().type != static_cast(XlsbRecordType::BrtSSTItem)) { + continue; + } + ByteSpan p = rec.value().payload; + auto flags = read_u8(p); + auto text = read_xlwidestring(p); + if (!flags || !text) { + ADD_FAILURE() << "BrtSSTItem truncated"; + return out; + } + DecodedSstItem item; + item.text = text.value(); + if ((flags.value() & 0x02U) != 0U) { + auto kana = read_xlwidestring(p); + auto count = read_u32(p); + if (!kana || !count) { + ADD_FAILURE() << "phonetic tail truncated"; + return out; + } + std::vector> runs; + for (std::uint32_t i = 0; i < count.value(); ++i) { + std::array fields{}; + for (std::uint16_t& field : fields) { + auto value = read_u16(p); + if (!value) { + ADD_FAILURE() << "phonetic run truncated"; + return out; + } + field = value.value(); + } + runs.push_back(fields); + } + const std::uint32_t kana_units = eval::utf16_units_in(kana.value()); + for (std::size_t i = 0; i < runs.size(); ++i) { + const std::uint32_t end = (i + 1U < runs.size()) ? runs[i + 1U][0] : kana_units; + item.phonetic.push_back(PhoneticRun{runs[i][1], static_cast(runs[i][1] + runs[i][2]), + eval::utf16_substring(kana.value(), runs[i][0], end - runs[i][0])}); + } + auto ifnt = read_u16(p); + auto tail_flags = read_u16(p); + if (!ifnt || !tail_flags) { + ADD_FAILURE() << "phonetic properties truncated"; + return out; + } + // Excel's own defaults for a guide that arrived without a + // ``: the workbook font, halfwidthKatakana, noControl. + EXPECT_EQ(ifnt.value(), 0U); + EXPECT_EQ(tail_flags.value(), 0x0030U); + } + out.push_back(std::move(item)); + } + return out; +} + TEST(XlsbSstBuilder, EmptyBuilderEmitsBeginEndFraming) { SstBuilder sst; EXPECT_TRUE(sst.empty()); @@ -72,25 +152,25 @@ TEST(XlsbSstBuilder, EmptyBuilderEmitsBeginEndFraming) { TEST(XlsbSstBuilder, InternsIdenticalStringsToSameIndex) { SstBuilder sst; - EXPECT_EQ(sst.intern("apple"), 0U); - EXPECT_EQ(sst.intern("banana"), 1U); - EXPECT_EQ(sst.intern("apple"), 0U); - EXPECT_EQ(sst.intern("cherry"), 2U); - EXPECT_EQ(sst.intern("banana"), 1U); + EXPECT_EQ(sst.intern("apple", kNoPhonetic), 0U); + EXPECT_EQ(sst.intern("banana", kNoPhonetic), 1U); + EXPECT_EQ(sst.intern("apple", kNoPhonetic), 0U); + EXPECT_EQ(sst.intern("cherry", kNoPhonetic), 2U); + EXPECT_EQ(sst.intern("banana", kNoPhonetic), 1U); EXPECT_EQ(sst.size(), 3U); ASSERT_EQ(sst.entries().size(), 3U); - EXPECT_EQ(sst.entries()[0], "apple"); - EXPECT_EQ(sst.entries()[1], "banana"); - EXPECT_EQ(sst.entries()[2], "cherry"); + EXPECT_EQ(sst.entries()[0].text, "apple"); + EXPECT_EQ(sst.entries()[1].text, "banana"); + EXPECT_EQ(sst.entries()[2].text, "cherry"); } TEST(XlsbSstBuilder, EmittedStreamRoundTripsThroughReader) { SstBuilder sst; - sst.intern("alpha"); - sst.intern("beta"); - sst.intern("alpha"); - sst.intern("gamma"); + sst.intern("alpha", kNoPhonetic); + sst.intern("beta", kNoPhonetic); + sst.intern("alpha", kNoPhonetic); + sst.intern("gamma", kNoPhonetic); auto body_or = emit_sst(sst); ASSERT_TRUE(static_cast(body_or)); @@ -105,9 +185,10 @@ TEST(XlsbSstBuilder, InternHandlesBmpAndSurrogatePairStrings) { SstBuilder sst; // BMP only ("日本") and a string that triggers surrogate pairs ("🌟ok") // to exercise the writer's UTF-16 expansion. - EXPECT_EQ(sst.intern("\xE6\x97\xA5\xE6\x9C\xAC"), 0U); + EXPECT_EQ(sst.intern("\xE6\x97\xA5\xE6\x9C\xAC", kNoPhonetic), 0U); EXPECT_EQ(sst.intern("\xF0\x9F\x8C\x9F" - "ok"), + "ok", + kNoPhonetic), 1U); auto body_or = emit_sst(sst); @@ -120,10 +201,39 @@ TEST(XlsbSstBuilder, InternHandlesBmpAndSurrogatePairStrings) { "ok"); } +TEST(XlsbSstBuilder, PhoneticGuideKeepsItsSpansAndSplitsTheInternKey) { + SstBuilder sst; + const std::vector tokyo{{0U, 2U, "トウキョウ"}, {2U, 3U, "ト"}}; + const std::vector other{{0U, 3U, "ヒガシキョウト"}}; + // Same surface text, different readings: the guide is part of the entry, + // so merging them would move one cell's furigana onto the other. + EXPECT_EQ(sst.intern("東京都", tokyo), 0U); + EXPECT_EQ(sst.intern("東京都", other), 1U); + EXPECT_EQ(sst.intern("東京都", tokyo), 0U); + EXPECT_EQ(sst.intern("東京都", kNoPhonetic), 2U); + EXPECT_EQ(sst.size(), 3U); + + auto body_or = emit_sst(sst); + ASSERT_TRUE(static_cast(body_or)); + const std::vector items = DecodeSstItems(body_or.value()); + ASSERT_EQ(items.size(), 3U); + EXPECT_EQ(items[0].text, "東京都"); + ASSERT_EQ(items[0].phonetic.size(), 2U); + EXPECT_EQ(items[0].phonetic[0].sb, 0U); + EXPECT_EQ(items[0].phonetic[0].eb, 2U); + EXPECT_EQ(items[0].phonetic[0].text, "トウキョウ"); + EXPECT_EQ(items[0].phonetic[1].sb, 2U); + EXPECT_EQ(items[0].phonetic[1].eb, 3U); + EXPECT_EQ(items[0].phonetic[1].text, "ト"); + ASSERT_EQ(items[1].phonetic.size(), 1U); + EXPECT_EQ(items[1].phonetic[0].text, "ヒガシキョウト"); + EXPECT_TRUE(items[2].phonetic.empty()); +} + TEST(XlsbSstBuilder, BeginRecordCarriesCountFields) { SstBuilder sst; - sst.intern("a"); - sst.intern("b"); + sst.intern("a", kNoPhonetic); + sst.intern("b", kNoPhonetic); auto body_or = emit_sst(sst); ASSERT_TRUE(static_cast(body_or)); const std::vector& body = body_or.value(); diff --git a/tests/unit/io/xlsb_phonetic_test.cpp b/tests/unit/io/xlsb_phonetic_test.cpp new file mode 100644 index 00000000..64a0e25e --- /dev/null +++ b/tests/unit/io/xlsb_phonetic_test.cpp @@ -0,0 +1,182 @@ +// +// Phonetic-guide fidelity for the MS-XLSB path, against a real Mac Excel +// 365-produced pair (`tests/fixtures/excel/xlsb_phonetic.{xlsb,xlsx}`). +// +// The fixture was authored by writing the `` blocks through the +// OOXML writer, opening the result in Excel and letting Excel save both +// containers, so the `.xlsb` carries Excel's own encoding of the guide +// rather than one derived from this reader's assumptions. Excel's +// re-saved `.xlsx` sibling is the cross-check: the two containers must +// decode to the same runs. +// +// The fixture's `Sheet1` column A: +// A1 = 東京都 with 「東京」→トウキョウ and 「都」→ト (two runs) +// A2 = 山田太郎 with 「山田」→ヤマダ and 「太郎」→タロウ (two runs) +// A3 = 大阪 with 「大阪」→オオサカ (whole-string) +// A4 = plain with no guide +// +// A3 is the case the binary format encodes differently from OOXML: Excel +// elides the run array entirely for a whole-string reading, so an empty +// array with a non-empty kana string is one run rather than none. + +#include +#include +#include +#include +#include +#include + +#include "cell.h" +#include "gtest/gtest.h" +#include "io/ooxml_reader.h" +#include "io/xlsb/reader.h" +#include "io/xlsb/writer.h" +#include "phonetic.h" +#include "sheet.h" +#include "value.h" +#include "workbook.h" + +#ifndef FORMULON_FIXTURES_DIR +#error "FORMULON_FIXTURES_DIR must be defined by the build" +#endif + +namespace formulon { +namespace { + +std::string XlsbPath() { + return std::string(FORMULON_FIXTURES_DIR) + "/excel/xlsb_phonetic.xlsb"; +} +std::string XlsxTwinPath() { + return std::string(FORMULON_FIXTURES_DIR) + "/excel/xlsb_phonetic.xlsx"; +} + +std::vector ReadFileBytes(const std::string& path) { + std::vector out; + FILE* file = std::fopen(path.c_str(), "rb"); + if (file == nullptr) { + ADD_FAILURE() << "could not open fixture: " << path; + return out; + } + std::fseek(file, 0, SEEK_END); + const long size = std::ftell(file); + std::fseek(file, 0, SEEK_SET); + if (size > 0) { + out.resize(static_cast(size)); + if (std::fread(out.data(), 1, out.size(), file) != out.size()) { + ADD_FAILURE() << "short read on fixture: " << path; + out.clear(); + } + } + std::fclose(file); + return out; +} + +io::ByteSpan SpanOf(const std::vector& bytes) { + return io::ByteSpan{bytes.data(), bytes.size()}; +} + +/// The runs attached to `(row, 0)` of the first sheet, flattened to a +/// comparable form so a mismatch prints the whole annotation. +std::string DescribeRuns(const Workbook& wb, std::uint32_t row) { + const Cell* cell = wb.sheet(0).cell_at(row, 0); + if (cell == nullptr) { + return ""; + } + std::string out; + for (const PhoneticRun& run : cell->phonetic_runs) { + out.append("[").append(std::to_string(run.sb)).append(",").append(std::to_string(run.eb)).append(")="); + out.append(run.text).append(" "); + } + return out; +} + +std::string CellText(const Workbook& wb, std::uint32_t row) { + const Cell* cell = wb.sheet(0).cell_at(row, 0); + if (cell == nullptr || !cell->cached_value.is_text()) { + return {}; + } + return std::string(cell->cached_value.as_text()); +} + +Workbook LoadXlsb() { + const std::vector bytes = ReadFileBytes(XlsbPath()); + if (bytes.empty()) { + return Workbook::create_empty(); + } + auto result_or = io::xlsb::read_xlsb(SpanOf(bytes)); + EXPECT_TRUE(static_cast(result_or)) << "read_xlsb failed: " << (result_or ? "" : result_or.error().message); + if (!result_or) { + return Workbook::create_empty(); + } + return std::move(result_or.value().workbook); +} + +TEST(XlsbPhonetic, ReadsExcelsEncodingOfEveryRun) { + Workbook wb = LoadXlsb(); + ASSERT_GE(wb.sheet_count(), 1U); + + EXPECT_EQ(CellText(wb, 0), "東京都"); + EXPECT_EQ(DescribeRuns(wb, 0), "[0,2)=トウキョウ [2,3)=ト "); + EXPECT_EQ(CellText(wb, 1), "山田太郎"); + EXPECT_EQ(DescribeRuns(wb, 1), "[0,2)=ヤマダ [2,4)=タロウ "); + // Excel wrote no run array for this one; the reader has to recover the + // implied whole-string span rather than dropping the reading. + EXPECT_EQ(CellText(wb, 2), "大阪"); + EXPECT_EQ(DescribeRuns(wb, 2), "[0,2)=オオサカ "); + EXPECT_EQ(CellText(wb, 3), "plain"); + EXPECT_EQ(DescribeRuns(wb, 3), ""); +} + +TEST(XlsbPhonetic, AgreesWithTheOoxmlTwinOfTheSameWorkbook) { + const std::vector xlsx_bytes = ReadFileBytes(XlsxTwinPath()); + ASSERT_FALSE(xlsx_bytes.empty()); + auto xlsx_or = io::read_ooxml(SpanOf(xlsx_bytes)); + ASSERT_TRUE(static_cast(xlsx_or)) << (xlsx_or ? "" : xlsx_or.error().message); + Workbook& from_xlsx = xlsx_or.value().workbook; + Workbook from_xlsb = LoadXlsb(); + + for (std::uint32_t row = 0; row < 4U; ++row) { + EXPECT_EQ(CellText(from_xlsb, row), CellText(from_xlsx, row)) << "row=" << row; + EXPECT_EQ(DescribeRuns(from_xlsb, row), DescribeRuns(from_xlsx, row)) << "row=" << row; + } +} + +TEST(XlsbPhonetic, SurvivesAWriteReadCycleThroughTheBinaryContainer) { + Workbook wb = LoadXlsb(); + ASSERT_GE(wb.sheet_count(), 1U); + + auto written_or = io::xlsb::write_xlsb(wb); + ASSERT_TRUE(static_cast(written_or)) << (written_or ? "" : written_or.error().message); + auto reread_or = io::xlsb::read_xlsb(SpanOf(written_or.value())); + ASSERT_TRUE(static_cast(reread_or)) << (reread_or ? "" : reread_or.error().message); + Workbook& back = reread_or.value().workbook; + + for (std::uint32_t row = 0; row < 4U; ++row) { + EXPECT_EQ(CellText(back, row), CellText(wb, row)) << "row=" << row; + EXPECT_EQ(DescribeRuns(back, row), DescribeRuns(wb, row)) << "row=" << row; + } +} + +TEST(XlsbPhonetic, KeepsTwoReadingsOfTheSameSurfaceTextApart) { + Workbook wb = Workbook::create(); + ASSERT_TRUE(static_cast(wb.set_cell_text(0, 0, 0, "東京都"))); + wb.sheet(0).set_cell_phonetic_runs(0, 0, {{0U, 2U, "トウキョウ"}, {2U, 3U, "ト"}}); + ASSERT_TRUE(static_cast(wb.set_cell_text(0, 1, 0, "東京都"))); + wb.sheet(0).set_cell_phonetic_runs(1, 0, {{0U, 3U, "ヒガシキョウト"}}); + // Same text again, this time unannotated: the shared-string table has to + // hold three distinct entries for one surface string. + ASSERT_TRUE(static_cast(wb.set_cell_text(0, 2, 0, "東京都"))); + + auto written_or = io::xlsb::write_xlsb(wb); + ASSERT_TRUE(static_cast(written_or)) << (written_or ? "" : written_or.error().message); + auto reread_or = io::xlsb::read_xlsb(SpanOf(written_or.value())); + ASSERT_TRUE(static_cast(reread_or)) << (reread_or ? "" : reread_or.error().message); + Workbook& back = reread_or.value().workbook; + + EXPECT_EQ(DescribeRuns(back, 0), "[0,2)=トウキョウ [2,3)=ト "); + EXPECT_EQ(DescribeRuns(back, 1), "[0,3)=ヒガシキョウト "); + EXPECT_EQ(DescribeRuns(back, 2), ""); +} + +} // namespace +} // namespace formulon From 04c6734ef3f1ac8b9bdaea8ee9f5d79f369d6519 Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 17:45:15 +0900 Subject: [PATCH 02/12] feat(bindings): expose phonetic run spans and the default font - add fm_workbook_set_cell_phonetic_runs plus fm_workbook_get_cell_phonetic_run_count and fm_workbook_get_cell_phonetic_run to src/c_api/formulon_c.h: each block travels as an ordered partition of the surface text, spanned in UTF-16 code units - the core has kept one run per since 0.11.0 while the bindings carried a single string in each direction, so reading a partially annotated cell and writing it back collapsed every span into one whole-cell annotation - surface the runs as setCellPhoneticRuns / getCellPhoneticRuns on WASM and the Node addon and as set_phonetic_runs / get_phonetic_runs in Python; the flattening getCellPhonetic is unchanged and still returns the readings concatenated - give the native Node addon getCellPhonetic / setCellPhonetic, the last cell-level pair that existed on WASM and Python only, and shrink the WASM-only list in tools/dev/check_binding_drift.py and the npm-native README method counts to match - add fm_styles_set_font, which overwrites an existing font slot in place, and fm_workbook_set_default_font, which declares font 0 -- the record every unstyled cell resolves to, seeded as Calibri 11 -- that fm_styles_add_font could never reach because it only appends - surface those as setFont / setDefaultFont (WASM + Node addon) and set_font / set_default_font (Python); the current default reads back through the existing getFont(0) - extend the C API, WASM, Node and Python test surfaces to cover the new entry points --- packages/npm-native/README.md | 7 +- packages/npm-native/index.d.ts | 37 +++++ packages/npm-native/test/smoke.test.mjs | 72 +++++++++ packages/npm/test/smoke.test.mjs | 1 + packages/python/README.md | 25 +++ packages/python/formulon/__init__.py | 2 + packages/python/formulon/__init__.pyi | 10 ++ packages/python/formulon/_c.py | 5 + packages/python/formulon/_structs.py | 8 + packages/python/formulon/workbook.py | 151 ++++++++++++++++++ .../python/tests/test_binding_contracts.py | 58 +++++++ packages/python/tests/test_surface.py | 1 + src/c_api/formulon_c.h | 141 +++++++++++++++- src/c_api/parts/cells.cpp | 92 +++++++++++ src/c_api/parts/styles.cpp | 33 ++++ src/node_addon/parts/lifecycle.cc | 95 +++++++++++ src/node_addon/parts/styles.cc | 25 +++ src/node_addon/parts/workbook_class.cc | 6 + src/node_addon/parts/workbook_class.h | 6 + src/wasm/formulon.d.ts | 33 ++++ src/wasm/parts/bindings_register.cpp | 4 + src/wasm/parts/workbook.h | 15 ++ src/wasm/parts/workbook_cells.cpp | 61 +++++++ src/wasm/parts/workbook_styles.cpp | 20 +++ tests/c_api/formulon_c_styles_test.cpp | 80 ++++++++++ tests/c_api/formulon_c_test.cpp | 70 ++++++++ tests/wasm/run.mjs | 57 +++++++ tools/dev/check_binding_drift.py | 2 - tools/wasm/capi_exports.txt | 5 + 29 files changed, 1115 insertions(+), 7 deletions(-) diff --git a/packages/npm-native/README.md b/packages/npm-native/README.md index 3c2fa5c9..c6f519dd 100644 --- a/packages/npm-native/README.md +++ b/packages/npm-native/README.md @@ -36,13 +36,13 @@ Why prefer the native build: This package exposes the shared `Workbook` surface of the WASM-backed `@libraz/formulon` package, all marshalling to the identical C-ABI functions. Its TypeScript declarations and its native class table -register 219 instance methods plus the three static factories. Of those -instance methods, 217 are shared with WASM; nine remain WASM-only, while +register 225 instance methods plus the three static factories. Of those +instance methods, 223 are shared with WASM; seven remain WASM-only, while `dispose()` and `memoryUsage()` are native-only lifecycle helpers. The shared `Workbook` methods use the same status-bearing result envelopes and field shapes; switching packages still requires updating the module import and validating the target platform's native prebuild. The additional -native-only methods are operational helpers; the nine +native-only methods are operational helpers; the seven WASM-only methods remain available through the WASM package. The WASM-only methods are, in full: @@ -50,7 +50,6 @@ The WASM-only methods are, in full: ``` addCellStyleXf, setCellStyle createTable, updateTable, removeTable -getCellPhonetic, setCellPhonetic getSheetAutoFilterXml, setSheetAutoFilterXml ``` diff --git a/packages/npm-native/index.d.ts b/packages/npm-native/index.d.ts index 7341c1e0..51ffcf2a 100644 --- a/packages/npm-native/index.d.ts +++ b/packages/npm-native/index.d.ts @@ -1114,6 +1114,20 @@ export interface CellXf { xfId?: number; } +/** One `` block: `text` reads the half-open span `[sb, eb)` of the + * cell's surface text, measured in UTF-16 code units. */ +export interface PhoneticRun { + sb: number; + eb: number; + text: string; +} + +/** Return type of `Workbook.getCellPhoneticRuns(sheet, row, col)`. */ +export interface PhoneticRunsResult { + status: Status; + runs: PhoneticRun[]; +} + /** Return type of `Workbook.getFont(fontIndex)`. */ export interface FontResult extends FontRecord { status: Status; @@ -1546,11 +1560,24 @@ export interface Workbook { /** Stores a static Excel error literal; `errorCode` is an ErrorCode ordinal. */ setError(sheet: number, row: number, col: number, errorCode: number): Status; setText(sheet: number, row: number, col: number, text: string): Status; + /** Stores (or, when empty, clears) the cell's OOXML phonetic guide (``). */ + setCellPhonetic(sheet: number, row: number, col: number, phonetic: string): Status; + /** Stores (or, when empty, clears) the cell's phonetic guide as one `` + * block per run. Unlike `setCellPhonetic`, which annotates the whole cell, + * this keeps the span each reading covers. The runs must be an ordered + * partition: each needs `sb <= eb` and must start at or after the previous + * run's `eb`. */ + setCellPhoneticRuns(sheet: number, row: number, col: number, runs: PhoneticRun[]): Status; setBlank(sheet: number, row: number, col: number): Status; setFormula(sheet: number, row: number, col: number, formula: string): Status; // Cell read. getValue(sheet: number, row: number, col: number): CellResult; + /** Returns the cell's OOXML phonetic guide (``), or an empty string. */ + getCellPhonetic(sheet: number, row: number, col: number): StringResult; + /** Returns the cell's `` blocks with their spans. `getCellPhonetic` + * returns the same readings concatenated, without the spans. */ + getCellPhoneticRuns(sheet: number, row: number, col: number): PhoneticRunsResult; /** Evaluates `formula` as if entered at `(sheet, row, col)` and returns a * single scalar result, without mutating the workbook. Local and * cross-sheet references, defined names, and `ROW()` / `COLUMN()` resolve @@ -2071,6 +2098,16 @@ export interface Workbook { /** Adds a font (deduplicating against existing entries) and returns * the resolved index. */ addFont(record: FontRecord): AddStyleResult; + /** Overwrites the font at `fontIndex` in place. Every `` naming that + * index restyles at once, so this is a bulk change rather than a local + * edit; `addFont` is the way to introduce a new appearance. The index + * must already exist -- the table does not auto-grow. */ + setFont(fontIndex: number, record: FontRecord): Status; + /** Declares the workbook's default font: font 0, the record an unstyled + * cell resolves to. A new workbook seeds it with Excel's Calibri 11 and + * `addFont` can only append beside it, so this is the way to change what + * a never-styled cell is saved as. Read it back with `getFont(0)`. */ + setDefaultFont(record: FontRecord): Status; /** Adds a fill (deduplicating against existing entries). */ addFill(record: FillRecord): AddStyleResult; /** Adds a border (deduplicating against existing entries). */ diff --git a/packages/npm-native/test/smoke.test.mjs b/packages/npm-native/test/smoke.test.mjs index 8d49833d..1b1f2aec 100644 --- a/packages/npm-native/test/smoke.test.mjs +++ b/packages/npm-native/test/smoke.test.mjs @@ -1314,6 +1314,77 @@ test('addFont / getFont preserve superscript and round-trip to the same index', assert.equal(reread.colorArgb, 0xff00ff00); }); +test('phonetic runs keep their spans through a save/load round trip', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + wb.setText(0, 0, 0, '東京都'); + assert.deepEqual(wb.getCellPhoneticRuns(0, 0, 0).runs, []); + + const runs = [ + { sb: 0, eb: 2, text: 'トウキョウ' }, + { sb: 2, eb: 3, text: 'ト' }, + ]; + const stored = wb.setCellPhoneticRuns(0, 0, 0, runs); + assert.ok(stored.ok, `setCellPhoneticRuns: ${JSON.stringify(stored)}`); + assert.deepEqual(wb.getCellPhoneticRuns(0, 0, 0).runs, runs); + // The flattening getter still reports the concatenation. + assert.equal(wb.getCellPhonetic(0, 0, 0).value, 'トウキョウト'); + + const saved = wb.save(); + assert.ok(saved.status.ok, `save: ${JSON.stringify(saved.status)}`); + const loaded = mod.Workbook.loadBytes(saved.bytes); + assert.deepEqual(loaded.getCellPhoneticRuns(0, 0, 0).runs, runs); + + // Writing the flattened reading back is the collapse the run API avoids. + wb.setCellPhonetic(0, 0, 0, 'トウキョウト'); + assert.deepEqual(wb.getCellPhoneticRuns(0, 0, 0).runs, [{ sb: 0, eb: 3, text: 'トウキョウト' }]); + + const rejected = wb.setCellPhoneticRuns(0, 0, 0, [ + { sb: 2, eb: 3, text: 'ト' }, + { sb: 0, eb: 2, text: 'トウ' }, + ]); + assert.equal(rejected.ok, false); + + loaded.dispose(); + wb.dispose(); +}); + +test('setDefaultFont declares what an unstyled cell is saved as', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + assert.equal(wb.getFont(0).name, 'Calibri'); + + // addFont can only ever append beside the seeded default. + const appended = wb.addFont({ name: '游ゴシック', size: 11 }); + assert.ok(appended.status.ok); + assert.ok(appended.index > 0); + assert.equal(wb.getFont(0).name, 'Calibri'); + + const declared = wb.setDefaultFont({ name: '游ゴシック', size: 11, hasCharset: true, charset: 128 }); + assert.ok(declared.ok, `setDefaultFont: ${JSON.stringify(declared)}`); + assert.equal(wb.getFont(0).name, '游ゴシック'); + assert.equal(wb.getFont(0).charset, 128); + + wb.dispose(); +}); + +test('setFont overwrites an existing slot and refuses an absent index', async () => { + const mod = await getModule(); + const wb = mod.Workbook.createDefault(); + const added = wb.addFont({ name: 'Meiryo', size: 12 }); + assert.ok(added.status.ok); + + const replaced = wb.setFont(added.index, { name: 'MS Gothic', size: 9 }); + assert.ok(replaced.ok, `setFont: ${JSON.stringify(replaced)}`); + assert.equal(wb.getFont(added.index).name, 'MS Gothic'); + + const before = wb.fontCount(); + assert.equal(wb.setFont(before, { name: 'MS Gothic', size: 9 }).ok, false); + assert.equal(wb.fontCount(), before); + + wb.dispose(); +}); + test('addDxf / getDxf round-trip a superscript differential font', async () => { const mod = await getModule(); const wb = mod.Workbook.createDefault(); @@ -2236,6 +2307,7 @@ function envelopeProbes(wb) { ['BorderResult', true, () => wb.getBorder(9999)], ['NumFmtResult', true, () => wb.getNumFmt(59999)], ['LambdaTextResult', true, () => wb.getLambdaText(99, 0, 0)], + ['PhoneticRunsResult', true, () => wb.getCellPhoneticRuns(99, 0, 0)], ['CellStyleResult', true, () => wb.getCellStyle(9999)], ['AddStyleResult', true, () => wb.addXf({ fontIndex: 9999 })], ['AddNumFmtResult', false, () => wb.addNumFmt('0.00')], diff --git a/packages/npm/test/smoke.test.mjs b/packages/npm/test/smoke.test.mjs index 2e629e97..54b08891 100644 --- a/packages/npm/test/smoke.test.mjs +++ b/packages/npm/test/smoke.test.mjs @@ -911,6 +911,7 @@ function envelopeProbes(wb) { ['BorderResult', true, () => wb.getBorder(9999)], ['NumFmtResult', true, () => wb.getNumFmt(59999)], ['LambdaTextResult', true, () => wb.getLambdaText(99, 0, 0)], + ['PhoneticRunsResult', true, () => wb.getCellPhoneticRuns(99, 0, 0)], ['CellStyleResult', true, () => wb.getCellStyle(9999)], ['AddStyleResult', true, () => wb.addXf({ fontIndex: 9999 })], ['AddNumFmtResult', false, () => wb.addNumFmt('0.00')], diff --git a/packages/python/README.md b/packages/python/README.md index 62327a51..9c8c3b5b 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -107,6 +107,22 @@ wb.set_phonetic(0, 0, 0, "ニホンゴ") wb.get_phonetic(0, 0, 0) # -> 'ニホンゴ'; '' when the cell has none ``` +A reading that covers only part of the text needs one run per span. The +spans are observable through `PHONETIC`, which substitutes each annotated +span and passes the rest through, so a partially annotated cell has to be +read and written as runs -- `set_phonetic` annotates the whole cell and +would collapse them: + +```python +wb.set_text(0, 0, 0, "東京都") +wb.set_phonetic_runs(0, 0, 0, [PhoneticRun(0, 2, "トウキョウ"), PhoneticRun(2, 3, "ト")]) +wb.get_phonetic_runs(0, 0, 0) # -> [PhoneticRun(0, 2, 'トウキョウ'), PhoneticRun(2, 3, 'ト')] +wb.get_phonetic(0, 0, 0) # -> 'トウキョウト' (the readings concatenated) +``` + +Offsets are UTF-16 code units, and the runs must be an ordered partition: +each needs `sb <= eb` and must start at or after the previous run's `eb`. + **AutoFilter** -- the raw `` fragment, preserved verbatim so filter criteria and extensions survive a round trip: @@ -174,6 +190,15 @@ xf = wb.add_cell_xf( wb.set_cell_xf_index(0, 0, 0, xf) ``` +`add_font` always appends beside font 0, the record an unstyled cell +resolves to. To change what a never-styled cell is saved as -- Calibri 11 +in a fresh workbook -- declare the default instead: + +```python +wb.set_default_font(FontRecord(name="游ゴシック", size=11.0, has_charset=True, charset=128)) +wb.get_font(0).name # -> '游ゴシック' +``` + **Conditional formatting** ```python diff --git a/packages/python/formulon/__init__.py b/packages/python/formulon/__init__.py index 91752358..9a561284 100644 --- a/packages/python/formulon/__init__.py +++ b/packages/python/formulon/__init__.py @@ -67,6 +67,7 @@ PageSetup, PaginationResult, PassthroughPart, + PhoneticRun, PivotAggregation, PivotAxis, PivotCalendar, @@ -191,6 +192,7 @@ def _resolve_version() -> str: "PageSetup", "PaginationResult", "PassthroughPart", + "PhoneticRun", "PivotAggregation", "PivotAxis", "PivotCalendar", diff --git a/packages/python/formulon/__init__.pyi b/packages/python/formulon/__init__.pyi index 20f7664e..a3d01f05 100644 --- a/packages/python/formulon/__init__.pyi +++ b/packages/python/formulon/__init__.pyi @@ -701,6 +701,12 @@ class ColorSpec: indexed: int = ..., ) -> None: ... +class PhoneticRun: + sb: int + eb: int + text: str + def __init__(self, sb: int = ..., eb: int = ..., text: str = ...) -> None: ... + class FontRecord: name: str size: float @@ -918,7 +924,9 @@ class Workbook: def set_blank(self, sheet: int, row: int, col: int) -> None: ... def set_formula(self, sheet: int, row: int, col: int, formula: str) -> None: ... def set_phonetic(self, sheet: int, row: int, col: int, text: str) -> None: ... + def set_phonetic_runs(self, sheet: int, row: int, col: int, runs: Sequence[PhoneticRun]) -> None: ... def get_phonetic(self, sheet: int, row: int, col: int) -> str: ... + def get_phonetic_runs(self, sheet: int, row: int, col: int) -> List[PhoneticRun]: ... def get_value(self, sheet: int, row: int, col: int) -> Value: ... def evaluate_formula_array(self, sheet: int, row: int, col: int, formula: str) -> List[List[Value]]: ... def lambda_text_at(self, sheet: int, row: int, col: int) -> str: ... @@ -1187,6 +1195,8 @@ class Workbook: def cell_style_xf_count(self) -> int: ... def dxf_count(self) -> int: ... def add_font(self, record: FontRecord) -> int: ... + def set_font(self, font_index: int, record: FontRecord) -> None: ... + def set_default_font(self, record: FontRecord) -> None: ... def add_fill(self, record: FillRecord) -> int: ... def add_border(self, sides: Dict[str, object]) -> int: ... def add_num_fmt(self, format_code: str) -> int: ... diff --git a/packages/python/formulon/_c.py b/packages/python/formulon/_c.py index 836a5068..d1b5070d 100644 --- a/packages/python/formulon/_c.py +++ b/packages/python/formulon/_c.py @@ -178,6 +178,7 @@ "fm_styles_get_font_count", "fm_styles_get_num_fmt_string", "fm_styles_set_cell_style", + "fm_styles_set_font", "fm_workbook_add_sheet", "fm_workbook_calc_mode", "fm_workbook_clear_pinned_now", @@ -197,6 +198,8 @@ "fm_workbook_external_link_at", "fm_workbook_external_link_count", "fm_workbook_get_cell_phonetic", + "fm_workbook_get_cell_phonetic_run", + "fm_workbook_get_cell_phonetic_run_count", "fm_workbook_get_iterative", "fm_workbook_get_value", "fm_workbook_insert_cols", @@ -283,6 +286,8 @@ "fm_workbook_set_bool", "fm_workbook_set_calc_mode", "fm_workbook_set_cell_phonetic", + "fm_workbook_set_cell_phonetic_runs", + "fm_workbook_set_default_font", "fm_workbook_set_defined_name", "fm_workbook_set_defined_name_scoped", "fm_workbook_set_error", diff --git a/packages/python/formulon/_structs.py b/packages/python/formulon/_structs.py index 58e85211..c76b915b 100644 --- a/packages/python/formulon/_structs.py +++ b/packages/python/formulon/_structs.py @@ -313,6 +313,14 @@ def zero_struct(lib, layout: Struct, ptr: int) -> None: [("sheet", U32), ("row", U32), ("col", U32)], ) +# One ```` block. Passed both ways: as an element of the array +# ``fm_workbook_set_cell_phonetic_runs`` takes, and as the out-parameter of +# ``fm_workbook_get_cell_phonetic_run``. +PHONETIC_RUN = Struct( + "fm_phonetic_run_t", + [("sb", U32), ("eb", U32), ("text", PTR)], +) + # fm_pivot_cell_t embeds an fm_value_t (16 bytes, 8-aligned) inline. The # ``value`` field is an opaque blob; decode it with Value._from_wasm # against ``cell_ptr + PIVOT_CELL_VALUE_OFFSET``. diff --git a/packages/python/formulon/workbook.py b/packages/python/formulon/workbook.py index 573e2ff2..653c1d30 100644 --- a/packages/python/formulon/workbook.py +++ b/packages/python/formulon/workbook.py @@ -1115,6 +1115,20 @@ class ColorSpec: indexed: int = 0 +@dataclass +class PhoneticRun: + """One ```` block: ``text`` reads the span ``[sb, eb)``. + + Offsets are UTF-16 code units, which is how Excel indexes string + positions. A whole-cell reading is the single run + ``PhoneticRun(0, , kana)``. + """ + + sb: int = 0 + eb: int = 0 + text: str = "" + + @dataclass class FontRecord: """A font record (``add_font`` input / ``get_font`` result). @@ -1538,6 +1552,25 @@ def _pack_merge_array(ranges: Sequence[MergeRange], owned: List[int]) -> int: return ptr +def _pack_phonetic_run_array(runs: Sequence[PhoneticRun], owned: List[int]) -> int: + """Pack a list of ``PhoneticRun`` into a contiguous WASM array. + + Returns the array pointer (0 when empty). Every buffer allocated here -- + the array and each run's kana string -- is appended to ``owned`` for + later release. + """ + if not runs: + return 0 + size = S.PHONETIC_RUN.size + ptr = LIB.alloc(size * len(runs)) + owned.append(ptr) + for i, run in enumerate(runs): + slot = ptr + i * size + S.PHONETIC_RUN.pack(LIB, slot, {"sb": _uint(run.sb, "sb"), "eb": _uint(run.eb, "eb")}) + S.write_str_field(LIB, slot, S.PHONETIC_RUN, "text", run.text, owned) + return ptr + + class Workbook: """Owns an ``fm_workbook_t*`` handle (a WASM i32 offset). @@ -1817,6 +1850,83 @@ def set_phonetic(self, sheet: int, row: int, col: int, text: str) -> None: finally: LIB.free(text_ptr) + def set_phonetic_runs(self, sheet: int, row: int, col: int, runs: Sequence[PhoneticRun]) -> None: + """Set the cell's phonetic guide as one ```` block per run. + + ``sheet``, ``row`` and ``col`` are all 0-based. An empty ``runs`` + removes the guide. Unlike :meth:`set_phonetic`, which annotates the + whole cell, this keeps the spans each reading covers -- the + difference ``PHONETIC`` and a partially annotated cell depend on. + + The runs must be an ordered partition: each needs ``sb <= eb`` and + must start at or after the previous run's ``eb``. + """ + h = self._require() + owned: List[int] = [] + ptr = _pack_phonetic_run_array(runs, owned) + try: + status = LIB.fm_workbook_set_cell_phonetic_runs( + h, + _uint(sheet, "sheet_index"), + _uint(row, "row"), + _uint(col, "col"), + ptr, + _uint(len(runs), "count"), + ) + _check(status, "fm_workbook_set_cell_phonetic_runs") + finally: + for p in owned: + LIB.free(p) + + def get_phonetic_runs(self, sheet: int, row: int, col: int) -> List[PhoneticRun]: + """Return the cell's ```` blocks, spans included. + + ``sheet``, ``row`` and ``col`` are all 0-based. An unannotated cell + yields an empty list. :meth:`get_phonetic` returns the same readings + concatenated, without the spans. + """ + h = self._require() + count_out = _alloc_out_ptr() + try: + _check( + LIB.fm_workbook_get_cell_phonetic_run_count( + h, _uint(sheet, "sheet_index"), _uint(row, "row"), _uint(col, "col"), count_out + ), + "fm_workbook_get_cell_phonetic_run_count", + ) + count = LIB.read_u32(count_out) + finally: + LIB.free(count_out) + out: List[PhoneticRun] = [] + run_ptr = S.alloc_struct(LIB, S.PHONETIC_RUN) + try: + for i in range(count): + S.zero_struct(LIB, S.PHONETIC_RUN, run_ptr) + _check( + LIB.fm_workbook_get_cell_phonetic_run( + h, + _uint(sheet, "sheet_index"), + _uint(row, "row"), + _uint(col, "col"), + _uint(i, "run_index"), + run_ptr, + ), + "fm_workbook_get_cell_phonetic_run", + ) + fields = S.PHONETIC_RUN.unpack(LIB, run_ptr) + # Decoded before the next read: the text points into the + # handle's scratch, which every read refreshes. + out.append( + PhoneticRun( + sb=fields["sb"], + eb=fields["eb"], + text=LIB.read_cstr(fields["text"]) if fields["text"] else "", + ) + ) + finally: + LIB.free(run_ptr) + return out + def get_phonetic(self, sheet: int, row: int, col: int) -> str: """Return the cell's phonetic guide, or ``""`` when it has none. @@ -4449,6 +4559,47 @@ def add_font(self, record: FontRecord) -> int: for p in owned: LIB.free(p) + def set_font(self, font_index: int, record: FontRecord) -> None: + """Overwrite an existing font slot in place. + + Every ```` naming ``font_index`` restyles at once, so this is a + bulk change rather than a local edit; :meth:`add_font` is the way to + introduce a new appearance. The index must already exist. + """ + h = self._require() + owned: List[int] = [] + ptr = S.alloc_struct(LIB, S.FONT_RECORD) + try: + _pack_font(ptr, record, owned) + _check( + LIB.fm_styles_set_font(h, _uint(font_index, "font_index"), ptr), + "fm_styles_set_font", + ) + finally: + LIB.free(ptr) + for p in owned: + LIB.free(p) + + def set_default_font(self, record: FontRecord) -> None: + """Declare the workbook's default font (font 0). + + Font 0 is what an unstyled cell resolves to. A new workbook seeds it + with Excel's Calibri 11, and :meth:`add_font` can only append beside + it, so this is the way a ja-JP host declares e.g. ``游ゴシック`` for + the cells it never styles explicitly. Read it back with + ``get_font(0)``. + """ + h = self._require() + owned: List[int] = [] + ptr = S.alloc_struct(LIB, S.FONT_RECORD) + try: + _pack_font(ptr, record, owned) + _check(LIB.fm_workbook_set_default_font(h, ptr), "fm_workbook_set_default_font") + finally: + LIB.free(ptr) + for p in owned: + LIB.free(p) + def add_fill(self, record: FillRecord) -> int: """Add (dedup) a fill record; return its index.""" h = self._require() diff --git a/packages/python/tests/test_binding_contracts.py b/packages/python/tests/test_binding_contracts.py index 142f52fb..fc61a1e8 100644 --- a/packages/python/tests/test_binding_contracts.py +++ b/packages/python/tests/test_binding_contracts.py @@ -31,6 +31,7 @@ FormulonError, LogLevel, MergeRange, + PhoneticRun, PivotAggregation, PivotAxis, PivotDataFieldSpec, @@ -461,6 +462,63 @@ def test_empty_phonetic_guide_clears_it(self) -> None: wb.set_phonetic(0, 0, 0, "") self.assertEqual(wb.get_phonetic(0, 0, 0), "") + def test_phonetic_runs_keep_their_spans(self) -> None: + runs = [PhoneticRun(0, 2, "トウキョウ"), PhoneticRun(2, 3, "ト")] + with Workbook.create_default() as wb: + wb.set_text(0, 0, 0, "東京都") + self.assertEqual(wb.get_phonetic_runs(0, 0, 0), []) + wb.set_phonetic_runs(0, 0, 0, runs) + self.assertEqual(wb.get_phonetic_runs(0, 0, 0), runs) + # The flattening getter still reports the concatenation. + self.assertEqual(wb.get_phonetic(0, 0, 0), "トウキョウト") + data = wb.save() + with Workbook.load(data) as reloaded: + self.assertEqual(reloaded.get_phonetic_runs(0, 0, 0), runs) + + def test_whole_cell_setter_collapses_the_spans(self) -> None: + with Workbook.create_default() as wb: + wb.set_text(0, 0, 0, "東京都") + wb.set_phonetic_runs(0, 0, 0, [PhoneticRun(0, 2, "トウキョウ"), PhoneticRun(2, 3, "ト")]) + wb.set_phonetic(0, 0, 0, wb.get_phonetic(0, 0, 0)) + self.assertEqual(wb.get_phonetic_runs(0, 0, 0), [PhoneticRun(0, 3, "トウキョウト")]) + + def test_empty_run_list_clears_the_guide(self) -> None: + with Workbook.create_default() as wb: + wb.set_text(0, 0, 0, "東京都") + wb.set_phonetic_runs(0, 0, 0, [PhoneticRun(0, 3, "トウキョウト")]) + wb.set_phonetic_runs(0, 0, 0, []) + self.assertEqual(wb.get_phonetic_runs(0, 0, 0), []) + self.assertEqual(wb.get_phonetic(0, 0, 0), "") + + def test_out_of_order_runs_are_rejected(self) -> None: + with Workbook.create_default() as wb: + wb.set_text(0, 0, 0, "東京都") + with self.assertRaises(FormulonError): + wb.set_phonetic_runs(0, 0, 0, [PhoneticRun(2, 3, "ト"), PhoneticRun(0, 2, "トウ")]) + + def test_default_font_replaces_what_an_unstyled_cell_saves_as(self) -> None: + with Workbook.create_default() as wb: + self.assertEqual(wb.get_font(0).name, "Calibri") + # add_font can only ever append beside the seeded default. + self.assertGreater(wb.add_font(FontRecord(name="游ゴシック", size=11.0)), 0) + self.assertEqual(wb.get_font(0).name, "Calibri") + + wb.set_default_font(FontRecord(name="游ゴシック", size=11.0, has_charset=True, charset=128)) + self.assertEqual(wb.get_font(0).name, "游ゴシック") + self.assertEqual(wb.get_font(0).charset, 128) + data = wb.save() + with Workbook.load(data) as reloaded: + self.assertEqual(reloaded.get_font(0).name, "游ゴシック") + + def test_set_font_overwrites_an_existing_slot(self) -> None: + with Workbook.create_default() as wb: + index = wb.add_font(FontRecord(name="Meiryo", size=12.0)) + wb.set_font(index, FontRecord(name="MS Gothic", size=9.0)) + self.assertEqual(wb.get_font(index).name, "MS Gothic") + self.assertEqual(wb.font_count(), index + 1) + with self.assertRaises(FormulonError): + wb.set_font(wb.font_count(), FontRecord(name="MS Gothic")) + def test_table_create_update_remove(self) -> None: with Workbook.create_default() as wb: wb.set_text(0, 0, 0, "A") diff --git a/packages/python/tests/test_surface.py b/packages/python/tests/test_surface.py index b9ca193a..abceb5e6 100644 --- a/packages/python/tests/test_surface.py +++ b/packages/python/tests/test_surface.py @@ -118,6 +118,7 @@ class StructLayoutTests(unittest.TestCase): "SHEET_PROTECTION": 88, "VIEWPORT": 20, "CELL_NODE": 12, + "PHONETIC_RUN": 12, "CFVO": 12, "CF_CELL_RANGE": 16, "CF_COLOR": 4, diff --git a/src/c_api/formulon_c.h b/src/c_api/formulon_c.h index 2151960a..ecc7cbbd 100644 --- a/src/c_api/formulon_c.h +++ b/src/c_api/formulon_c.h @@ -758,12 +758,37 @@ FM_API fm_status_t fm_workbook_set_text(fm_workbook_t* wb, size_t sheet_index, u const char* utf8); /** - * @brief Stores the phonetic guide (OOXML ``) for a cell. + * @brief One `` block: the kana reading `text` covers the half-open + * span `[sb, eb)` of the cell's surface text. + * + * Offsets are UTF-16 code units, which is how Excel indexes string + * positions everywhere else. A cell whose reading was typed in one go + * carries the single run `{0, , kana}`. + * + * The spans are observable rather than presentation detail: `PHONETIC` + * substitutes each annotated span with its kana and passes the text + * outside every span through unchanged, so a partially annotated string + * surfaces a mix of kana and original characters. + */ +typedef struct { + uint32_t sb; /* inclusive span start, in UTF-16 code units */ + uint32_t eb; /* exclusive span end, in UTF-16 code units */ + const char* text; /* NUL-terminated UTF-8 kana */ +} fm_phonetic_run_t; + +/** + * @brief Stores a whole-cell phonetic guide (OOXML ``). * * The guide is independent of the cell's visible text. Passing an empty * string clears an existing guide. The destination cell copies `utf8`, so * the input does not need to outlive the call. * + * This entry point has no span vocabulary, so the reading it stores covers + * the surface text end to end — one run spanning the whole cell. Feeding + * `fm_workbook_get_cell_phonetic`'s result back through here therefore + * flattens a partially annotated cell into a single whole-cell annotation. + * Use `fm_workbook_set_cell_phonetic_runs` to preserve the spans. + * * @return `kOk` on success; * `kBindingNullPointer` if any pointer argument is `NULL`; * `kInvalidArgument` when `sheet_index` is out of range. @@ -771,6 +796,36 @@ FM_API fm_status_t fm_workbook_set_text(fm_workbook_t* wb, size_t sheet_index, u FM_API fm_status_t fm_workbook_set_cell_phonetic(fm_workbook_t* wb, size_t sheet_index, uint32_t row, uint32_t col, const char* utf8); +/** + * @brief Stores a cell's phonetic guide as one `` block per run, + * spans included. + * + * `count == 0` clears the annotation (`runs` may be `NULL` in that case). + * Each run's `text` is copied, so the array and its strings do not need to + * outlive the call. + * + * The runs must describe a partition of the surface text in reading order: + * every run needs `sb <= eb`, and each run must start at or after the + * previous run's `eb`. Overlapping or out-of-order runs are rejected rather + * than normalised, because `PHONETIC` walks them in order and a run whose + * span has already been passed would silently move its kana onto characters + * it does not read. + * + * `eb` is not checked against the cell's current text length. The surface + * text may legitimately be written after the annotation, and every + * value-mutating setter clears the annotation anyway, so a span that + * overshoots is treated as covering the remainder of the text rather than + * as an error. + * + * @return `kOk` on success; + * `kBindingNullPointer` if `wb` is `NULL`, if `runs` is `NULL` with + * a non-zero `count`, or if any run's `text` is `NULL`; + * `kInvalidArgument` when `sheet_index` or the cell coordinate is + * out of range, or when the runs are not an ordered partition. + */ +FM_API fm_status_t fm_workbook_set_cell_phonetic_runs(fm_workbook_t* wb, size_t sheet_index, uint32_t row, uint32_t col, + const fm_phonetic_run_t* runs, size_t count); + /** * @brief Stores a `Blank` literal at `(row, col)`. Equivalent to * clearing a cell. @@ -824,6 +879,12 @@ FM_API fm_status_t fm_workbook_get_value(const fm_workbook_t* wb, size_t sheet_i * @brief Reads the cell's phonetic guide (OOXML ``), or an empty string * when the cell has no guide. * + * Every run's kana concatenated in reading order — the shape a furigana + * field wants. Which characters each run covers is dropped; read the runs + * individually when the spans matter, and note that writing this string + * back through `fm_workbook_set_cell_phonetic` collapses a partially + * annotated cell to a single whole-cell annotation. + * * `*out_text` is a read-scratch-backed view into the handle's transient read * storage. It may be invalidated by the next successful scratch-backed read * on this handle; a validation-rejected call does not refresh the storage. @@ -837,6 +898,35 @@ FM_API fm_status_t fm_workbook_get_value(const fm_workbook_t* wb, size_t sheet_i FM_API fm_status_t fm_workbook_get_cell_phonetic(const fm_workbook_t* wb, size_t sheet_index, uint32_t row, uint32_t col, const char** out_text); +/** + * @brief Counts the `` blocks attached to a cell. + * + * Zero for a cell with no annotation, including one that does not exist. + * Pair with `fm_workbook_get_cell_phonetic_run` to walk the spans. + * + * @return `kOk` on success; + * `kBindingNullPointer` if `wb` or `out_count` is `NULL`; + * `kInvalidArgument` when `sheet_index` is out of range. + */ +FM_API fm_status_t fm_workbook_get_cell_phonetic_run_count(const fm_workbook_t* wb, size_t sheet_index, uint32_t row, + uint32_t col, uint32_t* out_count); + +/** + * @brief Reads the `run_index`-th `` block of a cell. + * + * `out->text` is a read-scratch-backed view with the same lifetime rules as + * `fm_workbook_get_cell_phonetic`'s: the next successful scratch-backed read + * on this handle may invalidate it, so a caller collecting every run must + * copy each one before asking for the next. + * + * @return `kOk` on success; + * `kBindingNullPointer` if `wb` or `out` is `NULL`; + * `kInvalidArgument` when `sheet_index` is out of range or + * `run_index` is past the cell's run count. + */ +FM_API fm_status_t fm_workbook_get_cell_phonetic_run(const fm_workbook_t* wb, size_t sheet_index, uint32_t row, + uint32_t col, uint32_t run_index, fm_phonetic_run_t* out); + /** * @brief Renders the lambda closure stored at `(sheet_index, row, col)` * as Excel formula text. @@ -4744,6 +4834,55 @@ FM_API fm_status_t fm_styles_get_cell_style_xf(fm_workbook_t* wb, uint32_t index */ FM_API fm_status_t fm_styles_add_font(fm_workbook_t* wb, fm_font_record record, uint32_t* out_index); +/** + * @brief Overwrites the `font_index`-th font record in place. + * + * The counterpart to `fm_styles_add_font` for a slot that already exists. + * Every `` whose `font_index` names this slot renders with the new + * record, so this is a bulk restyle rather than a local edit: replacing a + * font that several cell formats share changes all of them at once. Use + * `fm_styles_add_font` when the intent is to introduce a new appearance and + * repoint selected `` records at it. + * + * No dedup and no auto-grow: the index must already be in range, and a + * record equal to another slot's is allowed to sit at two indices (the + * styles writer emits both, which Excel accepts). + * + * `record.name` follows `fm_styles_add_font`: NUL-terminated UTF-8, copied + * into the styles table, `NULL` treated as the empty string. + * + * @return `kOk` on success; + * `kBindingNullPointer` if `wb` is `NULL`; + * `kInvalidArgument` when `font_index >= fonts.size()`. + */ +FM_API fm_status_t fm_styles_set_font(fm_workbook_t* wb, uint32_t font_index, fm_font_record record); + +/** + * @brief Declares the workbook's default font. + * + * Font 0 is the record every unformatted cell resolves to: `fm_workbook_create` + * seeds it with Excel's `Calibri` 11, and cell xf 0 — the format of a cell + * that was never styled — names it. Because the seeded table already owns + * index 0, `fm_styles_add_font` can only ever append beside it, so this is + * the only way to change what an unstyled cell is saved as. A ja-JP host + * declaring `游ゴシック` calls this once after creating the workbook. + * + * Equivalent to `fm_styles_set_font(wb, 0, record)`, except that it also + * succeeds on a workbook whose styles table is empty (a table replaced + * wholesale by `fm_workbook_load` on a package with no `xl/styles.xml`): + * the reserved roots are seeded first, then index 0 is overwritten. + * + * Reading the current default is `fm_styles_get_font(wb, 0, out)`. + * + * The record is a font, not a theme: Formulon writes no `xl/theme/theme1.xml` + * for a workbook it created, so the name is stored literally and Excel + * resolves it without a `` indirection. + * + * @return `kOk` on success; + * `kBindingNullPointer` if `wb` is `NULL`. + */ +FM_API fm_status_t fm_workbook_set_default_font(fm_workbook_t* wb, fm_font_record record); + /** * @brief Adds a fill record to the workbook's styles table, deduplicating * against existing entries. diff --git a/src/c_api/parts/cells.cpp b/src/c_api/parts/cells.cpp index 35e18eab..0ba3841b 100644 --- a/src/c_api/parts/cells.cpp +++ b/src/c_api/parts/cells.cpp @@ -114,6 +114,56 @@ extern "C" fm_status_t fm_workbook_set_cell_phonetic(fm_workbook_t* wb, size_t s return 0; } +extern "C" fm_status_t fm_workbook_set_cell_phonetic_runs(fm_workbook_t* wb, size_t sheet_index, uint32_t row, + uint32_t col, const fm_phonetic_run_t* runs, size_t count) { + clear_last_error(); + if (auto rc = check_sheet_index(wb, sheet_index, "fm_workbook_set_cell_phonetic_runs"); rc != 0) { + return rc; + } + if (runs == nullptr && count != 0U) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, + "fm_workbook_set_cell_phonetic_runs: runs is NULL"); + } + if (row >= formulon::Sheet::kMaxRows || col >= formulon::Sheet::kMaxCols) { + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, + "fm_workbook_set_cell_phonetic_runs: cell coordinate out of range"); + } + + std::vector parsed; + parsed.reserve(count); + // `PHONETIC` walks the runs once in order, emitting each run's kana when + // the walk reaches its start. Runs that overlap or run backwards would + // still all be emitted, silently attaching kana to characters they do not + // read, so the ordered-partition rule is enforced here rather than + // repaired at composition time. + std::uint32_t previous_end = 0; + for (size_t i = 0; i < count; ++i) { + const fm_phonetic_run_t& run = runs[i]; + if (run.text == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, + "fm_workbook_set_cell_phonetic_runs: run text is NULL", + "run_index=" + std::to_string(i)); + } + if (run.eb < run.sb) { + return set_binding_error( + formulon::FormulonErrorCode::kInvalidArgument, + "fm_workbook_set_cell_phonetic_runs: run ends before it starts", + "run_index=" + std::to_string(i) + " sb=" + std::to_string(run.sb) + " eb=" + std::to_string(run.eb)); + } + if (i != 0U && run.sb < previous_end) { + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, + "fm_workbook_set_cell_phonetic_runs: runs overlap or are out of order", + "run_index=" + std::to_string(i) + " sb=" + std::to_string(run.sb) + + " previous_eb=" + std::to_string(previous_end)); + } + previous_end = run.eb; + parsed.push_back(formulon::PhoneticRun{run.sb, run.eb, std::string(run.text)}); + } + + wb->workbook().sheet(sheet_index).set_cell_phonetic_runs(row, col, std::move(parsed)); + return 0; +} + extern "C" fm_status_t fm_workbook_set_blank(fm_workbook_t* wb, size_t sheet_index, uint32_t row, uint32_t col) { clear_last_error(); if (auto rc = check_sheet_index(wb, sheet_index, "fm_workbook_set_blank"); rc != 0) { @@ -191,6 +241,48 @@ extern "C" fm_status_t fm_workbook_get_cell_phonetic(const fm_workbook_t* wb, si return 0; } +extern "C" fm_status_t fm_workbook_get_cell_phonetic_run_count(const fm_workbook_t* wb, size_t sheet_index, + uint32_t row, uint32_t col, uint32_t* out_count) { + clear_last_error(); + if (out_count == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, + "fm_workbook_get_cell_phonetic_run_count: out_count is NULL"); + } + if (auto rc = check_sheet_index(wb, sheet_index, "fm_workbook_get_cell_phonetic_run_count"); rc != 0) { + return rc; + } + const formulon::Cell* cell = wb->workbook().sheet(sheet_index).cell_at(row, col); + *out_count = cell == nullptr ? 0U : static_cast(cell->phonetic_runs.size()); + return 0; +} + +extern "C" fm_status_t fm_workbook_get_cell_phonetic_run(const fm_workbook_t* wb, size_t sheet_index, uint32_t row, + uint32_t col, uint32_t run_index, fm_phonetic_run_t* out) { + clear_last_error(); + if (out == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, + "fm_workbook_get_cell_phonetic_run: out is NULL"); + } + if (auto rc = check_sheet_index(wb, sheet_index, "fm_workbook_get_cell_phonetic_run"); rc != 0) { + return rc; + } + const formulon::Cell* cell = wb->workbook().sheet(sheet_index).cell_at(row, col); + const size_t run_count = cell == nullptr ? 0U : cell->phonetic_runs.size(); + if (run_index >= run_count) { + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, + "fm_workbook_get_cell_phonetic_run: run_index out of range", + "run_index=" + std::to_string(run_index) + " run_count=" + std::to_string(run_count)); + } + const formulon::PhoneticRun& run = cell->phonetic_runs[run_index]; + TextStore& store = const_cast(wb->read_scratch); + store.clear(); + store.emplace_back(run.text); + out->sb = run.sb; + out->eb = run.eb; + out->text = store.back().c_str(); + return 0; +} + extern "C" fm_status_t fm_workbook_lambda_text_at(fm_workbook_t* wb, size_t sheet_index, uint32_t row, uint32_t col, const char** out_text) { clear_last_error(); diff --git a/src/c_api/parts/styles.cpp b/src/c_api/parts/styles.cpp index 24cabc0b..5bd7d15c 100644 --- a/src/c_api/parts/styles.cpp +++ b/src/c_api/parts/styles.cpp @@ -741,6 +741,39 @@ extern "C" fm_status_t fm_styles_add_font(fm_workbook_t* wb, fm_font_record reco return 0; } +extern "C" fm_status_t fm_styles_set_font(fm_workbook_t* wb, uint32_t font_index, fm_font_record record) { + clear_last_error(); + if (wb == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, "fm_styles_set_font: NULL argument"); + } + formulon::io::StylesTable& styles = wb->workbook().mutable_styles(); + // No `ensure_default_style_roots` here: growing the table to satisfy an + // out-of-range index would install a default-constructed font at every + // slot below it, which is a different workbook than the caller asked for. + if (font_index >= styles.fonts.size()) { + return set_binding_error( + formulon::FormulonErrorCode::kInvalidArgument, "fm_styles_set_font: font_index out of range", + "font_index=" + std::to_string(font_index) + " fonts_count=" + std::to_string(styles.fonts.size())); + } + styles.fonts[font_index] = font_from_c(record); + return 0; +} + +extern "C" fm_status_t fm_workbook_set_default_font(fm_workbook_t* wb, fm_font_record record) { + clear_last_error(); + if (wb == nullptr) { + return set_binding_error(formulon::FormulonErrorCode::kBindingNullPointer, + "fm_workbook_set_default_font: NULL argument"); + } + formulon::io::StylesTable& styles = wb->workbook().mutable_styles(); + // Unlike `fm_styles_set_font`, seeding is right here: index 0 is reserved + // by construction, so a table without it is an empty table rather than one + // whose slot the caller mis-numbered. + ensure_default_style_roots(styles); + styles.fonts[0] = font_from_c(record); + return 0; +} + extern "C" fm_status_t fm_styles_add_fill(fm_workbook_t* wb, fm_fill_record record, uint32_t* out_index) { clear_last_error(); if (wb == nullptr || out_index == nullptr) { diff --git a/src/node_addon/parts/lifecycle.cc b/src/node_addon/parts/lifecycle.cc index 099142ef..b06e50d9 100644 --- a/src/node_addon/parts/lifecycle.cc +++ b/src/node_addon/parts/lifecycle.cc @@ -113,6 +113,54 @@ Napi::Value Workbook::SetText(const Napi::CallbackInfo& info) { return MakeStatus(env, rc); } +Napi::Value Workbook::SetCellPhonetic(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const std::string phonetic = ArgString(info, 3); + fm_status_t rc = fm_workbook_set_cell_phonetic(handle_, sheet, row, col, phonetic.c_str()); + return MakeStatus(env, rc); +} + +Napi::Value Workbook::SetCellPhoneticRuns(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + if (info.Length() <= 3 || !info[3].IsArray()) { + return MakeBindingArgumentError(env, "setCellPhoneticRuns: `runs` must be an array of { sb, eb, text }"); + } + const Napi::Array runs = info[3].As(); + const uint32_t count = runs.Length(); + // Two passes so no `c_str()` is taken before `texts` has finished growing. + std::vector texts; + texts.reserve(count); + std::vector records; + records.reserve(count); + for (uint32_t i = 0; i < count; ++i) { + Napi::Value element = runs.Get(i); + if (!element.IsObject()) { + return MakeBindingArgumentError(env, "setCellPhoneticRuns: each run must be an object { sb, eb, text }"); + } + const Napi::Object run = element.As(); + const Napi::Value text = run.Get("text"); + texts.push_back(text.IsString() ? text.As().Utf8Value() : std::string()); + records.push_back(fm_phonetic_run_t{SpecPullU32(run, "sb", 0U), SpecPullU32(run, "eb", 0U), nullptr}); + } + for (uint32_t i = 0; i < count; ++i) { + records[i].text = texts[i].c_str(); + } + fm_status_t rc = fm_workbook_set_cell_phonetic_runs(handle_, sheet, row, col, records.data(), records.size()); + return MakeStatus(env, rc); +} + Napi::Value Workbook::SetBlank(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (handle_ == nullptr) { @@ -156,6 +204,53 @@ Napi::Value Workbook::GetValue(const Napi::CallbackInfo& info) { return MakeValueResult(env, MakeOkStatus(env), v); } +Napi::Value Workbook::GetCellPhonetic(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return MakeStringFieldResult(env, NullHandleError(env), "value", ""); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + const char* text = nullptr; + fm_status_t rc = fm_workbook_get_cell_phonetic(handle_, sheet, row, col, &text); + if (rc != 0) { + return MakeStringFieldResult(env, MakeErrorStatus(env, rc), "value", ""); + } + return MakeStringFieldResult(env, MakeOkStatus(env), "value", text); +} + +Napi::Value Workbook::GetCellPhoneticRuns(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return MakeFieldResult(env, NullHandleError(env), "runs", Napi::Array::New(env, 0)); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + uint32_t count = 0; + fm_status_t rc = fm_workbook_get_cell_phonetic_run_count(handle_, sheet, row, col, &count); + Napi::Array out = Napi::Array::New(env, rc == 0 ? count : 0); + for (uint32_t i = 0; rc == 0 && i < count; ++i) { + fm_phonetic_run_t run{}; + rc = fm_workbook_get_cell_phonetic_run(handle_, sheet, row, col, i, &run); + if (rc != 0) { + break; + } + Napi::Object entry = Napi::Object::New(env); + entry.Set("sb", Napi::Number::New(env, run.sb)); + entry.Set("eb", Napi::Number::New(env, run.eb)); + // Copied immediately: each read refreshes the handle's scratch, so the + // previous run's pointer is dead by the time the next one lands. + entry.Set("text", Napi::String::New(env, run.text != nullptr ? run.text : "")); + out.Set(i, entry); + } + if (rc != 0) { + return MakeFieldResult(env, MakeErrorStatus(env, rc), "runs", Napi::Array::New(env, 0)); + } + return MakeFieldResult(env, MakeOkStatus(env), "runs", out); +} + Napi::Value Workbook::EvaluateFormulaText(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (handle_ == nullptr) { diff --git a/src/node_addon/parts/styles.cc b/src/node_addon/parts/styles.cc index ccce8d4e..3819f4c8 100644 --- a/src/node_addon/parts/styles.cc +++ b/src/node_addon/parts/styles.cc @@ -414,6 +414,31 @@ Napi::Value Workbook::AddFont(const Napi::CallbackInfo& info) { return MakeNumberFieldResult(env, MakeOkStatus(env), "index", idx); } +Napi::Value Workbook::SetFont(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const uint32_t font_index = ArgU32(info, 0); + Napi::Object record = (info.Length() > 1 && info[1].IsObject()) ? info[1].As() : Napi::Object::New(env); + std::string name; + fm_font_record fr{}; + PullFontRecord(record, &name, &fr); + return MakeStatus(env, fm_styles_set_font(handle_, font_index, fr)); +} + +Napi::Value Workbook::SetDefaultFont(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + Napi::Object record = (info.Length() > 0 && info[0].IsObject()) ? info[0].As() : Napi::Object::New(env); + std::string name; + fm_font_record fr{}; + PullFontRecord(record, &name, &fr); + return MakeStatus(env, fm_workbook_set_default_font(handle_, fr)); +} + Napi::Value Workbook::AddFill(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (handle_ == nullptr) { diff --git a/src/node_addon/parts/workbook_class.cc b/src/node_addon/parts/workbook_class.cc index d08bc5f5..1c83569c 100644 --- a/src/node_addon/parts/workbook_class.cc +++ b/src/node_addon/parts/workbook_class.cc @@ -220,6 +220,8 @@ Napi::Function Workbook::GetClass(Napi::Env env) { InstanceMethod<&Workbook::GetCellStyleXf>("getCellStyleXf"), InstanceMethod<&Workbook::GetCellXf>("getCellXf"), InstanceMethod<&Workbook::GetCellXfIndex>("getCellXfIndex"), + InstanceMethod<&Workbook::GetCellPhonetic>("getCellPhonetic"), + InstanceMethod<&Workbook::GetCellPhoneticRuns>("getCellPhoneticRuns"), InstanceMethod<&Workbook::GetComment>("getComment"), InstanceMethod<&Workbook::GetCommentResult>("getCommentResult"), InstanceMethod<&Workbook::GetComments>("getComments"), @@ -359,6 +361,8 @@ Napi::Function Workbook::GetClass(Napi::Env env) { InstanceMethod<&Workbook::SetCalcMode>("setCalcMode"), InstanceMethod<&Workbook::SetPinnedNow>("setPinnedNow"), InstanceMethod<&Workbook::SetCellXfIndex>("setCellXfIndex"), + InstanceMethod<&Workbook::SetCellPhonetic>("setCellPhonetic"), + InstanceMethod<&Workbook::SetCellPhoneticRuns>("setCellPhoneticRuns"), InstanceMethod<&Workbook::SetRangeXfIndex>("setRangeXfIndex"), InstanceMethod<&Workbook::SetColumnHidden>("setColumnHidden"), InstanceMethod<&Workbook::SetColumnOutline>("setColumnOutline"), @@ -366,6 +370,8 @@ Napi::Function Workbook::GetClass(Napi::Env env) { InstanceMethod<&Workbook::SetComment>("setComment"), InstanceMethod<&Workbook::SetDefinedName>("setDefinedName"), InstanceMethod<&Workbook::SetDefinedNameScoped>("setDefinedNameScoped"), + InstanceMethod<&Workbook::SetDefaultFont>("setDefaultFont"), + InstanceMethod<&Workbook::SetFont>("setFont"), InstanceMethod<&Workbook::SetError>("setError"), InstanceMethod<&Workbook::SetExcelProfileId>("setExcelProfileId"), InstanceMethod<&Workbook::SetFormula>("setFormula"), diff --git a/src/node_addon/parts/workbook_class.h b/src/node_addon/parts/workbook_class.h index 768e0f1f..b4092976 100644 --- a/src/node_addon/parts/workbook_class.h +++ b/src/node_addon/parts/workbook_class.h @@ -39,11 +39,15 @@ class Workbook : public Napi::ObjectWrap { Napi::Value SetBool(const Napi::CallbackInfo& info); Napi::Value SetError(const Napi::CallbackInfo& info); Napi::Value SetText(const Napi::CallbackInfo& info); + Napi::Value SetCellPhonetic(const Napi::CallbackInfo& info); + Napi::Value SetCellPhoneticRuns(const Napi::CallbackInfo& info); Napi::Value SetBlank(const Napi::CallbackInfo& info); Napi::Value SetFormula(const Napi::CallbackInfo& info); // Cell read. Napi::Value GetValue(const Napi::CallbackInfo& info); + Napi::Value GetCellPhonetic(const Napi::CallbackInfo& info); + Napi::Value GetCellPhoneticRuns(const Napi::CallbackInfo& info); // Ad-hoc, side-effect-free formula evaluation. Napi::Value EvaluateFormulaText(const Napi::CallbackInfo& info); @@ -281,6 +285,8 @@ class Workbook : public Napi::ObjectWrap { Napi::Value GetNumFmt(const Napi::CallbackInfo& info); Napi::Value GetDxf(const Napi::CallbackInfo& info); Napi::Value AddFont(const Napi::CallbackInfo& info); + Napi::Value SetFont(const Napi::CallbackInfo& info); + Napi::Value SetDefaultFont(const Napi::CallbackInfo& info); Napi::Value AddFill(const Napi::CallbackInfo& info); Napi::Value AddBorder(const Napi::CallbackInfo& info); Napi::Value AddNumFmt(const Napi::CallbackInfo& info); diff --git a/src/wasm/formulon.d.ts b/src/wasm/formulon.d.ts index 32756c40..2da1e678 100644 --- a/src/wasm/formulon.d.ts +++ b/src/wasm/formulon.d.ts @@ -1252,6 +1252,20 @@ export interface CellXf { xfId?: number; } +/** One `` block: `text` reads the half-open span `[sb, eb)` of the + * cell's surface text, measured in UTF-16 code units. */ +export interface PhoneticRun { + sb: number; + eb: number; + text: string; +} + +/** Return type of `Workbook.getCellPhoneticRuns(sheet, row, col)`. */ +export interface PhoneticRunsResult { + status: Status; + runs: PhoneticRun[]; +} + /** Return type of `Workbook.getFont(fontIndex)`. */ export interface FontResult extends FontRecord { status: Status; @@ -1610,6 +1624,12 @@ export interface Workbook { setText(sheet: number, row: number, col: number, text: string): Status; /** Stores (or, when empty, clears) the cell's OOXML phonetic guide (``). */ setCellPhonetic(sheet: number, row: number, col: number, phonetic: string): Status; + /** Stores (or, when empty, clears) the cell's phonetic guide as one `` + * block per run. Unlike `setCellPhonetic`, which annotates the whole cell, + * this keeps the span each reading covers. The runs must be an ordered + * partition: each needs `sb <= eb` and must start at or after the previous + * run's `eb`. */ + setCellPhoneticRuns(sheet: number, row: number, col: number, runs: PhoneticRun[]): Status; setBlank(sheet: number, row: number, col: number): Status; setFormula(sheet: number, row: number, col: number, formula: string): Status; @@ -1654,6 +1674,9 @@ export interface Workbook { getLambdaText(sheet: number, row: number, col: number): LambdaTextResult; /** Returns the cell's OOXML phonetic guide (``), or an empty string. */ getCellPhonetic(sheet: number, row: number, col: number): StringResult; + /** Returns the cell's `` blocks with their spans. `getCellPhonetic` + * returns the same readings concatenated, without the spans. */ + getCellPhoneticRuns(sheet: number, row: number, col: number): PhoneticRunsResult; /** Recalculates all dirty cells serially on the caller thread. * @@ -2157,6 +2180,16 @@ export interface Workbook { /** Adds a font (deduplicating against existing entries) and returns * the resolved index. */ addFont(record: FontRecord): AddStyleResult; + /** Overwrites the font at `fontIndex` in place. Every `` naming that + * index restyles at once, so this is a bulk change rather than a local + * edit; `addFont` is the way to introduce a new appearance. The index + * must already exist -- the table does not auto-grow. */ + setFont(fontIndex: number, record: FontRecord): Status; + /** Declares the workbook's default font: font 0, the record an unstyled + * cell resolves to. A new workbook seeds it with Excel's Calibri 11 and + * `addFont` can only append beside it, so this is the way to change what + * a never-styled cell is saved as. Read it back with `getFont(0)`. */ + setDefaultFont(record: FontRecord): Status; /** Adds a fill (deduplicating against existing entries). */ addFill(record: FillRecord): AddStyleResult; /** Adds a border (deduplicating against existing entries). */ diff --git a/src/wasm/parts/bindings_register.cpp b/src/wasm/parts/bindings_register.cpp index 3465cbac..196ee5f6 100644 --- a/src/wasm/parts/bindings_register.cpp +++ b/src/wasm/parts/bindings_register.cpp @@ -171,6 +171,8 @@ EMSCRIPTEN_BINDINGS(formulon) { .function("addDxf", &JsWorkbook::addDxf) .function("addFill", &JsWorkbook::addFill) .function("addFont", &JsWorkbook::addFont) + .function("setFont", &JsWorkbook::setFont) + .function("setDefaultFont", &JsWorkbook::setDefaultFont) .function("addHyperlink", &JsWorkbook::addHyperlink) .function("addHyperlinkRange", &JsWorkbook::addHyperlinkRange) .function("addMerge", &JsWorkbook::addMerge) @@ -214,6 +216,7 @@ EMSCRIPTEN_BINDINGS(formulon) { .function("getCellXf", &JsWorkbook::getCellXf) .function("getCellXfIndex", &JsWorkbook::getCellXfIndex) .function("getCellPhonetic", &JsWorkbook::getCellPhonetic) + .function("getCellPhoneticRuns", &JsWorkbook::getCellPhoneticRuns) .function("getComment", &JsWorkbook::getComment) .function("getCommentResult", &JsWorkbook::getCommentResult) .function("getComments", &JsWorkbook::getComments) @@ -343,6 +346,7 @@ EMSCRIPTEN_BINDINGS(formulon) { .function("clearSheetBreaks", &JsWorkbook::clearSheetBreaks) .function("setCellStyle", &JsWorkbook::setCellStyle) .function("setCellPhonetic", &JsWorkbook::setCellPhonetic) + .function("setCellPhoneticRuns", &JsWorkbook::setCellPhoneticRuns) .function("setColumnHidden", &JsWorkbook::setColumnHidden) .function("setColumnOutline", &JsWorkbook::setColumnOutline) .function("setColumnWidth", &JsWorkbook::setColumnWidth) diff --git a/src/wasm/parts/workbook.h b/src/wasm/parts/workbook.h index 7aa43cdf..d3f66dc1 100644 --- a/src/wasm/parts/workbook.h +++ b/src/wasm/parts/workbook.h @@ -83,11 +83,20 @@ class JsWorkbook { JsStatus setError(uint32_t sheet, uint32_t row, uint32_t col, int32_t errorCode); JsStatus setText(uint32_t sheet, uint32_t row, uint32_t col, const std::string& text); JsStatus setCellPhonetic(uint32_t sheet, uint32_t row, uint32_t col, const std::string& phonetic); + /// Stores a cell's furigana as one `` block per element of `runs`, + /// each `{ sb, eb, text }`. Unlike `setCellPhonetic`, which annotates the + /// whole cell, this preserves which characters each reading covers. See + /// `fm_workbook_set_cell_phonetic_runs` for the ordering rules. + JsStatus setCellPhoneticRuns(uint32_t sheet, uint32_t row, uint32_t col, emscripten::val runs); JsStatus setBlank(uint32_t sheet, uint32_t row, uint32_t col); JsStatus setFormula(uint32_t sheet, uint32_t row, uint32_t col, const std::string& formula); JsCellResult getValue(uint32_t sheet, uint32_t row, uint32_t col) const; emscripten::val getCellPhonetic(uint32_t sheet, uint32_t row, uint32_t col) const; + /// Reads a cell's furigana as `{ status, runs: [{ sb, eb, text }] }`, + /// spans included. `getCellPhonetic` returns the same readings + /// concatenated, without the spans. + emscripten::val getCellPhoneticRuns(uint32_t sheet, uint32_t row, uint32_t col) const; emscripten::val getLambdaText(uint32_t sheet, uint32_t row, uint32_t col) const; /// Evaluates `formula` as if entered at `(sheet, row, col)` and returns a @@ -272,6 +281,12 @@ class JsWorkbook { emscripten::val getDxf(uint32_t dxf_index) const; JsAddStyleResult addFont(emscripten::val record); + /// Overwrites an existing font slot; every xf naming it restyles at once. + /// See `fm_styles_set_font`. + JsStatus setFont(uint32_t font_index, emscripten::val record); + /// Declares the workbook's default font -- font 0, the record an unstyled + /// cell resolves to. See `fm_workbook_set_default_font`. + JsStatus setDefaultFont(emscripten::val record); JsAddStyleResult addFill(emscripten::val record); JsAddStyleResult addBorder(emscripten::val record); JsAddNumFmtResult addNumFmt(const std::string& format_code); diff --git a/src/wasm/parts/workbook_cells.cpp b/src/wasm/parts/workbook_cells.cpp index f8f11d50..db09ea3b 100644 --- a/src/wasm/parts/workbook_cells.cpp +++ b/src/wasm/parts/workbook_cells.cpp @@ -62,6 +62,33 @@ JsStatus JsWorkbook::setCellPhonetic(uint32_t sheet, uint32_t row, uint32_t col, return status_from_rc(rc); } +JsStatus JsWorkbook::setCellPhoneticRuns(uint32_t sheet, uint32_t row, uint32_t col, emscripten::val runs) { + if (handle_ == nullptr) { + return error_status(kBindingInvalidHandle); + } + if (!runs.isArray()) { + return binding_error_status(static_cast(formulon::FormulonErrorCode::kInvalidArgument), + "setCellPhoneticRuns: `runs` must be an array of { sb, eb, text }"); + } + const uint32_t count = runs["length"].as(); + // Two passes for the same reason `createTable` needs them: no `c_str()` + // may be taken before `texts` has finished growing. + std::vector texts; + texts.reserve(count); + std::vector records; + records.reserve(count); + for (uint32_t i = 0; i < count; ++i) { + const emscripten::val run = runs[i]; + texts.push_back(js_pull_string(run, "text")); + records.push_back(fm_phonetic_run_t{js_pull_u32(run, "sb", 0U), js_pull_u32(run, "eb", 0U), nullptr}); + } + for (uint32_t i = 0; i < count; ++i) { + records[i].text = texts[i].c_str(); + } + const fm_status_t rc = fm_workbook_set_cell_phonetic_runs(handle_, sheet, row, col, records.data(), records.size()); + return status_from_rc(rc); +} + JsStatus JsWorkbook::setBlank(uint32_t sheet, uint32_t row, uint32_t col) { if (handle_ == nullptr) { return error_status(7000); @@ -114,6 +141,40 @@ emscripten::val JsWorkbook::getCellPhonetic(uint32_t sheet, uint32_t row, uint32 return o; } +emscripten::val JsWorkbook::getCellPhoneticRuns(uint32_t sheet, uint32_t row, uint32_t col) const { + emscripten::val o = emscripten::val::object(); + emscripten::val out = emscripten::val::array(); + if (handle_ == nullptr) { + o.set("status", error_status(kBindingInvalidHandle)); + o.set("runs", out); + return o; + } + uint32_t count = 0; + fm_status_t rc = fm_workbook_get_cell_phonetic_run_count(handle_, sheet, row, col, &count); + for (uint32_t i = 0; rc == 0 && i < count; ++i) { + fm_phonetic_run_t run{}; + rc = fm_workbook_get_cell_phonetic_run(handle_, sheet, row, col, i, &run); + if (rc != 0) { + break; + } + emscripten::val entry = emscripten::val::object(); + entry.set("sb", run.sb); + entry.set("eb", run.eb); + // Copied immediately: each read refreshes the handle's scratch, so the + // previous run's pointer is dead by the time the next one lands. + entry.set("text", std::string(run.text != nullptr ? run.text : "")); + out.call("push", entry); + } + if (rc != 0) { + o.set("status", error_status(rc)); + o.set("runs", emscripten::val::array()); + return o; + } + o.set("status", ok_status()); + o.set("runs", out); + return o; +} + JsEvalResult JsWorkbook::evaluateFormulaText(uint32_t sheet, uint32_t row, uint32_t col, const std::string& formula) const { JsEvalResult r; diff --git a/src/wasm/parts/workbook_styles.cpp b/src/wasm/parts/workbook_styles.cpp index 3084f8c6..65257b7b 100644 --- a/src/wasm/parts/workbook_styles.cpp +++ b/src/wasm/parts/workbook_styles.cpp @@ -369,6 +369,26 @@ JsAddStyleResult JsWorkbook::addFont(emscripten::val record) { return r; } +JsStatus JsWorkbook::setFont(uint32_t font_index, emscripten::val record) { + if (handle_ == nullptr) { + return error_status(7000); + } + std::string name; + fm_font_record fr{}; + js_pull_font_record(record, &name, &fr); + return status_from_rc(fm_styles_set_font(handle_, font_index, fr)); +} + +JsStatus JsWorkbook::setDefaultFont(emscripten::val record) { + if (handle_ == nullptr) { + return error_status(7000); + } + std::string name; + fm_font_record fr{}; + js_pull_font_record(record, &name, &fr); + return status_from_rc(fm_workbook_set_default_font(handle_, fr)); +} + JsAddStyleResult JsWorkbook::addFill(emscripten::val record) { JsAddStyleResult r; if (handle_ == nullptr) { diff --git a/tests/c_api/formulon_c_styles_test.cpp b/tests/c_api/formulon_c_styles_test.cpp index 462af177..86ca96e5 100644 --- a/tests/c_api/formulon_c_styles_test.cpp +++ b/tests/c_api/formulon_c_styles_test.cpp @@ -2218,3 +2218,83 @@ TEST(FormulonCApiStyles, CreateEmptySeedsReservedStyleSlots) { ASSERT_NE(first_xf_end, std::string::npos); EXPECT_NE(styles_xml.substr(first_xf, first_xf_end - first_xf).find("fillId=\"0\""), std::string::npos); } + +TEST(FormulonCApiStyles, DefaultFontDeclaresWhatAnUnstyledCellSavesAs) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_text(wb.handle, 0, 0, 0, "unstyled"), 0); + + // The seeded default is Excel's Calibri 11, and `add_font` can only append + // beside it -- which is exactly why the declaring entry point exists. + fm_font_record seeded{}; + ASSERT_EQ(fm_styles_get_font(wb.handle, 0, &seeded), 0); + EXPECT_STREQ(seeded.name, "Calibri"); + fm_font_record appended{}; + appended.name = "游ゴシック"; + appended.size = 11.0; + uint32_t appended_index = 0; + ASSERT_EQ(fm_styles_add_font(wb.handle, appended, &appended_index), 0); + EXPECT_GT(appended_index, 0U); + + fm_font_record declared{}; + declared.name = "游ゴシック"; + declared.size = 11.0; + declared.has_family = 1; + declared.family = 2; + declared.has_charset = 1; + declared.charset = 128; + declared.color_argb = 0xFF000000U; + ASSERT_EQ(fm_workbook_set_default_font(wb.handle, declared), 0); + + fm_font_record read_back{}; + ASSERT_EQ(fm_styles_get_font(wb.handle, 0, &read_back), 0); + EXPECT_STREQ(read_back.name, "游ゴシック"); + EXPECT_EQ(read_back.charset, 128U); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + const std::string styles_xml = ExtractStylesXml(saved); + const std::size_t fonts_begin = styles_xml.find("", fonts_begin); + ASSERT_NE(first_font, std::string::npos); + const std::size_t first_font_end = styles_xml.find("", first_font); + ASSERT_NE(first_font_end, std::string::npos); + const std::string first = styles_xml.substr(first_font, first_font_end - first_font); + EXPECT_NE(first.find(""), std::string::npos); + EXPECT_EQ(first.find("Calibri"), std::string::npos); +} + +TEST(FormulonCApiStyles, SetFontOverwritesInPlaceAndRejectsAnAbsentIndex) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_font_record added{}; + added.name = "Meiryo"; + added.size = 12.0; + uint32_t index = 0; + ASSERT_EQ(fm_styles_add_font(wb.handle, added, &index), 0); + ASSERT_GT(index, 0U); + + fm_font_record replacement{}; + replacement.name = "MS Gothic"; + replacement.size = 9.0; + ASSERT_EQ(fm_styles_set_font(wb.handle, index, replacement), 0); + + fm_font_record read_back{}; + ASSERT_EQ(fm_styles_get_font(wb.handle, index, &read_back), 0); + EXPECT_STREQ(read_back.name, "MS Gothic"); + EXPECT_DOUBLE_EQ(read_back.size, 9.0); + + // Overwriting does not grow the table, and an index past its end is + // refused rather than filled in with default records. + uint32_t count = 0; + ASSERT_EQ(fm_styles_get_font_count(wb.handle, &count), 0); + EXPECT_EQ(count, index + 1U); + EXPECT_NE(fm_styles_set_font(wb.handle, count, replacement), 0); + ASSERT_EQ(fm_styles_get_font_count(wb.handle, &count), 0); + EXPECT_EQ(count, index + 1U); + + EXPECT_NE(fm_styles_set_font(nullptr, 0, replacement), 0); + EXPECT_NE(fm_workbook_set_default_font(nullptr, replacement), 0); +} diff --git a/tests/c_api/formulon_c_test.cpp b/tests/c_api/formulon_c_test.cpp index 36261125..b47ea028 100644 --- a/tests/c_api/formulon_c_test.cpp +++ b/tests/c_api/formulon_c_test.cpp @@ -398,6 +398,76 @@ TEST(FormulonCApi, CellPhoneticCanBeReadClearedAndRejectsInvalidArguments) { EXPECT_NE(fm_workbook_get_cell_phonetic(wb.handle, 0, 0, 0, nullptr), 0); } +TEST(FormulonCApi, CellPhoneticRunsPreserveTheirSpans) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_text(wb.handle, 0, 0, 0, "東京都"), 0); + + const fm_phonetic_run_t runs[] = {{0U, 2U, "トウキョウ"}, {2U, 3U, "ト"}}; + ASSERT_EQ(fm_workbook_set_cell_phonetic_runs(wb.handle, 0, 0, 0, runs, 2U), 0); + + uint32_t count = 0; + ASSERT_EQ(fm_workbook_get_cell_phonetic_run_count(wb.handle, 0, 0, 0, &count), 0); + EXPECT_EQ(count, 2U); + + fm_phonetic_run_t read{}; + ASSERT_EQ(fm_workbook_get_cell_phonetic_run(wb.handle, 0, 0, 0, 0U, &read), 0); + EXPECT_EQ(read.sb, 0U); + EXPECT_EQ(read.eb, 2U); + EXPECT_STREQ(read.text, "トウキョウ"); + ASSERT_EQ(fm_workbook_get_cell_phonetic_run(wb.handle, 0, 0, 0, 1U, &read), 0); + EXPECT_EQ(read.sb, 2U); + EXPECT_EQ(read.eb, 3U); + EXPECT_STREQ(read.text, "ト"); + + // The flattening getter still reports the concatenation, and feeding that + // back through the whole-cell setter is exactly the collapse the run API + // exists to avoid. + const char* flattened = nullptr; + ASSERT_EQ(fm_workbook_get_cell_phonetic(wb.handle, 0, 0, 0, &flattened), 0); + EXPECT_STREQ(flattened, "トウキョウト"); + ASSERT_EQ(fm_workbook_set_cell_phonetic(wb.handle, 0, 0, 0, "トウキョウト"), 0); + ASSERT_EQ(fm_workbook_get_cell_phonetic_run_count(wb.handle, 0, 0, 0, &count), 0); + EXPECT_EQ(count, 1U); + + // An empty batch clears; a cell that was never annotated reports zero runs. + ASSERT_EQ(fm_workbook_set_cell_phonetic_runs(wb.handle, 0, 0, 0, nullptr, 0U), 0); + ASSERT_EQ(fm_workbook_get_cell_phonetic_run_count(wb.handle, 0, 0, 0, &count), 0); + EXPECT_EQ(count, 0U); + ASSERT_EQ(fm_workbook_get_cell_phonetic_run_count(wb.handle, 0, 5, 5, &count), 0); + EXPECT_EQ(count, 0U); +} + +TEST(FormulonCApi, CellPhoneticRunsRejectMalformedInput) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_text(wb.handle, 0, 0, 0, "東京都"), 0); + + const fm_phonetic_run_t backwards[] = {{2U, 1U, "ト"}}; + EXPECT_NE(fm_workbook_set_cell_phonetic_runs(wb.handle, 0, 0, 0, backwards, 1U), 0); + const fm_phonetic_run_t overlapping[] = {{0U, 2U, "トウキョウ"}, {1U, 3U, "ト"}}; + EXPECT_NE(fm_workbook_set_cell_phonetic_runs(wb.handle, 0, 0, 0, overlapping, 2U), 0); + const fm_phonetic_run_t null_text[] = {{0U, 2U, nullptr}}; + EXPECT_NE(fm_workbook_set_cell_phonetic_runs(wb.handle, 0, 0, 0, null_text, 1U), 0); + + const fm_phonetic_run_t ok[] = {{0U, 3U, "トウキョウト"}}; + EXPECT_NE(fm_workbook_set_cell_phonetic_runs(nullptr, 0, 0, 0, ok, 1U), 0); + EXPECT_NE(fm_workbook_set_cell_phonetic_runs(wb.handle, 0, 0, 0, nullptr, 1U), 0); + EXPECT_NE(fm_workbook_set_cell_phonetic_runs(wb.handle, 0, formulon::Sheet::kMaxRows, 0, ok, 1U), 0); + + // A rejected batch leaves the previous annotation intact. + ASSERT_EQ(fm_workbook_set_cell_phonetic_runs(wb.handle, 0, 0, 0, ok, 1U), 0); + EXPECT_NE(fm_workbook_set_cell_phonetic_runs(wb.handle, 0, 0, 0, overlapping, 2U), 0); + uint32_t count = 0; + ASSERT_EQ(fm_workbook_get_cell_phonetic_run_count(wb.handle, 0, 0, 0, &count), 0); + EXPECT_EQ(count, 1U); + + fm_phonetic_run_t read{}; + EXPECT_NE(fm_workbook_get_cell_phonetic_run(wb.handle, 0, 0, 0, 1U, &read), 0); + EXPECT_NE(fm_workbook_get_cell_phonetic_run(wb.handle, 0, 0, 0, 0U, nullptr), 0); + EXPECT_NE(fm_workbook_get_cell_phonetic_run_count(wb.handle, 0, 0, 0, nullptr), 0); +} + TEST(FormulonCApi, LoadMapsCorruptAndEncryptedContainersToIoErrors) { fm_workbook_t* loaded = reinterpret_cast(0x1); const std::vector garbage = {0x01U, 0x02U, 0x03U, 0x04U}; diff --git a/tests/wasm/run.mjs b/tests/wasm/run.mjs index 37a00853..962d6c19 100644 --- a/tests/wasm/run.mjs +++ b/tests/wasm/run.mjs @@ -647,6 +647,63 @@ async function run() { } }); + test('setCellPhoneticRuns / getCellPhoneticRuns keep the spans apart', () => { + const wb = Module.Workbook.createDefault(); + try { + assert.ok(wb.setText(0, 0, 0, '東京都').ok); + assert.deepEqual(wb.getCellPhoneticRuns(0, 0, 0).runs, []); + + const runs = [ + { sb: 0, eb: 2, text: 'トウキョウ' }, + { sb: 2, eb: 3, text: 'ト' }, + ]; + assert.ok(wb.setCellPhoneticRuns(0, 0, 0, runs).ok); + assert.deepEqual(wb.getCellPhoneticRuns(0, 0, 0).runs, runs); + // The flattening getter still reports the concatenation. + assert.equal(wb.getCellPhonetic(0, 0, 0).value, 'トウキョウト'); + + // Writing that back through the whole-cell setter is the collapse the + // run API exists to avoid. + assert.ok(wb.setCellPhonetic(0, 0, 0, 'トウキョウト').ok); + assert.deepEqual(wb.getCellPhoneticRuns(0, 0, 0).runs, [{ sb: 0, eb: 3, text: 'トウキョウト' }]); + + assert.ok(wb.setCellPhoneticRuns(0, 0, 0, []).ok); + assert.deepEqual(wb.getCellPhoneticRuns(0, 0, 0).runs, []); + + assert.equal( + wb.setCellPhoneticRuns(0, 0, 0, [ + { sb: 2, eb: 3, text: 'ト' }, + { sb: 0, eb: 2, text: 'トウ' }, + ]).ok, + false, + ); + } finally { + wb.delete(); + } + }); + + test('setDefaultFont replaces font 0, which addFont can only append beside', () => { + const wb = Module.Workbook.createDefault(); + try { + assert.equal(wb.getFont(0).name, 'Calibri'); + const appended = wb.addFont({ name: '游ゴシック', size: 11 }); + assert.ok(appended.status.ok); + assert.ok(appended.index > 0); + assert.equal(wb.getFont(0).name, 'Calibri'); + + assert.ok(wb.setDefaultFont({ name: '游ゴシック', size: 11, hasCharset: true, charset: 128 }).ok); + assert.equal(wb.getFont(0).name, '游ゴシック'); + assert.equal(wb.getFont(0).charset, 128); + + const added = wb.addFont({ name: 'Meiryo', size: 12 }); + assert.ok(wb.setFont(added.index, { name: 'MS Gothic', size: 9 }).ok); + assert.equal(wb.getFont(added.index).name, 'MS Gothic'); + assert.equal(wb.setFont(wb.fontCount(), { name: 'MS Gothic', size: 9 }).ok, false); + } finally { + wb.delete(); + } + }); + test('table update omits fields without resetting existing metadata', () => { const wb = Module.Workbook.createDefault(); try { diff --git a/tools/dev/check_binding_drift.py b/tools/dev/check_binding_drift.py index 95b987be..4c9eff4f 100644 --- a/tools/dev/check_binding_drift.py +++ b/tools/dev/check_binding_drift.py @@ -159,10 +159,8 @@ WASM_ONLY_METHODS = { "addCellStyleXf", "createTable", - "getCellPhonetic", "getSheetAutoFilterXml", "removeTable", - "setCellPhonetic", "setCellStyle", "setSheetAutoFilterXml", "updateTable", diff --git a/tools/wasm/capi_exports.txt b/tools/wasm/capi_exports.txt index 15b78954..45b2e35f 100644 --- a/tools/wasm/capi_exports.txt +++ b/tools/wasm/capi_exports.txt @@ -49,10 +49,13 @@ fm_workbook_set_blank fm_workbook_set_formula fm_workbook_set_error fm_workbook_set_cell_phonetic +fm_workbook_set_cell_phonetic_runs # -- Cell read ------------------------------------------------------------------- fm_workbook_get_value fm_workbook_get_cell_phonetic +fm_workbook_get_cell_phonetic_run_count +fm_workbook_get_cell_phonetic_run fm_workbook_lambda_text_at # -- Ad-hoc array evaluation (two-step: evaluate + per-cell readback) ------------- @@ -191,6 +194,8 @@ fm_styles_add_cell_xf fm_styles_add_dxf fm_styles_add_cell_style_xf fm_styles_set_cell_style +fm_styles_set_font +fm_workbook_set_default_font # -- Pivot layout projection ----------------------------------------------------- fm_workbook_pivot_count From 7dc8e610148151f45bf3ea029e21b6fe13ab2892 Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 17:45:26 +0900 Subject: [PATCH 03/12] docs(changelog): note the phonetic run spans, xlsb guides and default font - record the Unreleased Added entries for fm_workbook_set_cell_phonetic_runs and the two run readers, the native Node addon gaining getCellPhonetic / setCellPhonetic, the BrtSSTItem phonetic tail surviving an xlsb round trip, and fm_workbook_set_default_font / fm_styles_set_font - name the binding-level spelling of each entry point alongside the C ABI one, so a reader on any surface can find it --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d4879a..bbd69106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- A workbook's default font can be declared. Font 0 is the record every + unstyled cell resolves to, and a new workbook seeds it with Excel's + Calibri 11; since the seeded table already owns index 0, `add_font` + could only ever append beside it, leaving a ja-JP host no way to say + what a never-styled cell should be saved as. `fm_workbook_set_default_font` + fills that gap, and `fm_styles_set_font` overwrites any existing slot + in place for the general case of restyling every `` that names it. + Both reach WASM and the native Node addon as `setDefaultFont` / + `setFont` and Python as `set_default_font` / `set_font`; the current + default reads back through the existing `getFont(0)`. + +- Phonetic guides can be authored span by span, not only as one reading + for the whole cell. The core has kept one run per `` block since + 0.11.0, but the bindings carried a single string in each direction, so + reading a partially annotated cell and writing it back collapsed every + span into one whole-cell annotation. + `fm_workbook_set_cell_phonetic_runs` takes the runs as an ordered + partition and `fm_workbook_get_cell_phonetic_run_count` / + `fm_workbook_get_cell_phonetic_run` read them back with their spans. + They reach WASM and the native Node addon as `setCellPhoneticRuns` / + `getCellPhoneticRuns` and Python as `set_phonetic_runs` / + `get_phonetic_runs`. The flattening `getCellPhonetic` is unchanged and + still returns the readings concatenated. + +- `getCellPhonetic` and `setCellPhonetic` reach the native Node addon, + which had neither. They were the last cell-level pair that existed on + WASM and Python only. + +- Phonetic guides survive the MS-XLSB container. `BrtSSTItem`'s phonetic + tail is now decoded and emitted, so furigana no longer disappears when + a workbook is saved as `.xlsb` or read back from one. The binary form + stores the kana once and gives each run a start offset into that + concatenation, and elides the run array entirely for a whole-string + reading; both shapes are handled. The shared-string interner keys on + the guide as well as the text, so two cells reading the same kanji + differently no longer collapse onto one entry. + ## [0.11.0] - 2026-08-22 ### Added From 5b057f5f48b5dc914345e81d5cfdec030bc442ba Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 19:20:03 +0900 Subject: [PATCH 04/12] fix(io): carry the phonetic properties block through both containers - add PhoneticProperties to src/phonetic.h -- ruby font id, kana form and alignment as ordinals -- hang it off Cell beside phonetic_runs, and give Sheet a set_cell_phonetic_props that is independent of the readings, so editing the kana does not reset how it renders - only the runs were carried before, so a guide set to hiragana or to a distributed layout came back as Excel's default halfwidth katakana on the next save - read the element in the DOM, SAX and shared-string paths, and emit it beside every non-empty run list with all three attributes spelled out rather than left to be inferred, which is what Excel writes - an absent resolves to halfwidthKatakana / noControl while a bare one falls back per attribute to fullwidthKatakana / left; the two states are different and are now read apart - put the OOXML attribute vocabulary in the new src/io/phonetic_pr.h so the model header stays free of XML; XLSB packs the same ordinals into BrtSSTItem's trailing (ifnt, flags) pair, so the binary path shares the struct without a mapping table - decode that pair in src/io/xlsb/reader.cpp leniently -- a record stopping short of it keeps the defaults rather than failing the load -- and emit the entry's own values from sst_writer.cpp in place of a fixed 0x0030 - key both shared-string interners on the properties as well as the text and the runs, so one reading rendered as hiragana and another as katakana stay separate string items - compare the font record's theme scheme in the xlsb fidelity and cross-format symmetry checks --- src/cell.h | 6 + src/io/cell_parser.cpp | 6 + src/io/cell_parser.h | 3 + src/io/ooxml/shared_strings_writer.cpp | 32 ++++- src/io/ooxml/shared_strings_writer.h | 16 ++- src/io/ooxml_reader.cpp | 3 + src/io/ooxml_writer_cell.cpp | 13 +- src/io/phonetic_pr.h | 120 ++++++++++++++++++ src/io/sax_xml_reader.cpp | 14 ++ src/io/sax_xml_reader.h | 3 + src/io/sheet_reader.cpp | 11 +- src/io/sst_reader.cpp | 14 ++ src/io/sst_reader.h | 4 + src/io/xlsb/cell_writer.cpp | 11 +- src/io/xlsb/reader.cpp | 59 +++++++-- src/io/xlsb/sst_writer.cpp | 48 ++++--- src/io/xlsb/sst_writer.h | 6 +- src/phonetic.h | 21 +++ src/sheet.cpp | 13 ++ src/sheet.h | 8 ++ tests/unit/io/sst_reader_test.cpp | 29 +++++ tests/unit/io/xlsb/sst_writer_test.cpp | 35 ++--- tests/unit/io/xlsb_fidelity_test.cpp | 11 +- tests/unit/io/xlsb_phonetic_test.cpp | 55 ++++++++ .../unit/io/xlsb_roundtrip_symmetry_test.cpp | 1 + 25 files changed, 467 insertions(+), 75 deletions(-) create mode 100644 src/io/phonetic_pr.h diff --git a/src/cell.h b/src/cell.h index 98d6d76b..adfae235 100644 --- a/src/cell.h +++ b/src/cell.h @@ -92,6 +92,12 @@ struct Cell { /// `cellXfs` index width (Excel allows up to ~65,000 entries; we leave /// headroom for future widening). std::uint32_t xf_index = 0; + /// The `` block attached to the same string item as + /// `phonetic_runs`. Meaningful only when that vector is non-empty; the + /// writer emits no element for a cell with no runs. Sits here rather + /// than beside the runs because it fits the tail padding `xf_index` + /// leaves, so carrying it costs the cell store nothing. + PhoneticProperties phonetic_props; }; } // namespace formulon diff --git a/src/io/cell_parser.cpp b/src/io/cell_parser.cpp index d7d238a8..bceddf9e 100644 --- a/src/io/cell_parser.cpp +++ b/src/io/cell_parser.cpp @@ -32,6 +32,7 @@ #include "io/a1_ref.h" #include "io/iso_date.h" +#include "io/phonetic_pr.h" #include "io/xml_escape.h" #include "io/xml_utils.h" #include "io/xsd_double.h" @@ -416,6 +417,11 @@ Expected parse_cell_element(const pugi::xml_node& node, std:: // from `text_storage`. if (is_node) { CollectInlinePhoneticRuns(is_node, out.phonetic_runs); + if (pugi::xml_node pr = is_node.child("phoneticPr")) { + out.phonetic_props.font_id = static_cast(pr.attribute("fontId").as_uint(0U)); + out.phonetic_props.type = parse_phonetic_type(pr.attribute("type").value()); + out.phonetic_props.alignment = parse_phonetic_alignment(pr.attribute("alignment").value()); + } } return out; } diff --git a/src/io/cell_parser.h b/src/io/cell_parser.h index 97979e2f..aa0503bf 100644 --- a/src/io/cell_parser.h +++ b/src/io/cell_parser.h @@ -78,6 +78,9 @@ struct ParsedCell { /// `SharedStringTable::phonetic_for_entries`. The runs own their kana /// and do not depend on `text_storage`. std::vector phonetic_runs; + /// The `` sibling of those runs, defaulted when the block + /// carries none. Only meaningful when `phonetic_runs` is non-empty. + PhoneticProperties phonetic_props; /// Index into the workbook's `StylesTable::cell_xfs`, sourced from the /// `s=` attribute on the `` element. Defaults to `0` (the default /// xf) when the attribute is absent. diff --git a/src/io/ooxml/shared_strings_writer.cpp b/src/io/ooxml/shared_strings_writer.cpp index e86284dd..5e6f4bf9 100644 --- a/src/io/ooxml/shared_strings_writer.cpp +++ b/src/io/ooxml/shared_strings_writer.cpp @@ -10,6 +10,7 @@ #include #include "cell.h" +#include "io/phonetic_pr.h" #include "io/xml_escape.h" #include "io/xml_utils.h" #include "phonetic.h" @@ -20,7 +21,8 @@ namespace formulon { namespace io { -std::string SharedStrings::key_for(std::string_view text, const std::vector& phonetic) { +std::string SharedStrings::key_for(std::string_view text, const std::vector& phonetic, + PhoneticProperties phonetic_props) { std::string key; key.reserve(text.size() + 32U + phonetic.size() * 16U); key.append(std::to_string(text.size())); @@ -39,24 +41,34 @@ std::string SharedStrings::key_for(std::string_view text, const std::vector& phonetic) { +std::uint32_t SharedStrings::intern(std::string_view text, const std::vector& phonetic, + PhoneticProperties phonetic_props) { ++total_count_; - const std::string key = key_for(text, phonetic); + const std::string key = key_for(text, phonetic, phonetic_props); const auto found = index_.find(key); if (found != index_.end()) { return found->second; } const std::uint32_t index = static_cast(entries_.size()); - entries_.push_back(SharedStringEntry{std::string(text), phonetic}); + entries_.push_back(SharedStringEntry{std::string(text), phonetic, phonetic_props}); index_.emplace(std::move(key), index); return index; } -std::uint32_t SharedStrings::index_of(std::string_view text, const std::vector& phonetic) const { - const auto found = index_.find(key_for(text, phonetic)); +std::uint32_t SharedStrings::index_of(std::string_view text, const std::vector& phonetic, + PhoneticProperties phonetic_props) const { + const auto found = index_.find(key_for(text, phonetic, phonetic_props)); // `BuildSharedStrings` visits the same literal-cell set before any // worksheet is written, so this fallback is unreachable in normal use. // Keep the writer exception-free even if a future caller violates that @@ -86,7 +98,7 @@ SharedStrings BuildSharedStrings(const Workbook& workbook) { const RowCells& cells = row_it->second; for (const Cell& cell : cells) { if (cell.formula_text.empty() && cell.cached_value.is_text()) { - strings.intern(cell.cached_value.as_text(), cell.phonetic_runs); + strings.intern(cell.cached_value.as_text(), cell.phonetic_runs, cell.phonetic_props); } } } @@ -119,6 +131,12 @@ std::string WriteSharedStrings(const SharedStrings& strings) { AppendXmlEscaped(out, run.text); out.append(""); } + // Excel writes the block for every annotated item, spelling out even + // the values it would otherwise infer, so emitting it unconditionally + // beside a non-empty run list reproduces its output exactly. + if (!entry.phonetic.empty()) { + append_phonetic_pr(out, entry.phonetic_props); + } out.append(""); } out.append("\n"); diff --git a/src/io/ooxml/shared_strings_writer.h b/src/io/ooxml/shared_strings_writer.h index 67bcf99c..5d66feb9 100644 --- a/src/io/ooxml/shared_strings_writer.h +++ b/src/io/ooxml/shared_strings_writer.h @@ -21,12 +21,17 @@ struct SharedStringEntry { /// One `` block per run, each covering the surface-text span it /// names. Empty for an unannotated entry. std::vector phonetic; + /// The `` block emitted beside those runs. Ignored for an + /// entry with no runs, which gets no element at all. + PhoneticProperties phonetic_props; }; class SharedStrings { public: - std::uint32_t intern(std::string_view text, const std::vector& phonetic); - std::uint32_t index_of(std::string_view text, const std::vector& phonetic) const; + std::uint32_t intern(std::string_view text, const std::vector& phonetic, + PhoneticProperties phonetic_props); + std::uint32_t index_of(std::string_view text, const std::vector& phonetic, + PhoneticProperties phonetic_props) const; bool empty() const noexcept { return entries_.empty(); } std::uint32_t total_count() const noexcept { return total_count_; } const std::vector& entries() const noexcept { return entries_; } @@ -35,8 +40,11 @@ class SharedStrings { /// Interning key. Two cells share an `` only when both their /// surface text and their full run list agree, so the spans are part /// of the key: `東京都` annotated over `[0,2)` and the same text - /// annotated over `[0,3)` are different string items. - static std::string key_for(std::string_view text, const std::vector& phonetic); + /// annotated over `[0,3)` are different string items. The + /// `` block joins the key for the same reason: one reading + /// rendered as hiragana and the other as katakana are two items. + static std::string key_for(std::string_view text, const std::vector& phonetic, + PhoneticProperties phonetic_props); std::uint32_t total_count_ = 0; std::vector entries_; diff --git a/src/io/ooxml_reader.cpp b/src/io/ooxml_reader.cpp index 8564d615..b6e86cd5 100644 --- a/src/io/ooxml_reader.cpp +++ b/src/io/ooxml_reader.cpp @@ -1004,6 +1004,9 @@ static Expected ReadOoxmlWithThreshold(ByteSpan bytes, s // so the runs are copied rather than moved out. if (idx < sst.phonetic_for_entries.size() && !sst.phonetic_for_entries[idx].empty()) { wb.sheet(i).set_cell_phonetic_runs(row, col, sst.phonetic_for_entries[idx]); + if (idx < sst.phonetic_props_for_entries.size()) { + wb.sheet(i).set_cell_phonetic_props(row, col, sst.phonetic_props_for_entries[idx]); + } } ++pending_sst_count; } diff --git a/src/io/ooxml_writer_cell.cpp b/src/io/ooxml_writer_cell.cpp index 76687089..267c53dc 100644 --- a/src/io/ooxml_writer_cell.cpp +++ b/src/io/ooxml_writer_cell.cpp @@ -32,6 +32,7 @@ #include "cell.h" #include "io/future_functions.h" #include "io/ooxml/shared_strings_writer.h" +#include "io/phonetic_pr.h" #include "io/xml_escape.h" #include "io/xml_utils.h" #include "parser/ast.h" @@ -117,13 +118,14 @@ void AppendErrorCellXml(std::string& out, std::string_view addr, ErrorCode code, // `phonetic` is the kana annotation associated with a Text-valued cell // (empty when none). When non-empty AND `value` is Text, the `` // block expands from `{text}` to -// `{text}{kana}...`, +// `{text}{kana}...`, // one block per run in the order the run list holds them. The spans are // emitted as stored rather than merged into one whole-string block: // PHONETIC leaves the text outside every span in place, so a merged // block would read kana over characters it does not cover. void AppendLiteralCellBody(std::string& out, const Value& value, const std::vector& phonetic, - const SharedStrings* shared_strings, std::uint32_t xf_index) { + PhoneticProperties phonetic_props, const SharedStrings* shared_strings, + std::uint32_t xf_index) { out.push_back('"'); AppendStyleAttr(out, xf_index); if (value.is_number()) { @@ -152,7 +154,7 @@ void AppendLiteralCellBody(std::string& out, const Value& value, const std::vect if (value.is_text()) { if (shared_strings != nullptr) { out.append(" t=\"s\">"); - out.append(std::to_string(shared_strings->index_of(value.as_text(), phonetic))); + out.append(std::to_string(shared_strings->index_of(value.as_text(), phonetic, phonetic_props))); out.append(""); return; } @@ -168,6 +170,9 @@ void AppendLiteralCellBody(std::string& out, const Value& value, const std::vect AppendXmlEscaped(out, run.text); out.append(""); } + if (!phonetic.empty()) { + append_phonetic_pr(out, phonetic_props); + } out.append(""); return; } @@ -370,7 +375,7 @@ bool AppendCellXml(std::string& out, const Sheet& sheet, std::uint32_t row, std: // through the inline-string block, spans intact. out.append("` attribute vocabulary, shared by every path that reads or +// writes a furigana guide. +// +// The element hangs off a string item (`` in the shared-string table, +// `` for an inline string) beside that item's `` runs and states +// how Excel renders and generates the ruby: which font, which kana form, +// and how the kana is distributed over the characters it covers. +// +// It is kept here rather than in `phonetic.h` because the model header is +// deliberately free of XML: `PhoneticProperties` holds ordinals, and this +// is the one place that knows their OOXML spelling. The binary container +// needs no counterpart -- XLSB packs the same ordinals into `BrtSSTItem`'s +// trailer numerically. +// +// An absent element and a present-but-bare one resolve differently, which +// is the trap in this part of the format. Both were measured by handing +// Excel a package that varies in exactly one attribute and reading the +// ordinals back out of the `.xlsb` it converts to: +// +// * No `` at all -> `fontId="0"`, `halfwidthKatakana`, +// `noControl`: the all-zero triple, so a default-constructed +// `PhoneticProperties` is right and a reader can leave it alone. +// * `` -> `fullwidthKatakana`, `left`: each +// attribute falls back to its own schema default, which is a +// different state from the element being missing. +// +// The writer sidesteps the distinction by spelling all three attributes +// out beside any non-empty run list, which is what Excel does too. + +#ifndef FORMULON_IO_PHONETIC_PR_H_ +#define FORMULON_IO_PHONETIC_PR_H_ + +#include +#include +#include + +#include "phonetic.h" + +namespace formulon { +namespace io { + +/// Maps a `type` attribute to `PhoneticProperties::type`. +/// +/// A missing or unrecognised attribute is `fullwidthKatakana`, which is +/// *not* the same as the all-zero state a missing element resolves to. +/// Call this only for an element that is actually present. +inline std::uint8_t parse_phonetic_type(std::string_view value) { + if (value == "halfwidthKatakana") { + return 0U; + } + if (value == "Hiragana") { + return 2U; + } + if (value == "noConversion") { + return 3U; + } + return 1U; // fullwidthKatakana, the attribute's schema default +} + +/// Maps an `alignment` attribute to `PhoneticProperties::alignment`. +/// A missing or unrecognised attribute is `left`, per the same rule. +inline std::uint8_t parse_phonetic_alignment(std::string_view value) { + if (value == "noControl") { + return 0U; + } + if (value == "center") { + return 2U; + } + if (value == "distributed") { + return 3U; + } + return 1U; // left, the attribute's schema default +} + +inline const char* phonetic_type_name(std::uint8_t type) { + switch (type) { + case 1U: + return "fullwidthKatakana"; + case 2U: + return "Hiragana"; + case 3U: + return "noConversion"; + default: + return "halfwidthKatakana"; + } +} + +inline const char* phonetic_alignment_name(std::uint8_t alignment) { + switch (alignment) { + case 1U: + return "left"; + case 2U: + return "center"; + case 3U: + return "distributed"; + default: + return "noControl"; + } +} + +/// Appends ``. +/// +/// All three attributes are always written, matching Excel: it spells the +/// defaults out rather than relying on them being inferred, so a saved +/// package compares byte for byte against the one it was read from. +inline void append_phonetic_pr(std::string& out, const PhoneticProperties& props) { + out.append(""); +} + +} // namespace io +} // namespace formulon + +#endif // FORMULON_IO_PHONETIC_PR_H_ diff --git a/src/io/sax_xml_reader.cpp b/src/io/sax_xml_reader.cpp index e2808c1a..95d1b6df 100644 --- a/src/io/sax_xml_reader.cpp +++ b/src/io/sax_xml_reader.cpp @@ -38,6 +38,7 @@ #include #include "io/a1_ref.h" +#include "io/phonetic_pr.h" #include "io/xsd_int.h" #include "io/zip_reader.h" #include "phonetic.h" @@ -777,6 +778,7 @@ struct CellScratch { /// body, each carrying the surface-text span its kana covers. Cleared /// at the start of each `ScanInlineString` call. std::vector inline_string_phonetic; + PhoneticProperties inline_string_phonetic_props; /// Scratch buffers for entity-decoded / normalized semantic attributes. /// Every simultaneously exposed view has a distinct backing string: the /// three `` attributes, the three `` attributes, and the four row @@ -814,6 +816,7 @@ struct CellScratch { bool ScanInlineString(const char* begin, const char* end, const char** p, CellScratch* scratch, Error* err) { scratch->inline_string.clear(); scratch->inline_string_phonetic.clear(); + scratch->inline_string_phonetic_props = PhoneticProperties{}; // Tracks whether we are currently inside an (phonetic guide) // subtree. wraps a element carrying kana, which must NOT // be concatenated into the surface text — otherwise PHONETIC's @@ -855,6 +858,15 @@ bool ScanInlineString(const char* begin, const char* end, const char** p, CellSc // Close of an inner element (e.g. ): keep streaming. continue; } + if (header.name == "phoneticPr") { + // Self-closing, so this arm has to precede the generic skip below. + // The attribute values are a fixed vocabulary with no escapes, so + // the raw slice is the decoded one. + scratch->inline_string_phonetic_props.font_id = static_cast(AttrUintOr(header, "fontId", 0U)); + scratch->inline_string_phonetic_props.type = parse_phonetic_type(AttrOfRaw(header, "type")); + scratch->inline_string_phonetic_props.alignment = parse_phonetic_alignment(AttrOfRaw(header, "alignment")); + continue; + } if (header.self_closing) { continue; } @@ -932,6 +944,7 @@ bool ScanCell(const char* begin, const char* end, const char** p, const TagHeade record->value = std::string_view{}; record->is_inline_string = false; record->phonetic = nullptr; + record->phonetic_props = PhoneticProperties{}; if (cell_header.self_closing) { return true; @@ -1013,6 +1026,7 @@ bool ScanCell(const char* begin, const char* end, const char** p, const TagHeade record->is_inline_string = true; record->value = std::string_view(scratch->inline_string); record->phonetic = &scratch->inline_string_phonetic; + record->phonetic_props = scratch->inline_string_phonetic_props; } else if (!child.self_closing) { // Unrecognised child of : skip it. if (!SkipUntilClose(begin, end, p, child.name, err)) { diff --git a/src/io/sax_xml_reader.h b/src/io/sax_xml_reader.h index d982a9e8..8015461c 100644 --- a/src/io/sax_xml_reader.h +++ b/src/io/sax_xml_reader.h @@ -106,6 +106,9 @@ struct CellRecord { /// is valid only for the duration of the `on_cell` callback, matching /// the lifetime of every `string_view` above. const std::vector* phonetic = nullptr; + /// The `` block that came with those runs. Only meaningful + /// when `phonetic` points at a non-empty vector. + PhoneticProperties phonetic_props; }; /// One `` element's opening attributes, surfaced to `on_row_start` diff --git a/src/io/sheet_reader.cpp b/src/io/sheet_reader.cpp index 0d69e942..1a463b8a 100644 --- a/src/io/sheet_reader.cpp +++ b/src/io/sheet_reader.cpp @@ -264,8 +264,8 @@ Expected ResolveFormula(const pugi::xml_node& c_node, } Expected ApplyParsedCell(const ParsedCell& parsed, std::string_view formula_text, std::uint32_t xf_index, - const std::vector* phonetic_runs, std::size_t sheet_index, - Workbook& workbook, SheetReadContext& ctx) { + const std::vector* phonetic_runs, PhoneticProperties phonetic_props, + std::size_t sheet_index, Workbook& workbook, SheetReadContext& ctx) { // A `` against a five-entry `` names no style. Fall // back to the default xf so the loaded workbook stays self-consistent: // `fm_cell_get_xf` resolves for every cell that loaded, and a save does @@ -326,6 +326,7 @@ Expected ApplyParsedCell(const ParsedCell& parsed, std::string_view if (!parsed.is_sst_index && phonetic_runs != nullptr && !phonetic_runs->empty()) { workbook.sheet(sheet_index).set_cell_phonetic_runs(parsed.row, parsed.col, *phonetic_runs); + workbook.sheet(sheet_index).set_cell_phonetic_props(parsed.row, parsed.col, phonetic_props); } return Expected::Ok(); } @@ -378,8 +379,8 @@ Expected read_sheet_data(const pugi::xml_document& sheet_doc, std:: } } - auto applied = - ApplyParsedCell(parsed, formula_text, parsed.xf_index, &parsed.phonetic_runs, sheet_index, workbook, ctx); + auto applied = ApplyParsedCell(parsed, formula_text, parsed.xf_index, &parsed.phonetic_runs, + parsed.phonetic_props, sheet_index, workbook, ctx); if (!applied) { return applied.error(); } @@ -784,7 +785,7 @@ Expected ApplyCellRecord(const CellRecord& rec, std::size_t sheet_i // SAX record. SST-referenced cells (rec.phonetic stays null by // construction) route their phonetic through the post-loop SST // resolution pass instead — same contract the DOM path uses. - auto applied = ApplyParsedCell(cell, formula_text, xf, rec.phonetic, sheet_index, workbook, ctx); + auto applied = ApplyParsedCell(cell, formula_text, xf, rec.phonetic, rec.phonetic_props, sheet_index, workbook, ctx); if (!applied) { return applied.error(); } diff --git a/src/io/sst_reader.cpp b/src/io/sst_reader.cpp index cfdedfa2..a62e9858 100644 --- a/src/io/sst_reader.cpp +++ b/src/io/sst_reader.cpp @@ -20,6 +20,7 @@ #include #include +#include "io/phonetic_pr.h" #include "io/xml_escape.h" #include "io/xml_utils.h" #include "phonetic.h" @@ -52,6 +53,18 @@ void CollectPhoneticRuns(const pugi::xml_node& si_node, std::vector } } +/// Reads the `` sibling of those runs. An absent element +/// leaves the defaults, which is the state Excel would have inferred. +PhoneticProperties ReadPhoneticProperties(const pugi::xml_node& si_node) { + PhoneticProperties props; + if (pugi::xml_node node = si_node.child("phoneticPr")) { + props.font_id = static_cast(node.attribute("fontId").as_uint(0U)); + props.type = parse_phonetic_type(node.attribute("type").value()); + props.alignment = parse_phonetic_alignment(node.attribute("alignment").value()); + } + return props; +} + } // namespace Expected read_shared_strings(std::vector sst_bytes, @@ -95,6 +108,7 @@ Expected read_shared_strings(std::vector // `phonetic_for_entries.size() == entries.size()`. table.phonetic_for_entries.emplace_back(); CollectPhoneticRuns(si, table.phonetic_for_entries.back()); + table.phonetic_props_for_entries.push_back(ReadPhoneticProperties(si)); } return table; diff --git a/src/io/sst_reader.h b/src/io/sst_reader.h index 7ffb31de..59f95b27 100644 --- a/src/io/sst_reader.h +++ b/src/io/sst_reader.h @@ -48,6 +48,10 @@ struct SharedStringTable { /// `entries`: `phonetic_for_entries.size() == entries.size()` is an /// invariant maintained by `read_shared_strings`. std::vector> phonetic_for_entries; + /// `phonetic_props_for_entries[i]` is the `` sibling of those + /// runs, defaulted when the entry carries none. Held parallel to + /// `entries` under the same invariant as `phonetic_for_entries`. + std::vector phonetic_props_for_entries; }; /// Parses an OOXML shared-strings part. diff --git a/src/io/xlsb/cell_writer.cpp b/src/io/xlsb/cell_writer.cpp index bfce0041..2655d38e 100644 --- a/src/io/xlsb/cell_writer.cpp +++ b/src/io/xlsb/cell_writer.cpp @@ -177,7 +177,8 @@ void EmitArrayFormulaRecord(std::vector& dst, std::uint32_t rw_fir /// the guide belongs to the string entry rather than the cell record, so /// two cells reading the same kanji differently need distinct entries. void EmitLiteralCellRecord(std::vector& dst, std::uint32_t col, std::uint32_t xf_index, - const Value& cached, const std::vector& phonetic, SstBuilder& sst) { + const Value& cached, const std::vector& phonetic, + PhoneticProperties phonetic_props, SstBuilder& sst) { switch (cached.kind()) { case ValueKind::Blank: { std::vector p; @@ -213,7 +214,7 @@ void EmitLiteralCellRecord(std::vector& dst, std::uint32_t col, st return; } case ValueKind::Text: { - const std::uint32_t idx = sst.intern(cached.as_text(), phonetic); + const std::uint32_t idx = sst.intern(cached.as_text(), phonetic, phonetic_props); std::vector p; EmitCellHeader(p, col, xf_index); emit_u32(p, idx); @@ -258,14 +259,14 @@ Expected emit_cell(std::vector& dst, const Cell& cell if (downgraded_formula_count != nullptr) { ++*downgraded_formula_count; } - EmitLiteralCellRecord(dst, col, cell.xf_index, cell.cached_value, cell.phonetic_runs, sst); + EmitLiteralCellRecord(dst, col, cell.xf_index, cell.cached_value, cell.phonetic_runs, cell.phonetic_props, sst); return Expected::Ok(); } EmitFormulaCellRecord(dst, col, cell.xf_index, cell.cached_value, formula_or.value()); return Expected::Ok(); } - EmitLiteralCellRecord(dst, col, cell.xf_index, cell.cached_value, cell.phonetic_runs, sst); + EmitLiteralCellRecord(dst, col, cell.xf_index, cell.cached_value, cell.phonetic_runs, cell.phonetic_props, sst); return Expected::Ok(); } @@ -288,7 +289,7 @@ Expected emit_array_anchor(std::vector& dst, const Ce if (downgraded_to_literal != nullptr) { *downgraded_to_literal = true; } - EmitLiteralCellRecord(dst, col, cell.xf_index, anchor_value, cell.phonetic_runs, sst); + EmitLiteralCellRecord(dst, col, cell.xf_index, anchor_value, cell.phonetic_runs, cell.phonetic_props, sst); return Expected::Ok(); } // The anchor's own cell record is a PtgExp shell typed by the spilled diff --git a/src/io/xlsb/reader.cpp b/src/io/xlsb/reader.cpp index b9f70b40..b703b4e2 100644 --- a/src/io/xlsb/reader.cpp +++ b/src/io/xlsb/reader.cpp @@ -766,7 +766,28 @@ constexpr std::size_t kStrRunSize = 4U; /// annotation type and alignment `` carries in OOXML -- is /// read past but not modelled: `PhoneticRun` holds the reading, not how /// Excel renders it. The OOXML reader drops the same element. -Expected DecodePhoneticTail(ByteSpan& cursor, std::string_view surface, std::vector& out) { +/// Reads the phonetic tail's closing `(u16 ifnt, u16 flags)` pair. +/// +/// Leniently: a record that stops short of the pair keeps the defaults +/// rather than failing the load. The runs have already been decoded by +/// then, and a guide missing only its rendering hints is still the +/// reading the user typed. +void DecodePhoneticProperties(ByteSpan& cursor, PhoneticProperties& out) { + auto font_or = read_u16(cursor); + if (!font_or) { + return; + } + auto flags_or = read_u16(cursor); + if (!flags_or) { + return; + } + out.font_id = font_or.value(); + out.type = static_cast(flags_or.value() & 0x03U); + out.alignment = static_cast((flags_or.value() >> 2U) & 0x03U); +} + +Expected DecodePhoneticTail(ByteSpan& cursor, std::string_view surface, std::vector& out, + PhoneticProperties& out_props) { auto phonetic_or = read_xlwidestring(cursor); if (!phonetic_or) { return phonetic_or.error(); @@ -781,6 +802,7 @@ Expected DecodePhoneticTail(ByteSpan& cursor, std::string_view surf if (!phonetic.empty()) { out.push_back(PhoneticRun{0U, eval::utf16_units_in(surface), phonetic}); } + DecodePhoneticProperties(cursor, out_props); return {}; } @@ -821,6 +843,7 @@ Expected DecodePhoneticTail(ByteSpan& cursor, std::string_view surf out.push_back(PhoneticRun{surface_start, surface_start + surface_length, eval::utf16_substring(phonetic, kana_start, kana_length)}); } + DecodePhoneticProperties(cursor, out_props); return {}; } @@ -828,10 +851,10 @@ Expected DecodePhoneticTail(ByteSpan& cursor, std::string_view surf /// payloads, appending each one into `text_storage` so cells can take /// non-owning views. `out_phonetic` is filled in parallel with `entries` /// -- one (possibly empty) run list per SST index, exactly as the OOXML -/// reader's `phonetic_for_entries` is. +/// reader's `phonetic_for_entries` is, and `out_phonetic_props` beside it. Expected, Error> DecodeSharedStringsBin( const std::vector& body, std::deque& text_storage, - std::vector>& out_phonetic) { + std::vector>& out_phonetic, std::vector& out_phonetic_props) { std::vector entries; ByteSpan cursor{body.data(), body.size()}; while (cursor.size > 0) { @@ -859,6 +882,7 @@ Expected, Error> DecodeSharedStringsBin( text_storage.push_back(std::move(str_or.value())); entries.push_back(text_storage.back()); out_phonetic.emplace_back(); + out_phonetic_props.emplace_back(); if ((flags & kRichStrPhonetic) == 0U) { continue; @@ -882,7 +906,8 @@ Expected, Error> DecodeSharedStringsBin( p.data += static_cast(rich_runs) * kStrRunSize; p.size -= static_cast(rich_runs) * kStrRunSize; } - if (auto decoded = DecodePhoneticTail(p, entries.back(), out_phonetic.back()); !decoded) { + if (auto decoded = DecodePhoneticTail(p, entries.back(), out_phonetic.back(), out_phonetic_props.back()); + !decoded) { return decoded.error(); } } @@ -1995,7 +2020,8 @@ Expected RegisterArraySpills(Workbook& wb, std::size_t sheet_index, Expected DispatchSheetRecord( const XlsbRecord& rec, XlsbRecordType type, const std::uint8_t* framed, std::size_t framed_size, SheetDecodeState& state, std::size_t sheet_index, Workbook& wb, const std::vector& sst_entries, - const std::vector>& sst_phonetic, std::deque& text_storage, + const std::vector>& sst_phonetic, + const std::vector& sst_phonetic_props, std::deque& text_storage, const std::vector& sheet_names, const std::vector& name_table, const std::vector& sheet_ranges, const XlsbExternalBooks& external_books, std::uint32_t* undecoded_formula_count) { @@ -2428,6 +2454,10 @@ Expected DispatchSheetRecord( if (idx_or.value() < sst_phonetic.size() && !sst_phonetic[idx_or.value()].empty()) { wb.sheet(sheet_index) .set_cell_phonetic_runs(state.current_row, col_or.value().col, sst_phonetic[idx_or.value()]); + if (idx_or.value() < sst_phonetic_props.size()) { + wb.sheet(sheet_index) + .set_cell_phonetic_props(state.current_row, col_or.value().col, sst_phonetic_props[idx_or.value()]); + } } if (auto r = ApplyXfIndex(wb, sheet_index, state.current_row, col_or.value().col, col_or.value().xf_index); !r) { return r.error(); @@ -2672,9 +2702,10 @@ Expected DispatchSheetRecord( Expected DecodeSheetBin( const std::vector& body, std::size_t sheet_index, Workbook& wb, const std::vector& sst_entries, const std::vector>& sst_phonetic, - std::deque& text_storage, const std::vector& sheet_names, - const std::vector& name_table, const std::vector& sheet_ranges, - const XlsbExternalBooks& external_books, std::uint32_t* undecoded_formula_count) { + const std::vector& sst_phonetic_props, std::deque& text_storage, + const std::vector& sheet_names, const std::vector& name_table, + const std::vector& sheet_ranges, const XlsbExternalBooks& external_books, + std::uint32_t* undecoded_formula_count) { SheetDecodeState state; ByteSpan cursor{body.data(), body.size()}; while (cursor.size > 0) { @@ -2692,8 +2723,8 @@ Expected DecodeSheetBin( // Every record resolves to a disposition; the result is consumed rather // than discarded so that no record can pass through unclassified. auto disposition_or = DispatchSheetRecord(rec, type, framed, framed_size, state, sheet_index, wb, sst_entries, - sst_phonetic, text_storage, sheet_names, name_table, sheet_ranges, - external_books, undecoded_formula_count); + sst_phonetic, sst_phonetic_props, text_storage, sheet_names, name_table, + sheet_ranges, external_books, undecoded_formula_count); if (!disposition_or) { return disposition_or.error(); } @@ -2883,12 +2914,13 @@ Expected read_xlsb(ByteSpan bytes) { std::vector sst_entries; // Parallel to `sst_entries`, one (possibly empty) run list per index. std::vector> sst_phonetic; + std::vector sst_phonetic_props; if (!wb_rels.sst_path.empty() && zip.has_entry(wb_rels.sst_path)) { auto sst_bytes_or = zip.read_entry(wb_rels.sst_path); if (!sst_bytes_or) { return sst_bytes_or.error(); } - auto sst_or = DecodeSharedStringsBin(sst_bytes_or.value(), text_storage, sst_phonetic); + auto sst_or = DecodeSharedStringsBin(sst_bytes_or.value(), text_storage, sst_phonetic, sst_phonetic_props); if (!sst_or) { return sst_or.error(); } @@ -2939,8 +2971,9 @@ Expected read_xlsb(ByteSpan bytes) { if (!sheet_bytes_or) { return sheet_bytes_or.error(); } - auto state_or = DecodeSheetBin(sheet_bytes_or.value(), i, wb, sst_entries, sst_phonetic, text_storage, sheet_names, - name_table, sheet_ranges, external_books, &undecoded_formula_count); + auto state_or = + DecodeSheetBin(sheet_bytes_or.value(), i, wb, sst_entries, sst_phonetic, sst_phonetic_props, text_storage, + sheet_names, name_table, sheet_ranges, external_books, &undecoded_formula_count); if (!state_or) { return state_or.error(); } diff --git a/src/io/xlsb/sst_writer.cpp b/src/io/xlsb/sst_writer.cpp index 56586429..5d432b24 100644 --- a/src/io/xlsb/sst_writer.cpp +++ b/src/io/xlsb/sst_writer.cpp @@ -25,16 +25,19 @@ namespace { /// `RichStr` flag bit announcing the phonetic tail ([MS-XLSB] §2.5.87). constexpr std::uint8_t kRichStrPhonetic = 0x02U; -/// The phonetic tail's closing `(u16 ifnt, u16 flags)`. +/// The constant high bits of the phonetic tail's closing flags word. /// -/// `ifnt = 0` names the workbook's default font, and the flags word -/// packs the annotation type in bits 0-1 and its alignment in bits 2-3 -/// over a constant `0x30`. `0x0030` is therefore -/// `halfwidthKatakana` / `noControl` — the pair Excel itself writes for -/// a guide that arrived without a ``, which is exactly what -/// the OOXML writer produces. -constexpr std::uint16_t kPhoneticDefaultFont = 0U; -constexpr std::uint16_t kPhoneticDefaultFlags = 0x0030U; +/// The word packs the annotation type in bits 0-1 and its alignment in +/// bits 2-3; bits 4-5 are set on every guide Excel writes. An all-default +/// `PhoneticProperties` therefore encodes as `0x0030`, which is +/// `halfwidthKatakana` / `noControl` — the pair Excel infers for a guide +/// that arrived without a ``. +constexpr std::uint16_t kPhoneticFlagsBase = 0x0030U; + +std::uint16_t pack_phonetic_flags(PhoneticProperties props) { + return static_cast(kPhoneticFlagsBase | (props.type & 0x03U) | + static_cast((props.alignment & 0x03U) << 2U)); +} /// Builds the interner key for one payload. /// @@ -42,7 +45,8 @@ constexpr std::uint16_t kPhoneticDefaultFlags = 0x0030U; /// strings writer does: without it, adjacent fields could be re-cut into /// the same byte sequence and collide two distinct annotations onto one /// entry. -std::string key_for(std::string_view text, const std::vector& phonetic) { +std::string key_for(std::string_view text, const std::vector& phonetic, + PhoneticProperties phonetic_props) { std::string key; key.reserve(text.size() + 32U + phonetic.size() * 16U); key.append(std::to_string(text.size())); @@ -58,6 +62,14 @@ std::string key_for(std::string_view text, const std::vector& phone key.push_back(':'); key.append(run.text); } + if (!phonetic.empty()) { + key.push_back('|'); + key.append(std::to_string(phonetic_props.font_id)); + key.push_back(','); + key.append(std::to_string(phonetic_props.type)); + key.push_back(','); + key.append(std::to_string(phonetic_props.alignment)); + } return key; } @@ -73,7 +85,8 @@ std::uint16_t narrow_offset(std::uint32_t units) { } /// Appends the phonetic tail for `runs` to `payload`. -void emit_phonetic_tail(std::vector& payload, const std::vector& runs) { +void emit_phonetic_tail(std::vector& payload, const std::vector& runs, + PhoneticProperties props) { std::string kana; for (const PhoneticRun& run : runs) { kana.append(run.text); @@ -89,19 +102,20 @@ void emit_phonetic_tail(std::vector& payload, const std::vector run.sb ? run.eb - run.sb : 0U)); kana_offset += eval::utf16_units_in(run.text); } - emit_u16(payload, kPhoneticDefaultFont); - emit_u16(payload, kPhoneticDefaultFlags); + emit_u16(payload, props.font_id); + emit_u16(payload, pack_phonetic_flags(props)); } } // namespace -std::uint32_t SstBuilder::intern(std::string_view text, const std::vector& phonetic) { - std::string key = key_for(text, phonetic); +std::uint32_t SstBuilder::intern(std::string_view text, const std::vector& phonetic, + PhoneticProperties phonetic_props) { + std::string key = key_for(text, phonetic, phonetic_props); if (auto it = index_.find(key); it != index_.end()) { return it->second; } const std::uint32_t idx = static_cast(entries_.size()); - entries_.push_back(SstEntry{std::string(text), phonetic}); + entries_.push_back(SstEntry{std::string(text), phonetic, phonetic_props}); index_.emplace(std::move(key), idx); return idx; } @@ -132,7 +146,7 @@ Expected, Error> emit_sst(const SstBuilder& sst) { emit_u8(p, has_phonetic ? kRichStrPhonetic : 0U); emit_xlwidestring(p, entry.text); if (has_phonetic) { - emit_phonetic_tail(p, entry.phonetic); + emit_phonetic_tail(p, entry.phonetic, entry.phonetic_props); } emit_record(body, static_cast(XlsbRecordType::BrtSSTItem), p); } diff --git a/src/io/xlsb/sst_writer.h b/src/io/xlsb/sst_writer.h index 426cc9fc..60bfc993 100644 --- a/src/io/xlsb/sst_writer.h +++ b/src/io/xlsb/sst_writer.h @@ -42,6 +42,9 @@ namespace xlsb { struct SstEntry { std::string text; std::vector phonetic; + /// The guide's `` equivalent, packed into the record's + /// trailing `(ifnt, flags)` pair. Ignored when `phonetic` is empty. + PhoneticProperties phonetic_props; }; /// Interns text payloads for `xl/sharedStrings.bin`. @@ -55,7 +58,8 @@ class SstBuilder { /// Interns `text` carrying `phonetic` and returns the assigned 0-based /// index. The first time a payload is seen the index equals the prior /// `size()`. - std::uint32_t intern(std::string_view text, const std::vector& phonetic); + std::uint32_t intern(std::string_view text, const std::vector& phonetic, + PhoneticProperties phonetic_props); /// Number of distinct payloads interned so far. Equals /// `entries().size()`. diff --git a/src/phonetic.h b/src/phonetic.h index 688f14e5..3cabaf15 100644 --- a/src/phonetic.h +++ b/src/phonetic.h @@ -37,6 +37,27 @@ struct PhoneticRun { std::string text; }; +/// The `` block that sits beside a string item's `` runs: +/// which font renders the ruby, which kana form Excel generates for it, and +/// how it is distributed over the surface text. +/// +/// The ordinals are the ones XLSB packs into `BrtSSTItem`'s phonetic +/// trailer (`flags = 0x30 | type | (alignment << 2)`), so the XML and +/// binary paths share this struct without a mapping table. +/// +/// Every field's `0` is the value Excel normalises an absent `` +/// to, which is why a default-constructed instance needs no presence flag: +/// writing the element out with all-zero fields reproduces what Excel would +/// have inferred anyway. +struct PhoneticProperties { + /// Index into the workbook's font table for the ruby text. + std::uint16_t font_id = 0; + /// 0=halfwidthKatakana, 1=fullwidthKatakana, 2=Hiragana, 3=noConversion. + std::uint8_t type = 0; + /// 0=noControl, 1=left, 2=center, 3=distributed. + std::uint8_t alignment = 0; +}; + /// Returns every run's kana concatenated in run order. /// /// This is the cell's reading with no regard for which characters each diff --git a/src/sheet.cpp b/src/sheet.cpp index e6f5e018..dfbeb1d5 100644 --- a/src/sheet.cpp +++ b/src/sheet.cpp @@ -246,6 +246,7 @@ void Sheet::set_cell_value(std::uint32_t row, std::uint32_t col, Value v) { Cell& slot = row_cells.ensure(col); slot.formula_text.clear(); slot.phonetic_runs.clear(); + slot.phonetic_props = PhoneticProperties{}; slot.cached_value = v; cell_enumeration_revision_.bump(); } @@ -267,6 +268,7 @@ void Sheet::set_cell_text(std::uint32_t row, std::uint32_t col, std::string_view Cell& slot = row_cells.ensure(col); slot.formula_text.clear(); slot.phonetic_runs.clear(); + slot.phonetic_props = PhoneticProperties{}; auto owned = std::make_unique(text); slot.cached_value = Value::text(*owned); slot.cached_text_owned = std::move(owned); @@ -292,6 +294,7 @@ void Sheet::set_cell_formula(std::uint32_t row, std::uint32_t col, std::string f Cell& slot = row_cells.ensure(col); slot.formula_text = std::move(formula); slot.phonetic_runs.clear(); + slot.phonetic_props = PhoneticProperties{}; slot.cached_value = Value::blank(); cell_enumeration_revision_.bump(); } @@ -402,6 +405,16 @@ void Sheet::set_cell_phonetic_runs(std::uint32_t row, std::uint32_t col, std::ve cell_enumeration_revision_.bump(); } +void Sheet::set_cell_phonetic_props(std::uint32_t row, std::uint32_t col, PhoneticProperties props) { + assert(row < kMaxRows && col < kMaxCols); + + const std::lock_guard guard(*spill_mutex_); + RowCells& row_cells = rows_[row]; + Cell& slot = row_cells.ensure(col); + slot.phonetic_props = props; + cell_enumeration_revision_.bump(); +} + void Sheet::set_cell_xf_index(std::uint32_t row, std::uint32_t col, std::uint32_t xf_index) { assert(row < kMaxRows && col < kMaxCols); // Formatting is orthogonal to a dynamic-array spill. In particular, a diff --git a/src/sheet.h b/src/sheet.h index cca9c22e..afa793b2 100644 --- a/src/sheet.h +++ b/src/sheet.h @@ -962,6 +962,14 @@ class Sheet { /// `set_cell_phonetic`; passing an empty vector clears the annotation. void set_cell_phonetic_runs(std::uint32_t row, std::uint32_t col, std::vector runs); + /// Stores the `` block that belongs with those runs. + /// + /// Independent of `set_cell_phonetic_runs` so a caller that edits only + /// the readings does not silently reset the guide's font, kana form and + /// alignment. The writer emits the element only for a cell that has + /// runs, so setting these on an unannotated cell has no on-disk effect. + void set_cell_phonetic_props(std::uint32_t row, std::uint32_t col, PhoneticProperties props); + /// Stores the cellXfs index for the cell at `(row, col)`. The cell must /// already exist (created via `set_cell_value` / `set_cell_formula`); on /// an absent cell this method is a no-op. `xf_index = 0` references the diff --git a/tests/unit/io/sst_reader_test.cpp b/tests/unit/io/sst_reader_test.cpp index 56b166b9..bc774627 100644 --- a/tests/unit/io/sst_reader_test.cpp +++ b/tests/unit/io/sst_reader_test.cpp @@ -133,6 +133,35 @@ TEST(SstReader, PhoneticGuidesAreNotFoldedIntoSurfaceText) { // Phonetic () capture // --------------------------------------------------------------------------- +TEST(SstReader, PhoneticPropertiesTravelWithTheRuns) { + std::string xml(kXmlDecl); + xml.append(""); + xml.append("\xE5\xA4\xA7\xE9\x98\xAA\xE3\x81\x8A\xE3\x81\x8A\xE3\x81\x95"); + xml.append("\xE3\x81\x8B"); + xml.append(""); + // A bare element is not the same state as no element: each attribute + // falls back to its own schema default (fullwidthKatakana / left), + // where a missing element resolves to halfwidthKatakana / noControl. + xml.append("barex"); + xml.append("plainx"); + xml.append(""); + + std::deque storage; + auto result_or = read_shared_strings(Bytes(xml), storage); + ASSERT_TRUE(static_cast(result_or)); + const SharedStringTable& table = result_or.value(); + ASSERT_EQ(table.phonetic_props_for_entries.size(), 3U); + EXPECT_EQ(table.phonetic_props_for_entries[0].font_id, 3U); + EXPECT_EQ(table.phonetic_props_for_entries[0].type, 2U); // Hiragana + EXPECT_EQ(table.phonetic_props_for_entries[0].alignment, 2U); // center + EXPECT_EQ(table.phonetic_props_for_entries[1].font_id, 0U); + EXPECT_EQ(table.phonetic_props_for_entries[1].type, 1U); // fullwidthKatakana + EXPECT_EQ(table.phonetic_props_for_entries[1].alignment, 1U); // left + EXPECT_EQ(table.phonetic_props_for_entries[2].font_id, 0U); + EXPECT_EQ(table.phonetic_props_for_entries[2].type, 0U); // halfwidthKatakana + EXPECT_EQ(table.phonetic_props_for_entries[2].alignment, 0U); // noControl +} + TEST(SstReader, EntryWithoutPhoneticHasEmptyParallelView) { // A plain ... entry should produce an empty run list // in the parallel `phonetic_for_entries` slot, keeping the index diff --git a/tests/unit/io/xlsb/sst_writer_test.cpp b/tests/unit/io/xlsb/sst_writer_test.cpp index 70fe9937..ed3b882c 100644 --- a/tests/unit/io/xlsb/sst_writer_test.cpp +++ b/tests/unit/io/xlsb/sst_writer_test.cpp @@ -29,6 +29,7 @@ namespace xlsb { namespace { const std::vector kNoPhonetic; +constexpr PhoneticProperties kDefaultProps{}; ByteSpan SpanOf(const std::vector& v) { return ByteSpan{v.data(), v.size()}; @@ -152,11 +153,11 @@ TEST(XlsbSstBuilder, EmptyBuilderEmitsBeginEndFraming) { TEST(XlsbSstBuilder, InternsIdenticalStringsToSameIndex) { SstBuilder sst; - EXPECT_EQ(sst.intern("apple", kNoPhonetic), 0U); - EXPECT_EQ(sst.intern("banana", kNoPhonetic), 1U); - EXPECT_EQ(sst.intern("apple", kNoPhonetic), 0U); - EXPECT_EQ(sst.intern("cherry", kNoPhonetic), 2U); - EXPECT_EQ(sst.intern("banana", kNoPhonetic), 1U); + EXPECT_EQ(sst.intern("apple", kNoPhonetic, kDefaultProps), 0U); + EXPECT_EQ(sst.intern("banana", kNoPhonetic, kDefaultProps), 1U); + EXPECT_EQ(sst.intern("apple", kNoPhonetic, kDefaultProps), 0U); + EXPECT_EQ(sst.intern("cherry", kNoPhonetic, kDefaultProps), 2U); + EXPECT_EQ(sst.intern("banana", kNoPhonetic, kDefaultProps), 1U); EXPECT_EQ(sst.size(), 3U); ASSERT_EQ(sst.entries().size(), 3U); @@ -167,10 +168,10 @@ TEST(XlsbSstBuilder, InternsIdenticalStringsToSameIndex) { TEST(XlsbSstBuilder, EmittedStreamRoundTripsThroughReader) { SstBuilder sst; - sst.intern("alpha", kNoPhonetic); - sst.intern("beta", kNoPhonetic); - sst.intern("alpha", kNoPhonetic); - sst.intern("gamma", kNoPhonetic); + sst.intern("alpha", kNoPhonetic, kDefaultProps); + sst.intern("beta", kNoPhonetic, kDefaultProps); + sst.intern("alpha", kNoPhonetic, kDefaultProps); + sst.intern("gamma", kNoPhonetic, kDefaultProps); auto body_or = emit_sst(sst); ASSERT_TRUE(static_cast(body_or)); @@ -185,10 +186,10 @@ TEST(XlsbSstBuilder, InternHandlesBmpAndSurrogatePairStrings) { SstBuilder sst; // BMP only ("日本") and a string that triggers surrogate pairs ("🌟ok") // to exercise the writer's UTF-16 expansion. - EXPECT_EQ(sst.intern("\xE6\x97\xA5\xE6\x9C\xAC", kNoPhonetic), 0U); + EXPECT_EQ(sst.intern("\xE6\x97\xA5\xE6\x9C\xAC", kNoPhonetic, kDefaultProps), 0U); EXPECT_EQ(sst.intern("\xF0\x9F\x8C\x9F" "ok", - kNoPhonetic), + kNoPhonetic, kDefaultProps), 1U); auto body_or = emit_sst(sst); @@ -207,10 +208,10 @@ TEST(XlsbSstBuilder, PhoneticGuideKeepsItsSpansAndSplitsTheInternKey) { const std::vector other{{0U, 3U, "ヒガシキョウト"}}; // Same surface text, different readings: the guide is part of the entry, // so merging them would move one cell's furigana onto the other. - EXPECT_EQ(sst.intern("東京都", tokyo), 0U); - EXPECT_EQ(sst.intern("東京都", other), 1U); - EXPECT_EQ(sst.intern("東京都", tokyo), 0U); - EXPECT_EQ(sst.intern("東京都", kNoPhonetic), 2U); + EXPECT_EQ(sst.intern("東京都", tokyo, kDefaultProps), 0U); + EXPECT_EQ(sst.intern("東京都", other, kDefaultProps), 1U); + EXPECT_EQ(sst.intern("東京都", tokyo, kDefaultProps), 0U); + EXPECT_EQ(sst.intern("東京都", kNoPhonetic, kDefaultProps), 2U); EXPECT_EQ(sst.size(), 3U); auto body_or = emit_sst(sst); @@ -232,8 +233,8 @@ TEST(XlsbSstBuilder, PhoneticGuideKeepsItsSpansAndSplitsTheInternKey) { TEST(XlsbSstBuilder, BeginRecordCarriesCountFields) { SstBuilder sst; - sst.intern("a", kNoPhonetic); - sst.intern("b", kNoPhonetic); + sst.intern("a", kNoPhonetic, kDefaultProps); + sst.intern("b", kNoPhonetic, kDefaultProps); auto body_or = emit_sst(sst); ASSERT_TRUE(static_cast(body_or)); const std::vector& body = body_or.value(); diff --git a/tests/unit/io/xlsb_fidelity_test.cpp b/tests/unit/io/xlsb_fidelity_test.cpp index fd7bd8a2..bf06ed4b 100644 --- a/tests/unit/io/xlsb_fidelity_test.cpp +++ b/tests/unit/io/xlsb_fidelity_test.cpp @@ -426,8 +426,8 @@ testing::AssertionResult FontsMatch(const io::FontRecord& lhs, const io::FontRec return testing::AssertionFailure() << "toggles differ for font " << lhs.name; } if (lhs.has_family != rhs.has_family || lhs.family != rhs.family || lhs.has_charset != rhs.has_charset || - lhs.charset != rhs.charset) { - return testing::AssertionFailure() << "family/charset differ for font " << lhs.name; + lhs.charset != rhs.charset || lhs.scheme != rhs.scheme) { + return testing::AssertionFailure() << "family/charset/scheme differ for font " << lhs.name; } if (lhs.color.kind != rhs.color.kind || lhs.color_argb != rhs.color_argb || lhs.color.theme != rhs.color.theme || lhs.color.indexed != rhs.color.indexed) { @@ -461,6 +461,13 @@ TEST(XlsbFidelity, FontTableMatchesTheXlsxTwin) { } } EXPECT_TRUE(saw_bold_red) << "the bold red font behind D3 did not survive the load"; + + // `bFontScheme`'s ordinals are not spelled out anywhere on disk; this + // fixture pins them, because its `.xlsx` twin says `` + // for the Normal font while the `.xlsb` says `2`. Asserted directly so a + // reader that stopped decoding the byte cannot pass by comparing two zeros. + EXPECT_EQ(binary.fonts[0].scheme, 2U); + EXPECT_EQ(xml.fonts[0].scheme, 2U); } TEST(XlsbFidelity, FillTableMatchesTheXlsxTwin) { diff --git a/tests/unit/io/xlsb_phonetic_test.cpp b/tests/unit/io/xlsb_phonetic_test.cpp index 64a0e25e..a91c39a7 100644 --- a/tests/unit/io/xlsb_phonetic_test.cpp +++ b/tests/unit/io/xlsb_phonetic_test.cpp @@ -29,6 +29,7 @@ #include "cell.h" #include "gtest/gtest.h" #include "io/ooxml_reader.h" +#include "io/ooxml_writer.h" #include "io/xlsb/reader.h" #include "io/xlsb/writer.h" #include "phonetic.h" @@ -157,6 +158,60 @@ TEST(XlsbPhonetic, SurvivesAWriteReadCycleThroughTheBinaryContainer) { } } +/// The `(font_id, type, alignment)` triple of `(row, 0)`, flattened so a +/// mismatch prints all three. +std::string DescribeProps(const Workbook& wb, std::uint32_t row) { + const Cell* cell = wb.sheet(0).cell_at(row, 0); + if (cell == nullptr) { + return ""; + } + return std::to_string(cell->phonetic_props.font_id) + "/" + std::to_string(cell->phonetic_props.type) + "/" + + std::to_string(cell->phonetic_props.alignment); +} + +TEST(XlsbPhonetic, ReadsThePhoneticPropertiesBothContainersCarry) { + const std::vector xlsx_bytes = ReadFileBytes(XlsxTwinPath()); + ASSERT_FALSE(xlsx_bytes.empty()); + auto xlsx_or = io::read_ooxml(SpanOf(xlsx_bytes)); + ASSERT_TRUE(static_cast(xlsx_or)) << (xlsx_or ? "" : xlsx_or.error().message); + Workbook from_xlsb = LoadXlsb(); + + // Excel wrote `fontId="0" type="halfwidthKatakana" alignment="noControl"` + // on every annotated `` here, which is the all-zero triple. + for (std::uint32_t row = 0; row < 3U; ++row) { + EXPECT_EQ(DescribeProps(from_xlsb, row), "0/0/0") << "row=" << row; + EXPECT_EQ(DescribeProps(xlsx_or.value().workbook, row), "0/0/0") << "row=" << row; + } +} + +TEST(XlsbPhonetic, CarriesNonDefaultPhoneticPropertiesThroughBothContainers) { + Workbook wb = Workbook::create(); + ASSERT_TRUE(static_cast(wb.set_cell_text(0, 0, 0, "大阪"))); + wb.sheet(0).set_cell_phonetic_runs(0, 0, {{0U, 2U, "おおさか"}}); + wb.sheet(0).set_cell_phonetic_props(0, 0, PhoneticProperties{3U, 2U, 2U}); + // Same text and same reading, differing only in how it renders: the + // shared-string interners have to keep the two entries apart or one + // cell's rendering silently becomes the other's. + ASSERT_TRUE(static_cast(wb.set_cell_text(0, 1, 0, "大阪"))); + wb.sheet(0).set_cell_phonetic_runs(1, 0, {{0U, 2U, "おおさか"}}); + + auto xlsb_or = io::xlsb::write_xlsb(wb); + ASSERT_TRUE(static_cast(xlsb_or)) << (xlsb_or ? "" : xlsb_or.error().message); + auto from_xlsb_or = io::xlsb::read_xlsb(SpanOf(xlsb_or.value())); + ASSERT_TRUE(static_cast(from_xlsb_or)) << (from_xlsb_or ? "" : from_xlsb_or.error().message); + EXPECT_EQ(DescribeProps(from_xlsb_or.value().workbook, 0), "3/2/2"); + EXPECT_EQ(DescribeProps(from_xlsb_or.value().workbook, 1), "0/0/0"); + EXPECT_EQ(DescribeRuns(from_xlsb_or.value().workbook, 0), "[0,2)=おおさか "); + + auto xlsx_or = io::write_ooxml(wb); + ASSERT_TRUE(static_cast(xlsx_or)) << (xlsx_or ? "" : xlsx_or.error().message); + auto from_xlsx_or = io::read_ooxml(SpanOf(xlsx_or.value())); + ASSERT_TRUE(static_cast(from_xlsx_or)) << (from_xlsx_or ? "" : from_xlsx_or.error().message); + EXPECT_EQ(DescribeProps(from_xlsx_or.value().workbook, 0), "3/2/2"); + EXPECT_EQ(DescribeProps(from_xlsx_or.value().workbook, 1), "0/0/0"); + EXPECT_EQ(DescribeRuns(from_xlsx_or.value().workbook, 0), "[0,2)=おおさか "); +} + TEST(XlsbPhonetic, KeepsTwoReadingsOfTheSameSurfaceTextApart) { Workbook wb = Workbook::create(); ASSERT_TRUE(static_cast(wb.set_cell_text(0, 0, 0, "東京都"))); diff --git a/tests/unit/io/xlsb_roundtrip_symmetry_test.cpp b/tests/unit/io/xlsb_roundtrip_symmetry_test.cpp index 408cb1fb..f95174d6 100644 --- a/tests/unit/io/xlsb_roundtrip_symmetry_test.cpp +++ b/tests/unit/io/xlsb_roundtrip_symmetry_test.cpp @@ -207,6 +207,7 @@ TEST(XlsbCrossFormatSymmetry, FontRecordContentsMatch) { EXPECT_EQ(fb[i].family, fx[i].family); EXPECT_EQ(fb[i].has_charset, fx[i].has_charset); EXPECT_EQ(fb[i].charset, fx[i].charset); + EXPECT_EQ(fb[i].scheme, fx[i].scheme); ExpectColorSpecEqual(fb[i].color, fx[i].color); } } From d22e056a4e83c78d4e303dce3cdf791b4009ab7e Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 19:20:17 +0900 Subject: [PATCH 05/12] fix(io): round-trip a font's theme scheme link - read into the new FontRecord::scheme and write it back as the last child of , after and , which is the order Excel emits them in - a ja-JP workbook's Normal font carries scheme="minor", which is what makes Excel show it as the body font and re-resolve it when the theme changes; the element was dropped on read, so re-saving rewrote the font as a literal name - collapse an unrecognised value to 0, which emits no element at all: writing an uninterpretable link back would leave a theme reference Excel cannot resolve - decode BrtFont's bFontScheme into the same field and emit it from the xlsb writer instead of a hardcoded 0; the ordinals match, so the binary path needs no mapping table and the field is no longer listed among the record's unmodelled ones - cover minor, major, an unrecognised value and a font with no link on both the reader and the writer --- src/io/styles_reader.cpp | 17 ++++++++++++++ src/io/styles_reader.h | 7 ++++++ src/io/styles_writer.cpp | 25 +++++++++++++++++--- src/io/xlsb/styles_reader.cpp | 6 ++++- src/io/xlsb/styles_reader.h | 6 ++--- src/io/xlsb/styles_writer.cpp | 2 +- tests/unit/io/styles_reader_test.cpp | 21 +++++++++++++++++ tests/unit/io/styles_writer_test.cpp | 34 ++++++++++++++++++++++++++++ 8 files changed, 110 insertions(+), 8 deletions(-) diff --git a/src/io/styles_reader.cpp b/src/io/styles_reader.cpp index 67e00761..13e172f3 100644 --- a/src/io/styles_reader.cpp +++ b/src/io/styles_reader.cpp @@ -96,6 +96,20 @@ std::uint8_t ParseVertAlign(std::string_view s) { return 0; } +/// Parses a `` link into the `FontRecord::scheme` +/// ordinal. An unknown value collapses to 0, which writes no element: +/// preserving an uninterpretable link would let the writer emit a theme +/// reference Excel cannot resolve. +std::uint8_t ParseFontScheme(std::string_view s) { + if (s == "major") { + return 1; + } + if (s == "minor") { + return 2; + } + return 0; +} + /// Maps OOXML border-style strings to the integer ordinal stored in /// `BorderSide::style`. Unknown strings collapse to `0` (none). std::uint8_t ParseBorderStyle(std::string_view s) { @@ -265,6 +279,9 @@ FontRecord ParseFontNode(const pugi::xml_node& f) { rec.has_charset = true; rec.charset = static_cast(charset.attribute("val").as_uint(0U)); } + if (pugi::xml_node scheme = f.child("scheme")) { + rec.scheme = ParseFontScheme(scheme.attribute("val").value()); + } rec.color_argb = ParseColorArgb(f.child("color"), 0xFF000000U); rec.color = ParseColorSpec(f.child("color")); return rec; diff --git a/src/io/styles_reader.h b/src/io/styles_reader.h index 5911223f..d25cdd2b 100644 --- a/src/io/styles_reader.h +++ b/src/io/styles_reader.h @@ -81,6 +81,13 @@ struct FontRecord { /// from an absent element. bool has_charset = false; std::uint8_t charset = 0; + /// `` theme-font link: 0=absent, 1=major, 2=minor. A ja-JP + /// workbook's Normal font carries `scheme="minor"`, which is what makes + /// Excel show it as the body font and re-resolve it when the theme + /// changes; dropping the element rewrites the font as a literal name. + /// The ordinals match XLSB `BrtFont`'s `bFontScheme`, so the binary and + /// XML paths share this field without a mapping table. + std::uint8_t scheme = 0; std::uint32_t color_argb = 0xFF000000U; ColorSpec color; }; diff --git a/src/io/styles_writer.cpp b/src/io/styles_writer.cpp index dd51bbef..91fa3487 100644 --- a/src/io/styles_writer.cpp +++ b/src/io/styles_writer.cpp @@ -340,7 +340,21 @@ void AppendVertAlign(std::string& out, std::uint8_t v) { } } -void AppendFontFamilyCharset(std::string& out, const FontRecord& f) { +const char* FontSchemeName(std::uint8_t v) { + switch (v) { + case 1: + return "major"; + case 2: + return "minor"; + default: + return nullptr; // 0 = no theme link; emit no element. + } +} + +/// Emits the trailing `` / `` / `` children in the +/// order Excel writes them, so a re-saved `` is byte-comparable with +/// its source. +void AppendFontFamilyCharsetScheme(std::string& out, const FontRecord& f) { if (f.has_family) { out.append(""); } + if (const char* sname = FontSchemeName(f.scheme); sname != nullptr) { + out.append(""); + } } const char* FillPatternName(std::uint8_t v) { @@ -546,7 +565,7 @@ void AppendFonts(std::string& out, const StylesTable& table) { } else { out.append(""); } - AppendFontFamilyCharset(out, f); + AppendFontFamilyCharsetScheme(out, f); out.append("\n"); } } @@ -809,7 +828,7 @@ void AppendFontFragment(std::string& out, const FontRecord& f) { AppendXmlAttrEscaped(out, f.name); out.append("\"/>"); } - AppendFontFamilyCharset(out, f); + AppendFontFamilyCharsetScheme(out, f); out.append(""); } diff --git a/src/io/xlsb/styles_reader.cpp b/src/io/xlsb/styles_reader.cpp index e18a5c42..0c7ced46 100644 --- a/src/io/xlsb/styles_reader.cpp +++ b/src/io/xlsb/styles_reader.cpp @@ -198,11 +198,15 @@ Expected DecodeFont(ByteSpan payload, StylesTable& table) { if (auto color = DecodeColor(p, /*unset_argb=*/rec.color_argb, rec.color_argb, rec.color); !color) { return color.error(); } - auto scheme_or = read_u8(p); // bFontScheme: no shared-model equivalent. + auto scheme_or = read_u8(p); if (!scheme_or) { return make_error(FormulonErrorCode::kIoXlsbRecordTruncated, "xlsb BrtFont scheme truncated", "context=xlsb_styles_reader"); } + // `bFontScheme` shares the shared model's ordinals (0=none, 1=major, + // 2=minor). Anything else is a value this build cannot name, and + // writing back a link Excel would not resolve is worse than dropping it. + rec.scheme = scheme_or.value() <= 2U ? scheme_or.value() : 0U; auto name_or = read_xlwidestring(p); if (!name_or) { return name_or.error(); diff --git a/src/io/xlsb/styles_reader.h b/src/io/xlsb/styles_reader.h index ac3d75bc..6def90e3 100644 --- a/src/io/xlsb/styles_reader.h +++ b/src/io/xlsb/styles_reader.h @@ -13,9 +13,9 @@ // retained bytes, so every field `CellXf` can hold is decoded here, // including the alignment, protection and `apply*` groups. // -// Three record fields have no shared-model equivalent and are consumed -// without being modelled: `BrtFont`'s theme font scheme, and `BrtXF`'s -// `fMergeCell` / `fSxButton`, which state a sheet-level condition rather +// Two record fields have no shared-model equivalent and are consumed +// without being modelled: `BrtXF`'s `fMergeCell` and `fSxButton`, which +// state a sheet-level condition rather // than a cell format and have no `` attribute to carry them. They // survive an `.xlsb` -> `.xlsb` cycle through the raw passthrough copy of // the part. The gap runs the other way too: `CellXf::relative_indent` has diff --git a/src/io/xlsb/styles_writer.cpp b/src/io/xlsb/styles_writer.cpp index aac11802..88473c33 100644 --- a/src/io/xlsb/styles_writer.cpp +++ b/src/io/xlsb/styles_writer.cpp @@ -101,7 +101,7 @@ void EmitFont(std::vector& out, const FontRecord& font) { emit_u8(payload, font.has_charset ? font.charset : 0U); emit_u8(payload, 0U); EmitColor(payload, font.color_argb, font.color); - emit_u8(payload, 0U); // no theme font scheme in the shared model + emit_u8(payload, font.scheme); // bFontScheme shares the shared model's ordinals emit_xlwidestring(payload, font.name.empty() ? std::string_view("Calibri") : std::string_view(font.name)); emit_record(out, static_cast(XlsbRecordType::BrtFont), payload); } diff --git a/tests/unit/io/styles_reader_test.cpp b/tests/unit/io/styles_reader_test.cpp index 51914373..69c90789 100644 --- a/tests/unit/io/styles_reader_test.cpp +++ b/tests/unit/io/styles_reader_test.cpp @@ -329,6 +329,27 @@ TEST(StylesReader, ReadsFontVertAlignFamilyCharset) { EXPECT_EQ(table.fonts[0].charset, 128U); } +TEST(StylesReader, ReadsFontThemeScheme) { + std::string xml(kXmlDecl); + xml.append("\n"); + xml.append(" "); + // The shape Excel writes for a ja-JP workbook's Normal font. + xml.append(""); + xml.append(""); + xml.append(""); + // A value from a future schema must not become a link Excel cannot follow. + xml.append(""); + xml.append("\n"); + + auto result_or = read_styles(Bytes(xml)); + ASSERT_TRUE(static_cast(result_or)) << "read failed: " << result_or.error().message; + const StylesTable& table = result_or.value(); + ASSERT_EQ(table.fonts.size(), 3U); + EXPECT_EQ(table.fonts[0].scheme, 2U); // minor + EXPECT_EQ(table.fonts[1].scheme, 1U); // major + EXPECT_EQ(table.fonts[2].scheme, 0U); +} + TEST(StylesReader, InternsCustomNumFmts) { std::string xml(kXmlDecl); xml.append("\n"); diff --git a/tests/unit/io/styles_writer_test.cpp b/tests/unit/io/styles_writer_test.cpp index e5c78e5f..339991af 100644 --- a/tests/unit/io/styles_writer_test.cpp +++ b/tests/unit/io/styles_writer_test.cpp @@ -516,6 +516,40 @@ TEST(StylesWriter, RoundTripsFontVertAlignFamilyCharset) { EXPECT_EQ(rt.fonts[0].charset, 128U); } +TEST(StylesWriter, RoundTripsFontThemeScheme) { + StylesTable original; + FontRecord minor; + minor.name = "\xE6\xB8\xB8\xE3\x82\xB4\xE3\x82\xB7\xE3\x83\x83\xE3\x82\xAF"; // "游ゴシック" + minor.has_family = true; + minor.family = 3; + minor.has_charset = true; + minor.charset = 128; + minor.scheme = 2; + FontRecord major; + major.name = "Calibri Light"; + major.scheme = 1; + FontRecord plain; + plain.name = "Calibri"; + original.fonts.push_back(minor); + original.fonts.push_back(major); + original.fonts.push_back(plain); + + const std::string xml = write_styles(original); + EXPECT_NE(xml.find(""), std::string::npos); + EXPECT_NE(xml.find(""), std::string::npos); + + std::vector bytes(xml.begin(), xml.end()); + auto round_or = read_styles(bytes); + ASSERT_TRUE(static_cast(round_or)) << "read failed: " << round_or.error().message; + const StylesTable& rt = round_or.value(); + ASSERT_EQ(rt.fonts.size(), 3U); + EXPECT_EQ(rt.fonts[0].scheme, 2U); + EXPECT_EQ(rt.fonts[1].scheme, 1U); + // A font with no theme link emits no element at all, so the last `` + // must not have picked one up from its neighbours. + EXPECT_EQ(rt.fonts[2].scheme, 0U); +} + TEST(StylesWriter, RoundTripsDxfThemeColor) { StylesTable original; DifferentialFormat dxf; From 89ed9e783c5c564b0aa3c3e07167381ad53c0112 Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 19:20:32 +0900 Subject: [PATCH 06/12] feat(bindings): expose the font theme scheme on every surface - add scheme to fm_font_record and carry it through font_to_c and font_from_c, so a read-modify-write of a font over the C ABI no longer unlinks it from the workbook theme - project it as scheme on WASM and the native Node addon and as FontRecord.scheme in Python, defaulting to 0 (no link) on each - the struct grew by one field, so the Python layout expectations for FONT_RECORD and DXF_RECORD and the C ABI offset assertions move with it - state on fm_styles_set_default_font that a workbook Formulon created carries no theme part, so leave scheme at 0 there; a package loaded with its own theme keeps that part, and preserving the link is what keeps Excel showing the font as the theme's body font - exercise the read-modify-write path over the ABI, down to the element in the saved styles part --- packages/npm-native/index.d.ts | 3 ++ packages/python/formulon/__init__.pyi | 2 ++ packages/python/formulon/_structs.py | 1 + packages/python/formulon/workbook.py | 4 +++ packages/python/tests/test_surface.py | 4 +-- src/c_api/formulon_c.h | 14 ++++++-- src/c_api/parts/styles.cpp | 2 ++ src/node_addon/parts/styles.cc | 2 ++ src/wasm/formulon.d.ts | 3 ++ src/wasm/parts/workbook_styles.cpp | 2 ++ tests/c_api/formulon_c_styles_test.cpp | 45 +++++++++++++++++++++++--- 11 files changed, 74 insertions(+), 8 deletions(-) diff --git a/packages/npm-native/index.d.ts b/packages/npm-native/index.d.ts index 51ffcf2a..aa04dcd7 100644 --- a/packages/npm-native/index.d.ts +++ b/packages/npm-native/index.d.ts @@ -1037,6 +1037,9 @@ export interface FontRecord { hasCharset: boolean; /** OOXML charset codepage id (e.g. 128 = Shift_JIS). */ charset: number; + /** `` theme link: 0=absent, 1=major, 2=minor. Meaningful only on + * a workbook that carries a theme part. */ + scheme: number; /** AARRGGBB literal RGB or compatibility fallback; not a resolved * theme/indexed/auto render colour. */ colorArgb: number; diff --git a/packages/python/formulon/__init__.pyi b/packages/python/formulon/__init__.pyi index a3d01f05..44178db9 100644 --- a/packages/python/formulon/__init__.pyi +++ b/packages/python/formulon/__init__.pyi @@ -723,6 +723,7 @@ class FontRecord: family: int has_charset: bool charset: int + scheme: int color: ColorSpec def __init__( self, @@ -741,6 +742,7 @@ class FontRecord: family: int = ..., has_charset: bool = ..., charset: int = ..., + scheme: int = ..., color: ColorSpec = ..., ) -> None: ... diff --git a/packages/python/formulon/_structs.py b/packages/python/formulon/_structs.py index c76b915b..13ad38b5 100644 --- a/packages/python/formulon/_structs.py +++ b/packages/python/formulon/_structs.py @@ -538,6 +538,7 @@ def zero_struct(lib, layout: Struct, ptr: int) -> None: ("vert_align", U8), ("family", U8), ("charset", U8), + ("scheme", U8), ("color", COLOR_SPEC_BLOB), ], ) diff --git a/packages/python/formulon/workbook.py b/packages/python/formulon/workbook.py index 653c1d30..af452fcf 100644 --- a/packages/python/formulon/workbook.py +++ b/packages/python/formulon/workbook.py @@ -1158,6 +1158,8 @@ class FontRecord: family: int = 0 has_charset: bool = False charset: int = 0 + #: ```` theme link: 0=absent, 1=major, 2=minor. + scheme: int = 0 color: ColorSpec = field(default_factory=ColorSpec) @@ -1234,6 +1236,7 @@ def _decode_font(ptr: int) -> FontRecord: family=d["family"], has_charset=bool(d["has_charset"]), charset=d["charset"], + scheme=d["scheme"], color=_decode_color(ptr + S.FONT_RECORD.offsets["color"][1]), ) @@ -1257,6 +1260,7 @@ def _pack_font(ptr: int, record: FontRecord, owned: List[int]) -> None: "family": _uint(record.family, "family", 8), "has_charset": 1 if record.has_charset else 0, "charset": _uint(record.charset, "charset", 8), + "scheme": _uint(record.scheme, "scheme", 8), }, ) _pack_color(ptr + S.FONT_RECORD.offsets["color"][1], record.color) diff --git a/packages/python/tests/test_surface.py b/packages/python/tests/test_surface.py index abceb5e6..a889dff2 100644 --- a/packages/python/tests/test_surface.py +++ b/packages/python/tests/test_surface.py @@ -138,11 +138,11 @@ class StructLayoutTests(unittest.TestCase): "ROW_LAYOUT": 32, "CELL_XF": 88, "COLOR_SPEC": 24, - "FONT_RECORD": 80, + "FONT_RECORD": 88, "FILL_RECORD": 64, "BORDER_SIDE": 32, "BORDER_RECORD": 168, - "DXF_RECORD": 360, + "DXF_RECORD": 368, # Fifteen pointer-or-`size_t` fields; both are four bytes on # wasm32, so the whole record is 15 x 4. "STYLES_BATCH": 60, diff --git a/src/c_api/formulon_c.h b/src/c_api/formulon_c.h index ecc7cbbd..dbab3a26 100644 --- a/src/c_api/formulon_c.h +++ b/src/c_api/formulon_c.h @@ -4422,6 +4422,12 @@ typedef struct { * means "leave the source formatting unchanged" while `` * means "switch bold off"; on a cell font a `has_*` flag without its * value emits the explicit-off form. + * + * `scheme` is the `` link to the workbook theme's major (heading) + * or minor (body) typeface. It needs no presence flag because OOXML has no + * "absent value" spelling for it: `0` is exactly "no `` element". + * Set it only on a workbook that carries a theme part — a link into a + * theme that is not there resolves to nothing. */ typedef struct { const char* name; /* NUL-terminated UTF-8, model-backed view */ @@ -4441,6 +4447,7 @@ typedef struct { uint8_t vert_align; /* 0=baseline, 1=superscript, 2=subscript */ uint8_t family; /* `` font-family class (0..5) */ uint8_t charset; /* `` codepage id (e.g. 128 = Shift_JIS) */ + uint8_t scheme; /* `` theme link: 0=absent, 1=major, 2=minor */ fm_color_spec color; } fm_font_record; @@ -4875,8 +4882,11 @@ FM_API fm_status_t fm_styles_set_font(fm_workbook_t* wb, uint32_t font_index, fm * Reading the current default is `fm_styles_get_font(wb, 0, out)`. * * The record is a font, not a theme: Formulon writes no `xl/theme/theme1.xml` - * for a workbook it created, so the name is stored literally and Excel - * resolves it without a `` indirection. + * for a workbook it created, so leave `record.scheme` at 0 and Excel resolves + * the name literally. A workbook loaded from a package that brought its own + * theme keeps that part, and there `scheme` is worth carrying: reading font 0, + * changing the name and writing it back with `scheme` preserved keeps Excel + * showing the font as the theme's body font. * * @return `kOk` on success; * `kBindingNullPointer` if `wb` is `NULL`. diff --git a/src/c_api/parts/styles.cpp b/src/c_api/parts/styles.cpp index 5bd7d15c..0cec5685 100644 --- a/src/c_api/parts/styles.cpp +++ b/src/c_api/parts/styles.cpp @@ -206,6 +206,7 @@ void font_to_c(const formulon::io::FontRecord& f, fm_font_record* out) noexcept out->vert_align = f.vert_align; out->family = f.family; out->charset = f.charset; + out->scheme = f.scheme; color_to_c(f.color, &out->color); } @@ -681,6 +682,7 @@ formulon::io::FontRecord font_from_c(const fm_font_record& record) { out.vert_align = record.vert_align; out.family = record.family; out.charset = record.charset; + out.scheme = record.scheme; out.color_argb = record.color_argb; out.color = color_from_c(record.color); return out; diff --git a/src/node_addon/parts/styles.cc b/src/node_addon/parts/styles.cc index 3819f4c8..8bc89bbf 100644 --- a/src/node_addon/parts/styles.cc +++ b/src/node_addon/parts/styles.cc @@ -103,6 +103,7 @@ Napi::Object FontRecordToJs(Napi::Env env, const fm_font_record& f) { out.Set("family", Napi::Number::New(env, static_cast(f.family))); out.Set("hasCharset", Napi::Boolean::New(env, f.has_charset != 0)); out.Set("charset", Napi::Number::New(env, static_cast(f.charset))); + out.Set("scheme", Napi::Number::New(env, static_cast(f.scheme))); out.Set("color", ColorSpecToJs(env, f.color)); return out; } @@ -129,6 +130,7 @@ void PullFontRecord(const Napi::Object& record, std::string* name_storage, fm_fo out->family = static_cast(SpecPullU32(record, "family", 0U) & 0xFFU); out->has_charset = SpecPullBool(record, "hasCharset", false) ? 1 : 0; out->charset = static_cast(SpecPullU32(record, "charset", 0U) & 0xFFU); + out->scheme = static_cast(SpecPullU32(record, "scheme", 0U) & 0xFFU); out->color_argb = SpecPullU32(record, "colorArgb", 0xFF000000U); out->color = PullColorSpec(record, "color"); } diff --git a/src/wasm/formulon.d.ts b/src/wasm/formulon.d.ts index 2da1e678..3cbaaa18 100644 --- a/src/wasm/formulon.d.ts +++ b/src/wasm/formulon.d.ts @@ -1170,6 +1170,9 @@ export interface FontRecord { hasCharset: boolean; /** OOXML charset codepage id (e.g. 128 = Shift_JIS). */ charset: number; + /** `` theme link: 0=absent, 1=major, 2=minor. Meaningful only on + * a workbook that carries a theme part. */ + scheme: number; /** AARRGGBB literal RGB or compatibility fallback; not a resolved * theme/indexed/auto render colour. */ colorArgb: number; diff --git a/src/wasm/parts/workbook_styles.cpp b/src/wasm/parts/workbook_styles.cpp index 65257b7b..fc341d61 100644 --- a/src/wasm/parts/workbook_styles.cpp +++ b/src/wasm/parts/workbook_styles.cpp @@ -111,6 +111,7 @@ emscripten::val js_font_record(const fm_font_record& f) { o.set("family", static_cast(f.family)); o.set("hasCharset", f.has_charset != 0); o.set("charset", static_cast(f.charset)); + o.set("scheme", static_cast(f.scheme)); o.set("color", js_color_spec(f.color)); return o; } @@ -133,6 +134,7 @@ void js_pull_font_record(const emscripten::val& record, std::string* name_storag out->family = js_pull_u8(record, "family", 0U); out->has_charset = js_pull_bool(record, "hasCharset", false) ? 1 : 0; out->charset = js_pull_u8(record, "charset", 0U); + out->scheme = js_pull_u8(record, "scheme", 0U); out->color_argb = js_pull_u32(record, "colorArgb", 0xFF000000U); out->color = js_pull_color_spec(record, "color"); } diff --git a/tests/c_api/formulon_c_styles_test.cpp b/tests/c_api/formulon_c_styles_test.cpp index 86ca96e5..02aca750 100644 --- a/tests/c_api/formulon_c_styles_test.cpp +++ b/tests/c_api/formulon_c_styles_test.cpp @@ -44,11 +44,11 @@ static_assert(offsetof(fm_cell_xf, has_horizontal_align) == 72U, "fm_cell_xf.has static_assert(offsetof(fm_cell_xf, has_vertical_align) == 76U, "fm_cell_xf.has_vertical_align offset changed"); static_assert(offsetof(fm_cell_xf, has_wrap_text) == 80U, "fm_cell_xf.has_wrap_text offset changed"); static_assert(offsetof(fm_cell_xf, has_justify_last_line) == 84U, "fm_cell_xf.has_justify_last_line offset changed"); -static_assert(sizeof(fm_dxf_record) == (sizeof(void*) == 4U ? 360U : 368U), "fm_dxf_record ABI layout changed"); -static_assert(offsetof(fm_dxf_record, num_fmt_code) == 344U, "fm_dxf_record.num_fmt_code offset changed"); -static_assert(offsetof(fm_dxf_record, alignment_xml) == (sizeof(void*) == 4U ? 348U : 352U), +static_assert(sizeof(fm_dxf_record) == (sizeof(void*) == 4U ? 368U : 376U), "fm_dxf_record ABI layout changed"); +static_assert(offsetof(fm_dxf_record, num_fmt_code) == 352U, "fm_dxf_record.num_fmt_code offset changed"); +static_assert(offsetof(fm_dxf_record, alignment_xml) == (sizeof(void*) == 4U ? 356U : 360U), "fm_dxf_record.alignment_xml offset changed"); -static_assert(offsetof(fm_dxf_record, protection_xml) == (sizeof(void*) == 4U ? 352U : 360U), +static_assert(offsetof(fm_dxf_record, protection_xml) == (sizeof(void*) == 4U ? 360U : 368U), "fm_dxf_record.protection_xml offset changed"); // `fm_styles_batch` is fifteen pointer-width slots: five (array, count, @@ -2298,3 +2298,40 @@ TEST(FormulonCApiStyles, SetFontOverwritesInPlaceAndRejectsAnAbsentIndex) { EXPECT_NE(fm_styles_set_font(nullptr, 0, replacement), 0); EXPECT_NE(fm_workbook_set_default_font(nullptr, replacement), 0); } + +TEST(FormulonCApiStyles, FontSchemeSurvivesAReadModifyWriteThroughTheAbi) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + fm_font_record body{}; + body.name = "游ゴシック"; + body.size = 11.0; + body.has_charset = 1; + body.charset = 128; + body.scheme = 2; // minor: the theme's body font + ASSERT_EQ(fm_workbook_set_default_font(wb.handle, body), 0); + + // The link has to come back out of the projection, or a host that reads a + // font, edits one field and writes it back silently unlinks it. + fm_font_record read_back{}; + ASSERT_EQ(fm_styles_get_font(wb.handle, 0, &read_back), 0); + EXPECT_EQ(read_back.scheme, 2U); + read_back.size = 12.0; + ASSERT_EQ(fm_styles_set_font(wb.handle, 0, read_back), 0); + + BufferGuard saved; + ASSERT_EQ(fm_workbook_save(wb.handle, &saved.data, &saved.len), 0); + const std::string styles_xml = ExtractStylesXml(saved); + EXPECT_NE(styles_xml.find(""), std::string::npos); + + // A record left at the default writes no element, so `add_font` sees a + // distinct entry rather than folding the two together. + fm_font_record unlinked{}; + unlinked.name = "游ゴシック"; + unlinked.size = 12.0; + unlinked.has_charset = 1; + unlinked.charset = 128; + uint32_t unlinked_index = 0; + ASSERT_EQ(fm_styles_add_font(wb.handle, unlinked, &unlinked_index), 0); + EXPECT_GT(unlinked_index, 0U); +} From 9a879b7f8a8326696a263e62f295d10bb6544eb8 Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 19:20:43 +0900 Subject: [PATCH 07/12] docs(print): record what the column-width constants assume - state in src/print/pagination.cpp that MDW is a property of the Normal font, so a workbook whose Normal style names a different one resolves a different number of points per character - record the measured scope: at 11 pt the family does not move the width (Calibri, Yu Gothic and MS PGothic resolve a 30-unit column alike), while the point size does move it roughly in proportion and the family starts to matter away from 11 pt - note that those observations are Mac Excel's, a different column-geometry regime from the Windows primary oracle the constants come from, so they establish the dependency without supplying the numbers to model it - until a Windows capture over the same sweep exists, a workbook whose Normal font is not 11 pt paginates against the 11 pt geometry --- src/print/pagination.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/print/pagination.cpp b/src/print/pagination.cpp index c17027a2..53526e7a 100644 --- a/src/print/pagination.cpp +++ b/src/print/pagination.cpp @@ -40,6 +40,27 @@ namespace { /// Pagination compares these against the printable body, so using the /// screen model made a wide print area fit roughly one column too many per /// page. +/// +/// The calibration is to one Normal font, and MDW is a property of that +/// font, so a workbook whose Normal style names a different one resolves a +/// different number of points per character. The scope of that was measured +/// by opening workbooks that differ only in font 0 and reading +/// `Range.Width` back: +/// +/// * At 11 pt the family does not move it. `Calibri`, `游ゴシック` and +/// `MS Pゴシック` all resolve a 30-unit column to the same width, so +/// a ja-JP host declaring a Japanese body font paginates identically. +/// * The point size does move it, roughly in proportion, and the family +/// starts to matter away from 11 pt: `Calibri 18` resolves half again +/// as wide as `Calibri 11`, and `游ゴシック 14` a seventh wider than +/// `Calibri 14`. +/// +/// Those observations are Mac Excel's, whose column geometry is a different +/// regime from the Windows primary oracle these constants come from, so +/// they establish that the dependency exists without supplying the numbers +/// to model it. Sizing the constants off the Normal font needs a Windows +/// capture over the same sweep; until then a workbook whose Normal font is +/// not 11 pt paginates against the 11 pt geometry. constexpr double kPointsPerColumnChar = 39.0 / 7.0; constexpr double kColumnPaddingPt = 27.0 / 7.0; From f3f1bc371e66ff209061f09ca82d8d2035a3318a Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 19:20:52 +0900 Subject: [PATCH 08/12] docs(changelog): note the font scheme and phonetic properties fixes - add an Unreleased Fixed entry for the theme link surviving a load and save through both containers and reaching the record projections on WASM, the native Node addon and Python - add the entry: the ruby font, kana form and distribution now round-trip, and an absent element is read apart from a bare one --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd69106..941c502f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the guide as well as the text, so two cells reading the same kanji differently no longer collapse onto one entry. +### Fixed + +- A font's `` theme link survives a load and save. A ja-JP + workbook's Normal font carries `scheme="minor"`, which is what makes + Excel show it as the body font and re-resolve it when the theme + changes; the element was dropped on read, so re-saving rewrote the font + as a literal name. It now round-trips through both containers — the + binary form is `BrtFont`'s `bFontScheme`, whose ordinals the field + shares — and reaches the record projections as `scheme` on WASM, the + native Node addon and Python, so reading a font, editing one field and + writing it back no longer unlinks it. + +- A phonetic guide's `` block survives a load and save. Only + the `` runs were carried, so a guide set to hiragana or to a + distributed layout came back as Excel's default half-width katakana on + the next save. Which font renders the ruby, which kana form it uses and + how it is distributed now round-trip through both containers, and are + written beside every annotated string item the way Excel writes them. + An absent element and a bare `` resolve differently — + half-width katakana / no control against full-width katakana / left — + and are now read apart. + ## [0.11.0] - 2026-08-22 ### Added From ad0edf7a0199c5f5fa27cd114a9eba8b07168aaa Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 20:09:42 +0900 Subject: [PATCH 09/12] feat(bindings): expose phonetic guide rendering on every surface - add fm_workbook_set_cell_phonetic_properties / fm_workbook_get_cell_phonetic_properties, carrying the OOXML triple: the font that draws the ruby, the kana form (FM_PHONETIC_TYPE_*) and how the kana is distributed over the characters it covers (FM_PHONETIC_ALIGNMENT_*); scalar arguments rather than a new struct, matching fm_workbook_set_iterative - project it as setCellPhoneticProperties / getCellPhoneticProperties on WASM and the native Node addon, and as set_phonetic_properties / get_phonetic_properties with a PhoneticProperties dataclass on Python - keep it independent of the run entry points in both directions: editing the readings does not reset the rendering, and setting the rendering does not touch the readings; a value-setting call still clears both - reject an out-of-range type, alignment or font_id rather than truncate it, since each of the two enumerations occupies two bits of the XLSB record's trailing flags word and the font id sixteen - report the defaults beside a failure status in the read result instead of dropping the payload keys the return type declares - the core already round-tripped these properties, so this adds only the authoring surface --- packages/npm-native/README.md | 4 +- packages/npm-native/index.d.ts | 25 +++++++ packages/npm-native/test/smoke.test.mjs | 31 ++++++++ packages/npm/test/smoke.test.mjs | 1 + packages/python/README.md | 15 ++++ packages/python/formulon/__init__.py | 2 + packages/python/formulon/__init__.pyi | 8 ++ packages/python/formulon/_c.py | 2 + packages/python/formulon/workbook.py | 74 +++++++++++++++++++ .../python/tests/test_binding_contracts.py | 39 ++++++++++ src/c_api/formulon_c.h | 60 +++++++++++++++ src/c_api/parts/cells.cpp | 65 ++++++++++++++++ src/node_addon/parts/lifecycle.cc | 47 ++++++++++++ src/node_addon/parts/workbook_class.cc | 2 + src/node_addon/parts/workbook_class.h | 2 + src/wasm/formulon.d.ts | 25 +++++++ src/wasm/parts/bindings_register.cpp | 2 + src/wasm/parts/workbook.h | 10 +++ src/wasm/parts/workbook_cells.cpp | 32 ++++++++ tests/c_api/formulon_c_test.cpp | 67 +++++++++++++++++ tests/wasm/run.mjs | 41 ++++++++++ tools/wasm/capi_exports.txt | 2 + 22 files changed, 554 insertions(+), 2 deletions(-) diff --git a/packages/npm-native/README.md b/packages/npm-native/README.md index c6f519dd..fb0209c4 100644 --- a/packages/npm-native/README.md +++ b/packages/npm-native/README.md @@ -36,8 +36,8 @@ Why prefer the native build: This package exposes the shared `Workbook` surface of the WASM-backed `@libraz/formulon` package, all marshalling to the identical C-ABI functions. Its TypeScript declarations and its native class table -register 225 instance methods plus the three static factories. Of those -instance methods, 223 are shared with WASM; seven remain WASM-only, while +register 227 instance methods plus the three static factories. Of those +instance methods, 225 are shared with WASM; seven remain WASM-only, while `dispose()` and `memoryUsage()` are native-only lifecycle helpers. The shared `Workbook` methods use the same status-bearing result envelopes and field shapes; switching packages still requires updating the module diff --git a/packages/npm-native/index.d.ts b/packages/npm-native/index.d.ts index aa04dcd7..7ba8f636 100644 --- a/packages/npm-native/index.d.ts +++ b/packages/npm-native/index.d.ts @@ -1131,6 +1131,23 @@ export interface PhoneticRunsResult { runs: PhoneticRun[]; } +/** How a cell's phonetic guide renders (OOXML ``). `fontId` + * indexes the workbook's font table for the ruby text; `type` is the kana + * form (0 half-width katakana, 1 full-width katakana, 2 hiragana, 3 no + * conversion) and `alignment` how the kana is distributed (0 no control, + * 1 left, 2 center, 3 distributed). The all-zero triple is what Excel + * infers for a guide written with no `` element. */ +export interface PhoneticProperties { + fontId: number; + type: number; + alignment: number; +} + +/** Return type of `Workbook.getCellPhoneticProperties(sheet, row, col)`. */ +export interface PhoneticPropertiesResult extends PhoneticProperties { + status: Status; +} + /** Return type of `Workbook.getFont(fontIndex)`. */ export interface FontResult extends FontRecord { status: Status; @@ -1571,6 +1588,11 @@ export interface Workbook { * partition: each needs `sb <= eb` and must start at or after the previous * run's `eb`. */ setCellPhoneticRuns(sheet: number, row: number, col: number, runs: PhoneticRun[]): Status; + /** Stores how the cell's guide renders. Independent of + * `setCellPhoneticRuns` in both directions, and only observable on a cell + * that has runs; every value setter clears both, so call it after the + * cell's text. */ + setCellPhoneticProperties(sheet: number, row: number, col: number, properties: PhoneticProperties): Status; setBlank(sheet: number, row: number, col: number): Status; setFormula(sheet: number, row: number, col: number, formula: string): Status; @@ -1581,6 +1603,9 @@ export interface Workbook { /** Returns the cell's `` blocks with their spans. `getCellPhonetic` * returns the same readings concatenated, without the spans. */ getCellPhoneticRuns(sheet: number, row: number, col: number): PhoneticRunsResult; + /** Returns how the cell's guide renders. A cell with no annotation + * reports the all-zero triple. */ + getCellPhoneticProperties(sheet: number, row: number, col: number): PhoneticPropertiesResult; /** Evaluates `formula` as if entered at `(sheet, row, col)` and returns a * single scalar result, without mutating the workbook. Local and * cross-sheet references, defined names, and `ROW()` / `COLUMN()` resolve diff --git a/packages/npm-native/test/smoke.test.mjs b/packages/npm-native/test/smoke.test.mjs index 1b1f2aec..6c5a257d 100644 --- a/packages/npm-native/test/smoke.test.mjs +++ b/packages/npm-native/test/smoke.test.mjs @@ -1349,6 +1349,36 @@ test('phonetic runs keep their spans through a save/load round trip', async () = wb.dispose(); }); +test('phonetic properties are independent of the runs and survive a round trip', async () => { + const mod = await getModule(); + // The result carries a `status` alongside the triple; compare the triple + // alone so a status-shape change does not read as a value change. + const props = (book) => { + const { fontId, type, alignment } = book.getCellPhoneticProperties(0, 0, 0); + return { fontId, type, alignment }; + }; + const wb = mod.Workbook.createDefault(); + wb.setText(0, 0, 0, '大阪'); + assert.deepEqual(props(wb), { fontId: 0, type: 0, alignment: 0 }); + + assert.ok(wb.setCellPhoneticProperties(0, 0, 0, { fontId: 3, type: 2, alignment: 2 }).ok); + // Setting the readings must not reset the rendering. + assert.ok(wb.setCellPhoneticRuns(0, 0, 0, [{ sb: 0, eb: 2, text: 'おおさか' }]).ok); + assert.deepEqual(props(wb), { fontId: 3, type: 2, alignment: 2 }); + + const saved = wb.save(); + assert.ok(saved.status.ok, `save: ${JSON.stringify(saved.status)}`); + const loaded = mod.Workbook.loadBytes(saved.bytes); + assert.deepEqual(props(loaded), { fontId: 3, type: 2, alignment: 2 }); + + // Two bits each on the binary side, so a wider ordinal is refused. + assert.equal(wb.setCellPhoneticProperties(0, 0, 0, { fontId: 0, type: 4, alignment: 0 }).ok, false); + assert.deepEqual(props(wb), { fontId: 3, type: 2, alignment: 2 }); + + loaded.dispose(); + wb.dispose(); +}); + test('setDefaultFont declares what an unstyled cell is saved as', async () => { const mod = await getModule(); const wb = mod.Workbook.createDefault(); @@ -2308,6 +2338,7 @@ function envelopeProbes(wb) { ['NumFmtResult', true, () => wb.getNumFmt(59999)], ['LambdaTextResult', true, () => wb.getLambdaText(99, 0, 0)], ['PhoneticRunsResult', true, () => wb.getCellPhoneticRuns(99, 0, 0)], + ['PhoneticPropertiesResult', true, () => wb.getCellPhoneticProperties(99, 0, 0)], ['CellStyleResult', true, () => wb.getCellStyle(9999)], ['AddStyleResult', true, () => wb.addXf({ fontIndex: 9999 })], ['AddNumFmtResult', false, () => wb.addNumFmt('0.00')], diff --git a/packages/npm/test/smoke.test.mjs b/packages/npm/test/smoke.test.mjs index 54b08891..88c6e4f3 100644 --- a/packages/npm/test/smoke.test.mjs +++ b/packages/npm/test/smoke.test.mjs @@ -912,6 +912,7 @@ function envelopeProbes(wb) { ['NumFmtResult', true, () => wb.getNumFmt(59999)], ['LambdaTextResult', true, () => wb.getLambdaText(99, 0, 0)], ['PhoneticRunsResult', true, () => wb.getCellPhoneticRuns(99, 0, 0)], + ['PhoneticPropertiesResult', true, () => wb.getCellPhoneticProperties(99, 0, 0)], ['CellStyleResult', true, () => wb.getCellStyle(9999)], ['AddStyleResult', true, () => wb.addXf({ fontIndex: 9999 })], ['AddNumFmtResult', false, () => wb.addNumFmt('0.00')], diff --git a/packages/python/README.md b/packages/python/README.md index 9c8c3b5b..213685f0 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -123,6 +123,21 @@ wb.get_phonetic(0, 0, 0) # -> 'トウキョウト' (the readings concatenated) Offsets are UTF-16 code units, and the runs must be an ordered partition: each needs `sb <= eb` and must start at or after the previous run's `eb`. +How the ruby renders is a separate call, so editing the readings does not +reset it and vice versa: + +```python +wb.set_phonetic_properties(0, 0, 0, PhoneticProperties(font_id=0, type=2, alignment=2)) +wb.get_phonetic_properties(0, 0, 0) # -> PhoneticProperties(font_id=0, type=2, alignment=2) +``` + +`type` is the kana form (0 half-width katakana, 1 full-width katakana, +2 hiragana, 3 no conversion) and `alignment` how the kana is distributed +(0 no control, 1 left, 2 center, 3 distributed). The all-zero default is +what Excel infers for a guide written without the block. Both are only +observable on a cell that has runs, and every value setter clears the +readings and the rendering together. + **AutoFilter** -- the raw `` fragment, preserved verbatim so filter criteria and extensions survive a round trip: diff --git a/packages/python/formulon/__init__.py b/packages/python/formulon/__init__.py index 9a561284..05057903 100644 --- a/packages/python/formulon/__init__.py +++ b/packages/python/formulon/__init__.py @@ -67,6 +67,7 @@ PageSetup, PaginationResult, PassthroughPart, + PhoneticProperties, PhoneticRun, PivotAggregation, PivotAxis, @@ -192,6 +193,7 @@ def _resolve_version() -> str: "PageSetup", "PaginationResult", "PassthroughPart", + "PhoneticProperties", "PhoneticRun", "PivotAggregation", "PivotAxis", diff --git a/packages/python/formulon/__init__.pyi b/packages/python/formulon/__init__.pyi index 44178db9..20560ec3 100644 --- a/packages/python/formulon/__init__.pyi +++ b/packages/python/formulon/__init__.pyi @@ -707,6 +707,12 @@ class PhoneticRun: text: str def __init__(self, sb: int = ..., eb: int = ..., text: str = ...) -> None: ... +class PhoneticProperties: + font_id: int + type: int + alignment: int + def __init__(self, font_id: int = ..., type: int = ..., alignment: int = ...) -> None: ... + class FontRecord: name: str size: float @@ -929,6 +935,8 @@ class Workbook: def set_phonetic_runs(self, sheet: int, row: int, col: int, runs: Sequence[PhoneticRun]) -> None: ... def get_phonetic(self, sheet: int, row: int, col: int) -> str: ... def get_phonetic_runs(self, sheet: int, row: int, col: int) -> List[PhoneticRun]: ... + def set_phonetic_properties(self, sheet: int, row: int, col: int, properties: PhoneticProperties) -> None: ... + def get_phonetic_properties(self, sheet: int, row: int, col: int) -> PhoneticProperties: ... def get_value(self, sheet: int, row: int, col: int) -> Value: ... def evaluate_formula_array(self, sheet: int, row: int, col: int, formula: str) -> List[List[Value]]: ... def lambda_text_at(self, sheet: int, row: int, col: int) -> str: ... diff --git a/packages/python/formulon/_c.py b/packages/python/formulon/_c.py index d1b5070d..13ba927d 100644 --- a/packages/python/formulon/_c.py +++ b/packages/python/formulon/_c.py @@ -198,6 +198,7 @@ "fm_workbook_external_link_at", "fm_workbook_external_link_count", "fm_workbook_get_cell_phonetic", + "fm_workbook_get_cell_phonetic_properties", "fm_workbook_get_cell_phonetic_run", "fm_workbook_get_cell_phonetic_run_count", "fm_workbook_get_iterative", @@ -286,6 +287,7 @@ "fm_workbook_set_bool", "fm_workbook_set_calc_mode", "fm_workbook_set_cell_phonetic", + "fm_workbook_set_cell_phonetic_properties", "fm_workbook_set_cell_phonetic_runs", "fm_workbook_set_default_font", "fm_workbook_set_defined_name", diff --git a/packages/python/formulon/workbook.py b/packages/python/formulon/workbook.py index af452fcf..fcf252fe 100644 --- a/packages/python/formulon/workbook.py +++ b/packages/python/formulon/workbook.py @@ -1129,6 +1129,24 @@ class PhoneticRun: text: str = "" +@dataclass +class PhoneticProperties: + """How a cell's phonetic guide renders (OOXML ````). + + ``font_id`` indexes the workbook's font table for the ruby text. + ``type`` is the kana form: 0 half-width katakana, 1 full-width + katakana, 2 hiragana, 3 no conversion. ``alignment`` is how the kana + is distributed: 0 no control, 1 left, 2 center, 3 distributed. + + The all-zero default is the state Excel infers for a guide written + with no ```` element at all. + """ + + font_id: int = 0 + type: int = 0 + alignment: int = 0 + + @dataclass class FontRecord: """A font record (``add_font`` input / ``get_font`` result). @@ -1931,6 +1949,62 @@ def get_phonetic_runs(self, sheet: int, row: int, col: int) -> List[PhoneticRun] LIB.free(run_ptr) return out + def set_phonetic_properties(self, sheet: int, row: int, col: int, properties: PhoneticProperties) -> None: + """Set how the cell's phonetic guide renders. + + ``sheet``, ``row`` and ``col`` are all 0-based. Independent of + :meth:`set_phonetic_runs` in both directions: editing the readings + does not reset the rendering, and this does not touch the readings. + Only observable on a cell that has runs, and every value-setting + method clears both, so call it after the cell's text. + """ + h = self._require() + _check( + LIB.fm_workbook_set_cell_phonetic_properties( + h, + _uint(sheet, "sheet_index"), + _uint(row, "row"), + _uint(col, "col"), + _uint(properties.font_id, "font_id"), + _uint(properties.type, "type"), + _uint(properties.alignment, "alignment"), + ), + "fm_workbook_set_cell_phonetic_properties", + ) + + def get_phonetic_properties(self, sheet: int, row: int, col: int) -> PhoneticProperties: + """Return how the cell's phonetic guide renders. + + ``sheet``, ``row`` and ``col`` are all 0-based. A cell with no + annotation reports the all-zero default. + """ + h = self._require() + font_ptr = _alloc_out_ptr() + type_ptr = _alloc_out_ptr() + alignment_ptr = _alloc_out_ptr() + try: + _check( + LIB.fm_workbook_get_cell_phonetic_properties( + h, + _uint(sheet, "sheet_index"), + _uint(row, "row"), + _uint(col, "col"), + font_ptr, + type_ptr, + alignment_ptr, + ), + "fm_workbook_get_cell_phonetic_properties", + ) + return PhoneticProperties( + font_id=LIB.read_u32(font_ptr), + type=LIB.read_u32(type_ptr), + alignment=LIB.read_u32(alignment_ptr), + ) + finally: + LIB.free(font_ptr) + LIB.free(type_ptr) + LIB.free(alignment_ptr) + def get_phonetic(self, sheet: int, row: int, col: int) -> str: """Return the cell's phonetic guide, or ``""`` when it has none. diff --git a/packages/python/tests/test_binding_contracts.py b/packages/python/tests/test_binding_contracts.py index fc61a1e8..d55eb9f3 100644 --- a/packages/python/tests/test_binding_contracts.py +++ b/packages/python/tests/test_binding_contracts.py @@ -31,6 +31,7 @@ FormulonError, LogLevel, MergeRange, + PhoneticProperties, PhoneticRun, PivotAggregation, PivotAxis, @@ -496,6 +497,44 @@ def test_out_of_order_runs_are_rejected(self) -> None: with self.assertRaises(FormulonError): wb.set_phonetic_runs(0, 0, 0, [PhoneticRun(2, 3, "ト"), PhoneticRun(0, 2, "トウ")]) + def test_phonetic_properties_are_independent_of_the_runs(self) -> None: + with Workbook.create_default() as wb: + wb.set_text(0, 0, 0, "大阪") + # An unannotated cell reports what Excel infers for a guide + # written with no element at all. + self.assertEqual(wb.get_phonetic_properties(0, 0, 0), PhoneticProperties(0, 0, 0)) + + wb.set_phonetic_properties(0, 0, 0, PhoneticProperties(font_id=3, type=2, alignment=2)) + # Setting the readings must not reset the rendering. + wb.set_phonetic_runs(0, 0, 0, [PhoneticRun(0, 2, "おおさか")]) + self.assertEqual(wb.get_phonetic_properties(0, 0, 0), PhoneticProperties(3, 2, 2)) + data = wb.save() + with Workbook.load(data) as reloaded: + self.assertEqual(reloaded.get_phonetic_properties(0, 0, 0), PhoneticProperties(3, 2, 2)) + + def test_phonetic_properties_reject_values_the_container_cannot_hold(self) -> None: + with Workbook.create_default() as wb: + wb.set_text(0, 0, 0, "大阪") + wb.set_phonetic_properties(0, 0, 0, PhoneticProperties(1, 3, 3)) + # Two bits each on the binary side, so a wider ordinal is + # refused rather than truncated into a valid-looking one. + with self.assertRaises(FormulonError): + wb.set_phonetic_properties(0, 0, 0, PhoneticProperties(type=4)) + with self.assertRaises(FormulonError): + wb.set_phonetic_properties(0, 0, 0, PhoneticProperties(alignment=4)) + with self.assertRaises(FormulonError): + wb.set_phonetic_properties(0, 0, 0, PhoneticProperties(font_id=0x10000)) + self.assertEqual(wb.get_phonetic_properties(0, 0, 0), PhoneticProperties(1, 3, 3)) + + def test_a_value_write_discards_the_guide_and_its_rendering(self) -> None: + with Workbook.create_default() as wb: + wb.set_text(0, 0, 0, "大阪") + wb.set_phonetic_runs(0, 0, 0, [PhoneticRun(0, 2, "おおさか")]) + wb.set_phonetic_properties(0, 0, 0, PhoneticProperties(3, 2, 2)) + wb.set_text(0, 0, 0, "京都") + self.assertEqual(wb.get_phonetic_runs(0, 0, 0), []) + self.assertEqual(wb.get_phonetic_properties(0, 0, 0), PhoneticProperties(0, 0, 0)) + def test_default_font_replaces_what_an_unstyled_cell_saves_as(self) -> None: with Workbook.create_default() as wb: self.assertEqual(wb.get_font(0).name, "Calibri") diff --git a/src/c_api/formulon_c.h b/src/c_api/formulon_c.h index dbab3a26..4fe76dcc 100644 --- a/src/c_api/formulon_c.h +++ b/src/c_api/formulon_c.h @@ -826,6 +826,49 @@ FM_API fm_status_t fm_workbook_set_cell_phonetic(fm_workbook_t* wb, size_t sheet FM_API fm_status_t fm_workbook_set_cell_phonetic_runs(fm_workbook_t* wb, size_t sheet_index, uint32_t row, uint32_t col, const fm_phonetic_run_t* runs, size_t count); +/** `type` values for `fm_workbook_set_cell_phonetic_properties`. */ +#define FM_PHONETIC_TYPE_HALFWIDTH_KATAKANA 0u +#define FM_PHONETIC_TYPE_FULLWIDTH_KATAKANA 1u +#define FM_PHONETIC_TYPE_HIRAGANA 2u +#define FM_PHONETIC_TYPE_NO_CONVERSION 3u + +/** `alignment` values for `fm_workbook_set_cell_phonetic_properties`. */ +#define FM_PHONETIC_ALIGNMENT_NO_CONTROL 0u +#define FM_PHONETIC_ALIGNMENT_LEFT 1u +#define FM_PHONETIC_ALIGNMENT_CENTER 2u +#define FM_PHONETIC_ALIGNMENT_DISTRIBUTED 3u + +/** + * @brief Stores how a cell's phonetic guide renders (OOXML ``). + * + * `font_id` indexes the workbook's font table for the ruby text; `type` is + * the kana form Excel generates readings in (`FM_PHONETIC_TYPE_*`) and + * `alignment` how the kana is distributed over the characters it covers + * (`FM_PHONETIC_ALIGNMENT_*`). + * + * Deliberately separate from `fm_workbook_set_cell_phonetic_runs` in both + * directions: editing the readings does not reset the rendering, and + * setting the rendering does not touch the readings. The two are stored + * together and travel together, but a host that only has one of them should + * not have to supply the other. + * + * Only observable on a cell that has runs — the writer emits no element for + * an unannotated cell — and any value-mutating setter clears the runs and + * these properties together, so set them after the cell's text. + * + * The all-zero triple is what Excel infers for a guide that arrived with no + * `` at all, so a host that does not care can leave it alone. + * + * @return `kOk` on success; + * `kBindingNullPointer` if `wb` is `NULL`; + * `kInvalidArgument` when `sheet_index` or the cell coordinate is + * out of range, when `font_id` exceeds 65535, or when `type` or + * `alignment` is not one of the four values above. + */ +FM_API fm_status_t fm_workbook_set_cell_phonetic_properties(fm_workbook_t* wb, size_t sheet_index, uint32_t row, + uint32_t col, uint32_t font_id, uint32_t type, + uint32_t alignment); + /** * @brief Stores a `Blank` literal at `(row, col)`. Equivalent to * clearing a cell. @@ -927,6 +970,23 @@ FM_API fm_status_t fm_workbook_get_cell_phonetic_run_count(const fm_workbook_t* FM_API fm_status_t fm_workbook_get_cell_phonetic_run(const fm_workbook_t* wb, size_t sheet_index, uint32_t row, uint32_t col, uint32_t run_index, fm_phonetic_run_t* out); +/** + * @brief Reads how a cell's phonetic guide renders (OOXML ``). + * + * Reports the all-zero triple for a cell with no annotation, including one + * that does not exist — the same state Excel infers for a guide written + * without a `` element. + * + * Any out-parameter may be `NULL` to skip that field. + * + * @return `kOk` on success; + * `kBindingNullPointer` if `wb` is `NULL`; + * `kInvalidArgument` when `sheet_index` is out of range. + */ +FM_API fm_status_t fm_workbook_get_cell_phonetic_properties(const fm_workbook_t* wb, size_t sheet_index, uint32_t row, + uint32_t col, uint32_t* out_font_id, uint32_t* out_type, + uint32_t* out_alignment); + /** * @brief Renders the lambda closure stored at `(sheet_index, row, col)` * as Excel formula text. diff --git a/src/c_api/parts/cells.cpp b/src/c_api/parts/cells.cpp index 0ba3841b..dc79c3d5 100644 --- a/src/c_api/parts/cells.cpp +++ b/src/c_api/parts/cells.cpp @@ -164,6 +164,47 @@ extern "C" fm_status_t fm_workbook_set_cell_phonetic_runs(fm_workbook_t* wb, siz return 0; } +extern "C" fm_status_t fm_workbook_set_cell_phonetic_properties(fm_workbook_t* wb, size_t sheet_index, uint32_t row, + uint32_t col, uint32_t font_id, uint32_t type, + uint32_t alignment) { + clear_last_error(); + if (auto rc = check_sheet_index(wb, sheet_index, "fm_workbook_set_cell_phonetic_properties"); rc != 0) { + return rc; + } + if (row >= formulon::Sheet::kMaxRows || col >= formulon::Sheet::kMaxCols) { + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, + "fm_workbook_set_cell_phonetic_properties: cell coordinate out of range"); + } + // Both enumerations occupy two bits of the XLSB trailer's flags word, so + // an out-of-range value would not survive an `.xlsb` save intact. Reject + // it here rather than truncate it into a different, valid-looking form. + constexpr uint32_t kMaxOrdinal = 3U; + if (type > kMaxOrdinal) { + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, + "fm_workbook_set_cell_phonetic_properties: type out of range", + "type=" + std::to_string(type)); + } + if (alignment > kMaxOrdinal) { + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, + "fm_workbook_set_cell_phonetic_properties: alignment out of range", + "alignment=" + std::to_string(alignment)); + } + constexpr uint32_t kMaxFontId = 0xFFFFU; + if (font_id > kMaxFontId) { + return set_binding_error(formulon::FormulonErrorCode::kInvalidArgument, + "fm_workbook_set_cell_phonetic_properties: font_id out of range", + "font_id=" + std::to_string(font_id)); + } + + wb->workbook() + .sheet(sheet_index) + .set_cell_phonetic_props( + row, col, + formulon::PhoneticProperties{static_cast(font_id), static_cast(type), + static_cast(alignment)}); + return 0; +} + extern "C" fm_status_t fm_workbook_set_blank(fm_workbook_t* wb, size_t sheet_index, uint32_t row, uint32_t col) { clear_last_error(); if (auto rc = check_sheet_index(wb, sheet_index, "fm_workbook_set_blank"); rc != 0) { @@ -283,6 +324,30 @@ extern "C" fm_status_t fm_workbook_get_cell_phonetic_run(const fm_workbook_t* wb return 0; } +extern "C" fm_status_t fm_workbook_get_cell_phonetic_properties(const fm_workbook_t* wb, size_t sheet_index, + uint32_t row, uint32_t col, uint32_t* out_font_id, + uint32_t* out_type, uint32_t* out_alignment) { + clear_last_error(); + if (auto rc = check_sheet_index(wb, sheet_index, "fm_workbook_get_cell_phonetic_properties"); rc != 0) { + return rc; + } + const formulon::Cell* cell = wb->workbook().sheet(sheet_index).cell_at(row, col); + // An absent cell reports the defaults rather than failing, matching the + // run-count reader: "no annotation" and "no cell" are the same answer to + // a host walking a range. + const formulon::PhoneticProperties props = cell == nullptr ? formulon::PhoneticProperties{} : cell->phonetic_props; + if (out_font_id != nullptr) { + *out_font_id = props.font_id; + } + if (out_type != nullptr) { + *out_type = props.type; + } + if (out_alignment != nullptr) { + *out_alignment = props.alignment; + } + return 0; +} + extern "C" fm_status_t fm_workbook_lambda_text_at(fm_workbook_t* wb, size_t sheet_index, uint32_t row, uint32_t col, const char** out_text) { clear_last_error(); diff --git a/src/node_addon/parts/lifecycle.cc b/src/node_addon/parts/lifecycle.cc index b06e50d9..83e0b78b 100644 --- a/src/node_addon/parts/lifecycle.cc +++ b/src/node_addon/parts/lifecycle.cc @@ -161,6 +161,26 @@ Napi::Value Workbook::SetCellPhoneticRuns(const Napi::CallbackInfo& info) { return MakeStatus(env, rc); } +Napi::Value Workbook::SetCellPhoneticProperties(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (handle_ == nullptr) { + return NullHandleError(env); + } + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + if (info.Length() <= 3 || !info[3].IsObject()) { + return MakeBindingArgumentError( + env, "setCellPhoneticProperties: `properties` must be an object { fontId, type, alignment }"); + } + const Napi::Object props = info[3].As(); + fm_status_t rc = fm_workbook_set_cell_phonetic_properties(handle_, sheet, row, col, + SpecPullU32(props, "fontId", 0U), + SpecPullU32(props, "type", 0U), + SpecPullU32(props, "alignment", 0U)); + return MakeStatus(env, rc); +} + Napi::Value Workbook::SetBlank(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (handle_ == nullptr) { @@ -251,6 +271,33 @@ Napi::Value Workbook::GetCellPhoneticRuns(const Napi::CallbackInfo& info) { return MakeFieldResult(env, MakeOkStatus(env), "runs", out); } +Napi::Value Workbook::GetCellPhoneticProperties(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + const std::size_t sheet = static_cast(ArgU32(info, 0)); + const uint32_t row = ArgU32(info, 1); + const uint32_t col = ArgU32(info, 2); + uint32_t font_id = 0; + uint32_t type = 0; + uint32_t alignment = 0; + const fm_status_t rc = + handle_ != nullptr ? fm_workbook_get_cell_phonetic_properties(handle_, sheet, row, col, &font_id, &type, + &alignment) + : kBindingInvalidHandle; + // The payload keys are declared unconditionally, so a failure reports the + // defaults beside the status rather than dropping them. + if (rc != 0) { + font_id = 0; + type = 0; + alignment = 0; + } + Napi::Object out = Napi::Object::New(env); + out.Set("fontId", Napi::Number::New(env, font_id)); + out.Set("type", Napi::Number::New(env, type)); + out.Set("alignment", Napi::Number::New(env, alignment)); + out.Set("status", MakeStatus(env, rc)); + return out; +} + Napi::Value Workbook::EvaluateFormulaText(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (handle_ == nullptr) { diff --git a/src/node_addon/parts/workbook_class.cc b/src/node_addon/parts/workbook_class.cc index 1c83569c..cbe12175 100644 --- a/src/node_addon/parts/workbook_class.cc +++ b/src/node_addon/parts/workbook_class.cc @@ -222,6 +222,7 @@ Napi::Function Workbook::GetClass(Napi::Env env) { InstanceMethod<&Workbook::GetCellXfIndex>("getCellXfIndex"), InstanceMethod<&Workbook::GetCellPhonetic>("getCellPhonetic"), InstanceMethod<&Workbook::GetCellPhoneticRuns>("getCellPhoneticRuns"), + InstanceMethod<&Workbook::GetCellPhoneticProperties>("getCellPhoneticProperties"), InstanceMethod<&Workbook::GetComment>("getComment"), InstanceMethod<&Workbook::GetCommentResult>("getCommentResult"), InstanceMethod<&Workbook::GetComments>("getComments"), @@ -363,6 +364,7 @@ Napi::Function Workbook::GetClass(Napi::Env env) { InstanceMethod<&Workbook::SetCellXfIndex>("setCellXfIndex"), InstanceMethod<&Workbook::SetCellPhonetic>("setCellPhonetic"), InstanceMethod<&Workbook::SetCellPhoneticRuns>("setCellPhoneticRuns"), + InstanceMethod<&Workbook::SetCellPhoneticProperties>("setCellPhoneticProperties"), InstanceMethod<&Workbook::SetRangeXfIndex>("setRangeXfIndex"), InstanceMethod<&Workbook::SetColumnHidden>("setColumnHidden"), InstanceMethod<&Workbook::SetColumnOutline>("setColumnOutline"), diff --git a/src/node_addon/parts/workbook_class.h b/src/node_addon/parts/workbook_class.h index b4092976..065f9dbb 100644 --- a/src/node_addon/parts/workbook_class.h +++ b/src/node_addon/parts/workbook_class.h @@ -41,6 +41,7 @@ class Workbook : public Napi::ObjectWrap { Napi::Value SetText(const Napi::CallbackInfo& info); Napi::Value SetCellPhonetic(const Napi::CallbackInfo& info); Napi::Value SetCellPhoneticRuns(const Napi::CallbackInfo& info); + Napi::Value SetCellPhoneticProperties(const Napi::CallbackInfo& info); Napi::Value SetBlank(const Napi::CallbackInfo& info); Napi::Value SetFormula(const Napi::CallbackInfo& info); @@ -48,6 +49,7 @@ class Workbook : public Napi::ObjectWrap { Napi::Value GetValue(const Napi::CallbackInfo& info); Napi::Value GetCellPhonetic(const Napi::CallbackInfo& info); Napi::Value GetCellPhoneticRuns(const Napi::CallbackInfo& info); + Napi::Value GetCellPhoneticProperties(const Napi::CallbackInfo& info); // Ad-hoc, side-effect-free formula evaluation. Napi::Value EvaluateFormulaText(const Napi::CallbackInfo& info); diff --git a/src/wasm/formulon.d.ts b/src/wasm/formulon.d.ts index 3cbaaa18..a35b630c 100644 --- a/src/wasm/formulon.d.ts +++ b/src/wasm/formulon.d.ts @@ -1269,6 +1269,23 @@ export interface PhoneticRunsResult { runs: PhoneticRun[]; } +/** How a cell's phonetic guide renders (OOXML ``). `fontId` + * indexes the workbook's font table for the ruby text; `type` is the kana + * form (0 half-width katakana, 1 full-width katakana, 2 hiragana, 3 no + * conversion) and `alignment` how the kana is distributed (0 no control, + * 1 left, 2 center, 3 distributed). The all-zero triple is what Excel + * infers for a guide written with no `` element. */ +export interface PhoneticProperties { + fontId: number; + type: number; + alignment: number; +} + +/** Return type of `Workbook.getCellPhoneticProperties(sheet, row, col)`. */ +export interface PhoneticPropertiesResult extends PhoneticProperties { + status: Status; +} + /** Return type of `Workbook.getFont(fontIndex)`. */ export interface FontResult extends FontRecord { status: Status; @@ -1633,6 +1650,11 @@ export interface Workbook { * partition: each needs `sb <= eb` and must start at or after the previous * run's `eb`. */ setCellPhoneticRuns(sheet: number, row: number, col: number, runs: PhoneticRun[]): Status; + /** Stores how the cell's guide renders. Independent of + * `setCellPhoneticRuns` in both directions, and only observable on a cell + * that has runs; every value setter clears both, so call it after the + * cell's text. */ + setCellPhoneticProperties(sheet: number, row: number, col: number, properties: PhoneticProperties): Status; setBlank(sheet: number, row: number, col: number): Status; setFormula(sheet: number, row: number, col: number, formula: string): Status; @@ -1680,6 +1702,9 @@ export interface Workbook { /** Returns the cell's `` blocks with their spans. `getCellPhonetic` * returns the same readings concatenated, without the spans. */ getCellPhoneticRuns(sheet: number, row: number, col: number): PhoneticRunsResult; + /** Returns how the cell's guide renders. A cell with no annotation + * reports the all-zero triple. */ + getCellPhoneticProperties(sheet: number, row: number, col: number): PhoneticPropertiesResult; /** Recalculates all dirty cells serially on the caller thread. * diff --git a/src/wasm/parts/bindings_register.cpp b/src/wasm/parts/bindings_register.cpp index 196ee5f6..dcf76353 100644 --- a/src/wasm/parts/bindings_register.cpp +++ b/src/wasm/parts/bindings_register.cpp @@ -217,6 +217,7 @@ EMSCRIPTEN_BINDINGS(formulon) { .function("getCellXfIndex", &JsWorkbook::getCellXfIndex) .function("getCellPhonetic", &JsWorkbook::getCellPhonetic) .function("getCellPhoneticRuns", &JsWorkbook::getCellPhoneticRuns) + .function("getCellPhoneticProperties", &JsWorkbook::getCellPhoneticProperties) .function("getComment", &JsWorkbook::getComment) .function("getCommentResult", &JsWorkbook::getCommentResult) .function("getComments", &JsWorkbook::getComments) @@ -347,6 +348,7 @@ EMSCRIPTEN_BINDINGS(formulon) { .function("setCellStyle", &JsWorkbook::setCellStyle) .function("setCellPhonetic", &JsWorkbook::setCellPhonetic) .function("setCellPhoneticRuns", &JsWorkbook::setCellPhoneticRuns) + .function("setCellPhoneticProperties", &JsWorkbook::setCellPhoneticProperties) .function("setColumnHidden", &JsWorkbook::setColumnHidden) .function("setColumnOutline", &JsWorkbook::setColumnOutline) .function("setColumnWidth", &JsWorkbook::setColumnWidth) diff --git a/src/wasm/parts/workbook.h b/src/wasm/parts/workbook.h index d3f66dc1..65038520 100644 --- a/src/wasm/parts/workbook.h +++ b/src/wasm/parts/workbook.h @@ -88,6 +88,12 @@ class JsWorkbook { /// whole cell, this preserves which characters each reading covers. See /// `fm_workbook_set_cell_phonetic_runs` for the ordering rules. JsStatus setCellPhoneticRuns(uint32_t sheet, uint32_t row, uint32_t col, emscripten::val runs); + + /// Stores how the cell's guide renders: `fontId`, `type` (0=halfwidth + /// katakana, 1=fullwidth katakana, 2=hiragana, 3=no conversion) and + /// `alignment` (0=no control, 1=left, 2=center, 3=distributed). + /// Independent of the readings in both directions. + JsStatus setCellPhoneticProperties(uint32_t sheet, uint32_t row, uint32_t col, emscripten::val properties); JsStatus setBlank(uint32_t sheet, uint32_t row, uint32_t col); JsStatus setFormula(uint32_t sheet, uint32_t row, uint32_t col, const std::string& formula); @@ -97,6 +103,10 @@ class JsWorkbook { /// spans included. `getCellPhonetic` returns the same readings /// concatenated, without the spans. emscripten::val getCellPhoneticRuns(uint32_t sheet, uint32_t row, uint32_t col) const; + + /// Reads the guide's rendering back as `{ fontId, type, alignment }`. + /// A cell with no annotation reports the all-zero triple. + emscripten::val getCellPhoneticProperties(uint32_t sheet, uint32_t row, uint32_t col) const; emscripten::val getLambdaText(uint32_t sheet, uint32_t row, uint32_t col) const; /// Evaluates `formula` as if entered at `(sheet, row, col)` and returns a diff --git a/src/wasm/parts/workbook_cells.cpp b/src/wasm/parts/workbook_cells.cpp index db09ea3b..177def2d 100644 --- a/src/wasm/parts/workbook_cells.cpp +++ b/src/wasm/parts/workbook_cells.cpp @@ -89,6 +89,16 @@ JsStatus JsWorkbook::setCellPhoneticRuns(uint32_t sheet, uint32_t row, uint32_t return status_from_rc(rc); } +JsStatus JsWorkbook::setCellPhoneticProperties(uint32_t sheet, uint32_t row, uint32_t col, emscripten::val properties) { + if (handle_ == nullptr) { + return error_status(kBindingInvalidHandle); + } + const fm_status_t rc = fm_workbook_set_cell_phonetic_properties( + handle_, sheet, row, col, js_pull_u32(properties, "fontId", 0U), js_pull_u32(properties, "type", 0U), + js_pull_u32(properties, "alignment", 0U)); + return status_from_rc(rc); +} + JsStatus JsWorkbook::setBlank(uint32_t sheet, uint32_t row, uint32_t col) { if (handle_ == nullptr) { return error_status(7000); @@ -175,6 +185,28 @@ emscripten::val JsWorkbook::getCellPhoneticRuns(uint32_t sheet, uint32_t row, ui return o; } +emscripten::val JsWorkbook::getCellPhoneticProperties(uint32_t sheet, uint32_t row, uint32_t col) const { + uint32_t font_id = 0; + uint32_t type = 0; + uint32_t alignment = 0; + const fm_status_t rc = handle_ != nullptr ? fm_workbook_get_cell_phonetic_properties(handle_, sheet, row, col, + &font_id, &type, &alignment) + : kBindingInvalidHandle; + // The payload keys are declared unconditionally, so a failure reports the + // defaults beside the status rather than dropping them. + if (rc != 0) { + font_id = 0; + type = 0; + alignment = 0; + } + emscripten::val o = emscripten::val::object(); + o.set("status", rc == 0 ? ok_status() : error_status(rc)); + o.set("fontId", font_id); + o.set("type", type); + o.set("alignment", alignment); + return o; +} + JsEvalResult JsWorkbook::evaluateFormulaText(uint32_t sheet, uint32_t row, uint32_t col, const std::string& formula) const { JsEvalResult r; diff --git a/tests/c_api/formulon_c_test.cpp b/tests/c_api/formulon_c_test.cpp index b47ea028..ca44ce2e 100644 --- a/tests/c_api/formulon_c_test.cpp +++ b/tests/c_api/formulon_c_test.cpp @@ -468,6 +468,73 @@ TEST(FormulonCApi, CellPhoneticRunsRejectMalformedInput) { EXPECT_NE(fm_workbook_get_cell_phonetic_run_count(wb.handle, 0, 0, 0, nullptr), 0); } +TEST(FormulonCApi, CellPhoneticPropertiesAreIndependentOfTheRuns) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + ASSERT_EQ(fm_workbook_set_text(wb.handle, 0, 0, 0, "大阪"), 0); + + // An unannotated cell reads the state Excel infers for a guide written + // with no `` at all. + uint32_t font_id = 9; + uint32_t type = 9; + uint32_t alignment = 9; + ASSERT_EQ(fm_workbook_get_cell_phonetic_properties(wb.handle, 0, 0, 0, &font_id, &type, &alignment), 0); + EXPECT_EQ(font_id, 0U); + EXPECT_EQ(type, FM_PHONETIC_TYPE_HALFWIDTH_KATAKANA); + EXPECT_EQ(alignment, FM_PHONETIC_ALIGNMENT_NO_CONTROL); + + ASSERT_EQ(fm_workbook_set_cell_phonetic_properties(wb.handle, 0, 0, 0, 3U, FM_PHONETIC_TYPE_HIRAGANA, + FM_PHONETIC_ALIGNMENT_CENTER), + 0); + // Writing the readings must not reset the rendering, which is the whole + // reason the two entry points are separate. + const fm_phonetic_run_t runs[] = {{0U, 2U, "おおさか"}}; + ASSERT_EQ(fm_workbook_set_cell_phonetic_runs(wb.handle, 0, 0, 0, runs, 1U), 0); + ASSERT_EQ(fm_workbook_get_cell_phonetic_properties(wb.handle, 0, 0, 0, &font_id, &type, &alignment), 0); + EXPECT_EQ(font_id, 3U); + EXPECT_EQ(type, FM_PHONETIC_TYPE_HIRAGANA); + EXPECT_EQ(alignment, FM_PHONETIC_ALIGNMENT_CENTER); + + // Each out-parameter is optional. + uint32_t only_type = 0; + ASSERT_EQ(fm_workbook_get_cell_phonetic_properties(wb.handle, 0, 0, 0, nullptr, &only_type, nullptr), 0); + EXPECT_EQ(only_type, FM_PHONETIC_TYPE_HIRAGANA); + + // Overwriting the value discards the guide and its rendering together. + ASSERT_EQ(fm_workbook_set_text(wb.handle, 0, 0, 0, "京都"), 0); + ASSERT_EQ(fm_workbook_get_cell_phonetic_properties(wb.handle, 0, 0, 0, &font_id, &type, &alignment), 0); + EXPECT_EQ(font_id, 0U); + EXPECT_EQ(type, 0U); + EXPECT_EQ(alignment, 0U); +} + +TEST(FormulonCApi, CellPhoneticPropertiesRejectValuesTheContainerCannotHold) { + WorkbookGuard wb; + ASSERT_EQ(fm_workbook_create(&wb.handle), 0); + + // type and alignment are two bits each in the xlsb trailer, and font_id a + // u16; a wider value would be truncated into a different, valid-looking + // one on save. + EXPECT_NE(fm_workbook_set_cell_phonetic_properties(wb.handle, 0, 0, 0, 0U, 4U, 0U), 0); + EXPECT_NE(fm_workbook_set_cell_phonetic_properties(wb.handle, 0, 0, 0, 0U, 0U, 4U), 0); + EXPECT_NE(fm_workbook_set_cell_phonetic_properties(wb.handle, 0, 0, 0, 0x10000U, 0U, 0U), 0); + EXPECT_NE(fm_workbook_set_cell_phonetic_properties(wb.handle, 0, formulon::Sheet::kMaxRows, 0, 0U, 0U, 0U), 0); + EXPECT_NE(fm_workbook_set_cell_phonetic_properties(wb.handle, 1U, 0, 0, 0U, 0U, 0U), 0); + EXPECT_NE(fm_workbook_set_cell_phonetic_properties(nullptr, 0, 0, 0, 0U, 0U, 0U), 0); + EXPECT_NE(fm_workbook_get_cell_phonetic_properties(nullptr, 0, 0, 0, nullptr, nullptr, nullptr), 0); + + // A rejected call leaves the stored rendering alone. + ASSERT_EQ(fm_workbook_set_cell_phonetic_properties(wb.handle, 0, 0, 0, 1U, FM_PHONETIC_TYPE_NO_CONVERSION, + FM_PHONETIC_ALIGNMENT_DISTRIBUTED), + 0); + EXPECT_NE(fm_workbook_set_cell_phonetic_properties(wb.handle, 0, 0, 0, 1U, 7U, 0U), 0); + uint32_t type = 0; + uint32_t alignment = 0; + ASSERT_EQ(fm_workbook_get_cell_phonetic_properties(wb.handle, 0, 0, 0, nullptr, &type, &alignment), 0); + EXPECT_EQ(type, FM_PHONETIC_TYPE_NO_CONVERSION); + EXPECT_EQ(alignment, FM_PHONETIC_ALIGNMENT_DISTRIBUTED); +} + TEST(FormulonCApi, LoadMapsCorruptAndEncryptedContainersToIoErrors) { fm_workbook_t* loaded = reinterpret_cast(0x1); const std::vector garbage = {0x01U, 0x02U, 0x03U, 0x04U}; diff --git a/tests/wasm/run.mjs b/tests/wasm/run.mjs index 962d6c19..937b88e1 100644 --- a/tests/wasm/run.mjs +++ b/tests/wasm/run.mjs @@ -682,6 +682,47 @@ async function run() { } }); + test('setCellPhoneticProperties survives a run edit and a save/load cycle', () => { + // The result carries a `status` alongside the triple; compare the + // triple alone so a status-shape change does not read as a value change. + const phoneticProps = (book, sheet, row, col) => { + const { fontId, type, alignment } = book.getCellPhoneticProperties(sheet, row, col); + return { fontId, type, alignment }; + }; + const wb = Module.Workbook.createDefault(); + try { + assert.ok(wb.setText(0, 0, 0, '大阪').ok); + // An unannotated cell reports what Excel infers for a guide written + // with no at all. + assert.deepEqual(phoneticProps(wb, 0, 0, 0), { fontId: 0, type: 0, alignment: 0 }); + + assert.ok(wb.setCellPhoneticProperties(0, 0, 0, { fontId: 3, type: 2, alignment: 2 }).ok); + // Setting the readings must not reset the rendering. + assert.ok(wb.setCellPhoneticRuns(0, 0, 0, [{ sb: 0, eb: 2, text: 'おおさか' }]).ok); + assert.deepEqual(phoneticProps(wb, 0, 0, 0), { fontId: 3, type: 2, alignment: 2 }); + + const saved = wb.save(); + assert.ok(saved.status.ok); + const loaded = Module.Workbook.loadBytes(saved.bytes); + try { + assert.deepEqual(phoneticProps(loaded, 0, 0, 0), { fontId: 3, type: 2, alignment: 2 }); + } finally { + loaded.delete(); + } + + // Two bits each on the binary side, so a wider ordinal is refused + // rather than truncated into a different, valid-looking one. + assert.equal(wb.setCellPhoneticProperties(0, 0, 0, { fontId: 0, type: 4, alignment: 0 }).ok, false); + assert.deepEqual(phoneticProps(wb, 0, 0, 0), { fontId: 3, type: 2, alignment: 2 }); + + // A value write discards the guide and its rendering together. + assert.ok(wb.setText(0, 0, 0, '京都').ok); + assert.deepEqual(phoneticProps(wb, 0, 0, 0), { fontId: 0, type: 0, alignment: 0 }); + } finally { + wb.delete(); + } + }); + test('setDefaultFont replaces font 0, which addFont can only append beside', () => { const wb = Module.Workbook.createDefault(); try { diff --git a/tools/wasm/capi_exports.txt b/tools/wasm/capi_exports.txt index 45b2e35f..62777ca2 100644 --- a/tools/wasm/capi_exports.txt +++ b/tools/wasm/capi_exports.txt @@ -50,12 +50,14 @@ fm_workbook_set_formula fm_workbook_set_error fm_workbook_set_cell_phonetic fm_workbook_set_cell_phonetic_runs +fm_workbook_set_cell_phonetic_properties # -- Cell read ------------------------------------------------------------------- fm_workbook_get_value fm_workbook_get_cell_phonetic fm_workbook_get_cell_phonetic_run_count fm_workbook_get_cell_phonetic_run +fm_workbook_get_cell_phonetic_properties fm_workbook_lambda_text_at # -- Ad-hoc array evaluation (two-step: evaluate + per-cell readback) ------------- From 9a1c2b296c4114c70ecf128a26cffd616b0aaec7 Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 20:09:54 +0900 Subject: [PATCH 10/12] docs(divergence): record that column geometry assumes one normal font - add the non-oracle deferred-feature entry pagination_column_width_calibrated_to_one_normal_font - Excel resolves a column's width in points from its stored character-unit width times the Normal font's maximum digit width, so the conversion is a property of that font and pagination carries one calibration for it - measured against Mac Excel 365 16.112.1 by opening workbooks that differ only in font 0: at 11 pt the family is not observable, since Calibri and both Japanese body faces resolve the same width; the point size is, and the family becomes observable away from 11 pt - closing it needs the same sweep captured on the Windows primary oracle, whose column geometry is a different regime from the Mac numbers --- tests/divergence.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/divergence.yaml b/tests/divergence.yaml index 19a96c4e..5a86b8a1 100644 --- a/tests/divergence.yaml +++ b/tests/divergence.yaml @@ -928,6 +928,28 @@ entries: prefer: formulon first_noted: 2026-08-16 last_verified_excel_version: "16.0.20228" + # -- Column geometry is calibrated to one Normal font --------------------- + # Excel resolves a column's width in points from its stored character-unit + # width times the Normal font's maximum digit width, so the conversion is a + # property of that font. Pagination carries a single calibration for it. + # The scope was measured by opening workbooks that differ only in font 0 + # and reading `Range.Width` back: at 11 pt the family does not move it at + # all, so a ja-JP workbook whose default font is a Japanese body face + # paginates identically. The point size does move it, and away from 11 pt + # the family starts to matter too. Recorded rather than fixed because the + # calibration this constant carries is the Windows one, and the sweep that + # would size it off the Normal font has only been run on Mac, whose column + # geometry is a different regime. + - id: pagination_column_width_calibrated_to_one_normal_font + cause: engine-gap + mode: skip-oracle + scope: deferred-feature + evidence: [src/print/pagination.cpp, tests/unit/print/pagination_test.cpp] + reason: "Column width in points is the stored character-unit width times the Normal font's max digit width. Mac Excel 365 16.112.1 resolves a 30-unit column to the same width under Calibri 11, 游ゴシック 11 and MS Pゴシック 11, so the font family is not observable at 11 pt; the point size is (Calibri 8/11/18 give 4/6/9 pt per stored unit) and the family becomes observable away from 11 pt (游ゴシック 14 gives 8 where Calibri 14 gives 7). Formulon paginates every workbook against the 11 pt calibration. Closing it needs the same sweep captured on the Windows primary oracle, whose column geometry is a different regime from the Mac numbers above." + prefer: mac-excel-365 + first_noted: 2026-08-22 + last_verified_excel_version: "16.112.1" + - id: xlookup_binary_search_dirty_data cause: accepted-divergence mode: skip-oracle From 4752fe6c4ab54d55710a5c19490dba7c54b7e3be Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 20:10:02 +0900 Subject: [PATCH 11/12] docs(changelog): note the phonetic properties authoring surface - record under Unreleased / Added that how a phonetic guide renders can now be authored, not only round-tripped, naming the C ABI pair and its WASM, Node addon and Python projections - state that it stays separate from the run entry points in both directions and that a value write still clears both --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 941c502f..f04bd4a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 which had neither. They were the last cell-level pair that existed on WASM and Python only. +- How a phonetic guide renders can be authored, not only round-tripped. + `fm_workbook_set_cell_phonetic_properties` / + `fm_workbook_get_cell_phonetic_properties` carry the font that draws the + ruby, the kana form Excel generates readings in and how the kana is + distributed over the characters it covers. They reach WASM and the + native Node addon as `setCellPhoneticProperties` / + `getCellPhoneticProperties` and Python as `set_phonetic_properties` / + `get_phonetic_properties`, with `FM_PHONETIC_TYPE_*` and + `FM_PHONETIC_ALIGNMENT_*` naming the ordinals. Deliberately separate + from the run entry points in both directions: editing the readings does + not reset the rendering, and setting the rendering does not touch the + readings. A value write still clears both. + - Phonetic guides survive the MS-XLSB container. `BrtSSTItem`'s phonetic tail is now decoded and emitted, so furigana no longer disappears when a workbook is saved as `.xlsb` or read back from one. The binary form From 66a53ac80bbc701e2b033a41c593294720f5f0dc Mon Sep 17 00:00:00 2001 From: libraz Date: Sat, 22 Aug 2026 21:43:50 +0900 Subject: [PATCH 12/12] docs(release): prepare release v0.11.1 - Bump the version to 0.11.1 across CMake, both npm packages, the Python project and the embedded version header - Close the changelog's unreleased section as 0.11.1 and add its compare link - Index v0.11.1 as the latest release --- CHANGELOG.md | 5 ++++- CMakeLists.txt | 2 +- docs/releases/README.md | 3 ++- packages/npm-native/package.json | 2 +- packages/npm/package.json | 2 +- packages/python/pyproject.toml | 2 +- src/version.h | 2 +- 7 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f04bd4a1..271c600d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.1] - 2026-08-22 + ### Added - A workbook's default font can be declared. Font 0 is the record every @@ -1380,7 +1382,8 @@ See the [GitHub release page](https://github.com/libraz/formulon/releases/tag/v0.9.0) for the full auto-generated change list. -[Unreleased]: https://github.com/libraz/formulon/compare/v0.11.0...HEAD +[Unreleased]: https://github.com/libraz/formulon/compare/v0.11.1...HEAD +[0.11.1]: https://github.com/libraz/formulon/compare/v0.11.0...v0.11.1 [0.11.0]: https://github.com/libraz/formulon/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/libraz/formulon/compare/v0.9.7...v0.10.0 [0.9.7]: https://github.com/libraz/formulon/compare/v0.9.6...v0.9.7 diff --git a/CMakeLists.txt b/CMakeLists.txt index 9d37152e..76e5bd3b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(formulon VERSION 0.11.0 LANGUAGES CXX) +project(formulon VERSION 0.11.1 LANGUAGES CXX) # --------------------------------------------------------------------------- # Language standard and global settings diff --git a/docs/releases/README.md b/docs/releases/README.md index b7b86a38..3cd6339e 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -6,7 +6,8 @@ The high-level summary lives in [`CHANGELOG.md`](../../CHANGELOG.md). ## Available Versions -- [v0.11.0](https://github.com/libraz/formulon/releases/tag/v0.11.0) — Latest release (2026-08-22) — a print-settings authoring API across the C ABI, WASM, the native Node addon and Python, the Excel minimum style table seeded on workbook creation, cross-workbook reference resolution and XLSB pivot decoding, the range operator composed to any depth, spill anchors that are computed rather than written out, and phonetic run spans preserved end to end. +- [v0.11.1](https://github.com/libraz/formulon/releases/tag/v0.11.1) — Latest release (2026-08-22) — a phonetic-guide authoring surface across every binding (run spans with the text they annotate, and the font, kana form and distribution that render them), a declarable workbook default font, a font's `` theme link and a guide's `` block round-tripping through both containers, and furigana carried through the MS-XLSB shared-string table. +- [v0.11.0](https://github.com/libraz/formulon/releases/tag/v0.11.0) — 2026-08-22 — a print-settings authoring API across the C ABI, WASM, the native Node addon and Python, the Excel minimum style table seeded on workbook creation, cross-workbook reference resolution and XLSB pivot decoding, the range operator composed to any depth, spill anchors that are computed rather than written out, and phonetic run spans preserved end to end. - [v0.10.0](https://github.com/libraz/formulon/releases/tag/v0.10.0) — 2026-08-18 — C ABI entry-point consolidation (the `_ex` variants folded into their base names, a binary break against v0.9.7), pivot report-filter rendering, a verified Windows Excel 365 primary oracle for the workbook track, and broad evaluation / I/O correctness work. - [v0.9.7](https://github.com/libraz/formulon/releases/tag/v0.9.7) — 2026-08-06 — pagination across the CLI, C ABI and every binding, workbook memory-footprint reporting, XLSB styles and worksheet-tail retention, and broad evaluation / I/O correctness work. - [v0.9.6](https://github.com/libraz/formulon/releases/tag/v0.9.6) — 2026-07-19 — full Excel 365 dynamic-array spill semantics plus broad security / robustness hardening across evaluation and I/O. diff --git a/packages/npm-native/package.json b/packages/npm-native/package.json index 5a408b95..30b2324d 100644 --- a/packages/npm-native/package.json +++ b/packages/npm-native/package.json @@ -1,6 +1,6 @@ { "name": "@libraz/formulon-native", - "version": "0.11.0", + "version": "0.11.1", "description": "Excel 365 calculation engine -- native N-API binding", "license": "Apache-2.0", "author": "libraz", diff --git a/packages/npm/package.json b/packages/npm/package.json index 9ff68b52..5077e685 100644 --- a/packages/npm/package.json +++ b/packages/npm/package.json @@ -1,6 +1,6 @@ { "name": "@libraz/formulon", - "version": "0.11.0", + "version": "0.11.1", "description": "Excel 365 calculation engine -- WASM binding", "license": "Apache-2.0", "author": "libraz", diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index 8a3df231..7d25eaf1 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -20,7 +20,7 @@ build-backend = "setuptools.build_meta" [project] name = "formulon" -version = "0.11.0" +version = "0.11.1" description = "Excel 365 calculation engine -- Python binding" readme = "README.md" license = "Apache-2.0" diff --git a/src/version.h b/src/version.h index ee2bfffd..2a531aab 100644 --- a/src/version.h +++ b/src/version.h @@ -9,7 +9,7 @@ #define FORMULON_VERSION_MAJOR 0 #define FORMULON_VERSION_MINOR 11 -#define FORMULON_VERSION_PATCH 0 +#define FORMULON_VERSION_PATCH 1 #define FORMULON_VERSION_STRINGIFY_(x) #x #define FORMULON_VERSION_STRINGIFY(x) FORMULON_VERSION_STRINGIFY_(x)