Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<xf>` 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 `<rPh>` 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 `<scheme>` 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 `<phoneticPr>` block survives a load and save. Only
the `<rPh>` 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 `<phoneticPr/>` resolve differently —
half-width katakana / no control against full-width katakana / left —
and are now read apart.

## [0.11.0] - 2026-08-22

### Added
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/releases/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<scheme>` theme link and a guide's `<phoneticPr>` 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.
Expand Down
7 changes: 3 additions & 4 deletions packages/npm-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,20 @@ 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:

```
addCellStyleXf, setCellStyle
createTable, updateTable, removeTable
getCellPhonetic, setCellPhonetic
getSheetAutoFilterXml, setSheetAutoFilterXml
```

Expand Down
65 changes: 65 additions & 0 deletions packages/npm-native/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,9 @@ export interface FontRecord {
hasCharset: boolean;
/** OOXML charset codepage id (e.g. 128 = Shift_JIS). */
charset: number;
/** `<scheme>` 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;
Expand Down Expand Up @@ -1114,6 +1117,37 @@ export interface CellXf {
xfId?: number;
}

/** One `<rPh>` 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 `<phoneticPr>`). `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 `<phoneticPr>` 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;
Expand Down Expand Up @@ -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 (`<rPh>`). */
setCellPhonetic(sheet: number, row: number, col: number, phonetic: string): Status;
/** Stores (or, when empty, clears) the cell's phonetic guide as one `<rPh>`
* 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 (`<rPh>`), or an empty string. */
getCellPhonetic(sheet: number, row: number, col: number): StringResult;
/** Returns the cell's `<rPh>` 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
Expand Down Expand Up @@ -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 `<xf>` 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). */
Expand Down
2 changes: 1 addition & 1 deletion packages/npm-native/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
103 changes: 103 additions & 0 deletions packages/npm-native/test/smoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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')],
Expand Down
2 changes: 1 addition & 1 deletion packages/npm/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/npm/test/smoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')],
Expand Down
Loading
Loading