Skip to content
Open
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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ class Sheet {
toObjects(opts?: { headerRow?: number; maxCells?: number }): Record<string, CellValue>[]
}

// rowCount/colCount track the occupied range and shrink when the outermost cell is cleared;
// rows appended with addRow() keep their place even when empty.
// toObjects() suffixes repeated headers (_2, _3, …) so no column is lost.

read(data: Buffer | Uint8Array, opts?: ReadOptions): Workbook
readFile(path: string, opts?: ReadOptions): Workbook

Expand Down Expand Up @@ -184,6 +188,8 @@ When reading files, cells formatted as Excel dates (built-in or custom date form

Formulas are stored the way OOXML stores them, without the leading `=` you would type in Excel. A leading `=` is accepted and stripped, so `{ formula: '=SUM(A1:B1)' }` and `{ formula: 'SUM(A1:B1)' }` are equivalent and `sheet.formula()` always returns `SUM(A1:B1)`. A formula that is only `=` throws a `TypeError`.

Shared formulas (the ones Excel writes when a formula is dragged across a range) are reconstructed on read: every dependent cell gets the master formula with its relative references shifted, so `sheet.formula()` returns a complete formula for each cell. Files whose elements carry a namespace prefix (`<x:row>`, as produced by Open XML SDK and other .NET tools) are read like any other.

---

## Supported
Expand Down Expand Up @@ -217,13 +223,15 @@ Writing a workbook throws an error if:
- A cell contains `NaN` or `Infinity`, since Excel cannot represent those values.
- A cell contains an invalid `Date` (e.g. `new Date(NaN)`).
- A cell's coordinates fall outside Excel's real grid (rows 1–1,048,576, columns A–XFD).
- A cell contains a `Date` before 1899-12-30, which Excel cannot display (it would render as `#####`).
- The workbook contains no worksheets.

Reading a workbook throws a descriptive error instead of silently producing corrupt or incorrect data if:

- The `.xlsx` container is not a valid ZIP, is truncated, has a corrupt CRC, contains duplicate parts, or requires ZIP64 (>4 GB entries), which minixlsx doesn't support.
- The archive would decompress beyond the configured budget (a "zip bomb"-style archive). Each entry is capped at 1 GiB, each XML part at 256 MiB, and the whole archive at `maxDecompressedSize` (default 1 GiB). Declared sizes are not trusted: the limit is enforced on the actual inflated output.
- A cell reference, sheet name, date value, or XML character reference is malformed, or an element is left unclosed.
- A `<row r>` is not a valid row number, a cell's `r` does not belong to the row that contains it, or a shared-string index points past the table.

### Hardening against untrusted files

Expand All @@ -237,7 +245,7 @@ minixlsx is designed to be safe to point at files uploaded by third parties:

### Sheet names

Sheet name rules (empty name, >31 characters, invalid characters, case-insensitive duplicates) are centralized and shared by every code path that produces a `Sheet`: `Workbook.addSheet`, reading, and writing (writing re-checks defensively, since `Workbook.sheets` is a mutable array).
Sheet name rules (empty name, >31 characters, invalid characters, case-insensitive duplicates) are centralized and shared by every code path that produces a `Sheet`: `Workbook.addSheet`, reading, and writing (writing re-checks defensively, since `Workbook.sheets` is a mutable array). `Workbook.sheet(name)` is case-insensitive too, matching Excel and the uniqueness rule. The errors thrown by these checks can be told apart from other errors with the exported `isSheetNameError(err)` guard, which also exposes the broken `rule`.

By default, `read()`/`readFile()` abort with a descriptive error the moment they encounter an invalid sheet name in the file — the error names the sheet's index, its name, and which specific rule was broken (`empty`, `too-long`, `invalid-chars`, or `duplicate`). Names are never silently renamed or sanitized.

Expand Down
82 changes: 47 additions & 35 deletions docs/bugs.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,32 @@ la rama. Ordenados por impacto en usuarios reales.

## Lectura

- [ ] **XML con prefijo de namespace se lee como hoja vacía.** *Verificado.* Un archivo
con `<x:worksheet xmlns:x="…"><x:sheetData><x:row><x:c>` devuelve `toRows() === []`
sin ningún error, porque `elements(xml, 'row')` busca literalmente `<row`. Lo producen
algunas herramientas .NET y Open XML SDK. **Propuesta:** en `elements()`, aceptar un
prefijo opcional (`<` + `[A-Za-z_][\w.-]*:` + tag) o normalizar los prefijos al cargar
cada parte según su `xmlns:*`.
- [ ] **Las fórmulas compartidas se pierden en las celdas dependientes.** *Verificado.*
- [x] **XML con prefijo de namespace se lee como hoja vacía.** *Verificado.* Un archivo
con `<x:worksheet xmlns:x="…"><x:sheetData><x:row><x:c>` devolvía `toRows() === []`
sin ningún error. Lo producen algunas herramientas .NET y Open XML SDK. **Corregido**
en `claude/fix-remaining-bugs`: `stripElementPrefixes()` normaliza cada parte al cargarla
(solo nombres de elemento; los atributos como `r:id` se conservan).
- [x] **Las fórmulas compartidas se pierden en las celdas dependientes.** *Verificado.*
Con `<f t="shared" ref="A1:A2" si="0">B1*2</f>` en A1 y `<f t="shared" si="0"/>` en A2,
`formula('A2')` devuelve `null`. Excel las genera siempre que se arrastra una fórmula.
**Propuesta:** guardar las fórmulas maestras por `si` y desplazar las referencias
relativas para cada celda dependiente. Mientras tanto, documentar la limitación.
`formula('A2')` devolvía `null`. **Corregido** en `claude/fix-remaining-bugs`:
`src/formula.ts` desplaza las referencias relativas de la maestra (literales y nombres
de hoja intactos, absolutas conservadas, rangos de columna desplazados, `#REF!` si
sale de la cuadrícula). Limitación conocida: los rangos de fila completa (`1:1`) no se
desplazan, porque un número suelto es ambiguo sin un parser de fórmulas.
- [ ] **Los errores de celda (`t="e"`) llegan como `string`.** *Verificado.* `#DIV/0!` es
indistinguible de un texto literal. **Propuesta:** tipo `CellError` (por ejemplo una
clase con `code`) o un valor de marca; documentar el cambio como breaking.
- [ ] **Mensajes con `NaN` y sin hoja.** *Verificado.* `<row r="abc">` produce
`Coordenadas de celda inválidas: fila NaN, columna 1`. **Propuesta:** validar `r` en el
lector y lanzar un error con nombre de hoja e índice de fila.
- [ ] **`sst[+vText]` fuera de rango devuelve `null` en silencio.** Un índice de shared
string inexistente debería ser un error de archivo corrupto, coherente con el resto.
- [ ] **`<c>` dentro de `<row>` con distinto número de fila.** El lector toma la fila del
`<row r>` e ignora la del `r="A5"` de la celda. Excel no lo produce, pero conviene
detectar la incoherencia.
indistinguible de un texto literal. **Pendiente de decisión:** PR #2 lo documentó en el
README como conversión con pérdida deliberada y lo fijó con tests. Resolverlo exige
ampliar `CellValue` con un tipo `CellError`, lo que rompe el `switch` exhaustivo de los
consumidores TypeScript. Conviene decidirlo para una versión mayor, no como parche.
- [x] **Mensajes con `NaN` y sin hoja.** *Verificado.* `<row r="abc">` producía
`Coordenadas de celda inválidas: fila NaN, columna 1`. **Corregido:** el lector valida
`r` y lanza `Fila inválida en la hoja "S": r="abc"`.
- [x] **`sst[+vText]` fuera de rango devuelve `null` en silencio.** **Corregido:** un
índice inexistente o no numérico lanza `Índice de cadena compartida fuera de rango en
A1 (hoja "S")`.
- [x] **`<c>` dentro de `<row>` con distinto número de fila.** **Corregido:** `<c r="A5">`
dentro de `<row r="1">` lanza `La celda "A5" no pertenece a la fila 1 de la hoja "S"`.
Una referencia sin número de fila (`r="A"`) sigue tomando la fila del `<row>`.

## Escritura

Expand All @@ -38,24 +42,32 @@ la rama. Ordenados por impacto en usuarios reales.
`{ formula: 'TODAY()', value: new Date() }` escribe solo `<f>` sin `<v>` ni estilo de
fecha. **Propuesta:** serializar el serial con `s="1"`/`s="2"` como en las celdas sin
fórmula.
- [ ] **Fechas anteriores a 1899-12-30 producen seriales negativos.** Excel las muestra
como `#####`. **Propuesta:** lanzar `RangeError` o documentar.
- [x] **Fechas anteriores a 1899-12-30 producen seriales negativos.** Excel las muestra
como `#####`. **Corregido:** `toBuffer()` lanza `RangeError` nombrando la celda, tanto
para fechas planas como para valores cacheados de fórmula. El serial 0 (1899-12-30)
sigue siendo válido. La lectura de seriales negativos (LibreOffice los escribe) se
mantiene tolerante.

## API

- [ ] **`Workbook.sheet()` distingue mayúsculas pero la unicidad no.** *Verificado.* Con una
hoja `Datos`, `sheet('datos')` devuelve `null` aunque `addSheet('datos')` falla por
duplicado. **Propuesta:** comparar sin distinguir mayúsculas, igual que Excel.
- [ ] **`rowCount` y `colCount` no decrecen** al borrar celdas con `null`. `_maxRow` y
`_maxCol` solo crecen. **Propuesta:** recalcular en `setCellAt` cuando se borra la
celda que definía el máximo, o documentar que son cotas superiores.
- [ ] **Cabeceras duplicadas en `toObjects()`** se pisan entre sí sin aviso.
**Propuesta:** sufijar (`nombre_2`) o lanzar; documentar la elección.
- [ ] **`isSheetNameError()`** acepta cualquier `Error` con propiedad `rule`. Usar una
clase `SheetNameError` real (ver [mejoras.md](mejoras.md), errores tipados).
- [x] **`Workbook.sheet()` distingue mayúsculas pero la unicidad no.** *Verificado.* Con una
hoja `Datos`, `sheet('datos')` devolvía `null`. **Corregido:** la búsqueda por nombre no
distingue mayúsculas, igual que Excel y que la validación de `addSheet`.
- [x] **`rowCount` y `colCount` no decrecen** al borrar celdas con `null`. **Corregido:**
al borrar la celda que definía el máximo se recalculan a partir de las celdas
pobladas. Las filas añadidas con `addRow()` conservan su reserva aunque queden vacías,
para que el siguiente `addRow()` siga cayendo debajo.
- [x] **Cabeceras duplicadas en `toObjects()`** se pisaban entre sí sin aviso.
**Corregido:** reciben sufijo `_2`, `_3`… saltando los nombres ya ocupados por otras
columnas.
- [x] **`isSheetNameError()`** aceptaba cualquier `Error` con propiedad `rule`.
**Corregido:** los errores de `validateSheetName` llevan una marca `Symbol` no
enumerable y `isSheetNameError` la comprueba. `isSheetNameError`, `SheetNameError` y
`SheetNameRule` se exportan desde el paquete. Los errores tipados con código para el
resto de la librería siguen en [mejoras.md](mejoras.md).

## Empaquetado

- [ ] **Campos `@internal` filtrados al `.d.ts`.** *Verificado.* `_cells`, `_maxRow`,
`_maxCol` y `_checkDense` aparecen en `dist/src/sheet.d.ts`. **Propuesta:**
`"stripInternal": true` en `tsconfig.build.json`.
- [x] **Campos `@internal` filtrados al `.d.ts`.** *Verificado.* **Corregido** con
`"stripInternal": true` en `tsconfig.build.json`; `dist/src/sheet.d.ts` ya no expone
`_cells` ni el resto de miembros internos.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "minixlsx",
"version": "0.3.0",
"version": "0.4.0",
"description": "A tiny, zero-dependency library for reading and writing Excel (.xlsx) files in Node.js.",
"keywords": [
"excel",
Expand Down
69 changes: 69 additions & 0 deletions src/formula.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Desplazamiento de referencias en fórmulas, necesario para reconstruir las fórmulas
// compartidas (`<f t="shared" si="N"/>`) que Excel escribe al arrastrar una fórmula: solo
// la celda maestra lleva el texto; las dependientes se obtienen desplazando las
// referencias relativas por la diferencia de fila y columna.
import { colToName, MAX_COLS, MAX_ROWS, nameToCol } from '#minixlsx/utils'

// Alternativas, en orden:
// 1. literal de texto "..." (con "" como escape) → se copia tal cual
// 2. nombre de hoja entrecomillado '...' (con '' escape) → se copia tal cual
// 3. referencia de celda [$]COL[$]FILA → se desplaza
// 4. rango de columnas completas [$]COL:[$]COL → se desplaza
// Los lookaround evitan tomar por referencia el final de un nombre de función (LOG10,
// ATAN2) o de un nombre definido. Las referencias a fila completa (1:1) no se desplazan:
// sin un parser de fórmulas, un número suelto es ambiguo.
const TOKEN =
/"(?:[^"]|"")*"|'(?:[^']|'')*'|(?<![\w.$])(\$?)([A-Za-z]{1,3})(\$?)(\d{1,7})(?![\w(])|(?<![\w.$])(\$?)([A-Za-z]{1,3}):(\$?)([A-Za-z]{1,3})(?![\w(])/g

