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/index.js b/test/index.js index 71a98ec..e8e72ab 100644 --- a/test/index.js +++ b/test/index.js @@ -137,4 +137,34 @@ 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() + }) + 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/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 44a68f5..c59a721 100644 Binary files a/test/snap/readme.snap.xlsx and b/test/snap/readme.snap.xlsx differ 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 ed8ead7..8c6b027 100644 Binary files a/test/snap/styles.snap.xlsx and b/test/snap/styles.snap.xlsx differ 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) diff --git a/xlsx.js b/xlsx.js index 498e766..d6cdf78 100644 --- a/xlsx.js +++ b/xlsx.js @@ -28,7 +28,33 @@ , 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 + , 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), {}) + , 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]) + '"' : '', ' '), @@ -41,7 +67,7 @@ ] , font = [ { sz: 11, name: 'Calibri' }, - { sz: 11, name: 'Calibri', b: true }, + { b: true, sz: 11, name: 'Calibri' }, ] , fill = [ { pattern: 'none' }, @@ -51,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] @@ -83,12 +117,12 @@ 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 - , 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' }) @@ -99,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 }]}) + '' : '') + - (firstRow ? '' : '') + (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)}) : '') + @@ -154,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 }) + + '' + '' }, { @@ -178,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