From 38435ad0115ad4846b065818208c52704c12ed56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 18:27:27 +0000 Subject: [PATCH 01/11] fix(reader): read parts whose elements carry a namespace prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open XML SDK and other .NET producers write with every element prefixed. The linear scanner looks for bare tag names, so those files silently read as empty sheets. Normalize each part once on load by stripping the prefix from element names only; attributes such as r:id are untouched. Co-Authored-By: Claude Fable 5.1 --- src/reader.ts | 14 +++++++++++++- test/reader.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/reader.ts b/src/reader.ts index 8300ae9..608460a 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -56,6 +56,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) @@ -201,7 +213,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/test/reader.test.ts b/test/reader.test.ts index 3c28437..2796ec2 100644 --- a/test/reader.test.ts +++ b/test/reader.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' import { read } from '#minixlsx/index' +import { stripElementPrefixes } from '#minixlsx/reader' import { zipSync } from '#minixlsx/zip' import type { CellValue } from '#minixlsx/index' @@ -199,3 +200,38 @@ 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', + ) + }) +}) From d0e4276014edf7ad0cd159442ec297c0d1af3e7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 18:28:10 +0000 Subject: [PATCH 02/11] fix(reader): reject corrupt row, cell and shared-string references A produced "fila NaN" from setCellAt with no hint of the sheet; a inside silently took the row's number; and a shared-string index past the table (or not a number) silently became null. All three are corrupt files and now fail with a message naming the sheet, the row and the reference. Co-Authored-By: Claude Fable 5.1 --- src/reader.ts | 34 ++++++++++++++++++++++++++-------- test/reader.test.ts | 44 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/reader.ts b/src/reader.ts index 608460a..b18b004 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' 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' @@ -147,21 +147,33 @@ function parseSheetXml(xml: string, sheet: Sheet, sst: string[], dateStyles: Set 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) @@ -172,7 +184,13 @@ function parseSheetXml(xml: string, sheet: Sheet, sst: string[], dateStyles: Set 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') { diff --git a/test/reader.test.ts b/test/reader.test.ts index 2796ec2..e50e392 100644 --- a/test/reader.test.ts +++ b/test/reader.test.ts @@ -25,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', () => { @@ -235,3 +237,39 @@ describe('elementos con prefijo de namespace', () => { ) }) }) + +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], + ]) + }) +}) From 7e493f03772be3d4c1f43d237a528c26e022f0c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 18:29:30 +0000 Subject: [PATCH 03/11] fix(reader): reconstruct shared formulas in dependent cells Excel writes a dragged formula once, on the master cell, and marks the dependents with an empty . Those cells read back with formula() === null. Keep the master per si and derive each dependent by shifting relative references (new src/formula.ts): text literals and quoted sheet names are left alone, absolute parts are kept, whole-column ranges shift, whole-row ranges are left as-is, and a reference pushed off the grid becomes #REF! like Excel. Co-Authored-By: Claude Fable 5.1 --- src/formula.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++ src/reader.ts | 16 ++++++++-- test/formula.test.ts | 40 +++++++++++++++++++++++++ test/reader.test.ts | 57 +++++++++++++++++++++++++++++++++++- 4 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/formula.ts create mode 100644 test/formula.test.ts 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/reader.ts b/src/reader.ts index b18b004..001ff39 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' +import { shiftFormula } from '#minixlsx/formula' import { isSheetNameError } from '#minixlsx/sheet-name' import { colToName, MAX_ROWS, nameToCol, serialToDate } from '#minixlsx/utils' import { Workbook } from '#minixlsx/workbook' @@ -144,6 +145,8 @@ 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') @@ -180,7 +183,7 @@ function parseSheetXml(xml: string, sheet: Sheet, sst: string[], dateStyles: Set // 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') { @@ -204,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) } 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 e50e392..88ada8f 100644 --- a/test/reader.test.ts +++ b/test/reader.test.ts @@ -1,7 +1,7 @@ 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' @@ -273,3 +273,58 @@ describe('referencias de fila y celda corruptas', () => { ]) }) }) + +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') + }) +}) From 52aaf9cc67446841f4f6d396e393abd4a1a496ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 18:30:03 +0000 Subject: [PATCH 04/11] fix(workbook): reject dates before 1899-12-30 when writing A Date before Excel's epoch serialized to a negative serial, which Excel renders as ##### with no error. Fail toBuffer() with a RangeError naming the cell instead, for plain and cached-formula dates alike. Serial 0 (1899-12-30) stays valid. Co-Authored-By: Claude Fable 5.1 --- src/workbook.ts | 17 +++++++++++++++-- test/roundtrip.test.ts | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/workbook.ts b/src/workbook.ts index 0daa50e..307a561 100644 --- a/src/workbook.ts +++ b/src/workbook.ts @@ -54,6 +54,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 +114,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 +123,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/roundtrip.test.ts b/test/roundtrip.test.ts index ab927ec..3536c93 100644 --- a/test/roundtrip.test.ts +++ b/test/roundtrip.test.ts @@ -129,6 +129,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/) }) From 234687d2f9e7299cac4986dda3f84186d1fb80c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 18:30:24 +0000 Subject: [PATCH 05/11] fix(workbook): look sheets up by name case-insensitively addSheet() rejects 'datos' when 'Datos' exists, yet sheet('datos') returned null. Excel itself treats sheet names case-insensitively, so make the lookup match the uniqueness rule. Co-Authored-By: Claude Fable 5.1 --- src/workbook.ts | 8 ++++++-- test/sheet.test.ts | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/workbook.ts b/src/workbook.ts index 307a561..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. */ diff --git a/test/sheet.test.ts b/test/sheet.test.ts index be66050..fe4cac1 100644 --- a/test/sheet.test.ts +++ b/test/sheet.test.ts @@ -220,11 +220,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', () => { From a4d4fce1fc07f9d416f277aa806f427576e6bae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 18:30:59 +0000 Subject: [PATCH 06/11] fix(sheet): shrink rowCount/colCount when the outermost cell is deleted _maxRow and _maxCol only ever grew, so clearing a far cell left the sheet reporting (and toRows() materializing) a range that was no longer occupied. Recompute the bounds when the deleted cell sat on the current edge. Rows appended with addRow() keep their reservation even when they end up empty, so the next addRow() still lands below them. Co-Authored-By: Claude Fable 5.1 --- src/sheet.ts | 21 ++++++++++++++++++++- test/sheet.test.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/sheet.ts b/src/sheet.ts index 1498811..93f751b 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -33,6 +33,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 @@ -63,7 +65,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) @@ -72,6 +75,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 @@ -79,6 +97,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 } diff --git a/test/sheet.test.ts b/test/sheet.test.ts index fe4cac1..50beccb 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', () => { From a861d8bffb5609434e0b31f1d37951f95bc003f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 18:31:21 +0000 Subject: [PATCH 07/11] fix(sheet): suffix duplicate headers in toObjects() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two columns with the same header collapsed into one key and the first column's values were silently lost. Repeated headers now get _2, _3, … appended until unique, skipping names already taken by other columns. Co-Authored-By: Claude Fable 5.1 --- src/sheet.ts | 9 ++++++++- test/sheet.test.ts | 9 +++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/sheet.ts b/src/sheet.ts index 93f751b..e77de37 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -160,6 +160,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, @@ -167,9 +169,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/test/sheet.test.ts b/test/sheet.test.ts index 50beccb..1a73eda 100644 --- a/test/sheet.test.ts +++ b/test/sheet.test.ts @@ -207,13 +207,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', () => { From a980e8ef06bb0cfebdf1cdd2fce98f14daddadd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 18:31:55 +0000 Subject: [PATCH 08/11] fix(sheet-name): brand sheet-name errors instead of duck-typing rule isSheetNameError() accepted any Error carrying a rule property. Mark the errors created by validateSheetName with a non-enumerable symbol and check for it, and export isSheetNameError, SheetNameError and SheetNameRule from the package entry point. Co-Authored-By: Claude Fable 5.1 --- src/index.ts | 1 + src/sheet-name.ts | 8 +++++++- test/sheet-name.test.ts | 45 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 test/sheet-name.test.ts 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/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/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"}') + } + }) +}) From 0ca1ab91aff298d5694cdfa442c4ce7b744121a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 18:32:18 +0000 Subject: [PATCH 09/11] build: strip @internal members from published declarations _cells, _maxRow and friends are implementation details shared between Sheet and the writer; they no longer leak into dist/*.d.ts. Co-Authored-By: Claude Fable 5.1 --- tsconfig.build.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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"] } From 5216ede36a178fa9bb3f95f5513f8e4e8eeb1245 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 18:33:33 +0000 Subject: [PATCH 10/11] docs: record the fixed bugs and document the new behaviour Mark the resolved items in docs/bugs.md, leave the t="e" error-cell change as a pending design decision, and describe in the README the case-insensitive sheet lookup, shrinking rowCount/colCount, suffixed duplicate headers, pre-1900 date validation, shared-formula reconstruction, namespace-prefixed parts and the new reader checks. Co-Authored-By: Claude Fable 5.1 --- README.md | 10 ++++++- docs/bugs.md | 82 ++++++++++++++++++++++++++++++---------------------- 2 files changed, 56 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index a89b3c9..420978e 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 @@ -182,6 +186,8 @@ interface ReadOptions { When reading files, cells formatted as Excel dates (built-in or custom date formats) are automatically converted to JavaScript `Date` objects. +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 @@ -215,6 +221,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: @@ -222,6 +229,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 @@ -235,7 +243,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 7ce5085..4dedd9c 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. From 7b155346c9a54a3e58acfd137c14075d963f5bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 04:56:20 +0000 Subject: [PATCH 11/11] chore: bump version to 0.4.0 Co-Authored-By: Claude Fable 5.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",