const REF_ERROR = '#REF!'

function shiftCol(name: string, absolute: string, delta: number): string | null {
let n: number
try {
n = nameToCol(name)
} catch {
return null
}
if (!absolute) n += delta
return n >= 1 && n <= MAX_COLS ? absolute + colToName(n) : null
}

function shiftRow(row: string, absolute: string, delta: number): string | null {
const n = absolute ? +row : +row + delta
return n >= 1 && n <= MAX_ROWS ? absolute + n : null
}

/**
* Devuelve `formula` con sus referencias relativas desplazadas `dr` filas y `dc` columnas.
* Las partes absolutas (`$A$1`) se conservan. Una referencia que quedaría fuera de la
* cuadrícula se sustituye por `#REF!`, como hace Excel.
*/
export function shiftFormula(formula: string, dr: number, dc: number): string {
if (dr === 0 && dc === 0) return formula
return formula.replace(
TOKEN,
(
m,
cAbs?: string,
cName?: string,
rAbs?: string,
row?: string,
rcAbs?: string,
rc1?: string,
rcAbs2?: string,
rc2?: string,
) => {
if (cName != null) {
const col = shiftCol(cName, cAbs ?? '', dc)
const r = shiftRow(row ?? '', rAbs ?? '', dr)
return col != null && r != null ? col + r : REF_ERROR
}
if (rc1 != null) {
const a = shiftCol(rc1, rcAbs ?? '', dc)
const b = shiftCol(rc2 ?? '', rcAbs2 ?? '', dc)
return a != null && b != null ? `${a}:${b}` : REF_ERROR
}
return m
},
)
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { type InvalidSheetNamesMode, MAX_PART_SIZE, type ReadOptions, read, readFile } from '#minixlsx/reader'
export { type CellInput, type CellValue, DEFAULT_MAX_CELLS, type DenseOptions, Sheet } from '#minixlsx/sheet'
export { isSheetNameError, type SheetNameError, type SheetNameRule } from '#minixlsx/sheet-name'
export { colToName, dateToSerial, MAX_COLS, MAX_ROWS, nameToCol, parseRef, serialToDate } from '#minixlsx/utils'
export { Workbook } from '#minixlsx/workbook'
export { MAX_ENTRY_SIZE, MAX_TOTAL_SIZE } from '#minixlsx/zip'
64 changes: 53 additions & 11 deletions src/reader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { readFileSync } from 'node:fs'

import { shiftFormula } from '#minixlsx/formula'
import { isSheetNameError } from '#minixlsx/sheet-name'
import { nameToCol, serialToDate } from '#minixlsx/utils'
import { colToName, MAX_ROWS, nameToCol, serialToDate } from '#minixlsx/utils'
import { Workbook } from '#minixlsx/workbook'
import { attr, decodeText, elements, firstElement, stripElements, unesc } from '#minixlsx/xml'
import { MAX_TOTAL_SIZE, unzipSync } from '#minixlsx/zip'
Expand Down Expand Up @@ -56,6 +57,18 @@ function isDateFormatCode(code: string): boolean {
return /[ymdhs]/i.test(stripped)
}

// Algunos productores (Open XML SDK, herramientas .NET) prefijan los elementos con el
// namespace (`<x:worksheet>`, `<x:row>`). Los helpers de xml.ts buscan nombres sin prefijo,
// así que cada parte se normaliza una vez al cargarla. Solo se tocan los nombres de elemento;
// los atributos (`r:id`, `xmlns:x`) se conservan. El patrón no tiene cuantificadores anidados:
// una pasada lineal.
const PREFIXED_ELEMENT = /<(\/?)[A-Za-z_][\w.-]*:(?=[A-Za-z_])/g

/** @internal Elimina el prefijo de namespace de todas las etiquetas de apertura y cierre. */
export function stripElementPrefixes(xml: string): string {
return xml.replace(PREFIXED_ELEMENT, '<$1')
}

function dirname(p: string): string {
const i = p.lastIndexOf('/')
return i < 0 ? '' : p.slice(0, i)
Expand Down Expand Up @@ -132,35 +145,55 @@ function parseDateStyles(xml: string | null): Set<number> {
function parseSheetXml(xml: string, sheet: Sheet, sst: string[], dateStyles: Set<number>, epoch1904: boolean): void {
const sheetData = firstElement(xml, 'sheetData')?.inner ?? ''
let lastRow = 0
// Fórmulas compartidas: la maestra (con texto) por `si`, con su posición, para derivar las dependientes.
const sharedFormulas = new Map<string, { formula: string; row: number; col: number }>()

for (const row of elements(sheetData, 'row')) {
const rAttr = attr(row.attrs, 'r')
const rowNum = rAttr ? +rAttr : lastRow + 1
let rowNum = lastRow + 1
if (rAttr != null) {
rowNum = /^\d+$/.test(rAttr) ? +rAttr : Number.NaN
if (!(rowNum >= 1 && rowNum <= MAX_ROWS)) {
throw new Error(`Fila inválida en la hoja "${sheet.name}": r="${rAttr}"`)
}
}
lastRow = rowNum

let lastCol = 0
for (const { attrs, inner } of elements(row.inner, 'c')) {
const ref = attr(attrs, 'r')
const rawRef = attr(attrs, 'r')
let col: number
if (ref) {
const colMatch = /^[A-Za-z]+/.exec(ref)
if (!colMatch) throw new Error(`Referencia de celda inválida en el XML: "${ref}"`)
col = nameToCol(colMatch[0])
if (rawRef != null) {
const m = /^([A-Za-z]+)(\d*)$/.exec(rawRef)
if (!m) throw new Error(`Referencia de celda inválida en la hoja "${sheet.name}": "${rawRef}"`)
col = nameToCol(m[1])
// Excel no lo produce, pero un archivo manipulado puede situar `<c r="A5">` dentro de
// `<row r="1">`; antes se tomaba la fila del <row> y se ignoraba la de la celda.
if (m[2] && +m[2] !== rowNum) {
throw new Error(`La celda "${rawRef}" no pertenece a la fila ${rowNum} de la hoja "${sheet.name}"`)
}
} else {
col = lastCol + 1
}
lastCol = col
const ref = rawRef ?? colToName(col) + rowNum

const type = attr(attrs, 't') ?? 'n'
const style = +(attr(attrs, 's') ?? -1)
// Un `<v/>` o `<v></v>` vacío (openpyxl lo escribe en fórmulas sin valor cacheado)
// equivale a no tener valor: no debe convertirse en 0 ni en el shared string 0.
const vText = firstElement(inner, 'v')?.inner || null
const fText = firstElement(inner, 'f')?.inner ?? null
const f = firstElement(inner, 'f')

let value: CellValue = null
if (type === 's') {
value = vText != null ? (sst[+vText] ?? null) : null
if (vText != null) {
const idx = /^\d+$/.test(vText) ? +vText : Number.NaN
if (!(idx < sst.length)) {
throw new Error(`Índice de cadena compartida fuera de rango en ${ref} (hoja "${sheet.name}"): "${vText}"`)
}
value = sst[idx]
}
} else if (type === 'str' || type === 'e') {
value = vText != null ? decodeText(vText) : null
} else if (type === 'b') {
Expand All @@ -174,7 +207,16 @@ function parseSheetXml(xml: string, sheet: Sheet, sst: string[], dateStyles: Set
value = dateStyles.has(style) ? serialToDate(n, epoch1904) : n
}

const formula = fText?.length ? unesc(fText) : null
let formula = f?.inner.length ? unesc(f.inner) : null
if (f && attr(f.attrs, 't') === 'shared') {
const si = attr(f.attrs, 'si') ?? ''
if (formula) {
sharedFormulas.set(si, { col, formula, row: rowNum })
} else {
const master = sharedFormulas.get(si)
if (master) formula = shiftFormula(master.formula, rowNum - master.row, col - master.col)
}
}
if (value != null || formula) {
sheet.setCellAt(rowNum, col, formula ? { value, formula } : value)
}
Expand All @@ -201,7 +243,7 @@ export function read(data: Buffer | Uint8Array, opts: ReadOptions = {}): Workboo
if (part.length > MAX_PART_SIZE) {
throw new Error(`La parte "${name}" supera el tamaño máximo admitido (${MAX_PART_SIZE} bytes)`)
}
return part.toString('utf8')
return stripElementPrefixes(part.toString('utf8'))
}

const rootXml = getXml('_rels/.rels')
Expand Down
Loading
Loading