Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(sheets): fix xlsx import styles + add borders, merges, column widths (#58)

scott 3099f538 eab70df2

+1266 -3
+1 -1
src/sheets/main.ts
··· 1744 1744 if (!yCell) { yCell = new Y.Map(); cells.set(id, yCell); } 1745 1745 if (data.v !== undefined) yCell.set('v', data.v); 1746 1746 if (data.f !== undefined) yCell.set('f', data.f); 1747 - if (data.s) { for (const [k, v] of Object.entries(data.s)) yCell.set(k, v); } 1747 + if (data.s && Object.keys(data.s).length > 0) yCell.set('s', JSON.stringify(data.s)); 1748 1748 }, 1749 1749 getCells, 1750 1750 renderGrid,
+4
src/sheets/types.ts
··· 354 354 verticalAlign?: 'top' | 'middle' | 'bottom'; 355 355 wrap?: boolean; 356 356 format?: string; 357 + borders?: BorderStyle; 357 358 } 358 359 359 360 export interface XlsxCellData { ··· 367 368 cells: Map<string, XlsxCellData>; 368 369 rowCount: number; 369 370 colCount: number; 371 + merges: string[]; 372 + colWidths: Record<number, number>; 373 + rowHeights: Record<number, number>; 370 374 } 371 375 372 376 export interface ImportXlsxOptions {
+105 -1
src/sheets/xlsx-import.ts
··· 53 53 } 54 54 55 55 /** 56 + * Convert a column letter (e.g. "A", "BC") to a 1-based column number. 57 + */ 58 + function letterToCol(letter: string): number { 59 + let col = 0; 60 + for (let i = 0; i < letter.length; i++) { 61 + col = col * 26 + (letter.charCodeAt(i) - 64); 62 + } 63 + return col; 64 + } 65 + 66 + /** 67 + * Parse a merge range string (e.g. "A1:C3") into { startCol, startRow, endCol, endRow }. 68 + */ 69 + function parseMergeRange(rangeStr: string) { 70 + const match = rangeStr.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/); 71 + if (!match) return null; 72 + return { 73 + startCol: letterToCol(match[1]), 74 + startRow: parseInt(match[2], 10), 75 + endCol: letterToCol(match[3]), 76 + endRow: parseInt(match[4], 10), 77 + }; 78 + } 79 + 80 + /** 56 81 * Extract style properties from an ExcelJS cell into our internal style format. 57 82 */ 58 83 function extractStyle(cell) { ··· 92 117 if (style.alignment.wrapText) s.wrap = true; 93 118 } 94 119 120 + // Borders 121 + if (style.border) { 122 + const borders: Record<string, string> = {}; 123 + for (const side of ['top', 'bottom', 'left', 'right']) { 124 + if (style.border[side]?.style) { 125 + const color = style.border[side].color?.argb 126 + ? '#' + style.border[side].color.argb.slice(-6) 127 + : '#000000'; 128 + const weight = style.border[side].style === 'medium' ? '2px' : '1px'; 129 + borders[side] = weight + ' solid ' + color; 130 + } 131 + } 132 + if (Object.keys(borders).length > 0) s.borders = borders; 133 + } 134 + 95 135 // Number format 96 136 if (style.numFmt && style.numFmt !== 'General') { 97 137 const fmt = mapExcelFormat(style.numFmt); ··· 116 156 cells: Map<string, { v: unknown; f: string; s: Record<string, unknown> }>; 117 157 rowCount: number; 118 158 colCount: number; 159 + merges: string[]; 160 + colWidths: Record<number, number>; 161 + rowHeights: Record<number, number>; 119 162 }> = []; 120 163 121 164 workbook.eachSheet((worksheet) => { ··· 154 197 }); 155 198 }); 156 199 200 + // Extract merge ranges 201 + const merges: string[] = worksheet.model.merges ? [...worksheet.model.merges] : []; 202 + 203 + // Extract column widths (ExcelJS width units are ~7px each) 204 + const colWidths: Record<number, number> = {}; 205 + if (worksheet.columns) { 206 + worksheet.columns.forEach((col, i) => { 207 + if (col.width) { 208 + colWidths[i] = Math.round(col.width * 7); 209 + } 210 + }); 211 + } 212 + 213 + // Extract row heights (only rows with custom heights) 214 + const rowHeights: Record<number, number> = {}; 215 + worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { 216 + if (row.height) { 217 + rowHeights[rowNumber] = row.height; 218 + } 219 + }); 220 + 157 221 sheets.push({ 158 222 name: worksheet.name, 159 223 cells, 160 224 rowCount: maxRow, 161 225 colCount: maxCol, 226 + merges, 227 + colWidths, 228 + rowHeights, 162 229 }); 163 230 }); 164 231 ··· 192 259 if (!excelFormat || excelFormat === 'General') return undefined; 193 260 194 261 // Currency patterns 195 - if (/\$|USD|EUR|GBP|¥|£/.test(excelFormat)) return 'currency'; 262 + if (/\$|USD|EUR|GBP|¥|£|€/.test(excelFormat)) return 'currency'; 196 263 197 264 // Percentage 198 265 if (/%/.test(excelFormat)) return 'percent'; ··· 297 364 if (data.s !== undefined) cell.set('s', JSON.stringify(data.s)); 298 365 } 299 366 totalCells++; 367 + } 368 + 369 + // Store merged cells into the Yjs merges Y.Map 370 + // merges Y.Map is keyed by top-left cell ID, value is JSON { startCol, startRow, endCol, endRow } 371 + if (parsed.merges && parsed.merges.length > 0) { 372 + let mergesMap = sheet.get('merges'); 373 + if (!mergesMap || typeof mergesMap.set !== 'function') { 374 + // If merges doesn't exist or isn't a Y.Map, the caller's ensureSheet 375 + // should have created it. Fall back to storing as JSON string. 376 + sheet.set('merges', JSON.stringify(parsed.merges)); 377 + } else { 378 + for (const rangeStr of parsed.merges) { 379 + const mergeData = parseMergeRange(rangeStr); 380 + if (mergeData) { 381 + const key = rangeStr.split(':')[0]; // top-left cell ID e.g. "A1" 382 + mergesMap.set(key, JSON.stringify(mergeData)); 383 + } 384 + } 385 + } 386 + } 387 + 388 + // Store column widths into the Yjs colWidths Y.Map 389 + if (parsed.colWidths && Object.keys(parsed.colWidths).length > 0) { 390 + let colWidthsMap = sheet.get('colWidths'); 391 + if (!colWidthsMap || typeof colWidthsMap.set !== 'function') { 392 + sheet.set('colWidths', JSON.stringify(parsed.colWidths)); 393 + } else { 394 + for (const [colIdx, width] of Object.entries(parsed.colWidths)) { 395 + // colWidths Y.Map is keyed by 1-based column number string 396 + colWidthsMap.set(String(Number(colIdx) + 1), width); 397 + } 398 + } 399 + } 400 + 401 + // Store row heights as JSON (no existing Y.Map pattern for row heights) 402 + if (parsed.rowHeights && Object.keys(parsed.rowHeights).length > 0) { 403 + sheet.set('rowHeights', JSON.stringify(parsed.rowHeights)); 300 404 } 301 405 302 406 // Expand grid dimensions if needed
+640
tests/xlsx-complex-import.test.ts
··· 1 + import { describe, it, expect, beforeAll } from 'vitest'; 2 + import { parseXlsxWorkbook } from '../src/sheets/xlsx-import.js'; 3 + import ExcelJS from 'exceljs'; 4 + 5 + /** 6 + * Complex multi-sheet XLSX import test. 7 + * 8 + * Creates a realistic small-business financial report workbook with 3 sheets, 9 + * exports it to a buffer, then imports via parseXlsxWorkbook and verifies 10 + * every aspect: values, formulas, fonts, colors, alignment, number formats, 11 + * dimensions, and multi-sheet handling. 12 + */ 13 + 14 + let workbookResult: Awaited<ReturnType<typeof parseXlsxWorkbook>>; 15 + 16 + /** 17 + * Build a realistic 3-sheet financial workbook and parse it once. 18 + * All tests share this parsed result (read-only). 19 + */ 20 + beforeAll(async () => { 21 + const workbook = new ExcelJS.Workbook(); 22 + 23 + // ================================================================ 24 + // Sheet 1: Financial Summary 25 + // ================================================================ 26 + const summary = workbook.addWorksheet('Financial Summary'); 27 + 28 + // Row 1: Merged header 29 + summary.mergeCells('A1:E1'); 30 + const headerCell = summary.getCell('A1'); 31 + headerCell.value = 'Q1 2024 Financial Report'; 32 + headerCell.font = { bold: true, size: 16, color: { argb: 'FFFFFFFF' } }; 33 + headerCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1F4E79' } }; 34 + headerCell.alignment = { horizontal: 'center', vertical: 'middle' }; 35 + 36 + // Row 2: empty spacer (no data) 37 + 38 + // Row 3: Column headers 39 + const colHeaders = ['Category', 'January', 'February', 'March', 'Q1 Total']; 40 + for (let c = 0; c < colHeaders.length; c++) { 41 + const cell = summary.getCell(3, c + 1); 42 + cell.value = colHeaders[c]; 43 + cell.font = { bold: true, size: 12 }; 44 + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9E2F3' } }; 45 + } 46 + 47 + // Row 4: Revenue 48 + summary.getCell('A4').value = 'Revenue'; 49 + summary.getCell('A4').font = { bold: true }; 50 + summary.getCell('B4').value = 45000; 51 + summary.getCell('B4').numFmt = '$#,##0.00'; 52 + summary.getCell('C4').value = 52000; 53 + summary.getCell('C4').numFmt = '$#,##0.00'; 54 + summary.getCell('D4').value = 48000; 55 + summary.getCell('D4').numFmt = '$#,##0.00'; 56 + summary.getCell('E4').value = { formula: 'SUM(B4:D4)', result: 145000 }; 57 + summary.getCell('E4').numFmt = '$#,##0.00'; 58 + 59 + // Row 5: Cost of Goods 60 + summary.getCell('A5').value = 'Cost of Goods'; 61 + summary.getCell('B5').value = 18000; 62 + summary.getCell('B5').numFmt = '$#,##0.00'; 63 + summary.getCell('C5').value = 21000; 64 + summary.getCell('C5').numFmt = '$#,##0.00'; 65 + summary.getCell('D5').value = 19500; 66 + summary.getCell('D5').numFmt = '$#,##0.00'; 67 + summary.getCell('E5').value = { formula: 'SUM(B5:D5)', result: 58500 }; 68 + summary.getCell('E5').numFmt = '$#,##0.00'; 69 + 70 + // Row 6: Gross Profit (formulas referencing other cells) 71 + summary.getCell('A6').value = 'Gross Profit'; 72 + summary.getCell('B6').value = { formula: 'B4-B5', result: 27000 }; 73 + summary.getCell('B6').numFmt = '$#,##0.00'; 74 + summary.getCell('C6').value = { formula: 'C4-C5', result: 31000 }; 75 + summary.getCell('C6').numFmt = '$#,##0.00'; 76 + summary.getCell('D6').value = { formula: 'D4-D5', result: 28500 }; 77 + summary.getCell('D6').numFmt = '$#,##0.00'; 78 + summary.getCell('E6').value = { formula: 'SUM(B6:D6)', result: 86500 }; 79 + summary.getCell('E6').numFmt = '$#,##0.00'; 80 + 81 + // Row 7: Operating Expenses 82 + summary.getCell('A7').value = 'Operating Expenses'; 83 + summary.getCell('B7').value = 12000; 84 + summary.getCell('B7').numFmt = '$#,##0.00'; 85 + summary.getCell('C7').value = 13500; 86 + summary.getCell('C7').numFmt = '$#,##0.00'; 87 + summary.getCell('D7').value = 12800; 88 + summary.getCell('D7').numFmt = '$#,##0.00'; 89 + summary.getCell('E7').value = { formula: 'SUM(B7:D7)', result: 38300 }; 90 + summary.getCell('E7').numFmt = '$#,##0.00'; 91 + 92 + // Row 8: Net Income (bold, green background) 93 + summary.getCell('A8').value = 'Net Income'; 94 + summary.getCell('A8').font = { bold: true }; 95 + summary.getCell('A8').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC6EFCE' } }; 96 + summary.getCell('B8').value = { formula: 'B6-B7', result: 15000 }; 97 + summary.getCell('B8').numFmt = '$#,##0.00'; 98 + summary.getCell('B8').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC6EFCE' } }; 99 + summary.getCell('C8').value = { formula: 'C6-C7', result: 17500 }; 100 + summary.getCell('C8').numFmt = '$#,##0.00'; 101 + summary.getCell('C8').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC6EFCE' } }; 102 + summary.getCell('D8').value = { formula: 'D6-D7', result: 15700 }; 103 + summary.getCell('D8').numFmt = '$#,##0.00'; 104 + summary.getCell('D8').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC6EFCE' } }; 105 + summary.getCell('E8').value = { formula: 'SUM(B8:D8)', result: 48200 }; 106 + summary.getCell('E8').numFmt = '$#,##0.00'; 107 + summary.getCell('E8').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC6EFCE' } }; 108 + 109 + // Row 10: Gross Margin (percent) 110 + summary.getCell('A10').value = 'Gross Margin'; 111 + summary.getCell('B10').value = { formula: 'B6/B4', result: 0.6 }; 112 + summary.getCell('B10').numFmt = '0.00%'; 113 + summary.getCell('C10').value = { formula: 'C6/C4', result: 0.5961538461538461 }; 114 + summary.getCell('C10').numFmt = '0.00%'; 115 + summary.getCell('D10').value = { formula: 'D6/D4', result: 0.59375 }; 116 + summary.getCell('D10').numFmt = '0.00%'; 117 + 118 + // Alternating row shading on data rows (light gray on odd data rows) 119 + for (const rowNum of [5, 7]) { 120 + for (let col = 1; col <= 5; col++) { 121 + const cell = summary.getCell(rowNum, col); 122 + if (!cell.fill || cell.fill.pattern === 'none') { 123 + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF2F2F2' } }; 124 + } 125 + } 126 + } 127 + 128 + // ================================================================ 129 + // Sheet 2: Monthly Details 130 + // ================================================================ 131 + const details = workbook.addWorksheet('Monthly Details'); 132 + 133 + // Headers 134 + const detailHeaders = ['Date', 'Description', 'Amount', 'Category', 'Percentage']; 135 + for (let c = 0; c < detailHeaders.length; c++) { 136 + const cell = details.getCell(1, c + 1); 137 + cell.value = detailHeaders[c]; 138 + cell.font = { bold: true }; 139 + cell.alignment = { horizontal: 'center' }; 140 + } 141 + 142 + // Data rows with dates 143 + const detailData = [ 144 + { date: new Date(2024, 0, 15), desc: 'Office supplies', amount: 450, cat: 'Expenses', pct: 0.0375 }, 145 + { date: new Date(2024, 0, 22), desc: 'Client payment - Project Alpha', amount: 15000, cat: 'Revenue', pct: 0.3333 }, 146 + { date: new Date(2024, 1, 3), desc: 'Software licenses\n(annual renewal)', amount: 2400, cat: 'Expenses', pct: 0.2 }, 147 + { date: new Date(2024, 1, 14), desc: 'Consulting revenue', amount: 8500, cat: 'Revenue', pct: 0.1636 }, 148 + { date: new Date(2024, 2, 1), desc: 'Rent', amount: 3200, cat: 'Expenses', pct: 0.2667 }, 149 + ]; 150 + 151 + for (let r = 0; r < detailData.length; r++) { 152 + const row = detailData[r]; 153 + const rowNum = r + 2; 154 + 155 + details.getCell(rowNum, 1).value = row.date; 156 + details.getCell(rowNum, 1).numFmt = 'mm/dd/yyyy'; 157 + details.getCell(rowNum, 1).alignment = { horizontal: 'left' }; 158 + 159 + details.getCell(rowNum, 2).value = row.desc; 160 + details.getCell(rowNum, 2).alignment = { horizontal: 'left', wrapText: true }; 161 + 162 + details.getCell(rowNum, 3).value = row.amount; 163 + details.getCell(rowNum, 3).numFmt = '$#,##0.00'; 164 + details.getCell(rowNum, 3).alignment = { horizontal: 'right' }; 165 + 166 + details.getCell(rowNum, 4).value = row.cat; 167 + details.getCell(rowNum, 4).alignment = { horizontal: 'center' }; 168 + 169 + details.getCell(rowNum, 5).value = row.pct; 170 + details.getCell(rowNum, 5).numFmt = '0.00%'; 171 + details.getCell(rowNum, 5).alignment = { horizontal: 'right' }; 172 + } 173 + 174 + // Row 8: Total formula 175 + details.getCell('C8').value = { formula: 'SUM(C2:C6)', result: 29550 }; 176 + details.getCell('C8').numFmt = '$#,##0.00'; 177 + details.getCell('C8').font = { bold: true }; 178 + 179 + // ================================================================ 180 + // Sheet 3: Notes 181 + // ================================================================ 182 + const notes = workbook.addWorksheet('Notes'); 183 + 184 + notes.getCell('A1').value = 'Quarterly Report Notes'; 185 + notes.getCell('A1').font = { bold: true, size: 14 }; 186 + 187 + notes.getCell('A3').value = 'Revenue exceeded projections by 12% in February.'; 188 + notes.getCell('A3').font = { italic: true }; 189 + 190 + notes.getCell('A4').value = 'Action item: review vendor contracts before Q2'; 191 + notes.getCell('A4').font = { underline: true }; 192 + 193 + notes.getCell('A5').value = 'IMPORTANT: Tax filing deadline April 15'; 194 + notes.getCell('A5').font = { bold: true, italic: true, size: 12, color: { argb: 'FFFF0000' } }; 195 + 196 + notes.getCell('A7').value = 'Prepared by Finance Department'; 197 + notes.getCell('A7').font = { size: 10 }; 198 + 199 + notes.getCell('A8').value = 'Confidential \u2014 do not distribute'; 200 + notes.getCell('A8').font = { italic: true, size: 9, color: { argb: 'FF888888' } }; 201 + 202 + // Export and parse 203 + const buffer = await workbook.xlsx.writeBuffer(); 204 + workbookResult = await parseXlsxWorkbook(buffer); 205 + }); 206 + 207 + // ============================================================ 208 + // Sheet structure 209 + // ============================================================ 210 + describe('Complex XLSX import — sheet structure', () => { 211 + it('parses all 3 sheets', () => { 212 + expect(workbookResult.sheets).toHaveLength(3); 213 + }); 214 + 215 + it('preserves sheet names', () => { 216 + expect(workbookResult.sheets[0].name).toBe('Financial Summary'); 217 + expect(workbookResult.sheets[1].name).toBe('Monthly Details'); 218 + expect(workbookResult.sheets[2].name).toBe('Notes'); 219 + }); 220 + }); 221 + 222 + // ============================================================ 223 + // Sheet 1: Financial Summary — values 224 + // ============================================================ 225 + describe('Complex XLSX import — Financial Summary values', () => { 226 + function getCell(id: string) { 227 + return workbookResult.sheets[0].cells.get(id); 228 + } 229 + 230 + it('has the merged header text', () => { 231 + expect(getCell('A1').v).toBe('Q1 2024 Financial Report'); 232 + }); 233 + 234 + it('has column header values', () => { 235 + expect(getCell('A3').v).toBe('Category'); 236 + expect(getCell('B3').v).toBe('January'); 237 + expect(getCell('C3').v).toBe('February'); 238 + expect(getCell('D3').v).toBe('March'); 239 + expect(getCell('E3').v).toBe('Q1 Total'); 240 + }); 241 + 242 + it('has Revenue label and currency values', () => { 243 + expect(getCell('A4').v).toBe('Revenue'); 244 + expect(getCell('B4').v).toBe(45000); 245 + expect(getCell('C4').v).toBe(52000); 246 + expect(getCell('D4').v).toBe(48000); 247 + }); 248 + 249 + it('has Cost of Goods values', () => { 250 + expect(getCell('A5').v).toBe('Cost of Goods'); 251 + expect(getCell('B5').v).toBe(18000); 252 + }); 253 + 254 + it('has Operating Expenses values', () => { 255 + expect(getCell('A7').v).toBe('Operating Expenses'); 256 + expect(getCell('B7').v).toBe(12000); 257 + }); 258 + 259 + it('has Net Income label', () => { 260 + expect(getCell('A8').v).toBe('Net Income'); 261 + }); 262 + 263 + it('has Gross Margin label', () => { 264 + expect(getCell('A10').v).toBe('Gross Margin'); 265 + }); 266 + }); 267 + 268 + // ============================================================ 269 + // Sheet 1: Financial Summary — formulas 270 + // ============================================================ 271 + describe('Complex XLSX import — Financial Summary formulas', () => { 272 + function getCell(id: string) { 273 + return workbookResult.sheets[0].cells.get(id); 274 + } 275 + 276 + it('extracts SUM formula on Revenue total (E4)', () => { 277 + expect(getCell('E4').f).toBe('SUM(B4:D4)'); 278 + }); 279 + 280 + it('extracts SUM formula on Cost of Goods total (E5)', () => { 281 + expect(getCell('E5').f).toBe('SUM(B5:D5)'); 282 + }); 283 + 284 + it('extracts subtraction formulas on Gross Profit row', () => { 285 + expect(getCell('B6').f).toBe('B4-B5'); 286 + expect(getCell('C6').f).toBe('C4-C5'); 287 + expect(getCell('D6').f).toBe('D4-D5'); 288 + }); 289 + 290 + it('extracts SUM formula on Gross Profit total (E6)', () => { 291 + expect(getCell('E6').f).toBe('SUM(B6:D6)'); 292 + }); 293 + 294 + it('extracts Net Income formulas', () => { 295 + expect(getCell('B8').f).toBe('B6-B7'); 296 + expect(getCell('C8').f).toBe('C6-C7'); 297 + expect(getCell('D8').f).toBe('D6-D7'); 298 + expect(getCell('E8').f).toBe('SUM(B8:D8)'); 299 + }); 300 + 301 + it('extracts Gross Margin division formulas', () => { 302 + expect(getCell('B10').f).toBe('B6/B4'); 303 + expect(getCell('C10').f).toBe('C6/C4'); 304 + expect(getCell('D10').f).toBe('D6/D4'); 305 + }); 306 + 307 + it('stores formula results as values', () => { 308 + expect(getCell('E4').v).toBe(145000); 309 + expect(getCell('E5').v).toBe(58500); 310 + expect(getCell('B6').v).toBe(27000); 311 + expect(getCell('E8').v).toBe(48200); 312 + }); 313 + 314 + it('non-formula cells have empty formula string', () => { 315 + expect(getCell('A4').f).toBe(''); 316 + expect(getCell('B4').f).toBe(''); 317 + }); 318 + }); 319 + 320 + // ============================================================ 321 + // Sheet 1: Financial Summary — font styles 322 + // ============================================================ 323 + describe('Complex XLSX import — Financial Summary font styles', () => { 324 + function getCell(id: string) { 325 + return workbookResult.sheets[0].cells.get(id); 326 + } 327 + 328 + it('header cell is bold', () => { 329 + expect(getCell('A1').s.bold).toBe(true); 330 + }); 331 + 332 + it('header cell has font size 16', () => { 333 + expect(getCell('A1').s.fontSize).toBe(16); 334 + }); 335 + 336 + it('column header cells are bold', () => { 337 + expect(getCell('A3').s.bold).toBe(true); 338 + expect(getCell('B3').s.bold).toBe(true); 339 + expect(getCell('E3').s.bold).toBe(true); 340 + }); 341 + 342 + it('column header cells have font size 12', () => { 343 + expect(getCell('A3').s.fontSize).toBe(12); 344 + expect(getCell('E3').s.fontSize).toBe(12); 345 + }); 346 + 347 + it('Revenue label is bold', () => { 348 + expect(getCell('A4').s.bold).toBe(true); 349 + }); 350 + 351 + it('Net Income label is bold', () => { 352 + expect(getCell('A8').s.bold).toBe(true); 353 + }); 354 + 355 + it('non-bold data cells do not have bold set', () => { 356 + expect(getCell('A5').s.bold).toBeUndefined(); 357 + }); 358 + }); 359 + 360 + // ============================================================ 361 + // Sheet 1: Financial Summary — background colors 362 + // ============================================================ 363 + describe('Complex XLSX import — Financial Summary background colors', () => { 364 + function getCell(id: string) { 365 + return workbookResult.sheets[0].cells.get(id); 366 + } 367 + 368 + it('header cell has blue background (#1F4E79)', () => { 369 + expect(getCell('A1').s.bg).toBe('#1F4E79'); 370 + }); 371 + 372 + it('column header cells have gray background (#D9E2F3)', () => { 373 + expect(getCell('A3').s.bg).toBe('#D9E2F3'); 374 + expect(getCell('B3').s.bg).toBe('#D9E2F3'); 375 + expect(getCell('E3').s.bg).toBe('#D9E2F3'); 376 + }); 377 + 378 + it('Net Income row has green background (#C6EFCE)', () => { 379 + expect(getCell('A8').s.bg).toBe('#C6EFCE'); 380 + expect(getCell('B8').s.bg).toBe('#C6EFCE'); 381 + expect(getCell('E8').s.bg).toBe('#C6EFCE'); 382 + }); 383 + 384 + it('alternating row shading on data rows (#F2F2F2)', () => { 385 + expect(getCell('B5').s.bg).toBe('#F2F2F2'); 386 + expect(getCell('B7').s.bg).toBe('#F2F2F2'); 387 + }); 388 + }); 389 + 390 + // ============================================================ 391 + // Sheet 1: Financial Summary — text colors 392 + // ============================================================ 393 + describe('Complex XLSX import — Financial Summary text colors', () => { 394 + function getCell(id: string) { 395 + return workbookResult.sheets[0].cells.get(id); 396 + } 397 + 398 + it('header cell has white text (#FFFFFF)', () => { 399 + expect(getCell('A1').s.color).toBe('#FFFFFF'); 400 + }); 401 + 402 + it('regular data cells do not have explicit text color', () => { 403 + expect(getCell('A4').s.color).toBeUndefined(); 404 + expect(getCell('B4').s.color).toBeUndefined(); 405 + }); 406 + }); 407 + 408 + // ============================================================ 409 + // Sheet 1: Financial Summary — number formats 410 + // ============================================================ 411 + describe('Complex XLSX import — Financial Summary number formats', () => { 412 + function getCell(id: string) { 413 + return workbookResult.sheets[0].cells.get(id); 414 + } 415 + 416 + it('currency-formatted cells have format "currency"', () => { 417 + expect(getCell('B4').s.format).toBe('currency'); 418 + expect(getCell('C4').s.format).toBe('currency'); 419 + expect(getCell('E4').s.format).toBe('currency'); 420 + expect(getCell('B5').s.format).toBe('currency'); 421 + expect(getCell('E8').s.format).toBe('currency'); 422 + }); 423 + 424 + it('percent-formatted cells have format "percent"', () => { 425 + expect(getCell('B10').s.format).toBe('percent'); 426 + expect(getCell('C10').s.format).toBe('percent'); 427 + expect(getCell('D10').s.format).toBe('percent'); 428 + }); 429 + 430 + it('text cells do not have a format', () => { 431 + expect(getCell('A4').s.format).toBeUndefined(); 432 + expect(getCell('A3').s.format).toBeUndefined(); 433 + }); 434 + }); 435 + 436 + // ============================================================ 437 + // Sheet 1: Financial Summary — alignment 438 + // ============================================================ 439 + describe('Complex XLSX import — Financial Summary alignment', () => { 440 + function getCell(id: string) { 441 + return workbookResult.sheets[0].cells.get(id); 442 + } 443 + 444 + it('header cell is center-aligned horizontally', () => { 445 + expect(getCell('A1').s.align).toBe('center'); 446 + }); 447 + 448 + it('header cell is middle-aligned vertically', () => { 449 + expect(getCell('A1').s.verticalAlign).toBe('middle'); 450 + }); 451 + }); 452 + 453 + // ============================================================ 454 + // Sheet 1: Financial Summary — dimensions 455 + // ============================================================ 456 + describe('Complex XLSX import — Financial Summary dimensions', () => { 457 + it('has correct row count (row 10 is the last with data)', () => { 458 + expect(workbookResult.sheets[0].rowCount).toBe(10); 459 + }); 460 + 461 + it('has correct column count (5 columns A-E)', () => { 462 + expect(workbookResult.sheets[0].colCount).toBe(5); 463 + }); 464 + }); 465 + 466 + // ============================================================ 467 + // Sheet 2: Monthly Details — values and types 468 + // ============================================================ 469 + describe('Complex XLSX import — Monthly Details values', () => { 470 + function getCell(id: string) { 471 + return workbookResult.sheets[1].cells.get(id); 472 + } 473 + 474 + it('has header row with bold centered text', () => { 475 + expect(getCell('A1').v).toBe('Date'); 476 + expect(getCell('A1').s.bold).toBe(true); 477 + expect(getCell('A1').s.align).toBe('center'); 478 + }); 479 + 480 + it('has date values (ExcelJS returns Date objects)', () => { 481 + const dateVal = getCell('A2').v; 482 + expect(dateVal).toBeInstanceOf(Date); 483 + }); 484 + 485 + it('has date format on date cells', () => { 486 + expect(getCell('A2').s.format).toBe('date'); 487 + }); 488 + 489 + it('has text descriptions', () => { 490 + expect(getCell('B2').v).toBe('Office supplies'); 491 + expect(getCell('B3').v).toBe('Client payment - Project Alpha'); 492 + }); 493 + 494 + it('has numeric amounts with currency format', () => { 495 + expect(getCell('C2').v).toBe(450); 496 + expect(getCell('C2').s.format).toBe('currency'); 497 + expect(getCell('C3').v).toBe(15000); 498 + }); 499 + 500 + it('has category text with center alignment', () => { 501 + expect(getCell('D2').v).toBe('Expenses'); 502 + expect(getCell('D2').s.align).toBe('center'); 503 + }); 504 + 505 + it('has percentage values with percent format', () => { 506 + expect(getCell('E2').v).toBeCloseTo(0.0375, 4); 507 + expect(getCell('E2').s.format).toBe('percent'); 508 + }); 509 + 510 + it('has right alignment on amount and percent cells', () => { 511 + expect(getCell('C2').s.align).toBe('right'); 512 + expect(getCell('E2').s.align).toBe('right'); 513 + }); 514 + 515 + it('has wrap text on description cells', () => { 516 + expect(getCell('B4').s.wrap).toBe(true); 517 + }); 518 + 519 + it('has a SUM formula in the total cell', () => { 520 + expect(getCell('C8').f).toBe('SUM(C2:C6)'); 521 + expect(getCell('C8').v).toBe(29550); 522 + expect(getCell('C8').s.bold).toBe(true); 523 + }); 524 + }); 525 + 526 + // ============================================================ 527 + // Sheet 2: Monthly Details — dimensions 528 + // ============================================================ 529 + describe('Complex XLSX import — Monthly Details dimensions', () => { 530 + it('has correct row count', () => { 531 + expect(workbookResult.sheets[1].rowCount).toBe(8); 532 + }); 533 + 534 + it('has correct column count (5 columns A-E)', () => { 535 + expect(workbookResult.sheets[1].colCount).toBe(5); 536 + }); 537 + }); 538 + 539 + // ============================================================ 540 + // Sheet 3: Notes — text content and formatting 541 + // ============================================================ 542 + describe('Complex XLSX import — Notes sheet', () => { 543 + function getCell(id: string) { 544 + return workbookResult.sheets[2].cells.get(id); 545 + } 546 + 547 + it('has title with bold and large font', () => { 548 + expect(getCell('A1').v).toBe('Quarterly Report Notes'); 549 + expect(getCell('A1').s.bold).toBe(true); 550 + expect(getCell('A1').s.fontSize).toBe(14); 551 + }); 552 + 553 + it('has italic text', () => { 554 + expect(getCell('A3').v).toBe('Revenue exceeded projections by 12% in February.'); 555 + expect(getCell('A3').s.italic).toBe(true); 556 + }); 557 + 558 + it('has underlined text', () => { 559 + expect(getCell('A4').v).toBe('Action item: review vendor contracts before Q2'); 560 + expect(getCell('A4').s.underline).toBe(true); 561 + }); 562 + 563 + it('has combined bold+italic with color', () => { 564 + expect(getCell('A5').v).toBe('IMPORTANT: Tax filing deadline April 15'); 565 + expect(getCell('A5').s.bold).toBe(true); 566 + expect(getCell('A5').s.italic).toBe(true); 567 + expect(getCell('A5').s.fontSize).toBe(12); 568 + expect(getCell('A5').s.color).toBe('#FF0000'); 569 + }); 570 + 571 + it('has small font size text', () => { 572 + expect(getCell('A7').s.fontSize).toBe(10); 573 + }); 574 + 575 + it('has very small italic gray text', () => { 576 + expect(getCell('A8').v).toBe('Confidential \u2014 do not distribute'); 577 + expect(getCell('A8').s.italic).toBe(true); 578 + expect(getCell('A8').s.fontSize).toBe(9); 579 + expect(getCell('A8').s.color).toBe('#888888'); 580 + }); 581 + }); 582 + 583 + // ============================================================ 584 + // Cross-sheet consistency 585 + // ============================================================ 586 + describe('Complex XLSX import — cross-sheet consistency', () => { 587 + it('each sheet has an independent cell map', () => { 588 + const summaryA1 = workbookResult.sheets[0].cells.get('A1'); 589 + const notesA1 = workbookResult.sheets[2].cells.get('A1'); 590 + expect(summaryA1.v).toBe('Q1 2024 Financial Report'); 591 + expect(notesA1.v).toBe('Quarterly Report Notes'); 592 + }); 593 + 594 + it('all sheets have cells as Map instances', () => { 595 + for (const sheet of workbookResult.sheets) { 596 + expect(sheet.cells).toBeInstanceOf(Map); 597 + } 598 + }); 599 + 600 + it('Financial Summary has the most cells', () => { 601 + expect(workbookResult.sheets[0].cells.size).toBeGreaterThan(workbookResult.sheets[2].cells.size); 602 + }); 603 + }); 604 + 605 + // ============================================================ 606 + // Combined style verification (cell with multiple style properties) 607 + // ============================================================ 608 + describe('Complex XLSX import — combined style properties', () => { 609 + it('header cell combines bold + fontSize + color + bg + alignment', () => { 610 + const s = workbookResult.sheets[0].cells.get('A1').s; 611 + expect(s.bold).toBe(true); 612 + expect(s.fontSize).toBe(16); 613 + expect(s.color).toBe('#FFFFFF'); 614 + expect(s.bg).toBe('#1F4E79'); 615 + expect(s.align).toBe('center'); 616 + expect(s.verticalAlign).toBe('middle'); 617 + }); 618 + 619 + it('Net Income E8 combines bg + format + formula', () => { 620 + const cell = workbookResult.sheets[0].cells.get('E8'); 621 + expect(cell.s.bg).toBe('#C6EFCE'); 622 + expect(cell.s.format).toBe('currency'); 623 + expect(cell.f).toBe('SUM(B8:D8)'); 624 + }); 625 + 626 + it('Monthly Details total combines bold + currency format + formula', () => { 627 + const cell = workbookResult.sheets[1].cells.get('C8'); 628 + expect(cell.s.bold).toBe(true); 629 + expect(cell.s.format).toBe('currency'); 630 + expect(cell.f).toBe('SUM(C2:C6)'); 631 + }); 632 + 633 + it('IMPORTANT note combines bold + italic + fontSize + color', () => { 634 + const s = workbookResult.sheets[2].cells.get('A5').s; 635 + expect(s.bold).toBe(true); 636 + expect(s.italic).toBe(true); 637 + expect(s.fontSize).toBe(12); 638 + expect(s.color).toBe('#FF0000'); 639 + }); 640 + });
+269
tests/xlsx-import-regression.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { parseXlsxWithLib, parseXlsxWorkbook, mapExcelFormat } from '../src/sheets/xlsx-import.js'; 3 + import ExcelJS from 'exceljs'; 4 + 5 + /** 6 + * Regression tests for XLSX import style handling. 7 + * 8 + * Background: setCellDataForSheet in main.ts had a bug where style properties 9 + * were stored as individual Y.Map keys (yCell.set(k, v) for each key in data.s) 10 + * instead of as a JSON string under the 's' key (yCell.set('s', JSON.stringify(data.s))). 11 + * 12 + * getCellData expects cell.get('s') to be a JSON string it can parse, so the 13 + * individual-key approach meant styles were silently lost — getCellData returned {} 14 + * for all styles. 15 + * 16 + * These tests verify the parsing layer (parseXlsxWithLib / parseXlsxWorkbook) 17 + * returns correct style objects, which is the prerequisite for the Yjs storage 18 + * layer to work correctly. 19 + */ 20 + 21 + /** 22 + * Helper: create an xlsx buffer with a single cell that has the given value and style. 23 + */ 24 + async function createStyledCell(value: string | number, style: { 25 + font?: Partial<ExcelJS.Font>; 26 + fill?: ExcelJS.Fill; 27 + alignment?: Partial<ExcelJS.Alignment>; 28 + numFmt?: string; 29 + }) { 30 + const workbook = new ExcelJS.Workbook(); 31 + const worksheet = workbook.addWorksheet('Sheet1'); 32 + const cell = worksheet.getCell('A1'); 33 + cell.value = value; 34 + if (style.font) cell.font = style.font; 35 + if (style.fill) cell.fill = style.fill; 36 + if (style.alignment) cell.alignment = style.alignment; 37 + if (style.numFmt) cell.numFmt = style.numFmt; 38 + return await workbook.xlsx.writeBuffer(); 39 + } 40 + 41 + // ============================================================ 42 + // Style object structure — ensures styles come back as proper objects 43 + // ============================================================ 44 + describe('XLSX import regression — style object structure', () => { 45 + it('returns style as a plain object with named keys, not individual Y.Map entries', async () => { 46 + const buf = await createStyledCell(100, { 47 + font: { bold: true }, 48 + fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF0000' } }, 49 + numFmt: '$#,##0.00', 50 + }); 51 + const result = await parseXlsxWithLib(buf); 52 + const s = result.cells.get('A1').s; 53 + 54 + // The style must be a plain object, not a Map or other collection 55 + expect(typeof s).toBe('object'); 56 + expect(s).not.toBeInstanceOf(Map); 57 + 58 + // All style keys should be accessible directly 59 + expect(s.bold).toBe(true); 60 + expect(s.bg).toBe('#FF0000'); 61 + expect(s.format).toBe('currency'); 62 + }); 63 + 64 + it('style object is defined even for unstyled cells', async () => { 65 + const workbook = new ExcelJS.Workbook(); 66 + workbook.addWorksheet('Sheet1').getCell('A1').value = 'plain'; 67 + const buf = await workbook.xlsx.writeBuffer(); 68 + 69 + const result = await parseXlsxWithLib(buf); 70 + const s = result.cells.get('A1').s; 71 + expect(s).toBeDefined(); 72 + expect(typeof s).toBe('object'); 73 + // An unstyled cell should have an empty style object 74 + expect(Object.keys(s).length).toBe(0); 75 + }); 76 + }); 77 + 78 + // ============================================================ 79 + // Background color extraction from pattern fills 80 + // ============================================================ 81 + describe('XLSX import regression — background color (s.bg)', () => { 82 + it('extracts bg from solid pattern fill', async () => { 83 + const buf = await createStyledCell('test', { 84 + fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF00FF00' } }, 85 + }); 86 + const result = await parseXlsxWithLib(buf); 87 + expect(result.cells.get('A1').s.bg).toBe('#00FF00'); 88 + }); 89 + 90 + it('strips ARGB alpha prefix to produce 6-char hex', async () => { 91 + const buf = await createStyledCell('test', { 92 + fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'CC123456' } }, 93 + }); 94 + const result = await parseXlsxWithLib(buf); 95 + expect(result.cells.get('A1').s.bg).toBe('#123456'); 96 + }); 97 + 98 + it('does not set bg when fill type is not pattern', async () => { 99 + const workbook = new ExcelJS.Workbook(); 100 + const ws = workbook.addWorksheet('Sheet1'); 101 + ws.getCell('A1').value = 'test'; 102 + // gradient fills should not produce a bg 103 + ws.getCell('A1').fill = { 104 + type: 'gradient', 105 + gradient: 'angle', 106 + degree: 0, 107 + stops: [ 108 + { position: 0, color: { argb: 'FFFF0000' } }, 109 + { position: 1, color: { argb: 'FF0000FF' } }, 110 + ], 111 + }; 112 + const buf = await workbook.xlsx.writeBuffer(); 113 + const result = await parseXlsxWithLib(buf); 114 + expect(result.cells.get('A1').s.bg).toBeUndefined(); 115 + }); 116 + 117 + it('does not set bg when no fill is present', async () => { 118 + const buf = await createStyledCell('test', {}); 119 + const result = await parseXlsxWithLib(buf); 120 + expect(result.cells.get('A1').s.bg).toBeUndefined(); 121 + }); 122 + }); 123 + 124 + // ============================================================ 125 + // Currency format detection 126 + // ============================================================ 127 + describe('XLSX import regression — currency format (s.format === "currency")', () => { 128 + it('detects $#,##0.00 as currency', async () => { 129 + const buf = await createStyledCell(1234.56, { numFmt: '$#,##0.00' }); 130 + const result = await parseXlsxWithLib(buf); 131 + expect(result.cells.get('A1').s.format).toBe('currency'); 132 + }); 133 + 134 + it('detects "$"#,##0 as currency', async () => { 135 + const buf = await createStyledCell(1000, { numFmt: '"$"#,##0' }); 136 + const result = await parseXlsxWithLib(buf); 137 + expect(result.cells.get('A1').s.format).toBe('currency'); 138 + }); 139 + 140 + it('detects EUR format as currency', async () => { 141 + const buf = await createStyledCell(500, { numFmt: '[$EUR] #,##0.00' }); 142 + const result = await parseXlsxWithLib(buf); 143 + expect(result.cells.get('A1').s.format).toBe('currency'); 144 + }); 145 + 146 + it('detects GBP symbol as currency', async () => { 147 + expect(mapExcelFormat('\u00a3#,##0.00')).toBe('currency'); 148 + }); 149 + 150 + it('detects yen symbol as currency', async () => { 151 + expect(mapExcelFormat('\u00a5#,##0')).toBe('currency'); 152 + }); 153 + }); 154 + 155 + // ============================================================ 156 + // Combined: background color AND currency format on same cell 157 + // ============================================================ 158 + describe('XLSX import regression — combined bg + currency on one cell', () => { 159 + it('a cell with both background fill and currency format retains both', async () => { 160 + const buf = await createStyledCell(50000, { 161 + fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC6EFCE' } }, 162 + numFmt: '$#,##0.00', 163 + }); 164 + const result = await parseXlsxWithLib(buf); 165 + const s = result.cells.get('A1').s; 166 + 167 + expect(s.bg).toBe('#C6EFCE'); 168 + expect(s.format).toBe('currency'); 169 + }); 170 + 171 + it('a cell with bg + format + bold retains all three', async () => { 172 + const buf = await createStyledCell(99999, { 173 + font: { bold: true }, 174 + fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9E2F3' } }, 175 + numFmt: '$#,##0.00', 176 + }); 177 + const result = await parseXlsxWithLib(buf); 178 + const s = result.cells.get('A1').s; 179 + 180 + expect(s.bold).toBe(true); 181 + expect(s.bg).toBe('#D9E2F3'); 182 + expect(s.format).toBe('currency'); 183 + }); 184 + 185 + it('a cell with bg + percent format retains both', async () => { 186 + const buf = await createStyledCell(0.75, { 187 + fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } }, 188 + numFmt: '0.00%', 189 + }); 190 + const result = await parseXlsxWithLib(buf); 191 + const s = result.cells.get('A1').s; 192 + 193 + expect(s.bg).toBe('#FFFF00'); 194 + expect(s.format).toBe('percent'); 195 + }); 196 + }); 197 + 198 + // ============================================================ 199 + // Multi-sheet style preservation 200 + // ============================================================ 201 + describe('XLSX import regression — styles preserved across sheets', () => { 202 + it('styles in second sheet are not lost or mixed with first sheet', async () => { 203 + const workbook = new ExcelJS.Workbook(); 204 + 205 + const ws1 = workbook.addWorksheet('Sheet1'); 206 + ws1.getCell('A1').value = 'red bg'; 207 + ws1.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF0000' } }; 208 + 209 + const ws2 = workbook.addWorksheet('Sheet2'); 210 + ws2.getCell('A1').value = 'blue bg'; 211 + ws2.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0000FF' } }; 212 + ws2.getCell('A1').numFmt = '$#,##0.00'; 213 + 214 + const buf = await workbook.xlsx.writeBuffer(); 215 + const result = await parseXlsxWorkbook(buf); 216 + 217 + expect(result.sheets[0].cells.get('A1').s.bg).toBe('#FF0000'); 218 + expect(result.sheets[0].cells.get('A1').s.format).toBeUndefined(); 219 + 220 + expect(result.sheets[1].cells.get('A1').s.bg).toBe('#0000FF'); 221 + expect(result.sheets[1].cells.get('A1').s.format).toBe('currency'); 222 + }); 223 + }); 224 + 225 + // ============================================================ 226 + // mapExcelFormat edge cases 227 + // ============================================================ 228 + describe('XLSX import regression — mapExcelFormat edge cases', () => { 229 + it('returns undefined for General', () => { 230 + expect(mapExcelFormat('General')).toBeUndefined(); 231 + }); 232 + 233 + it('returns undefined for empty string', () => { 234 + expect(mapExcelFormat('')).toBeUndefined(); 235 + }); 236 + 237 + it('returns undefined for null', () => { 238 + expect(mapExcelFormat(null)).toBeUndefined(); 239 + }); 240 + 241 + it('returns undefined for undefined', () => { 242 + expect(mapExcelFormat(undefined)).toBeUndefined(); 243 + }); 244 + 245 + it('detects #,##0.00 as number format', () => { 246 + expect(mapExcelFormat('#,##0.00')).toBe('number'); 247 + }); 248 + 249 + it('detects 0.00 as number format', () => { 250 + expect(mapExcelFormat('0.00')).toBe('number'); 251 + }); 252 + 253 + it('detects mm/dd/yyyy as date', () => { 254 + expect(mapExcelFormat('mm/dd/yyyy')).toBe('date'); 255 + }); 256 + 257 + it('detects yyyy-mm-dd as date', () => { 258 + expect(mapExcelFormat('yyyy-mm-dd')).toBe('date'); 259 + }); 260 + 261 + it('detects 0% as percent', () => { 262 + expect(mapExcelFormat('0%')).toBe('percent'); 263 + }); 264 + 265 + it('returns undefined for unrecognized format strings', () => { 266 + expect(mapExcelFormat('@')).toBeUndefined(); 267 + expect(mapExcelFormat(';;;')).toBeUndefined(); 268 + }); 269 + });
+221 -1
tests/xlsx-import-styles.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 - import { parseXlsxWithLib } from '../src/sheets/xlsx-import.js'; 2 + import { parseXlsxWithLib, parseXlsxWorkbook } from '../src/sheets/xlsx-import.js'; 3 3 import ExcelJS from 'exceljs'; 4 4 5 5 /** ··· 243 243 expect(result.cells.get('A1').s.italic).toBeUndefined(); 244 244 }); 245 245 }); 246 + 247 + // ============================================================ 248 + // Border extraction 249 + // ============================================================ 250 + 251 + describe('XLSX import — borders', () => { 252 + it('extracts thin borders from cell style', async () => { 253 + const workbook = new ExcelJS.Workbook(); 254 + const worksheet = workbook.addWorksheet('TestSheet'); 255 + const cell = worksheet.getCell('A1'); 256 + cell.value = 'bordered'; 257 + cell.border = { 258 + top: { style: 'thin', color: { argb: 'FF000000' } }, 259 + bottom: { style: 'thin', color: { argb: 'FF000000' } }, 260 + left: { style: 'thin', color: { argb: 'FF000000' } }, 261 + right: { style: 'thin', color: { argb: 'FF000000' } }, 262 + }; 263 + const buf = await workbook.xlsx.writeBuffer(); 264 + const result = await parseXlsxWithLib(buf); 265 + const s = result.cells.get('A1').s; 266 + expect(s.borders).toBeDefined(); 267 + expect(s.borders.top).toBe('1px solid #000000'); 268 + expect(s.borders.bottom).toBe('1px solid #000000'); 269 + expect(s.borders.left).toBe('1px solid #000000'); 270 + expect(s.borders.right).toBe('1px solid #000000'); 271 + }); 272 + 273 + it('extracts medium borders with color', async () => { 274 + const workbook = new ExcelJS.Workbook(); 275 + const worksheet = workbook.addWorksheet('TestSheet'); 276 + const cell = worksheet.getCell('A1'); 277 + cell.value = 'thick'; 278 + cell.border = { 279 + top: { style: 'medium', color: { argb: 'FFFF0000' } }, 280 + }; 281 + const buf = await workbook.xlsx.writeBuffer(); 282 + const result = await parseXlsxWithLib(buf); 283 + const s = result.cells.get('A1').s; 284 + expect(s.borders).toBeDefined(); 285 + expect(s.borders.top).toBe('2px solid #FF0000'); 286 + expect(s.borders.bottom).toBeUndefined(); 287 + }); 288 + 289 + it('defaults border color to black when no color specified', async () => { 290 + const workbook = new ExcelJS.Workbook(); 291 + const worksheet = workbook.addWorksheet('TestSheet'); 292 + const cell = worksheet.getCell('A1'); 293 + cell.value = 'default color'; 294 + cell.border = { 295 + left: { style: 'thin' }, 296 + }; 297 + const buf = await workbook.xlsx.writeBuffer(); 298 + const result = await parseXlsxWithLib(buf); 299 + const s = result.cells.get('A1').s; 300 + expect(s.borders).toBeDefined(); 301 + expect(s.borders.left).toBe('1px solid #000000'); 302 + }); 303 + 304 + it('does not set borders when no border style exists', async () => { 305 + const buf = await createStyledXlsx('no borders', { font: { bold: true } }); 306 + const result = await parseXlsxWithLib(buf); 307 + expect(result.cells.get('A1').s.borders).toBeUndefined(); 308 + }); 309 + 310 + it('handles partial borders (only some sides)', async () => { 311 + const workbook = new ExcelJS.Workbook(); 312 + const worksheet = workbook.addWorksheet('TestSheet'); 313 + const cell = worksheet.getCell('A1'); 314 + cell.value = 'partial'; 315 + cell.border = { 316 + top: { style: 'thin', color: { argb: 'FF0000FF' } }, 317 + bottom: { style: 'thin', color: { argb: 'FF00FF00' } }, 318 + }; 319 + const buf = await workbook.xlsx.writeBuffer(); 320 + const result = await parseXlsxWithLib(buf); 321 + const s = result.cells.get('A1').s; 322 + expect(s.borders).toBeDefined(); 323 + expect(s.borders.top).toBe('1px solid #0000FF'); 324 + expect(s.borders.bottom).toBe('1px solid #00FF00'); 325 + expect(s.borders.left).toBeUndefined(); 326 + expect(s.borders.right).toBeUndefined(); 327 + }); 328 + }); 329 + 330 + // ============================================================ 331 + // Merged cells extraction 332 + // ============================================================ 333 + 334 + describe('XLSX import — merged cells', () => { 335 + it('extracts merge ranges from worksheet', async () => { 336 + const workbook = new ExcelJS.Workbook(); 337 + const worksheet = workbook.addWorksheet('MergeSheet'); 338 + worksheet.getCell('A1').value = 'Header'; 339 + worksheet.mergeCells('A1:C1'); 340 + worksheet.getCell('A2').value = 'Data'; 341 + const buf = await workbook.xlsx.writeBuffer(); 342 + 343 + const { sheets } = await parseXlsxWorkbook(buf); 344 + expect(sheets[0].merges).toBeDefined(); 345 + expect(sheets[0].merges).toContain('A1:C1'); 346 + }); 347 + 348 + it('extracts multiple merge ranges', async () => { 349 + const workbook = new ExcelJS.Workbook(); 350 + const worksheet = workbook.addWorksheet('MultiMerge'); 351 + worksheet.getCell('A1').value = 'Header1'; 352 + worksheet.mergeCells('A1:C1'); 353 + worksheet.getCell('B3').value = 'Header2'; 354 + worksheet.mergeCells('B3:B5'); 355 + worksheet.getCell('D1').value = 'data'; 356 + const buf = await workbook.xlsx.writeBuffer(); 357 + 358 + const { sheets } = await parseXlsxWorkbook(buf); 359 + expect(sheets[0].merges.length).toBe(2); 360 + expect(sheets[0].merges).toContain('A1:C1'); 361 + expect(sheets[0].merges).toContain('B3:B5'); 362 + }); 363 + 364 + it('returns empty merges array when no merges exist', async () => { 365 + const workbook = new ExcelJS.Workbook(); 366 + const worksheet = workbook.addWorksheet('NoMerge'); 367 + worksheet.getCell('A1').value = 'plain'; 368 + const buf = await workbook.xlsx.writeBuffer(); 369 + 370 + const { sheets } = await parseXlsxWorkbook(buf); 371 + expect(sheets[0].merges).toBeDefined(); 372 + expect(sheets[0].merges.length).toBe(0); 373 + }); 374 + }); 375 + 376 + // ============================================================ 377 + // Column widths extraction 378 + // ============================================================ 379 + 380 + describe('XLSX import — column widths', () => { 381 + it('extracts custom column widths', async () => { 382 + const workbook = new ExcelJS.Workbook(); 383 + const worksheet = workbook.addWorksheet('WidthSheet'); 384 + worksheet.getCell('A1').value = 'A'; 385 + worksheet.getCell('B1').value = 'B'; 386 + worksheet.getColumn(1).width = 20; // ~140px 387 + worksheet.getColumn(2).width = 30; // ~210px 388 + const buf = await workbook.xlsx.writeBuffer(); 389 + 390 + const { sheets } = await parseXlsxWorkbook(buf); 391 + expect(sheets[0].colWidths).toBeDefined(); 392 + // ExcelJS width units * 7 = pixels 393 + expect(sheets[0].colWidths[0]).toBe(140); 394 + expect(sheets[0].colWidths[1]).toBe(210); 395 + }); 396 + 397 + it('returns empty colWidths when no custom widths set', async () => { 398 + const workbook = new ExcelJS.Workbook(); 399 + const worksheet = workbook.addWorksheet('DefaultWidth'); 400 + worksheet.getCell('A1').value = 'data'; 401 + const buf = await workbook.xlsx.writeBuffer(); 402 + 403 + const { sheets } = await parseXlsxWorkbook(buf); 404 + expect(sheets[0].colWidths).toBeDefined(); 405 + }); 406 + 407 + it('skips columns without explicit widths', async () => { 408 + const workbook = new ExcelJS.Workbook(); 409 + const worksheet = workbook.addWorksheet('SparseWidth'); 410 + worksheet.getCell('A1').value = 'A'; 411 + worksheet.getCell('B1').value = 'B'; 412 + worksheet.getCell('C1').value = 'C'; 413 + worksheet.getColumn(1).width = 15; // only col A has custom width 414 + const buf = await workbook.xlsx.writeBuffer(); 415 + 416 + const { sheets } = await parseXlsxWorkbook(buf); 417 + expect(sheets[0].colWidths[0]).toBe(105); // 15 * 7 418 + // Column B (index 1) should not have an explicit width entry 419 + }); 420 + }); 421 + 422 + // ============================================================ 423 + // Row heights extraction 424 + // ============================================================ 425 + 426 + describe('XLSX import — row heights', () => { 427 + it('extracts custom row heights', async () => { 428 + const workbook = new ExcelJS.Workbook(); 429 + const worksheet = workbook.addWorksheet('HeightSheet'); 430 + worksheet.getCell('A1').value = 'tall'; 431 + worksheet.getCell('A2').value = 'normal'; 432 + worksheet.getRow(1).height = 40; 433 + const buf = await workbook.xlsx.writeBuffer(); 434 + 435 + const { sheets } = await parseXlsxWorkbook(buf); 436 + expect(sheets[0].rowHeights).toBeDefined(); 437 + expect(sheets[0].rowHeights[1]).toBe(40); 438 + }); 439 + 440 + it('returns empty rowHeights when no custom heights set', async () => { 441 + const workbook = new ExcelJS.Workbook(); 442 + const worksheet = workbook.addWorksheet('DefaultHeight'); 443 + worksheet.getCell('A1').value = 'data'; 444 + const buf = await workbook.xlsx.writeBuffer(); 445 + 446 + const { sheets } = await parseXlsxWorkbook(buf); 447 + expect(sheets[0].rowHeights).toBeDefined(); 448 + }); 449 + 450 + it('extracts multiple custom row heights', async () => { 451 + const workbook = new ExcelJS.Workbook(); 452 + const worksheet = workbook.addWorksheet('MultiHeight'); 453 + worksheet.getCell('A1').value = 'r1'; 454 + worksheet.getCell('A2').value = 'r2'; 455 + worksheet.getCell('A3').value = 'r3'; 456 + worksheet.getRow(1).height = 50; 457 + worksheet.getRow(3).height = 30; 458 + const buf = await workbook.xlsx.writeBuffer(); 459 + 460 + const { sheets } = await parseXlsxWorkbook(buf); 461 + expect(sheets[0].rowHeights[1]).toBe(50); 462 + expect(sheets[0].rowHeights[2]).toBeUndefined(); 463 + expect(sheets[0].rowHeights[3]).toBe(30); 464 + }); 465 + });
+26
tests/xlsx-import.test.ts
··· 221 221 expect(mapExcelFormat(undefined)).toBeUndefined(); 222 222 expect(mapExcelFormat('')).toBeUndefined(); 223 223 }); 224 + 225 + it('maps Euro currency symbol', () => { 226 + expect(mapExcelFormat('€#,##0.00')).toBe('currency'); 227 + expect(mapExcelFormat('[$EUR] #,##0.00')).toBe('currency'); 228 + }); 229 + 230 + it('maps Yen and Pound currency symbols', () => { 231 + expect(mapExcelFormat('¥#,##0')).toBe('currency'); 232 + expect(mapExcelFormat('£#,##0.00')).toBe('currency'); 233 + }); 234 + 235 + it('maps GBP text format', () => { 236 + expect(mapExcelFormat('[$GBP] #,##0.00')).toBe('currency'); 237 + }); 238 + 239 + it('maps integer number format with thousands separator', () => { 240 + expect(mapExcelFormat('#,##0')).toBe('number'); 241 + }); 242 + 243 + it('returns undefined for plain text format strings', () => { 244 + expect(mapExcelFormat('@')).toBeUndefined(); 245 + }); 246 + 247 + it('maps date format with dashes', () => { 248 + expect(mapExcelFormat('dd-mm-yyyy')).toBe('date'); 249 + }); 224 250 });