diff --git a/.changeset/error-cells.md b/.changeset/error-cells.md new file mode 100644 index 0000000..00c0721 --- /dev/null +++ b/.changeset/error-cells.md @@ -0,0 +1,14 @@ +--- +"xls-reader": minor +--- + +Return Excel error cells as a typed `CellError` instead of collapsing them to +`null`. A cell holding `#DIV/0!`, `#N/A`, `#REF!`, `#VALUE!`, `#NAME?`, `#NUM!`, +or `#NULL!` (whether a literal error or a formula's cached error result) now comes +back as `new CellError(code)` — distinct from a blank cell (`null`) and from a +text cell that happens to contain that string. Check `cell instanceof CellError` +and read `cell.code`; in JSON it serializes to `{ "code": "#DIV/0!" }`. Exports +the new `CellError` class and `ExcelErrorCode` type. + +Behavior change: error cells were previously `null`. If you relied on that, treat +a `CellError` as empty explicitly. diff --git a/README.md b/README.md index d0ef92d..471410c 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,12 @@ The raw serial-number → `Date` (UTC) conversion, if you need it directly. ### Types ```ts -type Cell = string | number | boolean | Date | null; +type Cell = string | number | boolean | Date | CellError | null; + +// An Excel error value, e.g. new CellError("#DIV/0!"). `.code` is the error text. +class CellError { + readonly code: "#NULL!" | "#DIV/0!" | "#VALUE!" | "#REF!" | "#NAME?" | "#NUM!" | "#N/A"; +} type SheetVisibility = "visible" | "hidden" | "very-hidden"; @@ -184,7 +189,10 @@ interface Workbook { - **Dates** — a number whose cell format is a date/time (built-in or custom) is returned as a `Date` (UTC). Use `excelSerialToDate` if you need the raw conversion. -- **Blank / error** cells are `null`. +- **Error** cells come back as a `CellError` (e.g. `#DIV/0!`, `#N/A`) — check + `cell instanceof CellError` and read `cell.code`. In JSON they serialize to + `{ "code": "#DIV/0!" }`. +- **Blank** cells are `null`. - **Visibility** — each sheet reports whether it's `visible`, `hidden`, or `very-hidden`, so you can skip the hidden lookup/config tabs. Chart, macro, and VBA substreams aren't worksheets and are left out of `workbook.sheets`. diff --git a/README.pt-BR.md b/README.pt-BR.md index f84bcc6..f4e8889 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -172,7 +172,12 @@ diretamente. ### Tipos ```ts -type Cell = string | number | boolean | Date | null; +type Cell = string | number | boolean | Date | CellError | null; + +// Um valor de erro do Excel, ex.: new CellError("#DIV/0!"). `.code` é o texto do erro. +class CellError { + readonly code: "#NULL!" | "#DIV/0!" | "#VALUE!" | "#REF!" | "#NAME?" | "#NUM!" | "#N/A"; +} type SheetVisibility = "visible" | "hidden" | "very-hidden"; @@ -192,7 +197,10 @@ interface Workbook { - **Datas** — um número cujo formato de célula é de data/hora (embutido ou customizado) volta como `Date` (UTC). Use `excelSerialToDate` se precisar da conversão bruta. -- Células **em branco / de erro** viram `null`. +- Células **de erro** voltam como `CellError` (ex.: `#DIV/0!`, `#N/A`) — teste + `cell instanceof CellError` e leia `cell.code`. Em JSON viram + `{ "code": "#DIV/0!" }`. +- Células **em branco** viram `null`. - **Visibilidade** — cada planilha informa se é `visible`, `hidden` ou `very-hidden`, então dá para pular as abas ocultas de lookup/config. Substreams de gráfico, macro e VBA não são planilhas e ficam de fora de `workbook.sheets`. diff --git a/src/biff/cell-records.ts b/src/biff/cell-records.ts index 34f2ae0..0784c67 100644 --- a/src/biff/cell-records.ts +++ b/src/biff/cell-records.ts @@ -1,5 +1,6 @@ import { ByteReader } from "../byte-reader"; import type { Cell } from "../types"; +import { errorFromByte } from "./error-code"; import { decodeRk } from "./rk-number"; import { readUnicodeString } from "./unicode-string"; @@ -60,7 +61,7 @@ export function decodeBoolErr(data: Uint8Array): PositionedCell { const { row, col } = readHead(reader); const raw = reader.u8(); const isError = reader.u8() !== 0; - return { row, col, value: isError ? null : raw !== 0 }; + return { row, col, value: isError ? errorFromByte(raw) : raw !== 0 }; } // MULRK packs several RK cells sharing a row: [row][colFirst] then one (xf, rk) @@ -106,7 +107,10 @@ function nonNumericResult(row: number, col: number, result: Uint8Array): Formula const kind = result[0]; if (kind === 0) return { kind: "pending-string", row, col }; if (kind === 1) return { kind: "value", cell: { row, col, value: result[2] !== 0 } }; - return { kind: "value", cell: { row, col, value: null } }; // error (2) or blank (3) + // Byte 2 carries the value: for kind 2 (error) it's the error code, same slot + // the boolean above reads; kind 3 is a blank result. + const value = kind === 2 ? errorFromByte(result[2] ?? 0xff) : null; + return { kind: "value", cell: { row, col, value } }; } // Every cell record starts with row (u16), column (u16), and an xf index (u16). diff --git a/src/biff/error-code.ts b/src/biff/error-code.ts new file mode 100644 index 0000000..76ab0a2 --- /dev/null +++ b/src/biff/error-code.ts @@ -0,0 +1,20 @@ +import { CellError, type ExcelErrorCode } from "../types"; + +// BIFF8 stores an error cell's kind as a single byte ([MS-XLS] BErr). Map it to +// the worksheet-facing error, or `null` when the byte isn't one of the seven +// defined codes (a corrupt/unknown value — treated as blank rather than guessed). +const CODE_BY_BYTE: ReadonlyMap = new Map([ + [0x00, "#NULL!"], + [0x07, "#DIV/0!"], + [0x0f, "#VALUE!"], + [0x17, "#REF!"], + [0x1d, "#NAME?"], + [0x24, "#NUM!"], + [0x2a, "#N/A"], +]); + +// Turns a BIFF error byte into a CellError, or null if the byte is unrecognized. +export function errorFromByte(byte: number): CellError | null { + const code = CODE_BY_BYTE.get(byte); + return code === undefined ? null : new CellError(code); +} diff --git a/src/index.ts b/src/index.ts index d7f7a19..c2d4e03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -export type { Cell, RowObject, Sheet, SheetVisibility, Workbook } from "./types"; +export type { Cell, ExcelErrorCode, RowObject, Sheet, SheetVisibility, Workbook } from "./types"; +export { CellError } from "./types"; export { XlsError } from "./errors"; export { readXls, readFirstSheet } from "./reader"; export { sheetToObjects, type SheetToObjectsOptions } from "./sheet-to-objects"; diff --git a/src/types.ts b/src/types.ts index 5c0525b..f0a2615 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,25 @@ +// The seven Excel error values, in their worksheet spelling ([MS-XLS] BErr). +export type ExcelErrorCode = + "#NULL!" | "#DIV/0!" | "#VALUE!" | "#REF!" | "#NAME?" | "#NUM!" | "#N/A"; + +// An Excel error value sitting in a cell (e.g. `#DIV/0!`). It's a distinct type +// rather than `null` so an importer can tell an errored cell from a blank one, +// and rather than a plain string so it can't be confused with a text cell that +// literally contains "#N/A". Serializes to `{ "code": "#DIV/0!" }` in JSON. +// +// @example +// if (cell instanceof CellError) console.warn(`errored: ${cell.code}`); +export class CellError { + constructor(readonly code: ExcelErrorCode) {} + toString(): string { + return this.code; + } +} + // A single cell value, already decoded from BIFF8 to a plain JS value. Numbers -// formatted as dates in the source come back as `Date`; empty/blank cells and -// error cells come back as `null`. -export type Cell = string | number | boolean | Date | null; +// formatted as dates in the source come back as `Date`; error cells come back as +// a `CellError`; empty/blank cells come back as `null`. +export type Cell = string | number | boolean | Date | CellError | null; // How a sheet is surfaced in Excel: normally visible, hidden (re-shown from the // sheet-tab menu), or very-hidden (only togglable from the VBA editor — often a diff --git a/test/cell-records.test.ts b/test/cell-records.test.ts index ecbb6e0..4f38b90 100644 --- a/test/cell-records.test.ts +++ b/test/cell-records.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { decodeFormula, decodeMulRk, type CellContext } from "../src/biff/cell-records"; +import { + decodeBoolErr, + decodeFormula, + decodeMulRk, + type CellContext, +} from "../src/biff/cell-records"; +import { CellError } from "../src/types"; // A context that returns numbers verbatim (no date coercion) so tests assert the // decoded value, not the format layer. @@ -33,6 +39,42 @@ describe("decodeFormula", () => { const result = decodeFormula(formula([1, 0, 1, 0, 0, 0, 0xff, 0xff]), ctx); expect(result).toEqual({ kind: "value", cell: { row: 0, col: 0, value: true } }); }); + + it("decodes a cached error result as a CellError", () => { + // result[0]=2 (error), result[2]=0x17 (#REF!), 0xFFFF sentinel. + const result = decodeFormula(formula([2, 0, 0x17, 0, 0, 0, 0xff, 0xff]), ctx); + expect(result).toEqual({ + kind: "value", + cell: { row: 0, col: 0, value: new CellError("#REF!") }, + }); + }); + + it("decodes a cached blank result as null", () => { + // result[0]=3 (blank), 0xFFFF sentinel. + const result = decodeFormula(formula([3, 0, 0, 0, 0, 0, 0xff, 0xff]), ctx); + expect(result).toEqual({ kind: "value", cell: { row: 0, col: 0, value: null } }); + }); +}); + +// BOOLERR body: row, col, xf (each u16), then a value byte and an isError flag. +function boolErr(value: number, isError: number): Uint8Array { + return Uint8Array.from([0, 0, 0, 0, 0, 0, value, isError]); +} + +describe("decodeBoolErr", () => { + it("decodes booleans", () => { + expect(decodeBoolErr(boolErr(1, 0)).value).toBe(true); + expect(decodeBoolErr(boolErr(0, 0)).value).toBe(false); + }); + + it("decodes an error byte into a CellError", () => { + expect(decodeBoolErr(boolErr(0x07, 1)).value).toEqual(new CellError("#DIV/0!")); + expect(decodeBoolErr(boolErr(0x2a, 1)).value).toEqual(new CellError("#N/A")); + }); + + it("falls back to null for an unrecognized error byte", () => { + expect(decodeBoolErr(boolErr(0x99, 1)).value).toBeNull(); + }); }); describe("decodeMulRk", () => { diff --git a/test/error-code.test.ts b/test/error-code.test.ts new file mode 100644 index 0000000..048c3a5 --- /dev/null +++ b/test/error-code.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { errorFromByte } from "../src/biff/error-code"; +import { CellError } from "../src/types"; + +describe("errorFromByte", () => { + it("maps each defined BIFF error byte to its worksheet code", () => { + const cases: ReadonlyArray = [ + [0x00, "#NULL!"], + [0x07, "#DIV/0!"], + [0x0f, "#VALUE!"], + [0x17, "#REF!"], + [0x1d, "#NAME?"], + [0x24, "#NUM!"], + [0x2a, "#N/A"], + ]; + for (const [byte, code] of cases) { + const result = errorFromByte(byte); + expect(result).toBeInstanceOf(CellError); + expect(result?.code).toBe(code); + } + }); + + it("returns null for an unrecognized byte instead of guessing", () => { + expect(errorFromByte(0x99)).toBeNull(); + expect(errorFromByte(0xff)).toBeNull(); + }); + + it("CellError stringifies to its code", () => { + expect(String(new CellError("#REF!"))).toBe("#REF!"); + }); + + it("CellError serializes to an explicit object in JSON", () => { + expect(JSON.stringify(new CellError("#DIV/0!"))).toBe('{"code":"#DIV/0!"}'); + }); +});