/** * excel-styles.ts — Pure helper for ExcelJS style extraction. * No dependency on office.ts internals; only imports exceljs types + stdlib. */ import type ExcelJS from 'exceljs'; // --- Public API --- export interface SheetStyleResult { legend: Array<{ id: string; sig: string }>; // s1 = "fill:#FFF2CC;font:bold,#9C0006" assignments: Array<{ id: string; ranges: string[] }>; // s1: ['A1:D1','A10:D10'] merges: string[]; // ['A1:D1'] conditionalFormatting: boolean; truncated: boolean; // true if range count hit the cap } /** * Parse from a theme1 XML string into a theme-index→'#RRGGBB' map. * Returns [] if themeXml is missing/unparseable (caller then emits raw theme refs). */ export function resolveThemePalette(themeXml: string | undefined): string[] { if (!themeXml) return []; // Extract the clrScheme block const schemeMatch = themeXml.match(/]*>([\s\S]*?)<\/a:clrScheme>/); if (!schemeMatch) return []; const schemeXml = schemeMatch[1]!; // The 12 entries in XML order: dk1, lt1, dk2, lt2, accent1-6, hlink, folHlink const tagNames = ['dk1', 'lt1', 'dk2', 'lt2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink']; const scheme: string[] = []; for (const tag of tagNames) { const tagMatch = schemeXml.match(new RegExp(`[\\s\\S]*?`)); if (!tagMatch) { // Missing entry return []; } const block = tagMatch[0]!; // Try srgbClr first const srgbMatch = block.match(/]*val="([0-9A-Fa-f]{6})"/); if (srgbMatch) { scheme.push('#' + srgbMatch[1]!.toUpperCase()); continue; } // Try sysClr with lastClr const sysMatch = block.match(/]*lastClr="([0-9A-Fa-f]{6})"/); if (sysMatch) { scheme.push('#' + sysMatch[1]!.toUpperCase()); continue; } // Unrecognized return []; } if (scheme.length < 12) return []; // Apply the well-known lt/dk swap: // themeIndex 0=Light1=lt1=scheme[1], 1=Dark1=dk1=scheme[0], // 2=Light2=lt2=scheme[3], 3=Dark2=dk2=scheme[2], // 4-9=accent1-6=scheme[4..9], 10=hlink=scheme[10], 11=folHlink=scheme[11] return [ scheme[1]!, // 0: Light1 (lt1) scheme[0]!, // 1: Dark1 (dk1) scheme[3]!, // 2: Light2 (lt2) scheme[2]!, // 3: Dark2 (dk2) scheme[4]!, // 4: accent1 scheme[5]!, // 5: accent2 scheme[6]!, // 6: accent3 scheme[7]!, // 7: accent4 scheme[8]!, // 8: accent5 scheme[9]!, // 9: accent6 scheme[10]!, // 10: hlink scheme[11]!, // 11: folHlink ]; } /** * OOXML tint applied to a '#RRGGBB' hex. tint in [-1,1]. Best-effort (HSL lum). * if (tint < 0) L = L * (1 + tint); else L = L * (1 - tint) + tint */ export function applyTint(hexRgb: string, tint: number): string { if (tint === 0) return hexRgb; // Parse hex to [0,1] const r = parseInt(hexRgb.slice(1, 3), 16) / 255; const g = parseInt(hexRgb.slice(3, 5), 16) / 255; const b = parseInt(hexRgb.slice(5, 7), 16) / 255; // RGB to HSL const max = Math.max(r, g, b); const min = Math.min(r, g, b); let h = 0; let s = 0; let l = (max + min) / 2; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; case g: h = ((b - r) / d + 2) / 6; break; case b: h = ((r - g) / d + 4) / 6; break; } } // Apply tint to luminance if (tint < 0) { l = l * (1 + tint); } else { l = l * (1 - tint) + tint; } l = Math.max(0, Math.min(1, l)); // HSL back to RGB function hue2rgb(p: number, q: number, t: number): number { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1/6) return p + (q - p) * 6 * t; if (t < 1/2) return q; if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; return p; } let rOut: number, gOut: number, bOut: number; if (s === 0) { rOut = gOut = bOut = l; } else { const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; rOut = hue2rgb(p, q, h + 1/3); gOut = hue2rgb(p, q, h); bOut = hue2rgb(p, q, h - 1/3); } const toHex = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0').toUpperCase(); return '#' + toHex(rOut) + toHex(gOut) + toHex(bOut); } // --- Internal helpers --- // Standard 56-color indexed palette (BIFF8/OOXML standard colors) // Index 64 = system fg, 65 = system bg → omit (return undefined) const INDEXED_COLORS: (string | undefined)[] = [ '#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF', '#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF', '#800000', '#008000', '#000080', '#808000', '#800080', '#008080', '#C0C0C0', '#808080', '#9999FF', '#993366', '#FFFFCC', '#CCFFFF', '#660066', '#FF8080', '#0066CC', '#CCCCFF', '#000080', '#FF00FF', '#FFFF00', '#00FFFF', '#800080', '#800000', '#008080', '#0000FF', '#00CCFF', '#CCFFFF', '#CCFFCC', '#FFFF99', '#99CCFF', '#FF99CC', '#CC99FF', '#FFCC99', '#3366FF', '#33CCCC', '#99CC00', '#FFCC00', '#FF9900', '#FF6600', '#666699', '#969696', '#003366', '#339966', '#003300', '#333300', '#993300', '#993366', '#333399', '#333333', undefined, // 64 = system fg undefined, // 65 = system bg ]; type ExcelJSColor = { argb?: string; theme?: number; tint?: number; indexed?: number }; function renderColor(color: ExcelJSColor | undefined, palette: string[]): string | null { if (!color) return null; if (color.argb) { // ARGB: 'FFRRGGBB' → '#RRGGBB' const argb = color.argb; if (argb.length === 8) { return '#' + argb.slice(2).toUpperCase(); } return null; } if (color.theme !== undefined) { const tint = color.tint ?? 0; if (palette.length > 0 && palette[color.theme]) { const resolved = applyTint(palette[color.theme]!, tint); if (tint !== 0) { return `theme(${color.theme},tint=${tint.toFixed(4)},resolved=${resolved})`; } return `theme(${color.theme},resolved=${resolved})`; } // Palette empty or missing this index — raw only if (tint !== 0) { return `theme(${color.theme},tint=${tint.toFixed(4)})`; } return `theme(${color.theme})`; } if (color.indexed !== undefined) { const hex = INDEXED_COLORS[color.indexed]; if (!hex) return null; // system fg/bg return `indexed(${color.indexed},${hex})`; } return null; } /** * Returns true if a rendered color token represents the default color for the given theme index. * Handles both `theme(N)` (no palette) and `theme(N,resolved=...)` (palette resolved) forms. * Used to suppress the default font color (theme 1 = Dark1 ≈ black) and default border color * (theme 0 = Light1 ≈ white/auto) from style signatures. */ function isDefaultColorToken(token: string, defaultThemeIndex: number): boolean { // Exact bare form: "theme(N)" if (token === `theme(${defaultThemeIndex})`) return true; // Resolved form: "theme(N,resolved=#RRGGBB)" — tint variants are NOT default if (token.startsWith(`theme(${defaultThemeIndex},resolved=`) && !token.includes(',tint=')) return true; return false; } /** * Build a canonical style signature string from non-default cell parts. * Returns null if the cell has no non-default styling. */ function cellStyleSignature( cell: ExcelJS.Cell, palette: string[], ): string | null { const parts: string[] = []; // Fill: only pattern=solid (or other patterns) with fgColor const fill = cell.fill as ExcelJS.Fill | undefined; if (fill && (fill.type === 'pattern' || fill.type === 'gradient')) { if (fill.type === 'pattern' && fill.pattern && fill.pattern !== 'none') { const pf = fill as ExcelJS.FillPattern; if (pf.fgColor) { const colorStr = renderColor(pf.fgColor as ExcelJSColor, palette); if (colorStr) { parts.push(`fill:${colorStr}`); } } } else if (fill.type === 'gradient') { parts.push('fill:gradient'); } } // Font: only explicit flags + explicit color const font = cell.font as ExcelJS.Font | undefined; if (font) { const fontParts: string[] = []; if (font.bold === true) fontParts.push('bold'); if (font.italic === true) fontParts.push('italic'); if (font.underline) fontParts.push('underline'); // Color: only include if explicitly set (not the default theme 1 / auto-color) const fontColor = renderColor(font.color as ExcelJSColor | undefined, palette); if (fontColor && !isDefaultColorToken(fontColor, 1)) { fontParts.push(fontColor); } if (fontParts.length > 0) { parts.push(`font:${fontParts.join(',')}`); } } // Border: for each side with a style const border = cell.border as ExcelJS.Borders | undefined; if (border) { const sides: string[] = []; for (const side of ['top', 'bottom', 'left', 'right', 'diagonal'] as const) { const edge = border[side] as ExcelJS.Border | undefined; if (edge?.style) { const colorStr = renderColor(edge.color as ExcelJSColor | undefined, palette); if (colorStr && !isDefaultColorToken(colorStr, 0)) { sides.push(`${side}(${edge.style},${colorStr})`); } else { sides.push(`${side}(${edge.style})`); } } } if (sides.length > 0) { parts.push(`border:${sides.join(',')}`); } } // numFmt const numFmt = cell.numFmt as string | undefined; if (numFmt && numFmt !== 'General' && numFmt !== '') { parts.push(`numFmt:"${numFmt}"`); } // Alignment const alignment = cell.alignment as ExcelJS.Alignment | undefined; if (alignment) { const alignParts: string[] = []; if (alignment.horizontal) alignParts.push(alignment.horizontal); if (alignment.vertical) alignParts.push(alignment.vertical); if (alignment.wrapText) alignParts.push('wrap'); if (alignParts.length > 0) { parts.push(`align:${alignParts.join(',')}`); } } if (parts.length === 0) return null; return parts.join(';'); } /** Parse an Excel range like "A1:C3" into 1-based row/col bounds. Returns null on parse failure. */ function parseRange(rangeStr: string): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null { const match = rangeStr.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i); if (!match) return null; function letterToCol(letters: string): number { let col = 0; for (const ch of letters.toUpperCase()) { col = col * 26 + (ch.charCodeAt(0) - 64); } return col; } return { minRow: parseInt(match[2]!, 10), maxRow: parseInt(match[4]!, 10), minCol: letterToCol(match[1]!), maxCol: letterToCol(match[3]!), }; } /** Convert 1-based column index to Excel column letter(s). */ function colIndexToLetter(colIdx: number): string { let s = ''; let n = colIdx; while (n > 0) { const rem = (n - 1) % 26; s = String.fromCharCode(65 + rem) + s; n = Math.floor((n - 1) / 26); } return s; } /** * Scan a worksheet (includeEmpty:true so decorated-but-empty cells count) and * produce the dedup legend + range map. `palette` from resolveThemePalette. * * @param rangeBounds - When provided, only cells within [minRow..maxRow] x [minCol..maxCol] * are scanned, and merges are filtered to those intersecting this region. This must match * the same range filter applied to the values table so styles and values cover the same cells. */ export function extractSheetStyles( ws: ExcelJS.Worksheet, palette: string[], maxRanges: number, rangeBounds?: { minRow: number; maxRow: number; minCol: number; maxCol: number }, ): SheetStyleResult { // Step 1: Scan all cells, build sig→id map and per-sig cell list const sigToId = new Map(); const cellsBySig = new Map>(); let counter = 0; ws.eachRow({ includeEmpty: true }, (row, r) => { if (rangeBounds && (r < rangeBounds.minRow || r > rangeBounds.maxRow)) return; row.eachCell({ includeEmpty: true }, (cell, c) => { if (rangeBounds && (c < rangeBounds.minCol || c > rangeBounds.maxCol)) return; const sig = cellStyleSignature(cell, palette); if (!sig) return; if (!sigToId.has(sig)) { sigToId.set(sig, `s${++counter}`); cellsBySig.set(sig, []); } cellsBySig.get(sig)!.push([r, c]); }); }); // Step 2: Build legend in id order const idOrder: Array<{ id: string; sig: string }> = []; for (const [sig, id] of sigToId) { idOrder.push({ id, sig }); } idOrder.sort((a, b) => { const na = parseInt(a.id.slice(1), 10); const nb = parseInt(b.id.slice(1), 10); return na - nb; }); // Step 3: For each sig, group cells into rectangles const assignments: Array<{ id: string; ranges: string[] }> = []; let totalRanges = 0; let truncated = false; for (const { id, sig } of idOrder) { if (truncated) break; const cells = cellsBySig.get(sig)!; // Bucket by row const byRow = new Map(); for (const [r, c] of cells) { if (!byRow.has(r)) byRow.set(r, []); byRow.get(r)!.push(c); } // For each row, sort and split into contiguous horizontal runs // A run is (row, colStart, colEnd) const allRuns: Array<{ r: number; c1: number; c2: number }> = []; for (const [r, cols] of byRow) { cols.sort((a, b) => a - b); let start = cols[0]!; let prev = cols[0]!; for (let i = 1; i < cols.length; i++) { if (cols[i]! !== prev + 1) { allRuns.push({ r, c1: start, c2: prev }); start = cols[i]!; } prev = cols[i]!; } allRuns.push({ r, c1: start, c2: prev }); } // Sort runs by row, then c1 allRuns.sort((a, b) => a.r !== b.r ? a.r - b.r : a.c1 - b.c1); // Merge vertically: greedy - open rectangles extended when next row has same c1,c2. // O(K) implementation: openRects is keyed by (c1,c2); after finishing each row r, // any rect not extended this row (r2 !== r) is flushed to closedRects immediately. type Rect = { r1: number; r2: number; c1: number; c2: number }; // Key: `${c1},${c2}` → open rect const openMap = new Map(); const closedRects: Rect[] = []; // Group runs by row so we can flush after each row const runsByRow = new Map>(); for (const run of allRuns) { if (!runsByRow.has(run.r)) runsByRow.set(run.r, []); runsByRow.get(run.r)!.push({ c1: run.c1, c2: run.c2 }); } // Process rows in ascending order const sortedRows = [...runsByRow.keys()].sort((a, b) => a - b); for (const r of sortedRows) { const runs = runsByRow.get(r)!; const extendedKeys = new Set(); for (const { c1, c2 } of runs) { const key = `${c1},${c2}`; const existing = openMap.get(key); if (existing && existing.r2 === r - 1) { // Extend the open rect into this row existing.r2 = r; extendedKeys.add(key); } else { // If there was a stale open rect with this key, close it first if (existing) { closedRects.push(existing); } // Open a new rect openMap.set(key, { r1: r, r2: r, c1, c2 }); extendedKeys.add(key); } } // Flush any open rects that were NOT extended this row (r2 !== r) for (const [key, rect] of openMap) { if (rect.r2 !== r) { closedRects.push(rect); openMap.delete(key); } } } // Close all remaining open rects for (const rect of openMap.values()) { closedRects.push(rect); } // Emit ranges const ranges: string[] = []; for (const rect of closedRects) { if (totalRanges >= maxRanges) { truncated = true; break; } let rangeStr: string; if (rect.r1 === rect.r2 && rect.c1 === rect.c2) { rangeStr = `${colIndexToLetter(rect.c1)}${rect.r1}`; } else { rangeStr = `${colIndexToLetter(rect.c1)}${rect.r1}:${colIndexToLetter(rect.c2)}${rect.r2}`; } ranges.push(rangeStr); totalRanges++; } if (ranges.length > 0) { assignments.push({ id, ranges }); } if (truncated) break; } // Step 4: Merges from ws.model.merges (filtered to rangeBounds when provided) const allMerges: string[] = (ws.model.merges as string[] | undefined) ?? []; const merges: string[] = rangeBounds ? allMerges.filter((m) => { // Parse merge range "A1:C3" and check for intersection with rangeBounds const mp = parseRange(m); if (!mp) return true; // unparseable: keep it // Two rectangles intersect if NOT (one is completely to the side/above/below the other) return !(mp.maxRow < rangeBounds.minRow || mp.minRow > rangeBounds.maxRow || mp.maxCol < rangeBounds.minCol || mp.minCol > rangeBounds.maxCol); }) : allMerges; // Step 5: Conditional formatting const conditionalFormatting = Boolean( ((ws as unknown) as { conditionalFormattings?: unknown[] }).conditionalFormattings?.length ); return { legend: idOrder, assignments, merges, conditionalFormatting, truncated, }; }