diff --git a/README.md b/README.md index a610f0e..51214e4 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,10 @@ class Sheet { toObjects(opts?: { headerRow?: number; maxCells?: number }): Record[] } +// 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 @@ -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 (``, as produced by Open XML SDK and other .NET tools) are read like any other. + --- ## Supported @@ -217,6 +223,7 @@ 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: @@ -224,6 +231,7 @@ Reading a workbook throws a descriptive error instead of silently producing corr - 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 `` 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 @@ -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. diff --git a/docs/bugs.md b/docs/bugs.md index a886dff..d978de9 100644 --- a/docs/bugs.md +++ b/docs/bugs.md @@ -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 `` devuelve `toRows() === []` - sin ningún error, porque `elements(xml, 'row')` busca literalmente `` 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 `B1*2` en A1 y `` 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.* `` 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. -- [ ] **`` dentro de `` con distinto número de fila.** El lector toma la fila del - `` 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.* `` 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] **`` dentro de `` con distinto número de fila.** **Corregido:** `` + dentro de `` 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 ``. ## Escritura @@ -38,24 +42,32 @@ la rama. Ordenados por impacto en usuarios reales. `{ formula: 'TODAY()', value: new Date() }` escribe solo `` sin `` 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. diff --git a/package.json b/package.json index de80a65..fd422d6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/formula.ts b/src/formula.ts new file mode 100644 index 0000000..0d7457f --- /dev/null +++ b/src/formula.ts @@ -0,0 +1,69 @@ +// Desplazamiento de referencias en fórmulas, necesario para reconstruir las fórmulas +// compartidas (``) 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 = + /"(?:[^"]|"")*"|'(?:[^']|'')*'|(?= 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 + }, + ) +} diff --git a/src/index.ts b/src/index.ts index 13bd91a..0a3cf4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/reader.ts b/src/reader.ts index 8300ae9..001ff39 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -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' @@ -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 (``, ``). 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) @@ -132,35 +145,55 @@ function parseDateStyles(xml: string | null): Set { function parseSheetXml(xml: string, sheet: Sheet, sst: string[], dateStyles: Set, 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() 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 `` dentro de + // ``; antes se tomaba la fila del 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 `` o `` 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') { @@ -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) } @@ -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') diff --git a/src/sheet-name.ts b/src/sheet-name.ts index 2eb5c83..476626c 100644 --- a/src/sheet-name.ts +++ b/src/sheet-name.ts @@ -11,13 +11,19 @@ export interface SheetNameError extends Error { rule: SheetNameRule } +// Marca no enumerable que solo llevan los errores creados aquí. Antes bastaba con que +// cualquier Error tuviera una propiedad `rule` para pasar por error de nombre de hoja. +const BRAND: unique symbol = Symbol('minixlsx.SheetNameError') + +/** Indica si `err` es un error de validación de nombre de hoja producido por minixlsx. */ export function isSheetNameError(err: unknown): err is SheetNameError { - return err instanceof Error && 'rule' in err + return err instanceof Error && (err as { [BRAND]?: true })[BRAND] === true } function ruleError(rule: SheetNameRule, ErrorClass: new (message: string) => Error, message: string): SheetNameError { const err = new ErrorClass(message) as SheetNameError err.rule = rule + Object.defineProperty(err, BRAND, { value: true }) return err } diff --git a/src/sheet.ts b/src/sheet.ts index a5dcb70..356a3a1 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -47,6 +47,8 @@ export class Sheet { /** @internal */ _cells = new Map() /** @internal */ _maxRow = 0 /** @internal */ _maxCol = 0 + /** @internal Última fila ocupada por addRow(), aunque estuviera vacía: reserva su sitio. */ + _reservedRow = 0 constructor(name: string) { this.name = name @@ -77,7 +79,8 @@ export class Sheet { } const key = `${row},${col}` if (cell.value == null && !cell.formula) { - this._cells.delete(key) + // Si la celda borrada era la que definía el máximo, las dimensiones se recalculan. + if (this._cells.delete(key) && (row === this._maxRow || col === this._maxCol)) this._recomputeBounds() return this } this._cells.set(key, cell) @@ -86,6 +89,21 @@ export class Sheet { return this } + /** @internal Recalcula rowCount/colCount a partir de las celdas pobladas y las filas reservadas. */ + _recomputeBounds(): void { + let maxRow = this._reservedRow + let maxCol = 0 + for (const key of this._cells.keys()) { + const sep = key.indexOf(',') + const r = +key.slice(0, sep) + const c = +key.slice(sep + 1) + if (r > maxRow) maxRow = r + if (c > maxCol) maxCol = c + } + this._maxRow = maxRow + this._maxCol = maxCol + } + /** Añade una fila al final. Los huecos se indican con null/undefined. */ addRow(values: CellInput[]): this { const row = this._maxRow + 1 @@ -93,6 +111,7 @@ export class Sheet { if (v != null) this.setCellAt(row, i + 1, v) }) if (row > this._maxRow) this._maxRow = row // cuenta también filas vacías + this._reservedRow = row return this } @@ -155,6 +174,8 @@ export class Sheet { /** * Datos como array de objetos usando una fila como cabecera. * Cabeceras vacías usan la letra de columna. Filas totalmente vacías se omiten. + * Una cabecera repetida recibe el sufijo `_2`, `_3`… hasta ser única, para que + * ninguna columna se pierda al pisar a otra con el mismo nombre. */ toObjects({ headerRow = 1, @@ -162,9 +183,14 @@ export class Sheet { }: { headerRow?: number } & DenseOptions = {}): Record[] { this._checkDense(maxCells) const headers: string[] = [] + const used = new Set() for (let c = 1; c <= this._maxCol; c++) { const v = this.cellAt(headerRow, c) - headers.push(v == null ? colToName(c) : String(v)) + const base = v == null ? colToName(c) : String(v) + let header = base + for (let n = 2; used.has(header); n++) header = `${base}_${n}` + used.add(header) + headers.push(header) } const out: Record[] = [] for (let r = headerRow + 1; r <= this._maxRow; r++) { diff --git a/src/workbook.ts b/src/workbook.ts index 0daa50e..05933c0 100644 --- a/src/workbook.ts +++ b/src/workbook.ts @@ -32,10 +32,14 @@ export class Workbook { return this.sheets.map((s) => s.name) } - /** Busca una hoja por nombre o índice (desde 0). */ + /** + * Busca una hoja por nombre o índice (desde 0). El nombre se compara sin distinguir + * mayúsculas, igual que hace Excel y que la propia validación de unicidad de `addSheet`. + */ sheet(nameOrIndex: string | number): Sheet | null { if (typeof nameOrIndex === 'number') return this.sheets[nameOrIndex] ?? null - return this.sheets.find((s) => s.name === nameOrIndex) ?? null + const wanted = nameOrIndex.toLowerCase() + return this.sheets.find((s) => s.name.toLowerCase() === wanted) ?? null } /** Serializa el libro a un Buffer .xlsx. */ @@ -54,6 +58,19 @@ export class Workbook { export const STYLE_DATE = 1 export const STYLE_DATETIME = 2 +/** + * Serial de Excel de una fecha, validado: por debajo de 0 (antes de 1899-12-30) Excel no + * puede mostrar la celda (`#####`), así que se rechaza al escribir en lugar de producir + * un archivo que parece correcto hasta que se abre. + */ +function dateSerial(d: Date, ref: string): number { + const serial = dateToSerial(d) + if (serial < 0) { + throw new RangeError(`La celda ${ref} contiene una fecha anterior a 1899-12-30, que Excel no puede representar`) + } + return serial +} + /** Atributos de una celda de fecha: el estilo distingue fecha de fecha y hora. */ function dateCellAttrs(d: Date): string { const hasTime = d.getHours() || d.getMinutes() || d.getSeconds() || d.getMilliseconds() @@ -101,7 +118,7 @@ function sheetToXml(sheet: Sheet, sharedIdx: (s: string) => number): string { // Sin esta rama el valor cacheado se perdía en silencio: la celda salía // como sin y al releerla el valor era null. attrs = dateCellAttrs(v) - inner += `${dateToSerial(v)}` + inner += `${dateSerial(v, ref)}` } } else if (typeof v === 'number') { inner = `${v}` @@ -110,7 +127,7 @@ function sheetToXml(sheet: Sheet, sharedIdx: (s: string) => number): string { inner = `${v ? 1 : 0}` } else if (v instanceof Date) { attrs = dateCellAttrs(v) - inner = `${dateToSerial(v)}` + inner = `${dateSerial(v, ref)}` } else { attrs = ' t="s"' inner = `${sharedIdx(String(v))}` diff --git a/test/formula.test.ts b/test/formula.test.ts new file mode 100644 index 0000000..42a57f5 --- /dev/null +++ b/test/formula.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { shiftFormula } from '#minixlsx/formula' + +describe('shiftFormula', () => { + test('desplaza referencias relativas y respeta las absolutas', () => { + assert.equal(shiftFormula('A1+$A$1+A$1+$A1', 2, 3), 'D3+$A$1+D$1+$A3') + assert.equal(shiftFormula('SUM(A1:B2)', 1, 0), 'SUM(A2:B3)') + assert.equal(shiftFormula('B1*2', 0, 0), 'B1*2') + }) + + test('no toca literales de texto ni nombres de hoja entrecomillados', () => { + assert.equal(shiftFormula('IF(A1="A1","B2",C3)', 1, 1), 'IF(B2="A1","B2",D4)') + assert.equal(shiftFormula("'Hoja 1'!A1+'It''s A1'!B2", 1, 0), "'Hoja 1'!A2+'It''s A1'!B3") + assert.equal(shiftFormula('Datos!C1', 2, 1), 'Datos!D3') + }) + + test('no confunde nombres de función ni nombres definidos con referencias', () => { + assert.equal(shiftFormula('LOG10(A1)+ATAN2(B1,C1)', 1, 0), 'LOG10(A2)+ATAN2(B2,C2)') + assert.equal(shiftFormula('Total1+tax.A1', 1, 1), 'Total1+tax.A1') + }) + + test('desplaza rangos de columna completa y deja los de fila completa', () => { + assert.equal(shiftFormula('SUM(A:A)+SUM($B:$B)', 5, 2), 'SUM(C:C)+SUM($B:$B)') + assert.equal(shiftFormula('SUM(1:1)', 5, 2), 'SUM(1:1)') + }) + + test('una referencia que sale de la cuadrícula se convierte en #REF!', () => { + assert.equal(shiftFormula('A1', -1, 0), '#REF!') + assert.equal(shiftFormula('A1', 0, -1), '#REF!') + assert.equal(shiftFormula('XFD1', 0, 1), '#REF!') + assert.equal(shiftFormula('A1048576', 1, 0), '#REF!') + assert.equal(shiftFormula('A:A', 0, -1), '#REF!') + }) + + test('acepta referencias en minúsculas y las normaliza', () => { + assert.equal(shiftFormula('sum(a1:b2)', 1, 1), 'sum(B2:C3)') + }) +}) diff --git a/test/reader.test.ts b/test/reader.test.ts index 3c28437..88ada8f 100644 --- a/test/reader.test.ts +++ b/test/reader.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' -import { read } from '#minixlsx/index' +import { read, Workbook } from '#minixlsx/index' +import { stripElementPrefixes } from '#minixlsx/reader' import { zipSync } from '#minixlsx/zip' import type { CellValue } from '#minixlsx/index' @@ -24,9 +25,11 @@ describe('tipos de celda al leer', () => { assert.equal(readA1(xml, { sharedStringsXml: SST }), 'compartida') }) - test('t="s" con índice fuera de rango devuelve null en vez de undefined', () => { - const xml = worksheet('99') - assert.equal(readA1(xml, { sharedStringsXml: SST }), null) + test('t="s" con índice fuera de rango o no numérico es un archivo corrupto', () => { + const fuera = worksheet('99') + assert.throws(() => readA1(fuera, { sharedStringsXml: SST }), /fuera de rango en A1 \(hoja "S"\): "99"/) + const noNum = worksheet('abc') + assert.throws(() => readA1(noNum, { sharedStringsXml: SST }), /fuera de rango/) }) test('t="str" devuelve el resultado cacheado de una fórmula como texto', () => { @@ -199,3 +202,129 @@ describe('contenedores no válidos', () => { assert.throws(() => read(buildXlsx({ workbookXml, sheetXml: worksheet('') })), /no contiene hojas/) }) }) + +describe('elementos con prefijo de namespace', () => { + // Open XML SDK y otras herramientas .NET escriben `` con todos los + // elementos prefijados. Antes el lector devolvía una hoja vacía sin error. + const PREFIXED_WORKBOOK = + `${XML_DECL}` + + '' + const PREFIXED_SHEET = + `${XML_DECL}` + + '420' + + 'en línea' + + '' + const PREFIXED_SST = `${XML_DECL}compartida` + + test('lee libros cuyos elementos llevan prefijo de namespace', () => { + const wb = read( + buildXlsx({ workbookXml: PREFIXED_WORKBOOK, sheetXml: PREFIXED_SHEET, sharedStringsXml: PREFIXED_SST }), + ) + const s = wb.sheet('S') + assert.ok(s) + assert.deepEqual(s.toRows(), [[42, 'compartida', 'en línea']]) + }) + + test('los prefijos se quitan solo de los elementos, no de los atributos', () => { + assert.equal( + stripElementPrefixes('0'), + '0', + ) + // Un `:` fuera de una etiqueta (texto, declaraciones, instrucciones de proceso) se respeta. + assert.equal( + stripElementPrefixes('hora: 10:30'), + 'hora: 10:30', + ) + }) +}) + +describe('referencias de fila y celda corruptas', () => { + test('un no numérico o fuera del rango de Excel se rechaza nombrando la hoja', () => { + assert.throws( + () => readA1(worksheet('1')), + /Fila inválida en la hoja "S": r="abc"/, + ) + assert.throws( + () => readA1(worksheet('1')), + /Fila inválida en la hoja "S": r="0"/, + ) + assert.throws(() => readA1(worksheet('1')), /Fila inválida/) + }) + + test('una celda cuya referencia no coincide con su se rechaza', () => { + const xml = worksheet('1') + assert.throws(() => readA1(xml), /La celda "A5" no pertenece a la fila 1 de la hoja "S"/) + }) + + test('una referencia de celda sin número de fila toma la fila del ', () => { + assert.equal(readA1(worksheet('7')), 7) + }) + + test('las filas y celdas sin atributo r se numeran de forma correlativa', () => { + const wb = read( + buildXlsx({ + workbookXml: workbook(), + sheetXml: worksheet('123'), + }), + ) + assert.deepEqual(wb.sheet('S')?.toRows(), [ + [1, 2], + [3, null], + ]) + }) +}) + +describe('fórmulas compartidas', () => { + // Excel escribe la fórmula solo en la celda maestra; las dependientes llevan + // `` vacío y se obtienen desplazando las referencias relativas. + const readSheet = (rows: string) => { + const s = read(buildXlsx({ workbookXml: workbook(), sheetXml: worksheet(rows) })).sheet('S') + assert.ok(s) + return s + } + + test('las celdas dependientes reciben la fórmula maestra desplazada', () => { + const s = readSheet( + 'B1*2+$B$12' + + '4' + + '6', + ) + assert.equal(s.formula('A1'), 'B1*2+$B$1') + assert.equal(s.formula('A2'), 'B2*2+$B$1') + assert.equal(s.formula('A3'), 'B3*2+$B$1') + assert.deepEqual( + s.toRows().map((r) => r[0]), + [2, 4, 6], + ) // los valores cacheados se conservan + }) + + test('el desplazamiento cubre filas y columnas y varios grupos si', () => { + const s = readSheet( + 'C1' + + 'SUM(A:A)' + + '' + + '', + ) + assert.equal(s.formula('B1'), 'D1') + assert.equal(s.formula('A2'), 'C2') + assert.equal(s.formula('B2'), 'D2') + assert.equal(s.formula('D2'), 'SUM(A:A)') + }) + + test('una dependiente sin maestra conocida conserva el valor y queda sin fórmula', () => { + const s = readSheet('4') + assert.equal(s.cell('A2'), 4) + assert.equal(s.formula('A2'), null) + }) + + test('las fórmulas compartidas sobreviven a la reescritura como fórmulas normales', () => { + const s = readSheet( + 'B1*22' + + '4', + ) + const wb = new Workbook() + wb.addSheet('S').setCell('A2', { formula: s.formula('A2'), value: s.cell('A2') }) + const reread = read(wb.toBuffer()).sheet('S') + assert.equal(reread?.formula('A2'), 'B2*2') + }) +}) diff --git a/test/roundtrip.test.ts b/test/roundtrip.test.ts index 5948f40..1d918d7 100644 --- a/test/roundtrip.test.ts +++ b/test/roundtrip.test.ts @@ -138,6 +138,20 @@ describe('validaciones y edición', () => { assert.throws(() => s.setCell('A1', Number.POSITIVE_INFINITY), TypeError) }) + test('una fecha anterior a 1899-12-30 se rechaza al escribir en vez de salir como #####', () => { + const wb = new Workbook() + const s = wb.addSheet('X') + s.setCell('B2', new Date(1899, 11, 29)) + assert.throws(() => wb.toBuffer(), /La celda B2 contiene una fecha anterior a 1899-12-30/) + s.setCell('B2', { formula: 'DATE(1850,1,1)', value: new Date(1850, 0, 1) }) + assert.throws(() => wb.toBuffer(), RangeError) + // 1899-12-30 es el serial 0: el primer día que Excel puede mostrar. + s.setCell('B2', new Date(1899, 11, 30)) + const back = read(wb.toBuffer()).sheet('X')?.cell('B2') + assert.ok(back instanceof Date) + assert.equal(back.getTime(), new Date(1899, 11, 30).getTime()) + }) + test('libro sin hojas no se puede serializar', () => { assert.throws(() => new Workbook().toBuffer(), /al menos una hoja/) }) diff --git a/test/sheet-name.test.ts b/test/sheet-name.test.ts new file mode 100644 index 0000000..ba0a287 --- /dev/null +++ b/test/sheet-name.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { isSheetNameError, Workbook } from '#minixlsx/index' +import { validateSheetName } from '#minixlsx/sheet-name' + +describe('isSheetNameError', () => { + test('reconoce los errores producidos por la validación, con su regla', () => { + const cases: Array<[string, string[], string]> = [ + ['', [], 'empty'], + ['x'.repeat(32), [], 'too-long'], + ['a/b', [], 'invalid-chars'], + ['hoja', ['Hoja'], 'duplicate'], + ] + for (const [name, existing, rule] of cases) { + try { + validateSheetName(name, existing) + assert.fail(`debería haber lanzado para "${name}"`) + } catch (err) { + assert.ok(isSheetNameError(err), `no se reconoce el error de "${name}"`) + assert.equal(err.rule, rule) + } + } + }) + + test('un Error ajeno con una propiedad rule no se confunde con un error de nombre de hoja', () => { + const impostor = Object.assign(new RangeError('otra cosa'), { rule: 'empty' }) + assert.equal(isSheetNameError(impostor), false) + assert.equal(isSheetNameError(new Error('x')), false) + assert.equal(isSheetNameError('empty'), false) + }) + + test('la marca no aparece al enumerar ni al serializar el error', () => { + const wb = new Workbook() + wb.addSheet('A') + try { + wb.addSheet('a') + assert.fail('debería haber lanzado') + } catch (err) { + assert.ok(isSheetNameError(err)) + assert.deepEqual(Object.keys(err), ['rule']) + assert.equal(JSON.stringify(err), '{"rule":"duplicate"}') + } + }) +}) diff --git a/test/sheet.test.ts b/test/sheet.test.ts index 59ab244..4f821ff 100644 --- a/test/sheet.test.ts +++ b/test/sheet.test.ts @@ -22,14 +22,41 @@ describe('dimensiones de la hoja', () => { assert.equal(s.colCount, 3) // la columna máxima no retrocede }) - test('borrar una celda no reduce las dimensiones', () => { + test('borrar la celda más lejana reduce las dimensiones al rango realmente ocupado', () => { const s = newSheet() + s.setCell('A1', 'a') s.setCell('C3', 'x') s.setCell('C3', null) assert.equal(s.cell('C3'), null) + assert.equal(s.rowCount, 1) + assert.equal(s.colCount, 1) + s.setCell('A1', null) + assert.equal(s.rowCount, 0) + assert.equal(s.colCount, 0) + }) + + test('borrar una celda interior no cambia las dimensiones', () => { + const s = newSheet() + s.setCell('B2', 'x') + s.setCell('C3', 'y') + s.setCell('B2', null) assert.equal(s.rowCount, 3) assert.equal(s.colCount, 3) }) + + test('las filas reservadas por addRow se conservan aunque se borren sus celdas', () => { + const s = newSheet() + s.addRow([null, null]) // fila 1 vacía, reservada + s.addRow(['a']) // fila 2 + s.setCellAt(5, 4, 'lejos') + s.setCellAt(5, 4, null) + assert.equal(s.rowCount, 2) // vuelve a la última fila añadida, no a 5 + assert.equal(s.colCount, 1) + s.setCell('A2', null) + assert.equal(s.rowCount, 2) // addRow reservó la fila 2 aunque ahora esté vacía + s.addRow(['b']) + assert.equal(s.cellAt(3, 1), 'b') + }) }) describe('addRow y addRows', () => { @@ -209,13 +236,14 @@ describe('toObjects', () => { // Los dos casos siguientes fijan limitaciones conocidas de 0.2.x: documentan lo que // hoy ocurre para que un cambio de política sea visible en el diff, no un descuido. - test('LIMITACIÓN: con cabeceras duplicadas gana la última columna', () => { + test('las cabeceras duplicadas reciben sufijo en vez de pisarse', () => { const s = newSheet() s.addRows([ - ['a', 'a'], - [1, 2], + ['a', 'a', 'a', 'a_2'], + [1, 2, 3, 4], ]) - assert.deepEqual(s.toObjects(), [{ a: 2 }]) + // La cuarta cabecera ya se llama "a_2", así que el sufijo salta hasta el primer nombre libre. + assert.deepEqual(s.toObjects(), [{ a: 1, a_2: 2, a_3: 3, a_2_2: 4 }]) }) test('una cabecera __proto__ se conserva como clave propia sin alterar el prototipo', () => { @@ -249,11 +277,13 @@ describe('búsqueda de hojas en el libro', () => { assert.equal(wb.sheet(-1), null) }) - test('la búsqueda por nombre distingue mayúsculas aunque la validación no', () => { + test('la búsqueda por nombre no distingue mayúsculas, igual que Excel y que la validación', () => { const wb = new Workbook() wb.addSheet('Datos') assert.equal(wb.sheet('Datos')?.name, 'Datos') - assert.equal(wb.sheet('datos'), null) + assert.equal(wb.sheet('datos')?.name, 'Datos') + assert.equal(wb.sheet('DATOS')?.name, 'Datos') + assert.equal(wb.sheet('Dato'), null) }) test('addSheet genera nombres correlativos cuando no se le pasa ninguno', () => { diff --git a/tsconfig.build.json b/tsconfig.build.json index 3917e06..774a3e0 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -5,7 +5,8 @@ "outDir": "dist", "rootDir": ".", "declaration": true, - "rewriteRelativeImportExtensions": true + "rewriteRelativeImportExtensions": true, + "stripInternal": true }, "include": ["src/**/*.ts"] }