mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
feat(preview): render XLSX spreadsheets in the file-preview overlay
xlsx files were download-only. Add a read-only, virtualized preview (sheet tabs, number formats, merges, theme colours) parsed entirely in a browser Web Worker with exceljs and fflate, loaded only when a spreadsheet is opened. The workbook is checked against ZIP-bomb, entry and cell limits before exceljs loads; cell text is written with textContent, formulas are never evaluated and nothing referenced by the workbook is fetched. On the server xlsx only joins the existing allowlist and classification, with a 10 MB cap on ?preview=true. xls and ods stay download-only.
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* @fileoverview Pure XLSX admission, formatting, and sparse-grid helpers.
|
||||
*
|
||||
* Loaded only inside spreadsheet-preview-worker.js (never by the page) and by
|
||||
* test/spreadsheet-xlsx-core.test.ts. `admitXlsx()` walks the ZIP central
|
||||
* directory and streams every entry through fflate BEFORE ExcelJS sees the
|
||||
* bytes, enforcing {@link LIMITS}; a workbook that trips any cap is refused
|
||||
* rather than truncated.
|
||||
*/
|
||||
|
||||
(function initSpreadsheetXlsxCore(global) {
|
||||
'use strict';
|
||||
|
||||
const LIMITS = Object.freeze({
|
||||
maxEntries: 5000,
|
||||
maxInflatedBytes: 64 * 1024 * 1024,
|
||||
maxEntryBytes: 32 * 1024 * 1024,
|
||||
maxCompressionRatio: 100,
|
||||
maxWorksheets: 50,
|
||||
maxCells: 250000,
|
||||
maxCellsPerSheet: 100000,
|
||||
maxMergesPerSheet: 5000,
|
||||
maxStyles: 5000,
|
||||
});
|
||||
const MAX_ROW = 1048576;
|
||||
const MAX_COL = 16384;
|
||||
|
||||
class XlsxPreviewError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = 'XlsxPreviewError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(code, message) {
|
||||
throw new XlsxPreviewError(code, message);
|
||||
}
|
||||
|
||||
function mergedLimits(overrides) {
|
||||
return Object.assign({}, LIMITS, overrides || {});
|
||||
}
|
||||
|
||||
function u16(bytes, offset) {
|
||||
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(offset, true);
|
||||
}
|
||||
|
||||
function u32(bytes, offset) {
|
||||
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true);
|
||||
}
|
||||
|
||||
function inspectZipDirectory(bytes, overrides) {
|
||||
const limits = mergedLimits(overrides);
|
||||
if (bytes.length >= 4 && bytes[0] === 0xd0 && bytes[1] === 0xcf && bytes[2] === 0x11 && bytes[3] === 0xe0) {
|
||||
fail('encrypted', 'Encrypted or legacy OLE workbooks cannot be previewed');
|
||||
}
|
||||
let eocd = -1;
|
||||
const floor = Math.max(0, bytes.length - 65557);
|
||||
for (let i = bytes.length - 22; i >= floor; i -= 1) {
|
||||
if (u32(bytes, i) === 0x06054b50) {
|
||||
eocd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocd < 0) fail('malformed', 'Malformed XLSX ZIP directory');
|
||||
const entryCount = u16(bytes, eocd + 10);
|
||||
const directorySize = u32(bytes, eocd + 12);
|
||||
const directoryOffset = u32(bytes, eocd + 16);
|
||||
if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) {
|
||||
fail('zip64', 'ZIP64 workbooks are not supported');
|
||||
}
|
||||
if (entryCount > limits.maxEntries) fail('entry-limit', `Workbook exceeds ${limits.maxEntries} ZIP entries`);
|
||||
if (directoryOffset + directorySize > eocd) fail('malformed', 'Malformed XLSX central directory bounds');
|
||||
const entries = [];
|
||||
let cursor = directoryOffset;
|
||||
for (let i = 0; i < entryCount; i += 1) {
|
||||
if (cursor + 46 > eocd || u32(bytes, cursor) !== 0x02014b50)
|
||||
fail('malformed', 'Malformed XLSX central directory');
|
||||
const compressedSize = u32(bytes, cursor + 20);
|
||||
const declaredSize = u32(bytes, cursor + 24);
|
||||
const nameLength = u16(bytes, cursor + 28);
|
||||
const extraLength = u16(bytes, cursor + 30);
|
||||
const commentLength = u16(bytes, cursor + 32);
|
||||
const localHeaderOffset = u32(bytes, cursor + 42);
|
||||
if (compressedSize === 0xffffffff || declaredSize === 0xffffffff)
|
||||
fail('zip64', 'ZIP64 entries are not supported');
|
||||
const end = cursor + 46 + nameLength + extraLength + commentLength;
|
||||
if (end > eocd) fail('malformed', 'Malformed XLSX entry bounds');
|
||||
const name = new TextDecoder().decode(bytes.subarray(cursor + 46, cursor + 46 + nameLength));
|
||||
if (localHeaderOffset + 30 > directoryOffset || u32(bytes, localHeaderOffset) !== 0x04034b50) {
|
||||
fail('malformed', 'Malformed XLSX local file header');
|
||||
}
|
||||
const localNameLength = u16(bytes, localHeaderOffset + 26);
|
||||
const localExtraLength = u16(bytes, localHeaderOffset + 28);
|
||||
const localNameEnd = localHeaderOffset + 30 + localNameLength;
|
||||
if (localNameEnd + localExtraLength > directoryOffset) fail('malformed', 'Malformed XLSX local entry bounds');
|
||||
const localName = new TextDecoder().decode(bytes.subarray(localHeaderOffset + 30, localNameEnd));
|
||||
if (localName !== name) fail('malformed', 'XLSX local and central directory names do not match');
|
||||
entries.push({ name, compressedSize, declaredSize, localHeaderOffset });
|
||||
cursor = end;
|
||||
}
|
||||
return { entries };
|
||||
}
|
||||
|
||||
function featureForName(name) {
|
||||
if (name.startsWith('xl/charts/')) return 'charts';
|
||||
if (name.startsWith('xl/drawings/')) return 'drawings';
|
||||
if (name.startsWith('xl/pivotCache/')) return 'pivotTables';
|
||||
if (name.startsWith('xl/externalLinks/')) return 'externalLinks';
|
||||
if (/vbaProject\.bin$/i.test(name)) return 'macros';
|
||||
return null;
|
||||
}
|
||||
|
||||
function createXmlCounter(name, counts, limits) {
|
||||
let tail = '';
|
||||
const decoder = new TextDecoder();
|
||||
let sheetCells = 0;
|
||||
let sheetMerges = 0;
|
||||
let inCellXfs = false;
|
||||
const worksheet = /^xl\/worksheets\/[^/]+\.xml$/i.test(name);
|
||||
const styles = name === 'xl/styles.xml';
|
||||
return {
|
||||
push(chunk, final) {
|
||||
if (!worksheet && !styles) return;
|
||||
const text = tail + decoder.decode(chunk, { stream: !final });
|
||||
const safeEnd = final ? text.length : Math.max(0, text.length - 128);
|
||||
const scan = text.slice(0, safeEnd);
|
||||
if (worksheet) {
|
||||
const cells = (scan.match(/<c(?:\s|>)/g) || []).length;
|
||||
const merges = (scan.match(/<mergeCell(?:\s|>)/g) || []).length;
|
||||
sheetCells += cells;
|
||||
sheetMerges += merges;
|
||||
counts.cells += cells;
|
||||
counts.merges += merges;
|
||||
if (sheetCells > limits.maxCellsPerSheet || counts.cells > limits.maxCells)
|
||||
fail('cell-limit', 'Workbook exceeds the cells limit');
|
||||
if (sheetMerges > limits.maxMergesPerSheet) fail('merge-limit', 'Worksheet exceeds the merged ranges limit');
|
||||
}
|
||||
if (styles) {
|
||||
const tokens = scan.match(/<cellXfs(?:\s|>)|<\/cellXfs\s*>|<xf(?:\s|\/?>)/g) || [];
|
||||
for (const token of tokens) {
|
||||
if (token.startsWith('<cellXfs')) inCellXfs = true;
|
||||
else if (token.startsWith('</cellXfs')) inCellXfs = false;
|
||||
else if (inCellXfs) counts.styles += 1;
|
||||
}
|
||||
if (counts.styles > limits.maxStyles) fail('style-limit', 'Workbook exceeds the cell styles limit');
|
||||
}
|
||||
tail = text.slice(safeEnd);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function admitXlsx(bytes, zipApi, overrides) {
|
||||
const limits = mergedLimits(overrides);
|
||||
const directory = inspectZipDirectory(bytes, limits);
|
||||
const directoryByName = new Map(directory.entries.map((entry) => [entry.name, entry]));
|
||||
const expectedEntries = new Map();
|
||||
for (const entry of directory.entries) expectedEntries.set(entry.name, (expectedEntries.get(entry.name) || 0) + 1);
|
||||
const streamedEntries = new Map();
|
||||
const counts = { worksheets: 0, cells: 0, merges: 0, styles: 0 };
|
||||
const features = new Set();
|
||||
let totalInflated = 0;
|
||||
let seenEntries = 0;
|
||||
let thrown;
|
||||
const unzip = new zipApi.Unzip((file) => {
|
||||
if (!directoryByName.has(file.name)) fail('malformed', 'Local XLSX entry is absent from the central directory');
|
||||
streamedEntries.set(file.name, (streamedEntries.get(file.name) || 0) + 1);
|
||||
seenEntries += 1;
|
||||
if (seenEntries > limits.maxEntries) fail('entry-limit', 'Workbook exceeds the ZIP entries limit');
|
||||
if (/^xl\/worksheets\/[^/]+\.xml$/i.test(file.name)) {
|
||||
counts.worksheets += 1;
|
||||
if (counts.worksheets > limits.maxWorksheets) fail('worksheet-limit', 'Workbook exceeds the worksheet limit');
|
||||
}
|
||||
const feature = featureForName(file.name);
|
||||
if (feature) features.add(feature);
|
||||
const counter = createXmlCounter(file.name, counts, limits);
|
||||
let entryInflated = 0;
|
||||
file.ondata = (error, chunk, final) => {
|
||||
if (error) throw error;
|
||||
entryInflated += chunk.length;
|
||||
totalInflated += chunk.length;
|
||||
if (entryInflated > limits.maxEntryBytes) fail('entry-size', 'Inflated ZIP entry exceeds the entry limit');
|
||||
if (totalInflated > limits.maxInflatedBytes) fail('inflated-size', 'Workbook exceeds the inflated bytes limit');
|
||||
const compressed = directoryByName.get(file.name)?.compressedSize || 1;
|
||||
if (entryInflated / Math.max(1, compressed) > limits.maxCompressionRatio) {
|
||||
fail('compression-ratio', 'ZIP entry exceeds the compression ratio limit');
|
||||
}
|
||||
counter.push(chunk, final);
|
||||
};
|
||||
file.start();
|
||||
});
|
||||
unzip.register(zipApi.UnzipInflate);
|
||||
try {
|
||||
const inputChunkBytes = 64 * 1024;
|
||||
for (let offset = 0; offset < bytes.length && !thrown; offset += inputChunkBytes) {
|
||||
const end = Math.min(bytes.length, offset + inputChunkBytes);
|
||||
unzip.push(bytes.subarray(offset, end), end === bytes.length);
|
||||
}
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
if (thrown) throw thrown;
|
||||
for (const [name, count] of expectedEntries) {
|
||||
if (streamedEntries.get(name) !== count) fail('malformed', 'Central XLSX entry was not streamed for admission');
|
||||
}
|
||||
return { counts, features: Array.from(features), inflatedBytes: totalInflated };
|
||||
}
|
||||
|
||||
function parseCellRef(ref) {
|
||||
const match = /^\$?([A-Z]{1,3})\$?([1-9]\d*)$/i.exec(String(ref || ''));
|
||||
if (!match) return null;
|
||||
let col = 0;
|
||||
for (const char of match[1].toUpperCase()) col = col * 26 + char.charCodeAt(0) - 64;
|
||||
const row = Number(match[2]);
|
||||
return row <= MAX_ROW && col <= MAX_COL ? { row, col } : null;
|
||||
}
|
||||
|
||||
function parseRange(range) {
|
||||
const parts = String(range).split(':');
|
||||
const start = parseCellRef(parts[0]);
|
||||
const end = parseCellRef(parts[1] || parts[0]);
|
||||
return start && end ? { r1: start.row, c1: start.col, r2: end.row, c2: end.col } : null;
|
||||
}
|
||||
|
||||
function deriveExtent(cells, merges) {
|
||||
let rows = 0;
|
||||
let cols = 0;
|
||||
for (const ref of cells) {
|
||||
const cell = parseCellRef(ref);
|
||||
if (cell) {
|
||||
rows = Math.max(rows, cell.row);
|
||||
cols = Math.max(cols, cell.col);
|
||||
}
|
||||
}
|
||||
for (const merge of merges) {
|
||||
const range = parseRange(merge);
|
||||
if (range) {
|
||||
rows = Math.max(rows, range.r2);
|
||||
cols = Math.max(cols, range.c2);
|
||||
}
|
||||
}
|
||||
return { rows, cols };
|
||||
}
|
||||
|
||||
function createSparseAxis(count, defaultSize, overrides) {
|
||||
const sorted = Array.from(overrides || [])
|
||||
.filter(([index, size]) => index >= 1 && index <= count && Number.isFinite(size))
|
||||
.map(([index, size]) => [index, Math.max(0, size)])
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
return { count: Math.max(0, count), defaultSize: Math.max(0, defaultSize), overrides: sorted };
|
||||
}
|
||||
|
||||
function axisOffset(axis, index) {
|
||||
const bounded = Math.max(1, Math.min(axis.count + 1, index));
|
||||
let offset = (bounded - 1) * axis.defaultSize;
|
||||
for (const [overrideIndex, size] of axis.overrides) {
|
||||
if (overrideIndex >= bounded) break;
|
||||
offset += size - axis.defaultSize;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
function axisIndexAt(axis, offset) {
|
||||
let low = 1;
|
||||
let high = Math.max(1, axis.count);
|
||||
const target = Math.max(0, offset);
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high + 1) / 2);
|
||||
if (axisOffset(axis, mid) <= target) low = mid;
|
||||
else high = mid - 1;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
function computeViewport(axis, offset, viewportSize, overscan) {
|
||||
const pad = Math.max(0, overscan || 0);
|
||||
const start = Math.max(1, axisIndexAt(axis, offset) - pad);
|
||||
const end = Math.min(axis.count, axisIndexAt(axis, offset + Math.max(0, viewportSize)) + pad);
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
function intersectingMerges(merges, viewport) {
|
||||
return merges.filter((merge) => {
|
||||
const range = parseRange(merge);
|
||||
return (
|
||||
range &&
|
||||
range.r1 <= viewport.r2 &&
|
||||
range.r2 >= viewport.r1 &&
|
||||
range.c1 <= viewport.c2 &&
|
||||
range.c2 >= viewport.c1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function excelDate(serial, date1904) {
|
||||
if (date1904) return new Date(Date.UTC(1904, 0, 1) + Number(serial) * 86400000);
|
||||
const numeric = Number(serial);
|
||||
const adjusted = numeric >= 60 ? numeric - 1 : numeric;
|
||||
return new Date(Date.UTC(1899, 11, 31) + adjusted * 86400000);
|
||||
}
|
||||
|
||||
function formatCellValue(value, format, date1904) {
|
||||
if (value && typeof value === 'object' && 'formula' in value) {
|
||||
if (value.result !== undefined && value.result !== null) return formatCellValue(value.result, format, date1904);
|
||||
return { text: `=${String(value.formula)}`, warning: 'Formula has no cached result' };
|
||||
}
|
||||
if (value === null || value === undefined) return { text: '' };
|
||||
const code = String(format || 'General');
|
||||
if (typeof value !== 'number') return { text: String(value) };
|
||||
if (/^General$/i.test(code)) return { text: String(value) };
|
||||
if (/^[ymd\-/ ]+$/i.test(code)) {
|
||||
const date = excelDate(value, Boolean(date1904));
|
||||
const yyyy = date.getUTCFullYear();
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getUTCDate()).padStart(2, '0');
|
||||
return { text: `${yyyy}-${mm}-${dd}` };
|
||||
}
|
||||
if (/^[hms: ]+$/i.test(code)) {
|
||||
const seconds = Math.round((value - Math.floor(value)) * 86400) % 86400;
|
||||
const hh = String(Math.floor(seconds / 3600)).padStart(2, '0');
|
||||
const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
|
||||
const ss = String(seconds % 60).padStart(2, '0');
|
||||
return { text: `${hh}:${mm}:${ss}` };
|
||||
}
|
||||
if (/^[ymdhis\-/: ]+$/i.test(code)) {
|
||||
const date = excelDate(value, Boolean(date1904));
|
||||
const yyyy = date.getUTCFullYear();
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getUTCDate()).padStart(2, '0');
|
||||
const hh = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
return { text: `${yyyy}-${mm}-${dd} ${hh}:${minutes}` };
|
||||
}
|
||||
const percent = code.includes('%');
|
||||
const decimals = code.match(/\.([0#]+)/)?.[1].length || 0;
|
||||
const numericPattern = /^[€£¥$]?[#,0]+(?:\.[0#]+)?%?$/;
|
||||
if (numericPattern.test(code)) {
|
||||
const currency = /^[€£¥$]/.exec(code)?.[0] || '';
|
||||
const numeric = percent ? value * 100 : value;
|
||||
const useGrouping = code.includes(',');
|
||||
return {
|
||||
text:
|
||||
currency +
|
||||
numeric.toLocaleString('en-US', {
|
||||
useGrouping,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}) +
|
||||
(percent ? '%' : ''),
|
||||
};
|
||||
}
|
||||
return { text: String(value), warning: `Unsupported number format: ${code}` };
|
||||
}
|
||||
|
||||
// Colour resolution --------------------------------------------------------
|
||||
//
|
||||
// ExcelJS surfaces theme and indexed palette colours WITHOUT an `argb` key
|
||||
// (`{theme,tint}` / `{indexed}`), and theme colours are what Excel emits by
|
||||
// default. Dropping them left cells rendering the workbook's font colour on
|
||||
// the skin's own background, which is how black-on-dark (invisible) text got
|
||||
// shipped. Everything below is pure so `test/spreadsheet-xlsx-core.test.ts`
|
||||
// can pin it without a DOM.
|
||||
|
||||
// styles.xml `theme="N"` order. NOTE: theme1.xml's <a:clrScheme> lists
|
||||
// dk1, lt1, dk2, lt2 — indices 0/1 and 2/3 are SWAPPED between the two.
|
||||
const THEME_SLOT_ORDER = Object.freeze([
|
||||
'lt1',
|
||||
'dk1',
|
||||
'lt2',
|
||||
'dk2',
|
||||
'accent1',
|
||||
'accent2',
|
||||
'accent3',
|
||||
'accent4',
|
||||
'accent5',
|
||||
'accent6',
|
||||
'hlink',
|
||||
'folHlink',
|
||||
]);
|
||||
|
||||
// Default Office theme, used when theme1.xml is missing or unparseable.
|
||||
const DEFAULT_THEME_PALETTE = Object.freeze([
|
||||
'#ffffff',
|
||||
'#000000',
|
||||
'#eeece1',
|
||||
'#1f497d',
|
||||
'#4f81bd',
|
||||
'#c0504d',
|
||||
'#9bbb59',
|
||||
'#8064a2',
|
||||
'#4bacc6',
|
||||
'#f79646',
|
||||
'#0000ff',
|
||||
'#800080',
|
||||
]);
|
||||
|
||||
// Legacy 64-entry indexed palette. Indices 64/65 are the "auto" foreground and
|
||||
// background sentinels and deliberately have no entry here.
|
||||
// prettier-ignore
|
||||
const INDEXED_PALETTE = Object.freeze([
|
||||
'#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',
|
||||
]);
|
||||
|
||||
const MIN_CONTRAST_RATIO = 4.5;
|
||||
// A workbook with no fill renders on Excel's implicit white sheet background,
|
||||
// never on the viewer skin's `var(--bg-primary)`.
|
||||
const IMPLICIT_SHEET_BACKGROUND = '#ffffff';
|
||||
const IMPLICIT_SHEET_FOREGROUND = '#000000';
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex || ''));
|
||||
if (!match) return null;
|
||||
return [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)];
|
||||
}
|
||||
|
||||
function rgbToHex(rgb) {
|
||||
let hex = '#';
|
||||
for (const channel of rgb) {
|
||||
const bounded = Math.max(0, Math.min(255, Math.round(channel)));
|
||||
hex += (bounded < 16 ? '0' : '') + bounded.toString(16);
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function rgbToHsl(rgb) {
|
||||
const r = rgb[0] / 255;
|
||||
const g = rgb[1] / 255;
|
||||
const b = rgb[2] / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return { h: 0, s: 0, l };
|
||||
const delta = max - min;
|
||||
const s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min);
|
||||
let h;
|
||||
if (max === r) h = (g - b) / delta + (g < b ? 6 : 0);
|
||||
else if (max === g) h = (b - r) / delta + 2;
|
||||
else h = (r - g) / delta + 4;
|
||||
return { h: h / 6, s, l };
|
||||
}
|
||||
|
||||
function hueToChannel(p, q, hue) {
|
||||
let t = hue;
|
||||
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;
|
||||
}
|
||||
|
||||
function hslToRgb(hsl) {
|
||||
if (hsl.s === 0) {
|
||||
const gray = hsl.l * 255;
|
||||
return [gray, gray, gray];
|
||||
}
|
||||
const q = hsl.l < 0.5 ? hsl.l * (1 + hsl.s) : hsl.l + hsl.s - hsl.l * hsl.s;
|
||||
const p = 2 * hsl.l - q;
|
||||
return [
|
||||
hueToChannel(p, q, hsl.h + 1 / 3) * 255,
|
||||
hueToChannel(p, q, hsl.h) * 255,
|
||||
hueToChannel(p, q, hsl.h - 1 / 3) * 255,
|
||||
];
|
||||
}
|
||||
|
||||
// Excel tint acts on HSL luminance: negative darkens, positive lightens.
|
||||
function applyTint(hex, tint) {
|
||||
const amount = Number(tint);
|
||||
if (!Number.isFinite(amount) || amount === 0) return hex;
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return hex;
|
||||
const bounded = Math.max(-1, Math.min(1, amount));
|
||||
const hsl = rgbToHsl(rgb);
|
||||
const luminance = bounded < 0 ? hsl.l * (1 + bounded) : hsl.l * (1 - bounded) + bounded;
|
||||
return rgbToHex(hslToRgb({ h: hsl.h, s: hsl.s, l: Math.max(0, Math.min(1, luminance)) }));
|
||||
}
|
||||
|
||||
function parseSchemeColor(fragment) {
|
||||
const srgb = /<(?:[A-Za-z0-9_]+:)?srgbClr\b[^>]*\bval="([0-9A-Fa-f]{6})"/.exec(fragment);
|
||||
if (srgb) return `#${srgb[1].toLowerCase()}`;
|
||||
const sys = /<(?:[A-Za-z0-9_]+:)?sysClr\b[^>]*\blastClr="([0-9A-Fa-f]{6})"/.exec(fragment);
|
||||
if (sys) return `#${sys[1].toLowerCase()}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseThemePalette(xml) {
|
||||
const palette = DEFAULT_THEME_PALETTE.slice();
|
||||
const text = typeof xml === 'string' ? xml : '';
|
||||
const scheme = /<(?:[A-Za-z0-9_]+:)?clrScheme\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_]+:)?clrScheme\s*>/.exec(text);
|
||||
if (!scheme) return palette;
|
||||
// Fresh pattern per call: a shared /g regex would carry lastIndex across calls.
|
||||
const slots =
|
||||
/<(?:[A-Za-z0-9_]+:)?(lt1|dk1|lt2|dk2|accent[1-6]|hlink|folHlink)\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_]+:)?\1\s*>/g;
|
||||
let match = slots.exec(scheme[1]);
|
||||
while (match) {
|
||||
const index = THEME_SLOT_ORDER.indexOf(match[1]);
|
||||
const resolved = index >= 0 ? parseSchemeColor(match[2]) : undefined;
|
||||
if (resolved) palette[index] = resolved;
|
||||
match = slots.exec(scheme[1]);
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
function themePaletteOrDefault(palette) {
|
||||
return Array.isArray(palette) && palette.length === THEME_SLOT_ORDER.length ? palette : DEFAULT_THEME_PALETTE;
|
||||
}
|
||||
|
||||
// Accepts every colour shape ExcelJS emits: {argb}, {theme,tint}, {indexed}.
|
||||
function resolveColor(color, palette) {
|
||||
if (!color || typeof color !== 'object') return undefined;
|
||||
const argb = color.argb;
|
||||
if (typeof argb === 'string' && /^[A-Fa-f0-9]{8}$/.test(argb)) return `#${argb.slice(2).toLowerCase()}`;
|
||||
const theme = color.theme;
|
||||
if (Number.isInteger(theme) && theme >= 0 && theme < THEME_SLOT_ORDER.length) {
|
||||
const base = themePaletteOrDefault(palette)[theme];
|
||||
return typeof base === 'string' ? applyTint(base, color.tint) : undefined;
|
||||
}
|
||||
const indexed = color.indexed;
|
||||
if (Number.isInteger(indexed) && indexed >= 0 && indexed < INDEXED_PALETTE.length) return INDEXED_PALETTE[indexed];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function channelLuminance(channel) {
|
||||
const value = channel / 255;
|
||||
return value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
function relativeLuminance(hex) {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return 0;
|
||||
return 0.2126 * channelLuminance(rgb[0]) + 0.7152 * channelLuminance(rgb[1]) + 0.0722 * channelLuminance(rgb[2]);
|
||||
}
|
||||
|
||||
function contrastRatio(a, b) {
|
||||
const first = relativeLuminance(a);
|
||||
const second = relativeLuminance(b);
|
||||
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
|
||||
}
|
||||
|
||||
function ensureContrast(foreground, background, minRatio) {
|
||||
const minimum = Number.isFinite(minRatio) ? minRatio : MIN_CONTRAST_RATIO;
|
||||
if (contrastRatio(foreground, background) >= minimum) return foreground;
|
||||
return contrastRatio('#000000', background) >= contrastRatio('#ffffff', background) ? '#000000' : '#ffffff';
|
||||
}
|
||||
|
||||
// Returns a SELF-CONSISTENT pair, or nothing at all. Emitting only one half is
|
||||
// what let workbook text land on the skin's background and vanish.
|
||||
function resolveCellColors(fillColor, fontColor, palette) {
|
||||
const fill = resolveColor(fillColor, palette);
|
||||
const font = resolveColor(fontColor, palette);
|
||||
if (!fill && !font) return {};
|
||||
const background = fill || IMPLICIT_SHEET_BACKGROUND;
|
||||
return { background, foreground: ensureContrast(font || IMPLICIT_SHEET_FOREGROUND, background) };
|
||||
}
|
||||
|
||||
global.CodemanSpreadsheetXlsxCore = Object.freeze({
|
||||
LIMITS,
|
||||
MAX_ROW,
|
||||
MAX_COL,
|
||||
XlsxPreviewError,
|
||||
inspectZipDirectory,
|
||||
admitXlsx,
|
||||
parseCellRef,
|
||||
parseRange,
|
||||
deriveExtent,
|
||||
createSparseAxis,
|
||||
axisOffset,
|
||||
axisIndexAt,
|
||||
computeViewport,
|
||||
intersectingMerges,
|
||||
formatCellValue,
|
||||
DEFAULT_THEME_PALETTE,
|
||||
INDEXED_PALETTE,
|
||||
MIN_CONTRAST_RATIO,
|
||||
parseThemePalette,
|
||||
resolveColor,
|
||||
contrastRatio,
|
||||
ensureContrast,
|
||||
resolveCellColors,
|
||||
});
|
||||
})(typeof self !== 'undefined' ? self : globalThis);
|
||||
Reference in New Issue
Block a user