fix(preview): bound rich-text run walks, fold format notices, match Excel number display

- richTextPrefix visits at most maxCellTextChars + 1 runs. An empty run adds
  no text, so a length check alone walked every run of a shared string for
  every cell referencing it, on every tile.
- Two or more unsupported number format warnings in a tile fold into one
  "N unsupported number formats" entry, and the notice bar has a max-height
  and scrolls.
- General-format and unsupported-format numbers render at 15 significant
  digits, as Excel does (0.1+0.2 shows 0.3).
- TIME_FORMAT accepts a trailing AM/PM, so h:mm AM/PM renders as 2:30 PM
  instead of falling back to a date.
- SPREADSHEET_ASSET_VERSION refreshed.
This commit is contained in:
Aamer Akhter
2026-10-05 09:51:04 -04:00
parent 0ae5ce017a
commit 51b6be3e7a
9 changed files with 142 additions and 11 deletions
+52
View File
@@ -26,6 +26,7 @@ type Core = {
computeViewport(axis: unknown, offset: number, viewportSize: number, overscan?: number): [number, number];
intersectingMerges(merges: string[], range: { r1: number; c1: number; r2: number; c2: number }): string[];
formatCellValue(value: unknown, format: string, date1904?: boolean): { text: string; warning?: string };
foldWarnings(warnings: string[]): string[];
DEFAULT_THEME_PALETTE: string[];
INDEXED_PALETTE: string[];
parseThemePalette(xml?: string): string[];
@@ -428,6 +429,25 @@ describe('spreadsheet XLSX core', () => {
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(emoji)).toBe(false);
});
// An empty run adds no text, so stopping on the text length alone still
// visited every run, once per cell referencing the shared string, per tile.
it('visits at most maxCellTextChars + 1 rich-text runs, however many are empty', () => {
const bound = core.LIMITS.maxCellTextChars + 1;
const runs: Array<{ text: string }> = Array.from({ length: 5_000 }, () => ({ text: '' }));
runs[0] = { text: 'head' };
for (let index = bound; index < runs.length; index += 1) {
Object.defineProperty(runs, index, {
get() {
throw new Error(`rich-text run ${index} visited`);
},
});
}
expect(core.formatCellValue({ richText: runs }, 'General')).toEqual({ text: 'head' });
// A run inside the bound still contributes.
runs[bound - 1] = { text: 'tail' };
expect(core.formatCellValue({ richText: runs }, 'General').text).toBe('headtail');
});
it('refuses an entry whose declared compressed size runs past the file', () => {
const zip = workbookZip();
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
@@ -483,6 +503,38 @@ describe('spreadsheet XLSX core', () => {
expect(core.formatCellValue({ formula: 'SUM(A1:A2)' }, 'General')).toMatchObject({ text: '=SUM(A1:A2)' });
});
// Excel displays at most 15 significant digits; String() printed the binary noise.
it('shows General and unsupported-format numbers at 15 significant digits, as Excel does', () => {
expect(core.formatCellValue(0.1 + 0.2, 'General').text).toBe('0.3');
expect(core.formatCellValue(10.1 * 3, 'General').text).toBe('30.3');
expect(core.formatCellValue({ formula: '0.1+0.2', result: 0.1 + 0.2 }, 'General').text).toBe('0.3');
expect(core.formatCellValue(0.1 + 0.2, '0.00E+00')).toEqual({
text: '0.3',
warning: 'Unsupported number format: 0.00E+00',
});
expect(core.formatCellValue(1234.5, 'General').text).toBe('1234.5');
expect(core.formatCellValue(-42, 'General').text).toBe('-42');
});
it('renders a time-only AM/PM format as a 12-hour time, not a date', () => {
expect(core.formatCellValue(14.5 / 24, 'h:mm AM/PM')).toEqual({ text: '2:30 PM' });
expect(core.formatCellValue(0, 'h:mm AM/PM').text).toBe('12:00 AM');
expect(core.formatCellValue(0.5, 'h:mm AM/PM').text).toBe('12:00 PM');
expect(core.formatCellValue(9.25 / 24, 'hh:mm:ss AM/PM').text).toBe('09:15:00 AM');
// ExcelJS loads a time-formatted cell as a Date on the 1899-12-31 epoch day.
expect(core.formatCellValue(new Date(Date.UTC(1899, 11, 31, 14, 30)), 'h:mm AM/PM')).toEqual({
text: '2:30 PM',
});
});
it('folds unsupported number format warnings into one counted entry', () => {
const many = Array.from({ length: 800 }, (_, index) => `Unsupported number format: 0.0${'0'.repeat(index)}E+0`);
const folded = core.foldWarnings(['charts', ...many, 'Formula has no cached result']);
expect(folded).toEqual(['charts', '800 unsupported number formats', 'Formula has no cached result']);
expect(core.foldWarnings(['Unsupported number format: 0.00E+00'])).toEqual(['Unsupported number format: 0.00E+00']);
expect(core.foldWarnings(['charts'])).toEqual(['charts']);
});
// toLocaleString throws a RangeError above 100 fraction digits; Excel caps at 30.
it('caps a number format at 30 decimals instead of throwing', () => {
expect(core.formatCellValue(1.5, `0.${'0'.repeat(120)}`).text).toBe(`1.5${'0'.repeat(29)}`);