Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .changeset/error-cells.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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`.
Expand Down
12 changes: 10 additions & 2 deletions README.pt-BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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`.
Expand Down
8 changes: 6 additions & 2 deletions src/biff/cell-records.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down
20 changes: 20 additions & 0 deletions src/biff/error-code.ts
Original file line number Diff line number Diff line change
@@ -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<number, ExcelErrorCode> = 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);
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
24 changes: 21 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
44 changes: 43 additions & 1 deletion test/cell-records.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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", () => {
Expand Down
35 changes: 35 additions & 0 deletions test/error-code.test.ts
Original file line number Diff line number Diff line change
@@ -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<readonly [number, string]> = [
[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!"}');
});
});