diff --git a/docs/PARITY.md b/docs/PARITY.md index 6b21f59..5a61faa 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -376,6 +376,30 @@ field to either interface fails `tsc` until it is registered — as a probe that round-trips, or as a one-way entry with its reason. That register, not this list, is the thing that stays current. +### Newer functions read back with their `_xlfn.` prefix + +Functions added after Excel 2007 are stored under a prefixed name — +`_xlfn.XLOOKUP`, `_xlfn.IFS`, `_xlfn._xlws.FILTER` — that Excel hides in +the formula bar. The writer adds the prefix to cell, conditional +formatting, data validation, table total and defined name formulas, and +the reader keeps formula text as the file has it. So a formula written as +`XLOOKUP(…)` reads back as `_xlfn.XLOOKUP(…)`: the same formula, but a +strict equality check on a round trip sees the difference. Writing the +prefixed form again does not prefix it twice (#597). + +The names LET and LAMBDA bind get theirs too: `LET(x,2,x*3)` is written +`_xlfn.LET(_xlpm.x,2,_xlpm.x*3)`, and an optional `[b]` is bound as +`_xlop.b`. A use of a bound name is written in the spelling of its +binding, as Excel writes it, so `LET(Ab,2,aB)` reads back as +`_xlfn.LET(_xlpm.Ab,2,_xlpm.Ab)`. + +One prefix is not added: a function passed as an argument rather than +called, as in `GROUPBY(A2:A3,B2:B3,SUM)`, is stored `_xleta.SUM`. hucre +writes it as given, and Excel computes `#NAME?` for it. Telling such a +name from a defined name of the same spelling needs Excel's whole +function list. Pass a LAMBDA instead — `GROUPBY(A2:A3,B2:B3,LAMBDA(x,SUM(x)))` +— which is written in its stored form. + ### Styles read out of a file are shared, not copied `readXlsx(…, { readStyles: true })` gives every cell its own `CellStyle` diff --git a/src/_types.ts b/src/_types.ts index 8d9591a..41816ab 100644 --- a/src/_types.ts +++ b/src/_types.ts @@ -215,6 +215,16 @@ export interface Cell { * raw `TRUE`/`FALSE` value. */ checkbox?: boolean + /** + * Formula text in the form the file stores it. Functions newer than + * Excel 2007 are stored with a prefix (`_xlfn.XLOOKUP`, + * `_xlfn._xlws.FILTER`) that Excel hides in the formula bar. The writer + * adds it, so `XLOOKUP(…)` and `_xlfn.XLOOKUP(…)` write the same file, + * and a read hands back the prefixed form (#597). Conditional + * formatting, data validation, table total and defined name formulas + * are treated the same way, and so are the names LET and LAMBDA bind, + * stored as `_xlpm.x` (`_xlop.x` for an optional `[x]` parameter). + */ formula?: string formulaResult?: CellValue /** Formula type: "shared" | "array". Undefined means normal formula. */ @@ -407,7 +417,12 @@ export interface SplitPane { export interface NamedRange { name: string - /** Cell range reference (e.g. "Sheet1!$A$1:$D$10") */ + /** + * Cell range reference (e.g. "Sheet1!$A$1:$D$10"), or any formula a + * defined name can hold. Newer functions get their `_xlfn.` prefix on + * write, and a named LAMBDA's parameters their `_xlpm.`, as in + * `Cell.formula` (#597). + */ range: string /** Scope: undefined = workbook level, string = sheet name */ scope?: string diff --git a/src/xlsx/formula-prefix.ts b/src/xlsx/formula-prefix.ts new file mode 100644 index 0000000..56cb55d --- /dev/null +++ b/src/xlsx/formula-prefix.ts @@ -0,0 +1,555 @@ +// ── Future-function prefixes ──────────────────────────────────────── +// +// Functions added to Excel after the 2007 file format are stored in the +// file under a prefixed name — `_xlfn.XLOOKUP`, `_xlfn.IFS`, and +// `_xlfn._xlws.FILTER` / `_xlfn._xlws.SORT` for the two worksheet-only +// ones. Excel adds the prefix when it saves and hides it in the formula +// bar, so callers write the bare name. Written bare, the file names a +// function no reader knows and the cell computes `#NAME?` (#597). +// +// The names are the "future functions" of [MS-XLSX] §2.2.2 (Functions, +// and the `future-function-list` / `worksheet-only-function-list` rules +// of the Formulas grammar): +// https://learn.microsoft.com/en-us/openspecs/office_standards/ms-xlsx/5d1b6d44-6fc1-4ecd-8fef-0b27406cc2bf +// Added to that: XMATCH, ARRAYTOTEXT, VALUETOTEXT and IMAGE, which the +// spec does not list yet but Excel stores with `_xlfn.` too; XlsxWriter's +// future-function table (`_prepare_formula` in xlsxwriter/worksheet.py) +// has them. And DETECTLANGUAGE, GROUPBY, PERCENTOF, PIVOTBY, REGEXEXTRACT, +// REGEXREPLACE, REGEXTEST, STOCKHISTORY, TRANSLATE and TRIMRANGE, which +// neither list has yet: Excel 16.114 for Mac saved each with `_xlfn.` +// when it was typed in bare. Every name is in one of those lists or was +// seen that way: prefixing a name Excel stores bare would break a formula +// that works today. ENCODEURL is not here for that reason — neither list +// has it, and Excel for Mac, which lacks the function, stores it bare. +// +// Left out on purpose: +// - ECMA.CEILING, ISO.CEILING, NETWORKDAYS.INTL, WORKDAY.INTL: the spec +// lists them as future functions with no prefix. +// - `_xlfn._xlws.PY`: a Python cell is a solo function with metadata of +// its own, not something a prefix makes work. +// - `_xlfn.SINGLE` / `_xlfn.ANCHORARRAY`: the stored forms of the `@` and +// `#` operators, not names a caller types. +// +// ── Parameter prefixes ────────────────────────────────────────────── +// +// A name that LET or LAMBDA binds is stored as `_xlpm.x`, both where it +// is bound and everywhere it is used: `_xlfn.LET(_xlpm.x,2,_xlpm.x*3)`. +// An optional LAMBDA parameter, typed `[b]`, is stored `_xlop.b` where it +// is bound and `_xlpm.b` where it is used. Written bare, Excel for Mac +// asks to repair the file and removes the formula; a named LAMBDA goes +// with it, and every cell that calls it computes `#NAME?` (#597). +// +// That needs scope, which a scan for `NAME(` cannot see, so formulas that +// mention LET or LAMBDA take a second path that parses the argument lists. +// What it does is what Excel 16.114 for Mac wrote when given the bare +// form (each case is a test in test/formula-future-prefix.test.ts): +// - LET(n1, v1, n2, v2, …, calc): n1 is in scope from v2 onwards, not in +// v1, where `x` still means whatever it meant outside. +// - LAMBDA(p1, …, body): the parameters are in scope in the body only. +// The arguments of a LAMBDA invoked on the spot, `LAMBDA(x,x*2)(x)`, +// are outside it, so that last `x` stays bare. +// - A bound name called as a function, `f(2)`, is `_xlpm.f(2)`. +// - A use is rewritten to the spelling of its binding: `LET(Ab,2,aB)` is +// stored `_xlfn.LET(_xlpm.Ab,2,_xlpm.Ab)`. Names ignore case. +// - Inside a scope that binds `a`, `A:A` is `_xlpm.a:_xlpm.a`, but `$A$1`, +// `A$1` and `$A1` are still cells. + +const XLFN = "_xlfn." +const XLWS = "_xlfn._xlws." + +const XLFN_NAMES = [ + "ACOT", + "ACOTH", + "AGGREGATE", + "ARABIC", + "ARRAYTOTEXT", + "BASE", + "BETA.DIST", + "BETA.INV", + "BINOM.DIST", + "BINOM.DIST.RANGE", + "BINOM.INV", + "BITAND", + "BITLSHIFT", + "BITOR", + "BITRSHIFT", + "BITXOR", + "BYCOL", + "BYROW", + "CEILING.MATH", + "CEILING.PRECISE", + "CHISQ.DIST", + "CHISQ.DIST.RT", + "CHISQ.INV", + "CHISQ.INV.RT", + "CHISQ.TEST", + "CHOOSECOLS", + "CHOOSEROWS", + "COMBINA", + "CONCAT", + "CONFIDENCE.NORM", + "CONFIDENCE.T", + "COT", + "COTH", + "COVARIANCE.P", + "COVARIANCE.S", + "CSC", + "CSCH", + "DAYS", + "DECIMAL", + "DETECTLANGUAGE", + "DROP", + "ERF.PRECISE", + "ERFC.PRECISE", + "EXPAND", + "EXPON.DIST", + "F.DIST", + "F.DIST.RT", + "F.INV", + "F.INV.RT", + "F.TEST", + "FIELDVALUE", + "FILTERXML", + "FLOOR.MATH", + "FLOOR.PRECISE", + "FORECAST.ETS", + "FORECAST.ETS.CONFINT", + "FORECAST.ETS.SEASONALITY", + "FORECAST.ETS.STAT", + "FORECAST.LINEAR", + "FORMULATEXT", + "GAMMA", + "GAMMA.DIST", + "GAMMA.INV", + "GAMMALN.PRECISE", + "GAUSS", + "GROUPBY", + "HSTACK", + "HYPGEOM.DIST", + "IFNA", + "IFS", + "IMAGE", + "IMCOSH", + "IMCOT", + "IMCSC", + "IMCSCH", + "IMSEC", + "IMSECH", + "IMSINH", + "IMTAN", + "ISFORMULA", + "ISOMITTED", + "ISOWEEKNUM", + "LAMBDA", + "LET", + "LOGNORM.DIST", + "LOGNORM.INV", + "MAKEARRAY", + "MAP", + "MAXIFS", + "MINIFS", + "MODE.MULT", + "MODE.SNGL", + "MUNIT", + "NEGBINOM.DIST", + "NORM.DIST", + "NORM.INV", + "NORM.S.DIST", + "NORM.S.INV", + "NUMBERVALUE", + "PDURATION", + "PERCENTILE.EXC", + "PERCENTILE.INC", + "PERCENTOF", + "PERCENTRANK.EXC", + "PERCENTRANK.INC", + "PERMUTATIONA", + "PHI", + "PIVOTBY", + "POISSON.DIST", + "PQSOURCE", + "PYTHON_STR", + "PYTHON_TYPE", + "PYTHON_TYPENAME", + "QUARTILE.EXC", + "QUARTILE.INC", + "QUERYSTRING", + "RANDARRAY", + "RANK.AVG", + "RANK.EQ", + "REDUCE", + "REGEXEXTRACT", + "REGEXREPLACE", + "REGEXTEST", + "RRI", + "SCAN", + "SEC", + "SECH", + "SEQUENCE", + "SHEET", + "SHEETS", + "SKEW.P", + "SORTBY", + "STDEV.P", + "STDEV.S", + "STOCKHISTORY", + "SWITCH", + "T.DIST", + "T.DIST.2T", + "T.DIST.RT", + "T.INV", + "T.INV.2T", + "T.TEST", + "TAKE", + "TEXTAFTER", + "TEXTBEFORE", + "TEXTJOIN", + "TEXTSPLIT", + "TOCOL", + "TOROW", + "TRANSLATE", + "TRIMRANGE", + "UNICHAR", + "UNICODE", + "UNIQUE", + "VALUETOTEXT", + "VAR.P", + "VAR.S", + "VSTACK", + "WEBSERVICE", + "WEIBULL.DIST", + "WRAPCOLS", + "WRAPROWS", + "XLOOKUP", + "XMATCH", + "XOR", + "Z.TEST", +] + +/** Upper-case function name → the prefix it is stored under. */ +const FUTURE_FUNCTION_PREFIXES: ReadonlyMap = new Map([ + ...XLFN_NAMES.map((name): [string, string] => [name, XLFN]), + ["FILTER", XLWS], + ["SORT", XLWS], +]) + +/** + * A character that can be part of a function or defined name. Dots are in + * (`STDEV.S`, `_xlfn.IFS`) so a prefixed name scans as one token, and + * anything outside ASCII is in because defined names may use it. + */ +function isNameChar(code: number): boolean { + return ( + (code >= 0x41 && code <= 0x5a) || // A-Z + (code >= 0x61 && code <= 0x7a) || // a-z + (code >= 0x30 && code <= 0x39) || // 0-9 + code === 0x5f || // _ + code === 0x2e || // . + code === 0x5c || // \ + code > 0x7f + ) +} + +/** + * Skip a string literal or quoted sheet name starting at `i`; a doubled + * quote is an escape. Returns the index after the closing quote. + */ +function skipQuoted(formula: string, i: number): number { + const quote = formula.charCodeAt(i) + const n = formula.length + i++ + while (i < n) { + if (formula.charCodeAt(i) === quote) { + if (formula.charCodeAt(i + 1) === quote) i += 2 + else break + } else i++ + } + return i + 1 +} + +/** + * Skip a structured reference (`T[[#This Row],[Col]]`) or external book + * index (`[1]Sheet1!A1`) starting at `i`. Brackets nest, and `'` escapes + * the next character inside them. Returns the index after the last `]`. + */ +function skipBracket(formula: string, i: number): number { + const n = formula.length + let depth = 0 + while (i < n) { + const c = formula.charCodeAt(i) + if (c === 0x27) i++ + else if (c === 0x5b) depth++ + else if (c === 0x5d && --depth === 0) break + i++ + } + return i + 1 +} + +/** `LET(` or `LAMBDA(`, prefixed or not; a false hit only costs the slow path. */ +const BINDS_NAMES = /(?:LET|LAMBDA)\(/i + +/** + * Add the stored prefixes to `formula`: `_xlfn.` (or `_xlfn._xlws.`) on + * every call of a future function, so `XLOOKUP(` is written as + * `_xlfn.XLOOKUP(` (#597), and `_xlpm.` / `_xlop.` on the names LET and + * LAMBDA bind. + * + * Only a name immediately followed by `(` is a call. String literals, + * quoted sheet names and `[…]` structured references are skipped whole, + * and a name after `!` is a sheet- or workbook-scoped name, not a + * built-in. Already-prefixed text scans as a different name and is left + * as it is, so the reader's output can be written back unchanged. + */ +export function addFuturePrefixes(formula: string): string { + // No call, nothing to prefix; this is on the per-cell write path. + if (!formula.includes("(")) return formula + if (BINDS_NAMES.test(formula)) return addScopedPrefixes(formula) + let out = "" + let last = 0 + let i = 0 + const n = formula.length + while (i < n) { + const ch = formula.charCodeAt(i) + if (ch === 0x22 /* " */ || ch === 0x27 /* ' */) { + i = skipQuoted(formula, i) + } else if (ch === 0x5b /* [ */) { + i = skipBracket(formula, i) + } else if (isNameChar(ch)) { + const start = i + while (i < n && isNameChar(formula.charCodeAt(i))) i++ + if (formula.charCodeAt(i) !== 0x28 /* ( */) continue + if (start > 0 && formula.charCodeAt(start - 1) === 0x21 /* ! */) continue + const prefix = FUTURE_FUNCTION_PREFIXES.get(formula.slice(start, i).toUpperCase()) + if (prefix === undefined) continue + out += formula.slice(last, start) + prefix + last = start + } else { + i++ + } + } + return out === "" ? formula : out + formula.slice(last) +} + +// ── The scoped path ───────────────────────────────────────────────── + +const XLPM = "_xlpm." +const XLOP = "_xlop." + +interface Span { + start: number + end: number +} + +/** + * What the parse keeps of a formula: names, calls with their argument + * lists, and `[…]` groups (an optional LAMBDA parameter is one). Anything + * else is `other`, kept only so an argument of `x+1` is not mistaken for + * the bare name `x`. + */ +type Item = + | { kind: "name"; span: Span } + | { kind: "bracket"; span: Span } + | { kind: "call"; callee: Span | undefined; args: Item[][] } + | { kind: "other" } + +const OTHER: Item = { kind: "other" } + +/** Replace `formula.slice(start, end)` with `text`; `start === end` inserts. */ +interface Edit extends Span { + text: string +} + +/** Upper-case name → the spelling it was bound with. */ +type Scope = ReadonlyMap + +function addScopedPrefixes(formula: string): string { + const edits: Edit[] = [] + const [top] = parseList(formula, 0, false) + resolve(formula, top[0], new Map(), edits) + if (edits.length === 0) return formula + // Each edit is at a different token, so in source order they never overlap. + edits.sort((a, b) => a.start - b.start) + let out = "" + let last = 0 + for (const e of edits) { + out += formula.slice(last, e.start) + e.text + last = e.end + } + return out + formula.slice(last) +} + +/** + * Parse from `i` to the `)` that closes the call (`inCall`), or to the end + * of the formula. Returns the argument lists — one when not in a call — + * and the index after the `)`. A stray `)` at the top level, or a missing + * one at the end, is tolerated: this is a writer, not a validator. + */ +function parseList(formula: string, i: number, inCall: boolean): [Item[][], number] { + const n = formula.length + const args: Item[][] = [[]] + let current = args[0] + // Commas inside an array constant `{1,2;3,4}` separate its columns, + // not arguments: `LET(x,{1,2},x)` still binds one name. + let braces = 0 + while (i < n) { + const ch = formula.charCodeAt(i) + if (ch === 0x22 /* " */ || ch === 0x27 /* ' */) { + i = skipQuoted(formula, i) + current.push(OTHER) + } else if (ch === 0x5b /* [ */) { + const start = i + i = skipBracket(formula, i) + current.push({ kind: "bracket", span: { start, end: Math.min(i, n) } }) + } else if (isNameChar(ch)) { + const start = i + while (i < n && isNameChar(formula.charCodeAt(i))) i++ + current.push({ kind: "name", span: { start, end: i } }) + } else if (ch === 0x28 /* ( */) { + // A name right before the `(` is the function being called. After + // a `)` it is not — `LAMBDA(x,x*2)(3)` calls the LAMBDA's result — + // and neither is a bare `(` grouping an expression. + const prev = current[current.length - 1] + let callee: Span | undefined + if (prev?.kind === "name" && prev.span.end === i) { + current.pop() + callee = prev.span + } + const [sub, next] = parseList(formula, i + 1, true) + current.push({ kind: "call", callee, args: sub }) + i = next + } else if (ch === 0x29 /* ) */ && inCall) { + return [args, i + 1] + } else if (ch === 0x2c /* , */ && inCall && braces === 0) { + current = [] + args.push(current) + i++ + } else { + if (ch === 0x7b /* { */) braces++ + else if (ch === 0x7d /* } */ && braces > 0) braces-- + if (ch !== 0x20 && ch !== 0x0a && ch !== 0x0d && ch !== 0x09) current.push(OTHER) + i++ + } + } + return [args, n] +} + +function resolve(formula: string, items: Item[], scope: Scope, edits: Edit[]): void { + for (const item of items) { + if (item.kind === "name") reference(formula, item.span, scope, edits) + else if (item.kind === "call") call(formula, item, scope, edits) + } +} + +/** + * Whether the name at `span` stands on its own as a reference, rather than + * being a sheet (`x!A1`), a table (`T[Col]`), a name qualified by its sheet + * (`Sheet1!x`) or part of an absolute cell reference (`$A$1`, `A$1`). + */ +function standsAlone(formula: string, span: Span): boolean { + const before = span.start > 0 ? formula.charCodeAt(span.start - 1) : 0 + const after = formula.charCodeAt(span.end) + return before !== 0x21 && before !== 0x24 && after !== 0x21 && after !== 0x5b && after !== 0x24 +} + +/** A use of a name: `_xlpm.` if a LET or LAMBDA around it binds it. */ +function reference(formula: string, span: Span, scope: Scope, edits: Edit[]): void { + if (scope.size === 0 || !standsAlone(formula, span)) return + const bound = scope.get(formula.slice(span.start, span.end).toUpperCase()) + if (bound !== undefined) edits.push({ ...span, text: XLPM + bound }) +} + +function call( + formula: string, + item: Extract, + scope: Scope, + edits: Edit[], +): void { + const { callee, args } = item + let fn = "" + if (callee !== undefined && standsAlone(formula, callee)) { + const upper = formula.slice(callee.start, callee.end).toUpperCase() + const bound = scope.get(upper) + if (bound !== undefined) { + // A LET-bound LAMBDA called by its name: `_xlpm.f(2)`. + edits.push({ ...callee, text: XLPM + bound }) + } else { + const prefix = FUTURE_FUNCTION_PREFIXES.get(upper) + if (prefix !== undefined) edits.push({ start: callee.start, end: callee.start, text: prefix }) + fn = upper.startsWith("_XLFN.") ? upper.slice(XLFN.length) : upper + } + } + if (fn === "LET") { + const inner = new Map(scope) + let k = 0 + for (; k + 1 < args.length; k += 2) { + const name = binding(formula, args[k], false, edits) + if (name === undefined) resolve(formula, args[k], inner, edits) + // The value is resolved before its name is bound: in + // `LET(x,x+1,…)` the second `x` is the one from outside. + resolve(formula, args[k + 1], inner, edits) + if (name !== undefined) inner.set(name.toUpperCase(), name) + } + if (k < args.length) resolve(formula, args[k], inner, edits) + } else if (fn === "LAMBDA") { + const inner = new Map(scope) + const body = args.length - 1 + for (let k = 0; k < body; k++) { + const name = binding(formula, args[k], true, edits) + if (name === undefined) resolve(formula, args[k], scope, edits) + else inner.set(name.toUpperCase(), name) + } + resolve(formula, args[body], inner, edits) + } else { + for (const arg of args) resolve(formula, arg, scope, edits) + } +} + +/** + * The name an argument in a binding position binds, after queueing the + * edit that prefixes it — or `undefined` when the argument is not a lone + * name, which leaves a malformed LET or LAMBDA as it was. `optional` is + * for LAMBDA, whose `[b]` is stored `_xlop.b`. + */ +function binding( + formula: string, + arg: Item[], + optional: boolean, + edits: Edit[], +): string | undefined { + if (arg.length !== 1) return undefined + const item = arg[0] + if (item.kind === "name") { + const text = formula.slice(item.span.start, item.span.end) + const upper = text.toUpperCase() + // Already in the stored form, as the reader hands it back. + if (upper.startsWith("_XLPM.") || (optional && upper.startsWith("_XLOP."))) { + return text.slice(XLPM.length) + } + if (!isParameterName(text)) return undefined + edits.push({ start: item.span.start, end: item.span.start, text: XLPM }) + return text + } + if (item.kind === "bracket" && optional && formula.charCodeAt(item.span.end - 1) === 0x5d) { + const name = formula.slice(item.span.start + 1, item.span.end - 1).trim() + if (!isParameterName(name)) return undefined + edits.push({ ...item.span, text: XLOP + name }) + return name + } + return undefined +} + +/** + * Whether `text` can be a LET or LAMBDA name: name characters, not + * starting with a digit, and no dot — Excel refuses one in a parameter, + * which is what keeps `_xlpm.x` from being read as a name of its own. + */ +function isParameterName(text: string): boolean { + if (text === "") return false + const first = text.charCodeAt(0) + if (first >= 0x30 && first <= 0x39) return false + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i) + if (c === 0x2e || !isNameChar(c)) return false + } + return true +} diff --git a/src/xlsx/table-writer.ts b/src/xlsx/table-writer.ts index acc8d87..1583b25 100644 --- a/src/xlsx/table-writer.ts +++ b/src/xlsx/table-writer.ts @@ -3,6 +3,7 @@ import type { TableDefinition } from "../_types" import { xmlDocument, xmlElement, xmlSelfClose, xmlEscape } from "../xml/writer" +import { addFuturePrefixes } from "./formula-prefix" const NS_SPREADSHEET = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" @@ -76,9 +77,14 @@ export function writeTable( colAttrs["totalsRowLabel"] = col.totalLabel } - // Custom formula in total row + // Custom formula in total row, in the stored form a cell formula + // takes: `_xlfn.` on newer functions (#597). if (showTotalRow && col.totalFunction === "custom" && col.totalFormula) { - const formulaChild = xmlElement("totalsRowFormula", undefined, xmlEscape(col.totalFormula)) + const formulaChild = xmlElement( + "totalsRowFormula", + undefined, + xmlEscape(addFuturePrefixes(col.totalFormula)), + ) colElements.push(xmlElement("tableColumn", colAttrs, [formulaChild])) } else { colElements.push(xmlSelfClose("tableColumn", colAttrs)) diff --git a/src/xlsx/workbook-writer.ts b/src/xlsx/workbook-writer.ts index 9e070d5..c2ffdea 100644 --- a/src/xlsx/workbook-writer.ts +++ b/src/xlsx/workbook-writer.ts @@ -5,6 +5,7 @@ import type { WriteSheet, NamedRange } from "../_types" import { xmlDocument, xmlElement, xmlSelfClose, xmlEscape } from "../xml/writer" import { hashSheetPassword } from "./password" import { METADATA_REL_TYPE } from "./metadata" +import { addFuturePrefixes } from "./formula-prefix" import { FPB_REL_TYPE } from "./feature-property-bag" const NS_SPREADSHEET = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" @@ -138,7 +139,9 @@ export function writeWorkbookXml( attrs["comment"] = nr.comment } - dnElements.push(xmlElement("definedName", attrs, xmlEscape(nr.range))) + // A defined name holds a formula, not only a range — a named LAMBDA + // is one — so newer functions get their `_xlfn.` here too (#597). + dnElements.push(xmlElement("definedName", attrs, xmlEscape(addFuturePrefixes(nr.range)))) } parts.push(xmlElement("definedNames", undefined, dnElements)) diff --git a/src/xlsx/worksheet-writer.ts b/src/xlsx/worksheet-writer.ts index 381cc64..3ab1c02 100644 --- a/src/xlsx/worksheet-writer.ts +++ b/src/xlsx/worksheet-writer.ts @@ -30,6 +30,7 @@ import { isHyperlinkValue } from "./hyperlink" import { xmlDocument, xmlElement, xmlSelfClose, xmlEscape, xmlTextElement } from "../xml/writer" import { calculateColumnWidth } from "./auto-width" import { DYNAMIC_ARRAY_CM } from "./metadata" +import { addFuturePrefixes } from "./formula-prefix" import { hashSheetPassword } from "./password" import { validateColumnIndex } from "../_validate" import { toCellValue } from "../_inline-cells" @@ -980,6 +981,11 @@ export function serializeCell( // cellMetadata collection; see ./metadata.ts for the part itself. if (formulaDynamic) cellAttrs["cm"] = DYNAMIC_ARRAY_CM + // Functions newer than Excel 2007 are stored as `_xlfn.XLOOKUP` and + // the like; written bare they compute `#NAME?` (#597). The streaming + // writers come through here too. + const fText = xmlEscape(addFuturePrefixes(formula)) + // Build element with appropriate attributes let fElement: string if (formulaType === "shared") { @@ -990,18 +996,18 @@ export function serializeCell( if (formula === "") { fElement = xmlSelfClose("f", fAttrs) } else { - fElement = xmlElement("f", fAttrs, xmlEscape(formula)) + fElement = xmlElement("f", fAttrs, fText) } } else if (formulaType === "array") { const fAttrs: Record = { t: "array" } if (formulaRef) fAttrs["ref"] = formulaRef - fElement = xmlElement("f", fAttrs, xmlEscape(formula)) + fElement = xmlElement("f", fAttrs, fText) } else { // Normal formula. `formulaDynamic` used to be honoured only inside // the `t="array"` branch above, so a spilling function written // without `formulaType: "array"` silently lost the flag (#407); it // now lands on `` for both shapes. - fElement = xmlElement("f", undefined, xmlEscape(formula)) + fElement = xmlElement("f", undefined, fText) } const children: string[] = [fElement] @@ -1198,11 +1204,12 @@ function serializeDataValidations(validations: DataValidation[]): string { const quotedList = `"${dv.values.join(",")}"` children.push(xmlElement("formula1", undefined, xmlEscape(quotedList))) } else if (dv.formula1 !== undefined) { - children.push(xmlElement("formula1", undefined, xmlEscape(dv.formula1))) + // Same stored form as a cell formula: `_xlfn.` on newer functions (#597). + children.push(xmlElement("formula1", undefined, xmlEscape(addFuturePrefixes(dv.formula1)))) } if (dv.formula2 !== undefined) { - children.push(xmlElement("formula2", undefined, xmlEscape(dv.formula2))) + children.push(xmlElement("formula2", undefined, xmlEscape(addFuturePrefixes(dv.formula2)))) } if (children.length > 0) { @@ -1686,8 +1693,9 @@ function serializeCfRule(rule: ConditionalRule, styles: StylesCollector): string // Formulas if (rule.formula !== undefined) { const formulas = Array.isArray(rule.formula) ? rule.formula : [rule.formula] + // Same stored form as a cell formula: `_xlfn.` on newer functions (#597). for (const f of formulas) { - children.push(xmlElement("formula", undefined, xmlEscape(f))) + children.push(xmlElement("formula", undefined, xmlEscape(addFuturePrefixes(f)))) } } diff --git a/test/formula-future-prefix.test.ts b/test/formula-future-prefix.test.ts new file mode 100644 index 0000000..b3c3344 --- /dev/null +++ b/test/formula-future-prefix.test.ts @@ -0,0 +1,438 @@ +import { describe, expect, it } from "vitest" +import { writeXlsx } from "../src/xlsx/writer" +import { readXlsx } from "../src/xlsx/reader" +import { writeXlsxStream } from "../src/xlsx/stream-writer" +import { ZipReader } from "../src/zip/reader" +import type { Cell, WriteOptions, WriteSheet } from "../src/_types" + +// ═══════════════════════════════════════════════════════════════════════ +// #597 — functions added after Excel 2007 are stored in the file with a +// prefix: `_xlfn.XLOOKUP`, `_xlfn.IFS`, and `_xlfn._xlws.FILTER` / +// `_xlfn._xlws.SORT` ([MS-XLSX] §2.2.2, "future functions"). Excel adds +// the prefix when it saves and hides it in the formula bar, so callers +// write the bare name. hucre wrote that bare name into ``, and a file +// that says `XLOOKUP(` names no function any reader knows: LibreOffice +// shows `#NAME?`. +// +// The writer now adds the prefix wherever formula text goes into the +// file — cells (all three `` shapes and the streaming writers), +// conditional formatting, data validation, table totals and defined +// names. It has to be idempotent, because the reader hands back the +// prefixed text from Excel's files and that has to survive a rewrite. +// ═══════════════════════════════════════════════════════════════════════ + +const decoder = new TextDecoder("utf-8") + +async function part(buf: Uint8Array, path: string): Promise { + return decoder.decode(await new ZipReader(buf).extract(path)) +} + +function unescape(text: string): string { + return text + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&") +} + +/** Every element's text, in document order, unescaped. */ +function texts(xml: string, tag: string): string[] { + const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([^<]*)`, "g") + return [...xml.matchAll(re)].map((m) => unescape(m[1])) +} + +async function sheetXml(sheet: WriteSheet, options?: Omit) { + return part(await writeXlsx({ sheets: [sheet], ...options }), "xl/worksheets/sheet1.xml") +} + +/** Write one formula into A1 and hand back the `` text as stored. */ +async function stored(formula: string): Promise { + const xml = await sheetXml({ name: "Sheet1", cells: new Map([["0,0", { formula }]]) }) + const [f] = texts(xml, "f") + return f +} + +describe("future functions are written with their stored prefix (#597)", () => { + it("prefixes the five functions from the issue", async () => { + const formulas = [ + 'XLOOKUP("b",A1:A3,B1:B3)', + 'IFS(A1>1,"big",TRUE,"small")', + 'TEXTJOIN(",",TRUE,A1:A3)', + "FILTER(A1:A3,B1:B3>1)", + "SORT(A1:A3)", + ] + const xml = await sheetXml({ + name: "Sheet1", + rows: [ + ["a", 1], + ["b", 2], + ["c", 3], + ], + cells: new Map(formulas.map((formula, i) => [`${i},3`, { formula }])), + }) + expect(texts(xml, "f")).toEqual([ + '_xlfn.XLOOKUP("b",A1:A3,B1:B3)', + '_xlfn.IFS(A1>1,"big",TRUE,"small")', + '_xlfn.TEXTJOIN(",",TRUE,A1:A3)', + "_xlfn._xlws.FILTER(A1:A3,B1:B3>1)", + "_xlfn._xlws.SORT(A1:A3)", + ]) + }) + + it("prefixes dotted names and the dynamic-array family", async () => { + expect(await stored("STDEV.S(A1:A9)+CEILING.MATH(B1)")).toBe( + "_xlfn.STDEV.S(A1:A9)+_xlfn.CEILING.MATH(B1)", + ) + expect(await stored("UNIQUE(SORTBY(A1:A9,B1:B9))")).toBe( + "_xlfn.UNIQUE(_xlfn.SORTBY(A1:A9,B1:B9))", + ) + expect(await stored("XMATCH(1,SEQUENCE(5))")).toBe("_xlfn.XMATCH(1,_xlfn.SEQUENCE(5))") + }) + + it("prefixes the newer functions Excel was seen to store with _xlfn.", async () => { + // Neither [MS-XLSX] nor XlsxWriter lists these yet. Excel 16.114 for + // Mac saved each with `_xlfn.` when the bare formula was typed in. + // GROUPBY and PIVOTBY take a LAMBDA here: given a bare `SUM`, Excel + // stores `_xleta.SUM`, a prefix hucre does not add (see PARITY.md). + const seen: Array<[string, string]> = [ + ["PERCENTOF(B2,B2:B3)", "_xlfn.PERCENTOF(B2,B2:B3)"], + ["TRIMRANGE(B1:B10)", "_xlfn.TRIMRANGE(B1:B10)"], + ['REGEXTEST(A1,"a")', '_xlfn.REGEXTEST(A1,"a")'], + ['REGEXEXTRACT(A1,"b")', '_xlfn.REGEXEXTRACT(A1,"b")'], + ['REGEXREPLACE(A1,"a","z")', '_xlfn.REGEXREPLACE(A1,"a","z")'], + ['TRANSLATE(A1,"en","fi")', '_xlfn.TRANSLATE(A1,"en","fi")'], + ["DETECTLANGUAGE(A1)", "_xlfn.DETECTLANGUAGE(A1)"], + ['STOCKHISTORY("MSFT",DATE(2024,1,2))', '_xlfn.STOCKHISTORY("MSFT",DATE(2024,1,2))'], + [ + "GROUPBY(A2:A3,B2:B3,LAMBDA(x,SUM(x)))", + "_xlfn.GROUPBY(A2:A3,B2:B3,_xlfn.LAMBDA(_xlpm.x,SUM(_xlpm.x)))", + ], + [ + "PIVOTBY(A2:A3,A2:A3,B2:B3,LAMBDA(x,SUM(x)))", + "_xlfn.PIVOTBY(A2:A3,A2:A3,B2:B3,_xlfn.LAMBDA(_xlpm.x,SUM(_xlpm.x)))", + ], + ] + for (const [typed, saved] of seen) expect(await stored(typed)).toBe(saved) + }) + + it("does not prefix ENCODEURL, which no source shows prefixed", async () => { + expect(await stored("ENCODEURL(A1)")).toBe("ENCODEURL(A1)") + }) + + it("leaves functions Excel 2007 already had alone", async () => { + expect(await stored('IFERROR(XLOOKUP(A1,B:B,C:C),"")')).toBe( + 'IFERROR(_xlfn.XLOOKUP(A1,B:B,C:C),"")', + ) + expect(await stored("SUM(A1:A3)*VLOOKUP(1,B:C,2,FALSE)")).toBe( + "SUM(A1:A3)*VLOOKUP(1,B:C,2,FALSE)", + ) + }) + + // The spec lists these four as future functions with no prefix. + it("does not prefix the future functions the spec stores bare", async () => { + const f = "NETWORKDAYS.INTL(A1,B1)+WORKDAY.INTL(A1,1)+ISO.CEILING(C1)+ECMA.CEILING(C1,1)" + expect(await stored(f)).toBe(f) + }) + + it("matches the name in any case and keeps the caller's spelling", async () => { + expect(await stored("xlookup(1,A:A,B:B)")).toBe("_xlfn.xlookup(1,A:A,B:B)") + expect(await stored("Filter(A1:A3,B1:B3)")).toBe("_xlfn._xlws.Filter(A1:A3,B1:B3)") + }) + + it("does not prefix an already-prefixed name again", async () => { + for (const f of [ + '_xlfn.XLOOKUP("b",A1:A3,B1:B3)', + "_xlfn._xlws.FILTER(A1:A3,B1:B3>1)", + "_xlfn._xlws.SORT(_xlfn.UNIQUE(A1:A3))", + "_XLFN.IFS(TRUE,1)", + ]) { + expect(await stored(f)).toBe(f) + } + }) + + it("does not read SORTBY as SORT", async () => { + expect(await stored("SORTBY(A1:A3,B1:B3)")).toBe("_xlfn.SORTBY(A1:A3,B1:B3)") + }) + + it("leaves string literals alone", async () => { + expect(await stored('IF(A1="XLOOKUP(",IFS(TRUE,1),"say ""SORT("" twice")')).toBe( + 'IF(A1="XLOOKUP(",_xlfn.IFS(TRUE,1),"say ""SORT("" twice")', + ) + }) + + it("leaves quoted sheet names alone", async () => { + expect(await stored("SUM('IFS(x'!A1,'it''s SORT('!B2)+SORT(C1:C3)")).toBe( + "SUM('IFS(x'!A1,'it''s SORT('!B2)+_xlfn._xlws.SORT(C1:C3)", + ) + }) + + it("leaves structured-reference brackets alone", async () => { + expect(await stored("SUM(T[[#This Row],[IFS(a)]])+T['[SORT(]")).toBe( + "SUM(T[[#This Row],[IFS(a)]])+T['[SORT(]", + ) + expect(await stored('TEXTJOIN(",",TRUE,T[Name])')).toBe('_xlfn.TEXTJOIN(",",TRUE,T[Name])') + }) + + it("leaves names that are not a call alone", async () => { + // A sheet called IFS, a defined name called SORT, a cell reference + // inside a longer name, and a sheet-qualified name call. + expect(await stored("IFS!A1+SORT+MYSORT(1)+Book!SORT(1)+SORT.X(1)")).toBe( + "IFS!A1+SORT+MYSORT(1)+Book!SORT(1)+SORT.X(1)", + ) + }) + + it("prefixes shared and array formulas, and leaves a shared slave empty", async () => { + const cells = new Map>([ + [ + "0,0", + { + formula: "XLOOKUP(A2,B:B,C:C)", + formulaType: "shared", + formulaSharedIndex: 0, + formulaRef: "A1:A2", + }, + ], + ["1,0", { formula: "", formulaType: "shared", formulaSharedIndex: 0 }], + ["0,1", { formula: "SORT(C1:C3)", formulaType: "array", formulaRef: "B1:B3" }], + ]) + const xml = await sheetXml({ name: "Sheet1", cells }) + expect(xml).toContain('_xlfn.XLOOKUP(A2,B:B,C:C)') + expect(xml).toContain('') + expect(xml).toContain('_xlfn._xlws.SORT(C1:C3)') + }) + + it("prefixes formulas from the streaming writer", async () => { + const stream = writeXlsxStream([[{ formula: 'TEXTJOIN(",",TRUE,B1:B3)' }]], { name: "S" }) + const chunks: Uint8Array[] = [] + for await (const chunk of stream) chunks.push(chunk) + const buf = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)) + let offset = 0 + for (const c of chunks) { + buf.set(c, offset) + offset += c.length + } + const xml = await part(buf, "xl/worksheets/sheet1.xml") + expect(texts(xml, "f")).toEqual(['_xlfn.TEXTJOIN(",",TRUE,B1:B3)']) + }) + + it("prefixes conditional formatting and data validation formulas", async () => { + const xml = await sheetXml({ + name: "Sheet1", + rows: [[1]], + conditionalRules: [ + { + type: "expression", + priority: 1, + range: "A1:A3", + formula: "MAXIFS(B:B,C:C,A1)>1", + style: { font: { bold: true } }, + }, + ], + dataValidations: [ + { type: "custom", range: "A1:A3", formula1: "ISNUMBER(XMATCH(A1,UNIQUE(B:B)))" }, + { + type: "whole", + operator: "between", + range: "B1", + formula1: "MINIFS(C:C,D:D,1)", + formula2: "MAXIFS(C:C,D:D,1)", + }, + ], + }) + expect(texts(xml, "formula")).toEqual(["_xlfn.MAXIFS(B:B,C:C,A1)>1"]) + expect(texts(xml, "formula1")).toEqual([ + "ISNUMBER(_xlfn.XMATCH(A1,_xlfn.UNIQUE(B:B)))", + "_xlfn.MINIFS(C:C,D:D,1)", + ]) + expect(texts(xml, "formula2")).toEqual(["_xlfn.MAXIFS(C:C,D:D,1)"]) + }) + + it("prefixes table total-row formulas and defined names", async () => { + const buf = await writeXlsx({ + sheets: [ + { + name: "Sheet1", + rows: [["Name"], ["a"], ["b"]], + tables: [ + { + name: "T", + range: "A1:A4", + showTotalRow: true, + columns: [ + { + name: "Name", + totalFunction: "custom", + totalFormula: 'TEXTJOIN(",",TRUE,[Name])', + }, + ], + }, + ], + }, + ], + namedRanges: [ + { name: "Joined", range: 'TEXTJOIN(",",TRUE,Sheet1!$A$2:$A$3)' }, + { name: "Data", range: "Sheet1!$A$1:$A$3" }, + ], + }) + expect(texts(await part(buf, "xl/tables/table1.xml"), "totalsRowFormula")).toEqual([ + '_xlfn.TEXTJOIN(",",TRUE,[Name])', + ]) + expect(texts(await part(buf, "xl/workbook.xml"), "definedName")).toEqual([ + '_xlfn.TEXTJOIN(",",TRUE,Sheet1!$A$2:$A$3)', + "Sheet1!$A$1:$A$3", + ]) + }) + + it("reads back the stored form and does not prefix it twice on rewrite", async () => { + const first = await writeXlsx({ + sheets: [{ name: "Sheet1", cells: new Map([["0,0", { formula: "IFS(A2>1,SORT(B1:B3))" }]]) }], + }) + const wb = await readXlsx(first) + const formula = wb.sheets[0].cells?.get("0,0")?.formula + expect(formula).toBe("_xlfn.IFS(A2>1,_xlfn._xlws.SORT(B1:B3))") + + const second = await writeXlsx({ + sheets: [{ name: "Sheet1", cells: new Map([["0,0", { formula: formula! }]]) }], + }) + expect(texts(await part(second, "xl/worksheets/sheet1.xml"), "f")).toEqual([ + "_xlfn.IFS(A2>1,_xlfn._xlws.SORT(B1:B3))", + ]) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════ +// #597, continued — the names LET and LAMBDA bind are stored with a +// prefix too: `_xlpm.x` where bound and where used, `_xlop.b` for an +// optional `[b]` where it is bound. With only `_xlfn.` added, Excel for +// Mac asked to repair the file and deleted both the formula and a named +// LAMBDA, leaving `=Double(B2)` at `#NAME?`. +// +// Every expected string below is what Excel 16.114 for Mac itself wrote +// when the bare formula was typed into a cell and the book saved. +// ═══════════════════════════════════════════════════════════════════════ + +describe("LET and LAMBDA names are written with their stored prefix (#597)", () => { + // [bare as typed, stored as Excel saved it] + const EXCEL: [string, string][] = [ + ["LET(x,2,x*3)", "_xlfn.LET(_xlpm.x,2,_xlpm.x*3)"], + ["LAMBDA(x,x*2)(3)", "_xlfn.LAMBDA(_xlpm.x,_xlpm.x*2)(3)"], + // Nested, and an inner LET binding a name the outer one already has. + [ + "LET(x,1,y,x+1,LET(x,10,x+y))", + "_xlfn.LET(_xlpm.x,1,_xlpm.y,_xlpm.x+1,_xlfn.LET(_xlpm.x,10,_xlpm.x+_xlpm.y))", + ], + // A LET-bound LAMBDA called by name. + ["LET(f,LAMBDA(n,n+1),f(2))", "_xlfn.LET(_xlpm.f,_xlfn.LAMBDA(_xlpm.n,_xlpm.n+1),_xlpm.f(2))"], + [ + "LET(p,LAMBDA(x,x*2),q,p,q(4))", + "_xlfn.LET(_xlpm.p,_xlfn.LAMBDA(_xlpm.x,_xlpm.x*2),_xlpm.q,_xlpm.p,_xlpm.q(4))", + ], + // An optional parameter is bound as `_xlop.` and used as `_xlpm.`. + [ + "LAMBDA(a,[b],IF(ISOMITTED(b),a,a+b))(1)", + "_xlfn.LAMBDA(_xlpm.a,_xlop.b,IF(_xlfn.ISOMITTED(_xlpm.b),_xlpm.a,_xlpm.a+_xlpm.b))(1)", + ], + ["LET(x,1,x)+LET(y,2,y)", "_xlfn.LET(_xlpm.x,1,_xlpm.x)+_xlfn.LET(_xlpm.y,2,_xlpm.y)"], + // A LET name seen from a LAMBDA inside it. + [ + "LET(k,3,SUM(MAP(A1:A3,LAMBDA(v,v*k))))", + "_xlfn.LET(_xlpm.k,3,SUM(_xlfn.MAP(A1:A3,_xlfn.LAMBDA(_xlpm.v,_xlpm.v*_xlpm.k))))", + ], + // The arguments of a LAMBDA invoked on the spot are outside it: the + // last `x` is the outer LET's. + ["LET(x,5,LAMBDA(x,x+1)(x))", "_xlfn.LET(_xlpm.x,5,_xlfn.LAMBDA(_xlpm.x,_xlpm.x+1)(_xlpm.x))"], + [ + "LAMBDA(x,LET(y,x*2,y+x))(4)", + "_xlfn.LAMBDA(_xlpm.x,_xlfn.LET(_xlpm.y,_xlpm.x*2,_xlpm.y+_xlpm.x))(4)", + ], + ["LET(t,A1:A3,SUM(t))", "_xlfn.LET(_xlpm.t,A1:A3,SUM(_xlpm.t))"], + ['LET(x,"x",x&"x")', '_xlfn.LET(_xlpm.x,"x",_xlpm.x&"x")'], + ["LET(x,{1,2,3},SUM(x))", "_xlfn.LET(_xlpm.x,{1,2,3},SUM(_xlpm.x))"], + // Excel rewrites a use to its binding's spelling. + ["LET(Ab,2,aB*3)", "_xlfn.LET(_xlpm.Ab,2,_xlpm.Ab*3)"], + // Spaces stay where they are (Excel also drops the ones before a + // comma, which hucre does not need to copy). + ["LET( x, 2, x * 3 )", "_xlfn.LET( _xlpm.x, 2, _xlpm.x * 3 )"], + // Intersection of two bound ranges. + [ + "LET(a,A1:A3,b,A1:A2,SUM(a b))", + "_xlfn.LET(_xlpm.a,A1:A3,_xlpm.b,A1:A2,SUM(_xlpm.a _xlpm.b))", + ], + // With `a` bound, `A:A` is the name twice; `$` makes a cell. + ["LET(a,2,a+SUM(A:A))", "_xlfn.LET(_xlpm.a,2,_xlpm.a+SUM(_xlpm.a:_xlpm.a))"], + ["LET(a,2,a+$A$1+A$1+$A1)", "_xlfn.LET(_xlpm.a,2,_xlpm.a+$A$1+A$1+$A1)"], + ["LET(c,2,c*SUM(C1:C1))", "_xlfn.LET(_xlpm.c,2,_xlpm.c*SUM(C1:C1))"], + ] + + it("writes the forms Excel writes", async () => { + for (const [bare, excel] of EXCEL) expect(await stored(bare)).toBe(excel) + }) + + it("does not prefix Excel's own form again", async () => { + for (const [, excel] of EXCEL) expect(await stored(excel)).toBe(excel) + }) + + // These two ran with a workbook-level name `x` defined as 5, which is + // what the bare `x` outside each scope means. + it("leaves a name outside the scope that binds it alone", async () => { + // A LET value is resolved before its own name is bound. + expect(await stored("LET(y,x,y)+x")).toBe("_xlfn.LET(_xlpm.y,x,_xlpm.y)+x") + expect(await stored("LET(y,x,x,y+1,x)+x")).toBe( + "_xlfn.LET(_xlpm.y,x,_xlpm.x,_xlpm.y+1,_xlpm.x)+x", + ) + expect(await stored("LAMBDA(x,x*2)(x)")).toBe("_xlfn.LAMBDA(_xlpm.x,_xlpm.x*2)(x)") + }) + + it("leaves a bound name in a string, a sheet name or a qualified name alone", async () => { + expect(await stored(`LET(x,1,x+Sheet1!x+x!A1+'x'!A1+"x"&T[x])`)).toBe( + `_xlfn.LET(_xlpm.x,1,_xlpm.x+Sheet1!x+x!A1+'x'!A1+"x"&T[x])`, + ) + }) + + it("prefixes a named LAMBDA, and leaves the cell that calls it alone", async () => { + const buf = await writeXlsx({ + sheets: [ + { + name: "Sheet1", + rows: [[null, 1]], + cells: new Map([["0,0", { formula: "Double(B2)" }]]), + }, + ], + namedRanges: [{ name: "Double", range: "LAMBDA(x,x*2)" }], + }) + expect(texts(await part(buf, "xl/workbook.xml"), "definedName")).toEqual([ + "_xlfn.LAMBDA(_xlpm.x,_xlpm.x*2)", + ]) + expect(texts(await part(buf, "xl/worksheets/sheet1.xml"), "f")).toEqual(["Double(B2)"]) + }) + + it("reads back the stored form and does not prefix it twice on rewrite", async () => { + const first = await writeXlsx({ + sheets: [{ name: "Sheet1", cells: new Map([["0,0", { formula: "LET(x,2,x*3)" }]]) }], + }) + const formula = (await readXlsx(first)).sheets[0].cells?.get("0,0")?.formula + expect(formula).toBe("_xlfn.LET(_xlpm.x,2,_xlpm.x*3)") + expect(await stored(formula!)).toBe(formula) + }) + + // A formula naming LET or LAMBDA goes through the scoped parse rather + // than the plain scan; it has to treat everything else the same way. + it("prefixes the rest of a formula the same way inside a LET", async () => { + for (const f of [ + 'XLOOKUP("b",A1:A3,B1:B3)', + "UNIQUE(SORTBY(A1:A9,B1:B9))", + "Filter(A1:A3,B1:B3)", + "_xlfn._xlws.SORT(_xlfn.UNIQUE(A1:A3))", + 'IF(A1="XLOOKUP(",IFS(TRUE,1),"say ""SORT("" twice")', + "SUM('IFS(x'!A1,'it''s SORT('!B2)+SORT(C1:C3)", + "SUM(T[[#This Row],[IFS(a)]])+T['[SORT(]", + "IFS!A1+SORT+MYSORT(1)+Book!SORT(1)+SORT.X(1)", + "NETWORKDAYS.INTL(A1,B1)+ISO.CEILING(C1)", + ]) { + expect(await stored(`LET(z,1,${f})`)).toBe(`_xlfn.LET(_xlpm.z,1,${await stored(f)})`) + } + // A name that only contains LET still gets the functions after it. + expect(await stored("OUTLET(1)+IFS(TRUE,1)")).toBe("OUTLET(1)+_xlfn.IFS(TRUE,1)") + }) +}) diff --git a/test/formulas-advanced.test.ts b/test/formulas-advanced.test.ts index f093321..c89483a 100644 --- a/test/formulas-advanced.test.ts +++ b/test/formulas-advanced.test.ts @@ -231,7 +231,8 @@ describe("array formula writing", () => { expect(fEl.attrs["cm"]).toBeUndefined() expect(fEl.attrs["t"]).toBe("array") expect(fEl.attrs["ref"]).toBe("B2:B10") - expect(getElementText(fEl)).toBe("SORT(A2:A10)") + // SORT is stored as `_xlfn._xlws.SORT`; the bare name is `#NAME?` (#597). + expect(getElementText(fEl)).toBe("_xlfn._xlws.SORT(A2:A10)") }) it("marks a plain (non-array) dynamic formula on the cell too", async () => { @@ -459,7 +460,8 @@ describe("array formula reading", () => { const cell = sheetCells.get("1,1") expect(cell).toBeDefined() - expect(cell!.formula).toBe("SORT(A2:A10)") + // Read back in the stored form the writer gave it (#597). + expect(cell!.formula).toBe("_xlfn._xlws.SORT(A2:A10)") expect(cell!.formulaType).toBe("array") expect(cell!.formulaRef).toBe("B2:B10") expect(cell!.formulaDynamic).toBe(true) @@ -625,7 +627,8 @@ describe("array formula round-trip", () => { expect(cell!.formulaType).toBe("array") expect(cell!.formulaRef).toBe("B1:B5") expect(cell!.formulaDynamic).toBe(true) - expect(cell!.formula).toBe("UNIQUE(A1:A5)") + // Read back in the stored form the writer gave it (#597). + expect(cell!.formula).toBe("_xlfn.UNIQUE(A1:A5)") }) })