← back to Rentv Adintel
src/export/xlsx.js
242 lines
'use strict';
/**
* Pure-Node minimal XLSX writer — Open XML SpreadsheetML.
*
* An .xlsx file is a ZIP (PKWARE) containing Open XML parts:
* [Content_Types].xml
* _rels/.rels
* xl/workbook.xml
* xl/_rels/workbook.xml.rels
* xl/worksheets/sheet1.xml
* xl/sharedStrings.xml
*
* This implementation:
* - Produces one sheet per call.
* - Stores strings in the shared string table (type="s") for small file size.
* - Stores numbers as inline numbers (type="n").
* - Does NOT support formulas, styles, or multi-sheet workbooks (not needed here).
* - Reuses zip.js (also pure Node, STORE method).
*
* Usage:
* const buf = toXlsxBuffer(rows, { columns: ['name','score'] });
* fs.writeFileSync('out.xlsx', buf);
*
* @module src/export/xlsx
*/
const { ZipWriter } = require('./zip');
/**
* Escape XML special characters.
* @param {string} str
* @returns {string}
*/
function xmlEscape(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
// Strip control characters (not valid XML 1.0 except tab/CR/LF)
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
}
/**
* Convert a 1-based column index to Excel letter notation (1=A, 26=Z, 27=AA).
* @param {number} n
* @returns {string}
*/
function colLetter(n) {
let s = '';
while (n > 0) {
const rem = (n - 1) % 26;
s = String.fromCharCode(65 + rem) + s;
n = Math.floor((n - 1) / 26);
}
return s;
}
/**
* Build the shared strings table XML and index map.
* Returns { xml, index } where index is a Map<string, number>.
*
* @param {Object[]} rows
* @param {string[]} columns
* @returns {{ xml: string, index: Map<string,number>, count: number }}
*/
function buildSharedStrings(rows, columns) {
const index = new Map();
const strings = [];
function intern(val) {
if (val === null || val === undefined) return;
const s = String(val);
if (typeof val === 'number' && isFinite(val)) return; // kept inline
if (!index.has(s)) {
index.set(s, strings.length);
strings.push(s);
}
}
// Intern column headers
for (const col of columns) intern(col);
// Intern all string cell values
for (const row of rows) {
for (const col of columns) {
const v = row[col];
if (typeof v !== 'number' || !isFinite(v)) {
intern(v === null || v === undefined ? '' : String(v));
}
}
}
const total = strings.length;
const items = strings.map((s) => `<si><t xml:space="preserve">${xmlEscape(s)}</t></si>`).join('');
const xml =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="${total}" uniqueCount="${total}">` +
items +
`</sst>`;
return { xml, index, count: total };
}
/**
* Build sheet1.xml — the worksheet XML.
*
* @param {Object[]} rows
* @param {string[]} columns
* @param {Map<string,number>} ssIndex - shared string index
* @returns {string}
*/
function buildSheetXml(rows, columns, ssIndex) {
const totalRows = rows.length + 1; // +1 for header
/**
* Build a cell reference like "A1".
* @param {number} col 1-based column index
* @param {number} row 1-based row index
*/
const ref = (col, row) => `${colLetter(col)}${row}`;
/** Build a cell element. */
function cellEl(colIdx, rowIdx, value) {
const r = ref(colIdx, rowIdx);
if (value === null || value === undefined || value === '') {
// Empty string shared string
const si = ssIndex.get('');
if (si !== undefined) {
return `<c r="${r}" t="s"><v>${si}</v></c>`;
}
return `<c r="${r}"/>`;
}
if (typeof value === 'number' && isFinite(value)) {
return `<c r="${r}" t="n"><v>${value}</v></c>`;
}
const s = String(value);
const si = ssIndex.get(s);
if (si !== undefined) {
return `<c r="${r}" t="s"><v>${si}</v></c>`;
}
// Fallback: inline string (shouldn't happen if buildSharedStrings ran first)
return `<c r="${r}" t="inlineStr"><is><t>${xmlEscape(s)}</t></is></c>`;
}
// Build row XML strings
const rowEls = [];
// Header row (row 1)
const headerCells = columns.map((col, i) => cellEl(i + 1, 1, col)).join('');
rowEls.push(`<row r="1">${headerCells}</row>`);
// Data rows
for (let ri = 0; ri < rows.length; ri++) {
const rowIdx = ri + 2;
const cells = columns.map((col, ci) => cellEl(ci + 1, rowIdx, rows[ri][col])).join('');
rowEls.push(`<row r="${rowIdx}">${cells}</row>`);
}
// Dimension ref e.g. "A1:E5"
const dimRef =
totalRows > 0 && columns.length > 0
? `A1:${colLetter(columns.length)}${totalRows}`
: 'A1';
return (
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"` +
` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
`<dimension ref="${dimRef}"/>` +
`<sheetData>` +
rowEls.join('') +
`</sheetData>` +
`</worksheet>`
);
}
/** Static Open XML relationship + content-type parts. */
const CONTENT_TYPES_XML =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
`<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +
`<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>` +
`</Types>`;
const RELS_XML =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>` +
`</Relationships>`;
const WORKBOOK_XML =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"` +
` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
`<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>` +
`</workbook>`;
const WORKBOOK_RELS_XML =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>` +
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>` +
`</Relationships>`;
/**
* Convert an array of row objects to an XLSX Buffer.
*
* @param {Object[]} rows
* @param {Object} [opts]
* @param {string[]} [opts.columns] - column order; defaults to Object.keys(rows[0])
* @returns {Buffer}
*/
function toXlsxBuffer(rows, opts = {}) {
const safeRows = rows || [];
const columns =
opts.columns ||
(safeRows.length > 0 ? Object.keys(safeRows[0]) : []);
const { xml: ssXml, index: ssIndex } = buildSharedStrings(safeRows, columns);
const sheetXml = buildSheetXml(safeRows, columns, ssIndex);
const zip = new ZipWriter();
zip.addEntry('[Content_Types].xml', CONTENT_TYPES_XML);
zip.addEntry('_rels/.rels', RELS_XML);
zip.addEntry('xl/workbook.xml', WORKBOOK_XML);
zip.addEntry('xl/_rels/workbook.xml.rels', WORKBOOK_RELS_XML);
zip.addEntry('xl/worksheets/sheet1.xml', sheetXml);
zip.addEntry('xl/sharedStrings.xml', ssXml);
return zip.finalize();
}
module.exports = { toXlsxBuffer, colLetter, xmlEscape };