diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d4879a..271c600d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,82 @@ 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 + 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. + +- 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 + 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. + +### 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 @@ -1306,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/README.md b/packages/npm-native/README.md index 3c2fa5c9..fb0209c4 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 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 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..7ba8f636 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; @@ -1114,6 +1117,37 @@ 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[]; +} + +/** 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; @@ -1546,11 +1580,32 @@ 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; + /** 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; // 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; + /** 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 @@ -2071,6 +2126,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/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-native/test/smoke.test.mjs b/packages/npm-native/test/smoke.test.mjs index 8d49833d..6c5a257d 100644 --- a/packages/npm-native/test/smoke.test.mjs +++ b/packages/npm-native/test/smoke.test.mjs @@ -1314,6 +1314,107 @@ 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('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(); + 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 +2337,8 @@ 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)], + ['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/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/npm/test/smoke.test.mjs b/packages/npm/test/smoke.test.mjs index 2e629e97..88c6e4f3 100644 --- a/packages/npm/test/smoke.test.mjs +++ b/packages/npm/test/smoke.test.mjs @@ -911,6 +911,8 @@ 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)], + ['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 62327a51..213685f0 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -107,6 +107,37 @@ 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`. + +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: @@ -174,6 +205,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..05057903 100644 --- a/packages/python/formulon/__init__.py +++ b/packages/python/formulon/__init__.py @@ -67,6 +67,8 @@ PageSetup, PaginationResult, PassthroughPart, + PhoneticProperties, + PhoneticRun, PivotAggregation, PivotAxis, PivotCalendar, @@ -191,6 +193,8 @@ def _resolve_version() -> str: "PageSetup", "PaginationResult", "PassthroughPart", + "PhoneticProperties", + "PhoneticRun", "PivotAggregation", "PivotAxis", "PivotCalendar", diff --git a/packages/python/formulon/__init__.pyi b/packages/python/formulon/__init__.pyi index 20f7664e..20560ec3 100644 --- a/packages/python/formulon/__init__.pyi +++ b/packages/python/formulon/__init__.pyi @@ -701,6 +701,18 @@ class ColorSpec: indexed: int = ..., ) -> None: ... +class PhoneticRun: + sb: int + eb: int + 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 @@ -717,6 +729,7 @@ class FontRecord: family: int has_charset: bool charset: int + scheme: int color: ColorSpec def __init__( self, @@ -735,6 +748,7 @@ class FontRecord: family: int = ..., has_charset: bool = ..., charset: int = ..., + scheme: int = ..., color: ColorSpec = ..., ) -> None: ... @@ -918,7 +932,11 @@ 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 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: ... @@ -1187,6 +1205,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..13ba927d 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,9 @@ "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", "fm_workbook_get_value", "fm_workbook_insert_cols", @@ -283,6 +287,9 @@ "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", "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..13ad38b5 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``. @@ -530,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 573e2ff2..fcf252fe 100644 --- a/packages/python/formulon/workbook.py +++ b/packages/python/formulon/workbook.py @@ -1115,6 +1115,38 @@ 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 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). @@ -1144,6 +1176,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) @@ -1220,6 +1254,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]), ) @@ -1243,6 +1278,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) @@ -1538,6 +1574,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 +1872,139 @@ 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 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. @@ -4449,6 +4637,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/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/packages/python/tests/test_binding_contracts.py b/packages/python/tests/test_binding_contracts.py index 142f52fb..d55eb9f3 100644 --- a/packages/python/tests/test_binding_contracts.py +++ b/packages/python/tests/test_binding_contracts.py @@ -31,6 +31,8 @@ FormulonError, LogLevel, MergeRange, + PhoneticProperties, + PhoneticRun, PivotAggregation, PivotAxis, PivotDataFieldSpec, @@ -461,6 +463,101 @@ 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_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") + # 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..a889dff2 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, @@ -137,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 2151960a..4fe76dcc 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,79 @@ 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); + +/** `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. @@ -824,6 +922,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 +941,52 @@ 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 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. @@ -4332,6 +4482,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 */ @@ -4351,6 +4507,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; @@ -4744,6 +4901,58 @@ 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 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`. + */ +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..dc79c3d5 100644 --- a/src/c_api/parts/cells.cpp +++ b/src/c_api/parts/cells.cpp @@ -114,6 +114,97 @@ 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_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) { @@ -191,6 +282,72 @@ 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_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/c_api/parts/styles.cpp b/src/c_api/parts/styles.cpp index 24cabc0b..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; @@ -741,6 +743,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/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/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/cell_writer.cpp b/src/io/xlsb/cell_writer.cpp index defbebad..2655d38e 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,13 @@ 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, + PhoneticProperties phonetic_props, SstBuilder& sst) { switch (cached.kind()) { case ValueKind::Blank: { std::vector p; @@ -208,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()); + 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); @@ -253,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, 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, sst); + EmitLiteralCellRecord(dst, col, cell.xf_index, cell.cached_value, cell.phonetic_runs, cell.phonetic_props, sst); return Expected::Ok(); } @@ -283,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, 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 c2676c9b..b703b4e2 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,124 @@ 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. +/// 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(); + } + 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}); + } + DecodePhoneticProperties(cursor, out_props); + 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)}); + } + DecodePhoneticProperties(cursor, out_props); + 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, 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_props) { std::vector entries; ByteSpan cursor{body.data(), body.size()}; while (cursor.size > 0) { @@ -750,22 +866,50 @@ 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(); + out_phonetic_props.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(), out_phonetic_props.back()); + !decoded) { + return decoded.error(); + } } return entries; } @@ -1876,9 +2020,11 @@ 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, + 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) { switch (type) { case XlsbRecordType::BrtBeginWsView: { // BrtBeginWsView ([MS-XLSB] §2.4.141) stores the SheetView fields @@ -2301,6 +2447,18 @@ 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 (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(); } @@ -2541,14 +2699,13 @@ 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, + 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) { @@ -2565,9 +2722,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, sst_phonetic_props, text_storage, sheet_names, name_table, + sheet_ranges, external_books, undecoded_formula_count); if (!disposition_or) { return disposition_or.error(); } @@ -2755,12 +2912,15 @@ 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; + 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); + auto sst_or = DecodeSharedStringsBin(sst_bytes_or.value(), text_storage, sst_phonetic, sst_phonetic_props); if (!sst_or) { return sst_or.error(); } @@ -2811,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, 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 b2179e9c..5d432b24 100644 --- a/src/io/xlsb/sst_writer.cpp +++ b/src/io/xlsb/sst_writer.cpp @@ -4,31 +4,118 @@ #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 constant high bits of the phonetic tail's closing flags word. +/// +/// 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. +/// +/// 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, + PhoneticProperties phonetic_props) { + 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); + } + 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; +} + +/// 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, + PhoneticProperties props) { + 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, props.font_id); + emit_u16(payload, pack_phonetic_flags(props)); +} + +} // namespace + +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(key); + entries_.push_back(SstEntry{std::string(text), phonetic, phonetic_props}); index_.emplace(std::move(key), idx); return idx; } @@ -52,11 +139,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, 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 b00aabba..60bfc993 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,46 @@ 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; + /// 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`. /// -/// 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, + PhoneticProperties phonetic_props); - /// 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 +86,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/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/src/node_addon/parts/lifecycle.cc b/src/node_addon/parts/lifecycle.cc index 099142ef..83e0b78b 100644 --- a/src/node_addon/parts/lifecycle.cc +++ b/src/node_addon/parts/lifecycle.cc @@ -113,6 +113,74 @@ 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::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) { @@ -156,6 +224,80 @@ 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::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/styles.cc b/src/node_addon/parts/styles.cc index ccce8d4e..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"); } @@ -414,6 +416,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..cbe12175 100644 --- a/src/node_addon/parts/workbook_class.cc +++ b/src/node_addon/parts/workbook_class.cc @@ -220,6 +220,9 @@ 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::GetCellPhoneticProperties>("getCellPhoneticProperties"), InstanceMethod<&Workbook::GetComment>("getComment"), InstanceMethod<&Workbook::GetCommentResult>("getCommentResult"), InstanceMethod<&Workbook::GetComments>("getComments"), @@ -359,6 +362,9 @@ 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::SetCellPhoneticProperties>("setCellPhoneticProperties"), InstanceMethod<&Workbook::SetRangeXfIndex>("setRangeXfIndex"), InstanceMethod<&Workbook::SetColumnHidden>("setColumnHidden"), InstanceMethod<&Workbook::SetColumnOutline>("setColumnOutline"), @@ -366,6 +372,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..065f9dbb 100644 --- a/src/node_addon/parts/workbook_class.h +++ b/src/node_addon/parts/workbook_class.h @@ -39,11 +39,17 @@ 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 SetCellPhoneticProperties(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); + Napi::Value GetCellPhoneticProperties(const Napi::CallbackInfo& info); // Ad-hoc, side-effect-free formula evaluation. Napi::Value EvaluateFormulaText(const Napi::CallbackInfo& info); @@ -281,6 +287,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/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/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; 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/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) diff --git a/src/wasm/formulon.d.ts b/src/wasm/formulon.d.ts index 32756c40..a35b630c 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; @@ -1252,6 +1255,37 @@ 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[]; +} + +/** 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; @@ -1610,6 +1644,17 @@ 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; + /** 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; @@ -1654,6 +1699,12 @@ 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; + /** 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. * @@ -2157,6 +2208,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..dcf76353 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,8 @@ EMSCRIPTEN_BINDINGS(formulon) { .function("getCellXf", &JsWorkbook::getCellXf) .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) @@ -343,6 +347,8 @@ EMSCRIPTEN_BINDINGS(formulon) { .function("clearSheetBreaks", &JsWorkbook::clearSheetBreaks) .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 7aa43cdf..65038520 100644 --- a/src/wasm/parts/workbook.h +++ b/src/wasm/parts/workbook.h @@ -83,11 +83,30 @@ 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); + + /// 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); 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; + + /// 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 @@ -272,6 +291,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..177def2d 100644 --- a/src/wasm/parts/workbook_cells.cpp +++ b/src/wasm/parts/workbook_cells.cpp @@ -62,6 +62,43 @@ 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::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); @@ -114,6 +151,62 @@ 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; +} + +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/src/wasm/parts/workbook_styles.cpp b/src/wasm/parts/workbook_styles.cpp index 3084f8c6..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"); } @@ -369,6 +371,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/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/c_api/formulon_c_styles_test.cpp b/tests/c_api/formulon_c_styles_test.cpp index 462af177..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, @@ -2218,3 +2218,120 @@ 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); +} + +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); +} diff --git a/tests/c_api/formulon_c_test.cpp b/tests/c_api/formulon_c_test.cpp index 36261125..ca44ce2e 100644 --- a/tests/c_api/formulon_c_test.cpp +++ b/tests/c_api/formulon_c_test.cpp @@ -398,6 +398,143 @@ 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, 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/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 diff --git a/tests/fixtures/excel/xlsb_phonetic.xlsb b/tests/fixtures/excel/xlsb_phonetic.xlsb new file mode 100644 index 00000000..fba4fb7c Binary files /dev/null and b/tests/fixtures/excel/xlsb_phonetic.xlsb differ diff --git a/tests/fixtures/excel/xlsb_phonetic.xlsx b/tests/fixtures/excel/xlsb_phonetic.xlsx new file mode 100644 index 00000000..b1a919d4 Binary files /dev/null and b/tests/fixtures/excel/xlsb_phonetic.xlsx differ 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/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; diff --git a/tests/unit/io/xlsb/sst_writer_test.cpp b/tests/unit/io/xlsb/sst_writer_test.cpp index 91b685f3..ed3b882c 100644 --- a/tests/unit/io/xlsb/sst_writer_test.cpp +++ b/tests/unit/io/xlsb/sst_writer_test.cpp @@ -8,20 +8,29 @@ #include "io/xlsb/sst_writer.h" +#include +#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; +constexpr PhoneticProperties kDefaultProps{}; + ByteSpan SpanOf(const std::vector& v) { return ByteSpan{v.data(), v.size()}; } @@ -58,6 +67,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 +153,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, 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); - 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, 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)); @@ -105,9 +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"), 0U); + EXPECT_EQ(sst.intern("\xE6\x97\xA5\xE6\x9C\xAC", kNoPhonetic, kDefaultProps), 0U); EXPECT_EQ(sst.intern("\xF0\x9F\x8C\x9F" - "ok"), + "ok", + kNoPhonetic, kDefaultProps), 1U); auto body_or = emit_sst(sst); @@ -120,10 +202,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, 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); + 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, 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 new file mode 100644 index 00000000..a91c39a7 --- /dev/null +++ b/tests/unit/io/xlsb_phonetic_test.cpp @@ -0,0 +1,237 @@ +// +// 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/ooxml_writer.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; + } +} + +/// 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, "東京都"))); + 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 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); } } diff --git a/tests/wasm/run.mjs b/tests/wasm/run.mjs index 37a00853..937b88e1 100644 --- a/tests/wasm/run.mjs +++ b/tests/wasm/run.mjs @@ -647,6 +647,104 @@ 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('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 { + 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..62777ca2 100644 --- a/tools/wasm/capi_exports.txt +++ b/tools/wasm/capi_exports.txt @@ -49,10 +49,15 @@ fm_workbook_set_blank 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) ------------- @@ -191,6 +196,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