Skip to content

fix(worksheet): preserve _xlfn. prefix on read so dynamic-array formulas round-trip - #101

Merged
baseballyama merged 2 commits into
office-kit:mainfrom
michaelcradock76-spec:fix/preserve-xlfn-prefix-roundtrip
Jul 12, 2026
Merged

fix(worksheet): preserve _xlfn. prefix on read so dynamic-array formulas round-trip#101
baseballyama merged 2 commits into
office-kit:mainfrom
michaelcradock76-spec:fix/preserve-xlfn-prefix-roundtrip

Conversation

@michaelcradock76-spec

Copy link
Copy Markdown
Contributor

Summary

Future-function (dynamic-array) formulas are corrupted on a load → save round-trip. A workbook loaded with _xlfn.SCAN(...) is written back as bare SCAN(...), which Excel renders as #NAME?. This preserves the _xlfn. prefix verbatim on read (matching openpyxl), so every modern function survives round-tripping intact.

Motivation

In OOXML, modern functions are stored in <f> with an _xlfn. (or _xlfn._xlws.) prefix — Excel strips it for display and re-adds it on save. A stored bare SCAN(...) is an unknown name, so Excel shows #NAME?.

The reader stripped the prefix into the in-memory model (stripFutureFunctionPrefix in src/worksheet/reader.ts), while the writer emits formula text verbatim (serializeFormulaCell in src/worksheet/writer.ts). So a loaded _xlfn.SCAN(...) became bare SCAN(...) on the way out — corrupting every dynamic-array / future function (SCAN, BYCOL, LAMBDA, XLOOKUP, FILTER, SEQUENCE, LET, ANCHORARRAY, …), including cells the caller never touched (e.g. loading a workbook, editing one input, saving).

Minimal repro:

const wb = createWorkbook();
const ws = addWorksheet(wb, 'Sheet1');
setArrayFormula(setCell(ws, 2, 8, null), 'H2:L2',
  '_xlfn.SCAN(0,B2:F2,_xlfn.LAMBDA(_xlpm.a,_xlpm.b,_xlpm.a+_xlpm.b))', { cachedValue: 10 });

const bytes1 = await workbookToBytes(wb);           // sheet1.xml contains _xlfn.SCAN ✅
const wb2    = await loadWorkbook(fromBuffer(bytes1)); // model formula is now bare SCAN(...) ❌
const bytes2 = await workbookToBytes(wb2);           // sheet1.xml now contains bare SCAN(...) → #NAME?

A note on the earlier decision to ship bare names (commit 2cfc97d / scenario 30): the concern was that the _xlfn. storage form needs a paired cm= + xl/metadata.xml or Excel raises the recovery dialog. That coupling applies to the dynamic-array cell metadata (the cm attribute that marks a spill as a resizing array), not to the _xlfn. name prefix itself — Excel writes _xlfn.XLOOKUP in ordinary cells with no metadata at all. This PR does not synthesise any new _xlfn. form: it only preserves what the source file already contained, so it cannot produce a form Excel did not itself author.

Closes #

Changes

  • src/worksheet/reader.ts: keep <f> formula text verbatim on read instead of stripping the _xlfn. / _xlfn._xlws. prefix. Removes the now-unused stripFutureFunctionPrefix helper and its regex.
  • Behavior change: cell.value.formula for a future function now reads _xlfn.SCAN(...) (as stored, and as openpyxl surfaces it) rather than bare SCAN(...).

Testing

  • New regression test tests/formula/future-function-roundtrip.test.ts builds a _xlfn.SCAN / _xlfn.LAMBDA spill anchor, round-trips it through workbookToBytesloadWorkbookworkbookToBytes, and asserts the serialized sheet1.xml still contains the prefix (and no bare SCAN(). It fails on main (model formula comes back as SCAN(0,B2:F2,LAMBDA(...))) and passes with this change.
  • pnpm test — full unit/property/round-trip suite green (the only failures in my environment are the 4 tests/conformance/* files that shell out to xmllint, which isn't installed locally; they are unrelated to this change).
  • pnpm test:e2e, pnpm typecheck, pnpm lint, pnpm build all pass.

Reproduce:

pnpm test tests/formula/future-function-roundtrip.test.ts

Breaking changes

None for file output. Minor observable change to the in-memory API: consumers that read cell.value.formula for a future function now see the _xlfn.-prefixed form (previously stripped). This matches how the formula is stored on disk and how openpyxl surfaces it. A changeset is included (patch).

Related (out of scope, happy to follow up)

Separately, the reader/writer has no cell-metadata model, so the cm attribute that marks a spill as a true resizing dynamic array is dropped on round-trip. Values stay correct (the formula survives as a CSE array over its stored ref), but the array won't auto-resize. That's a larger change (needs an xl/metadata.xml model) — I've kept this PR scoped to the _xlfn. fix and can open a separate issue/PR for the metadata if useful.

Checklist

  • I have read CLAUDE.md and followed the project's conventions.
  • I have added or updated tests for the change.
  • I have added or updated documentation where user-visible behavior changed.
  • If this is a user-visible change, I have run pnpm changeset and committed the result.
  • If this is a breaking change, I have flagged it above and the changeset is marked accordingly.
  • I have re-read my own diff and removed dead code, debug prints, and stale comments.
  • If I used an LLM to draft this PR, I have verified each change myself, this PR represents real work that warrants a maintainer's review, and I am willing to defend each line in review.

🤖 Generated with Claude Code

michaelcradock76-spec and others added 2 commits July 12, 2026 14:22
…las round-trip

The reader stripped the `_xlfn.` / `_xlfn._xlws.` future-function prefix into
the in-memory model, while the writer emits formula text verbatim. A workbook
loaded with `_xlfn.SCAN(...)` was therefore written back as bare `SCAN(...)` —
an unknown name that Excel renders as #NAME?. This corrupted every
dynamic-array / future function (SCAN, BYCOL, LAMBDA, XLOOKUP, FILTER,
SEQUENCE, LET, ANCHORARRAY, …) on a load -> save round-trip, including cells
the caller never touched.

The prefix is part of the stored OOXML grammar and is independent of the
`cm` dynamic-array cell metadata: Excel writes `_xlfn.NAME` for these
functions in every cell, metadata or not. Keep the formula text verbatim on
read (matching openpyxl, which surfaces the prefix as-is). Because the reader
now only preserves what the source file already contained, it can never
synthesise an `_xlfn.` form Excel did not itself author.

A regression test builds a `_xlfn.SCAN`/`_xlfn.LAMBDA` spill anchor, round-trips
it through load -> save, and asserts the serialized XML still carries the
prefix; it fails without this change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…. prefix

The reader change preserved the `_xlfn.` prefix but left two comments
asserting the old, over-generalized rationale that the prefix itself is
coupled to `cm=` / `xl/metadata.xml`:

- src/worksheet/writer.ts: the writer still passes formula text verbatim,
  so it no longer "ships bare names" once the reader feeds a prefixed name
  through. Correct the comment: the `_xlfn.` name prefix needs no metadata;
  the `cm=` coupling is a separate, unimplemented concern (spill resize).
- tests/e2e/scenarios/30: clarify the AVERAGE/VLOOKUP fallback is chosen
  for Excel-version compatibility, not because of the prefix coupling.

Also add an `_xlfn._xlws.` (FILTER/SORT) round-trip case so the compound
worksheet-function prefix is covered, not just the leading `_xlfn.`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYPZEKSkTxi46S3x2sAyGg

@baseballyama baseballyama left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@baseballyama
baseballyama merged commit 06dbfd8 into office-kit:main Jul 12, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants