fix(worksheet): preserve _xlfn. prefix on read so dynamic-array formulas round-trip - #101
Merged
baseballyama merged 2 commits intoJul 12, 2026
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Future-function (dynamic-array) formulas are corrupted on a load → save round-trip. A workbook loaded with
_xlfn.SCAN(...)is written back as bareSCAN(...), 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 bareSCAN(...)is an unknown name, so Excel shows#NAME?.The reader stripped the prefix into the in-memory model (
stripFutureFunctionPrefixinsrc/worksheet/reader.ts), while the writer emits formula text verbatim (serializeFormulaCellinsrc/worksheet/writer.ts). So a loaded_xlfn.SCAN(...)became bareSCAN(...)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:
A note on the earlier decision to ship bare names (commit 2cfc97d / scenario 30): the concern was that the
_xlfn.storage form needs a pairedcm=+xl/metadata.xmlor Excel raises the recovery dialog. That coupling applies to the dynamic-array cell metadata (thecmattribute that marks a spill as a resizing array), not to the_xlfn.name prefix itself — Excel writes_xlfn.XLOOKUPin 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-unusedstripFutureFunctionPrefixhelper and its regex.cell.value.formulafor a future function now reads_xlfn.SCAN(...)(as stored, and as openpyxl surfaces it) rather than bareSCAN(...).Testing
tests/formula/future-function-roundtrip.test.tsbuilds a_xlfn.SCAN/_xlfn.LAMBDAspill anchor, round-trips it throughworkbookToBytes→loadWorkbook→workbookToBytes, and asserts the serializedsheet1.xmlstill contains the prefix (and no bareSCAN(). It fails onmain(model formula comes back asSCAN(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 4tests/conformance/*files that shell out toxmllint, which isn't installed locally; they are unrelated to this change).pnpm test:e2e,pnpm typecheck,pnpm lint,pnpm buildall pass.Reproduce:
pnpm test tests/formula/future-function-roundtrip.test.tsBreaking changes
None for file output. Minor observable change to the in-memory API: consumers that read
cell.value.formulafor 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
cmattribute 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 storedref), but the array won't auto-resize. That's a larger change (needs anxl/metadata.xmlmodel) — I've kept this PR scoped to the_xlfn.fix and can open a separate issue/PR for the metadata if useful.Checklist
pnpm changesetand committed the result.🤖 Generated with Claude Code