From 878ddcff393fa7bacdb66b76b8f76923951c1c94 Mon Sep 17 00:00:00 2001 From: Roland Boon Date: Tue, 16 Jun 2026 09:28:56 +0000 Subject: [PATCH 1/5] Fix dimension ref to span widest used row The dimension element must cover the full used range (CT_SheetDimension). Derive the ref from the maximum column and row index across all rows, including sparse rows and rows wider than the first row. --- test/index.js | 15 +++++++++++++++ xlsx.js | 19 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/test/index.js b/test/index.js index 71a98ec..d795164 100644 --- a/test/index.js +++ b/test/index.js @@ -137,4 +137,19 @@ describe("xlsx", function() { assert.ok(sheet.indexOf('ref="A1:B2"') > -1, 'dimension uses column count from row data') assert.end() }) + test("dimension spans the widest row", function(assert) { + var sheet = createFiles({ + sheets: [{ + data: [ + ['Report', 'Q1'], + ['Year', 2026, 'Status', 'Draft'], + [], + ['A','B','C','D','E','F','G','H','I','J','K','L','M','N'], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + ] + }] + }).find(function(f) { return f.name === 'xl/worksheets/sheet1.xml' }).content + assert.ok(sheet.indexOf('ref="A1:N5"') > -1, 'dimension covers all used columns') + assert.end() + }) }) diff --git a/xlsx.js b/xlsx.js index 498e766..39ca5f5 100644 --- a/xlsx.js +++ b/xlsx.js @@ -28,6 +28,21 @@ , isTruthy = s => s , mapEntries = (obj, fn, separator) => !obj ? '' : isStr(obj) ? obj : Object.entries(obj).map(fn).filter(isTruthy).join(separator) , toCol = num => (num > 25 ? toCol((0 | num / 26) - 1) : '') + String.fromCharCode(65 + num % 26) + , sheetBounds = data => { + var maxCol = 0 + , maxRow = 0 + , rowIndex = 0 + data.forEach(row => { + ++rowIndex + maxRow = rowIndex + row = dataArr(row) + if (!row || !row.data) return + row.data.forEach((val, col) => { + if (val != null) maxCol = Math.max(maxCol, col + 1) + }) + }) + return maxCol > 0 && maxRow > 0 ? 'A1:' + toCol(maxCol - 1) + maxRow : '' + } , toVal = (b, key) => Object.entries(b).reduce((accum, arr) => (accum[arr[0]] = [arr[1] === true ? {} : { [key]: arr[1] }], accum), {}) , esc = val => ('' + val).replace(/&/g, '&').replace(/ ( @@ -83,7 +98,7 @@ i++ sheet = dataArr(sheet) var cols = sheet.cols - , firstRow = dataArr(sheet.data.find(isTruthy)) + , dimension = sheetBounds(sheet.data) , rowIndex = 0 , freeze = sheet.freeze , freezeRows = freeze && freeze.rows @@ -109,7 +124,7 @@ state: 'frozen' }], selection: [{ pane: freezePane }]}) + '' : '') + - (firstRow ? '' : '') + + (dimension ? '' : '') + (cols ? toXml('cols', 0, { col: (isStr(cols) ? cols.split(',') : cols).map( (w, col) => w ? assign({ min: col + 1, max: col + 1 }, isStr(w) ? { width: w, customWidth: 1 } : w) : 0 ).filter(isTruthy)}) : '') + From 9fa8a24b9fa6b696fd829a4d3f4269a7cb43ec1c Mon Sep 17 00:00:00 2001 From: Roland Boon Date: Tue, 16 Jun 2026 09:29:41 +0000 Subject: [PATCH 2/5] Fix styles.xml for Open XML schema Emit a complete styleSheet part per SpreadsheetML: cellXfs with numFmtId, fillId, borderId and xfId; required cellStyleXfs and cellStyles sections; CT_Font children in schema order with boolean val attributes; and 8-digit ARGB rgb values (ST_UnsignedIntHex) for solid fills. --- test/index.js | 15 +++++++++++++++ xlsx.js | 45 +++++++++++++++++++++++++++++++++------------ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/test/index.js b/test/index.js index d795164..e8e72ab 100644 --- a/test/index.js +++ b/test/index.js @@ -152,4 +152,19 @@ describe("xlsx", function() { assert.ok(sheet.indexOf('ref="A1:N5"') > -1, 'dimension covers all used columns') assert.end() }) + test("styles.xml is Excel-compatible", function(assert) { + var styles = createFiles({ + styles: { + Bold: { font: { b: true } }, + Header: { font: { b: true }, fill: 'E8E8E8' }, + }, + sheets: [{ data: [[{ style: 'Bold', value: 'Title' }]] }] + }).find(function(f) { return f.name === 'xl/styles.xml' }).content + assert.ok(styles.indexOf(' -1, 'cellStyleXfs present') + assert.ok(styles.indexOf(' -1, 'cellStyles present') + assert.ok(styles.indexOf('') > -1, 'bold fonts use val attribute') + assert.ok(styles.indexOf('') === -1, 'fonts include defaults') + assert.ok(styles.indexOf('rgb="FFE8E8E8"') > -1, 'fill colors use ARGB') + assert.end() + }) }) diff --git a/xlsx.js b/xlsx.js index 39ca5f5..057668b 100644 --- a/xlsx.js +++ b/xlsx.js @@ -28,6 +28,8 @@ , isTruthy = s => s , mapEntries = (obj, fn, separator) => !obj ? '' : isStr(obj) ? obj : Object.entries(obj).map(fn).filter(isTruthy).join(separator) , toCol = num => (num > 25 ? toCol((0 | num / 26) - 1) : '') + String.fromCharCode(65 + num % 26) + , defaultFont = { sz: 11, name: 'Calibri' } + , normalizeRgb = rgb => isStr(rgb) && rgb.length === 6 ? 'FF' + rgb.toUpperCase() : rgb , sheetBounds = data => { var maxCol = 0 , maxRow = 0 @@ -44,6 +46,15 @@ return maxCol > 0 && maxRow > 0 ? 'A1:' + toCol(maxCol - 1) + maxRow : '' } , toVal = (b, key) => Object.entries(b).reduce((accum, arr) => (accum[arr[0]] = [arr[1] === true ? {} : { [key]: arr[1] }], accum), {}) + , fontPropOrder = ['b', 'i', 'strike', 'outline', 'shadow', 'condense', 'extend', 'u', 'vertAlign', 'sz', 'color', 'name', 'family', 'charset', 'scheme'] + , boolFontProps = { b: 1, i: 1, strike: 1, outline: 1, shadow: 1, condense: 1, extend: 1 } + , toFontXml = f => '' + fontPropOrder.map(key => { + var val = f[key] + if (val == null || val === false) return '' + if (boolFontProps[key]) return val ? '<' + key + ' val="1"/>' : '' + if (key === 'color') return isStr(val) ? '' : toXml('color', val) + return toXml(key, { val: val }) + }).join('') + '' , esc = val => ('' + val).replace(/&/g, '&').replace(/ ( attrs = mapEntries(attrs, a => a[1] != null ? a[0] + '="' + esc(a[1]) + '"' : '', ' '), @@ -56,7 +67,7 @@ ] , font = [ { sz: 11, name: 'Calibri' }, - { sz: 11, name: 'Calibri', b: true }, + { b: true, sz: 11, name: 'Calibri' }, ] , fill = [ { pattern: 'none' }, @@ -66,22 +77,30 @@ {} ] , xf = [ - { fontId: 0, applyFont: 1 }, - { numFmtId: 164, applyNumberFormat: 1 }, - { numFmtId: 165, applyNumberFormat: 1 }, - { numFmtId: 0, fontId: 1, applyFont: 1 }, + { numFmtId: 0, fontId: 0, fillId: 0, borderId: 0, xfId: 0, applyFont: 1 }, + { numFmtId: 164, fontId: 0, fillId: 0, borderId: 0, xfId: 0, applyNumberFormat: 1 }, + { numFmtId: 165, fontId: 0, fillId: 0, borderId: 0, xfId: 0, applyNumberFormat: 1 }, + { numFmtId: 0, fontId: 1, fillId: 0, borderId: 0, xfId: 0, applyFont: 1 }, ] , styles = Object.entries(workbook.styles||{}).reduce((accum, a) => { var newBorder = a[1].border , newFill = a[1].fill + , customFont = a[1].font ? assign({}, defaultFont, a[1].font) : UNDEF if (isStr(newBorder)) newBorder = { left: newBorder, right: newBorder, top: newBorder, bottom: newBorder } if (isStr(newFill)) newFill = { fgColor: newFill } - if (newFill) newFill.pattern = newFill.pattern || 'solid' + if (newFill) { + newFill.pattern = newFill.pattern || 'solid' + if (newFill.fgColor) newFill.fgColor = normalizeRgb(newFill.fgColor) + if (newFill.bgColor) newFill.bgColor = normalizeRgb(newFill.bgColor) + } a[1] = xf.push({ - fontId: a[1].font ? font.push(a[1].font) - 1 : 0, - borderId: newBorder ? border.push(newBorder) - 1 : UNDEF, + numFmtId: 0, + fontId: customFont ? font.push(customFont) - 1 : 0, + borderId: newBorder ? border.push(newBorder) - 1 : 0, + fillId: newFill ? fill.push(newFill) - 1 : 0, + xfId: 0, + applyFont: customFont ? 1 : UNDEF, applyBorder: newBorder ? 1 : UNDEF, - fillId: newFill ? fill.push(newFill) - 1 : UNDEF, applyFill: newFill ? 1 : UNDEF, }) - 1 accum[a[0]] = a[1] @@ -169,15 +188,17 @@ name: 'xl/styles.xml', content: xmlHead + '' + toXml('numFmts', { count: numFmt.length }, { numFmt }) + - toXml('fonts', { count: font.length }, { font }, 'val') + + toXml('fonts', { count: font.length }, font.map(toFontXml).join('')) + toXml('fills', { count: fill.length }, fill.map( f => '' + toXml('patternFill', { patternType: f.pattern }, { - fgColor: f.fgColor ? [{ rgb: f.fgColor }] : UNDEF, - bgColor: f.bgColor ? [{ rgb: f.bgColor }] : UNDEF, + fgColor: f.fgColor ? [{ rgb: normalizeRgb(f.fgColor) }] : UNDEF, + bgColor: f.bgColor ? [{ rgb: normalizeRgb(f.bgColor) }] : UNDEF, }) + '' ).join('')) + toXml('borders', { count: border.length }, { border }, 'style') + + '' + toXml('cellXfs', { count: xf.length }, { xf }) + + '' + '' }, { From 189a2f49cb516b39a3145b72aa3353a87cb85c62 Mon Sep 17 00:00:00 2001 From: Roland Boon Date: Tue, 16 Jun 2026 09:30:06 +0000 Subject: [PATCH 3/5] Fix worksheet freeze pane layout Place dimension before sheetViews per CT_Worksheet child sequence. Treat freeze rows and cols of zero explicitly so topLeftCell stays a valid ST_CellRef and xSplit/ySplit are omitted when no split is requested. Detect CommonJS module.exports for the IIFE export target. --- xlsx.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/xlsx.js b/xlsx.js index 057668b..d6cdf78 100644 --- a/xlsx.js +++ b/xlsx.js @@ -120,9 +120,9 @@ , dimension = sheetBounds(sheet.data) , rowIndex = 0 , freeze = sheet.freeze - , freezeRows = freeze && freeze.rows - , freezeCols = freeze && freeze.cols - , freezePane = freeze && (freezeRows ? 'bottom' : 'top') + (freezeCols ? 'Right' : 'Left') + , freezeRows = freeze && 'rows' in freeze ? freeze.rows : UNDEF + , freezeCols = freeze && 'cols' in freeze ? freeze.cols : UNDEF + , freezePane = freeze && (freezeRows > 0 ? 'bottom' : 'top') + (freezeCols > 0 ? 'Right' : 'Left') , name = 'worksheets/sheet' + i + '.xml' types.push({ PartName: '/xl/' + name, ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml' }) @@ -133,17 +133,17 @@ name: 'xl/' + name, content: xmlHead + '' + + (dimension ? '' : '') + (freeze ? '' + toXml('sheetView', { workbookViewId: 0 }, { pane: [{ - xSplit: freezeCols || UNDEF, - ySplit: freezeRows || UNDEF, - topLeftCell: toCol(freezeCols) + (freezeRows + 1), + xSplit: freezeCols > 0 ? freezeCols : UNDEF, + ySplit: freezeRows > 0 ? freezeRows : UNDEF, + topLeftCell: toCol(freezeCols > 0 ? freezeCols : 0) + ((freezeRows > 0 ? freezeRows : 0) + 1), activePane: freezePane, state: 'frozen' }], selection: [{ pane: freezePane }]}) + '' : '') + - (dimension ? '' : '') + (cols ? toXml('cols', 0, { col: (isStr(cols) ? cols.split(',') : cols).map( (w, col) => w ? assign({ min: col + 1, max: col + 1 }, isStr(w) ? { width: w, customWidth: 1 } : w) : 0 ).filter(isTruthy)}) : '') + @@ -214,5 +214,4 @@ exports.createXlsx = (workbook, opts, next) => createZip(createFiles(workbook), opts, next) // this is `exports` in module and `window` in browser -})(this, Object) // jshint ignore:line - +})(typeof module !== 'undefined' && module.exports ? module.exports : typeof exports !== 'undefined' ? exports : this, Object) // jshint ignore:line From 9adbeb739f657690af4a764200fbbd2e84f2c0cf Mon Sep 17 00:00:00 2001 From: Roland Boon Date: Tue, 16 Jun 2026 09:30:16 +0000 Subject: [PATCH 4/5] Update test snapshots --- test/snap/readme.snap.json | 4 ++-- test/snap/readme.snap.xlsx | Bin 3520 -> 3571 bytes test/snap/styles.snap.json | 4 ++-- test/snap/styles.snap.xlsx | Bin 2317 -> 2375 bytes 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/snap/readme.snap.json b/test/snap/readme.snap.json index 9542d82..74368d2 100644 --- a/test/snap/readme.snap.json +++ b/test/snap/readme.snap.json @@ -13,7 +13,7 @@ }, { "name": "xl/styles.xml", - "content": "" + "content": "" }, { "name": "xl/workbook.xml", @@ -33,6 +33,6 @@ }, { "name": "xl/worksheets/sheet4.xml", - "content": "nulltrue1false0Empty stringEmpty objectObject as valueEmpty arrayDefault Date43102.573495Datetime43102.573495Date43102.573495" + "content": "nulltrue1false0Empty stringEmpty objectObject as valueEmpty arrayDefault Date43102.573495Datetime43102.573495Date43102.573495" } ] \ No newline at end of file diff --git a/test/snap/readme.snap.xlsx b/test/snap/readme.snap.xlsx index 44a68f5b9888a50ffc311625830ef8a60153394a..c59a721ddf837856e03ba23011cacc2dc56f09d8 100644 GIT binary patch delta 1051 zcmV+$1myd`8}l2mAp?KyDiZd20RR9y1ONaH0001ZY%go4ONO)60;7c6+EOyybzL{n zdQz5{+|@6?)1$gxYrXb929n`&PAh9}bBGR3{TsRnU^vVAdxVy0zZIYgr+P+Grf)IG zCC9 z%^Ci1Md@(J=}`jgW9wh58);jODnE;LH&;^u3}(WlO~j6Ai9NzD9>Suw8Vu1i(B(E| zNWQR!VfrI&ch-e;c|TM4smp($j^3R;@NP2Hb=16@)?FFBD2^w?yO;=!fW=usQ07kC zJg4OIN5J0!vw;M{1PU0Cox17)003$Qlc5C}e>5(5ZETg)+m4$s5C-5^v3%bc$a!VF z$~LW3sZuL#57!PE5_f$-_JkyT`U+UNX=@_&1`K$fQacrlu^;Dd6ur1>PH1Z?H@ z${tGk^~?J=EBYw-+4+}n1VC~z+J=%I5t|3bLUjZq!^AaUFGknS8;N1!{DFnWgWUH; ze@3$+%NEQ?WvSqMWx#fM#6w-uP5$tdXOs!vPyVLh9B#&>%icNn(j9!*mo%e-H_`&R zJ~vunNpnijH6NkIx1e=NHw7iKLe;`&X`!S$hv-aqosxhOp`^8UKf(5yprOL9nda}w zOmG(4xE%+}y%hg3)>{KL*8e>=a+ zy4??`FvZSO>>{#@5%yCL5*bJr3%s;d!o3$;9HBwLBe zW>=Ji;R9>MY?^1qWWJov*7Nk+T*tiiSe`^cWs+Iz|H{g<-^(Vula@;A3c|4P?nbej z#meH3Q~Ux@O927^6chje2nYbVlVuCV3hgQq_IUvS06PSeGz?1tx08SjJOaN3lTis3 zlhX_@0!#;!Q3(~384WK2yaCUMcQSTb<(aRAq^5A5zs2^*H0l?x^A7;lfiyozZQnQ%iOWO(4Yt>k%dGJf? zPS|%&@Y)X|t?(+#wpep%fDv=ng$^E7_G|)$)to|Oc&;hr+cluE-NDv^LX0skv|iLT z;Jf_hFCMk+PV1fbgAh;Vj5OBV&M+hx@gL~I6@@%cK3t3Oe&9-$PNp6s`7mEzH7N|{ zQmI>WsT3m}4?$))Q%I%IbUwyTP%zer(K>Pe&?m=ZzS?{fq~IATt*eEbopbm~4d_&4 zq!LQKH#4;cM*0Q~qYqD=0peY!VqwP?I8}eFE)g$hT?$vEt=uO!50V+o_W5snJPphU zzW!A92~bM|1e1;o8ne{|qyz=}3P_6WlYR*#f0Wyf(=ZT*U&Zo0b&{S}j#pg@5)u+f z%i-EhC#_i@B72&)JUvRXG6kGU+$3Xr{_h`6GUksLqe%-sC}&HWPclltR&KBCp`>5G zynnNzkAk0_e+fqbBp0J?DCrTgd0;G5M=&x>Tm$xEbnU#67$(jiSZF-ReJ?Va6;X^~MVKrsS9e}X>&eg4ys zzoUY;BIm44z^4&D9f8+Uha`P^L(kImS)|WK=5|hQQ zC<((4){5CQ&x*->Ii0QN>94tte(NzmiGa!^z1IKbm1n=lCcER7a_R(O7`(ey>}FV5 zyg0=#P)h*<02CAe00;;Gxosj$lbZ{|3MKA4GD86X0NetTXADaLfs?ZgJOPQ5@C-!) z76+5@3>A}14KD(Q2$S&)6_b(;6&(5sNQ&(N003$Q000;O0000000000AOHXWH3^gP Q3>A~u4HX7h3jhEB0Ifi^lmGw# diff --git a/test/snap/styles.snap.json b/test/snap/styles.snap.json index 18376a5..030d069 100644 --- a/test/snap/styles.snap.json +++ b/test/snap/styles.snap.json @@ -13,7 +13,7 @@ }, { "name": "xl/styles.xml", - "content": "" + "content": "" }, { "name": "xl/workbook.xml", @@ -21,6 +21,6 @@ }, { "name": "xl/worksheets/sheet1.xml", - "content": "Apple My1Banana PlainSized Row1Filled2" + "content": "Apple My1Banana PlainSized Row1Filled2" } ] \ No newline at end of file diff --git a/test/snap/styles.snap.xlsx b/test/snap/styles.snap.xlsx index ed8ead78c3ad9622077516fa7bbff5bbcb57eafd..8c6b0274983a3657cd4f12598435cff3e5cc1b39 100644 GIT binary patch delta 1027 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 From 7164db0badfe7de524c91845cd20f0146770af65 Mon Sep 17 00:00:00 2001 From: Roland Boon Date: Tue, 16 Jun 2026 09:30:30 +0000 Subject: [PATCH 5/5] Add validate-snaps script Run Microsoft Open XML SDK validation on snapshot xlsx files via npx (@xarsh/ooxml-validator), without adding a package dependency. Wired into the Coverage CI job. --- .github/workflows/test.yml | 1 + package.json | 3 +- test/validate-snaps.js | 76 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 test/validate-snaps.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e4869ef..878710b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,7 @@ jobs: - run: node --import=@litejs/cli/test.js test/load.mjs - run: npx lj lint - run: npx tsc -p test/tsconfig.json --noEmit + - run: npm run validate-snaps - uses: coverallsapp/github-action@v2 name: Upload to coveralls.io with: diff --git a/package.json b/package.json index 97e7faf..784d4a6 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "repository": "github:litejs/xlsx", "scripts": { "lint": "npx jshint -c .github/jshint.json *.js", - "test": "lj t --tz=UTC test/index.js" + "test": "lj t --tz=UTC test/index.js", + "validate-snaps": "node test/validate-snaps.js" }, "devDependencies": { "@litejs/cli": "26.4.0" diff --git a/test/validate-snaps.js b/test/validate-snaps.js new file mode 100644 index 0000000..dd8e229 --- /dev/null +++ b/test/validate-snaps.js @@ -0,0 +1,76 @@ +'use strict' +var cp = require('child_process') +var fs = require('fs') +var os = require('os') +var path = require('path') + +var root = path.join(__dirname, '..') +var snaps = fs.readdirSync(path.join(__dirname, 'snap')) + .filter(function (f) { return f.endsWith('.snap.xlsx') }) + .map(function (f) { return path.join(__dirname, 'snap', f) }) +var versions = ['Microsoft365', 'Office2007'] + +function platformId() { + var plat = { linux: 'linux', darwin: 'macos', win32: 'windows' }[process.platform] || 'linux' + var arch = { x64: 'x64', arm64: 'arm64', ia32: 'x86' }[process.arch] || process.arch + return 'ooxml-validator-' + plat + '-' + arch +} + +function findBin() { + var caches = [process.env.npm_config_cache, process.env.NPM_CONFIG_CACHE, path.join(os.homedir(), '.npm')] + if (process.env.LOCALAPPDATA) caches.push(path.join(process.env.LOCALAPPDATA, 'npm-cache')) + var id = platformId() + for (var i = 0; i < caches.length; i++) { + if (!caches[i]) continue + var npxDir = path.join(caches[i], '_npx') + if (!fs.existsSync(npxDir)) continue + var hit = walk(npxDir, id) + if (hit) return hit + } + return null +} + +function walk(dir, id) { + var entries + try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (e) { return null } + for (var i = 0; i < entries.length; i++) { + var ent = entries[i] + var p = path.join(dir, ent.name) + if (!ent.isDirectory()) continue + if (ent.name === id) { + var bin = path.join(p, 'ooxml-validator' + (process.platform === 'win32' ? '.exe' : '')) + if (fs.existsSync(bin)) return bin + } + var nested = walk(p, id) + if (nested) return nested + } + return null +} + +function ensureBin() { + var bin = findBin() + if (bin) return bin + cp.execFileSync('npx', ['--yes', '@xarsh/ooxml-validator', snaps[0]], { stdio: 'pipe', cwd: root }) + bin = findBin() + if (!bin) throw new Error('ooxml-validator binary not found after npx install') + return bin +} + +var bin = ensureBin() +var fails = 0 + +snaps.forEach(function (file) { + versions.forEach(function (ver) { + var out = cp.execFileSync(bin, [file, ver], { encoding: 'utf8' }) + var result = JSON.parse(out) + console.log('# validate %s (%s) %s', path.basename(file), ver, result.ok ? 'ok' : 'FAIL') + if (!result.ok) { + fails++ + result.errors.forEach(function (e) { + console.error(' %s: %s', e.path, e.description) + }) + } + }) +}) + +process.exit(fails ? 1 : 0)