/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import type { CellValue } from '@ifc-lite/lists'; import { displayCell, type ExportModel, type ExportColumn } from './model'; const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const NUM_FMT = '#,##0.####'; /** px → Excel column-width units (≈ 7px per char). */ const excelWidth = (px: number): number => Math.max(8, Math.min(80, Math.round(px / 7))); /** Excel keeps real numbers (so the recipient can re-aggregate); other types * fall back to the same display string the table shows. */ function cellValue(v: CellValue, c: ExportColumn): string | number | null { if (v === null || v === undefined) return null; if (c.numeric && typeof v === 'number' && Number.isFinite(v)) return v; return displayCell(v); } export async function toXlsx(model: ExportModel): Promise { const ExcelJS = (await import('exceljs')).default; const wb = new ExcelJS.Workbook(); wb.creator = 'IFC-Lite'; const ws = wb.addWorksheet((model.title || 'List').slice(0, 31), { views: [{ state: 'frozen', ySplit: 4 }], }); const cols = model.columns; const ncol = cols.length; // Title + meta. ws.addRow([model.title || 'List']); ws.mergeCells(1, 1, 1, ncol); ws.getCell(1, 1).font = { bold: true, size: 14 }; ws.addRow([`${model.totals.count.toLocaleString()} elements · ${model.generatedAt}`]); ws.mergeCells(2, 1, 2, ncol); ws.getCell(2, 1).font = { italic: true, size: 9, color: { argb: 'FF94A3B8' } }; ws.addRow([]); // Header. const header = ws.addRow(cols.map((c) => c.label)); header.font = { bold: true, color: { argb: 'FFFFFFFF' } }; header.eachCell((cell) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF334155' } }; cell.alignment = { vertical: 'middle' }; }); // Column widths + numeric formatting/alignment. cols.forEach((c, i) => { const col = ws.getColumn(i + 1); col.width = excelWidth(c.width); if (c.numeric) { col.numFmt = NUM_FMT; col.alignment = { horizontal: 'right' }; } }); const addDataRow = (values: CellValue[], outline?: number) => { const r = ws.addRow(cols.map((c, i) => cellValue(values[i], c))); if (outline) r.outlineLevel = outline; }; if (model.groups) { for (const g of model.groups) { const gr = ws.addRow(cols.map((c, i) => (i === 0 ? `${g.label} (${g.count})` : (c.summed ? g.sums[c.id] : null)))); gr.font = { bold: true }; gr.eachCell((cell) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } }; }); for (const row of g.rows) addDataRow(row, 1); } ws.properties.outlineLevelRow = 1; } else { for (const row of model.rows) addDataRow(row); } // Grand total. if (model.sumColumnIds.length > 0) { const tr = ws.addRow(cols.map((c, i) => (c.summed ? model.totals.sums[c.id] : (i === 0 ? `Total (${model.totals.count})` : null)))); tr.font = { bold: true }; tr.eachCell((cell) => { cell.border = { top: { style: 'double', color: { argb: 'FF334155' } } }; }); } const buf = await wb.xlsx.writeBuffer(); return new Blob([buf], { type: XLSX_MIME }); }