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
+2 -1
View File
@@ -265,7 +265,8 @@ function sendTile(message) {
sheetId: String(message.sheetId),
cells,
merges,
warnings: Array.from(warnings),
// One counted entry for many unsupported number formats keeps the notice bar short.
warnings: core.foldWarnings(Array.from(warnings)),
});
}
+1 -1
View File
@@ -20,7 +20,7 @@
(function initSpreadsheetPreview(global) {
'use strict';
const SPREADSHEET_ASSET_VERSION = 'd83f632d5c69';
const SPREADSHEET_ASSET_VERSION = '4d543b11c25c';
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 20000;
const MAX_SCROLL_PX = 8000000;
+50 -7
View File
@@ -569,7 +569,8 @@
}
const DATE_FORMAT = /^[ymd\-/ ]+$/i;
const TIME_FORMAT = /^[hms: ]+$/i;
// An optional trailing AM/PM (built-in format 18 is `h:mm AM/PM`).
const TIME_FORMAT = /^[hms: ]+(?:AM\/PM)?$/i;
const DATE_TIME_FORMAT = /^[ymdhis\-/: ]+$/i;
function isFormulaValue(value) {
@@ -588,16 +589,50 @@
}
// Joins rich-text runs only until the cap is passed, so a long run is never
// copied whole once per cell.
// copied whole once per cell. It also visits at most maxCellTextChars + 1
// runs: an empty run (`<r/>`, 4 bytes) adds no text, so a length check alone
// walked every run, for every cell sharing the string, on every tile. Any
// maxCellTextChars + 1 non-empty runs already pass the cap.
function richTextPrefix(runs) {
let text = '';
for (const run of runs) {
const visit = Math.min(runs.length, LIMITS.maxCellTextChars + 1);
for (let index = 0; index < visit; index += 1) {
if (text.length > LIMITS.maxCellTextChars) break;
const run = runs[index];
if (typeof run?.text === 'string') text += run.text.slice(0, LIMITS.maxCellTextChars + 1);
}
return text;
}
// Excel displays at most 15 significant digits, so `=0.1+0.2` shows 0.3,
// never the binary float's 0.30000000000000004.
function generalNumber(value) {
return Number.isFinite(value) ? String(Number(value.toPrecision(15))) : String(value);
}
const UNSUPPORTED_FORMAT_PREFIX = 'Unsupported number format: ';
/**
* Folds every unsupported number format warning into one counted entry when
* there is more than one, so a sheet with a code per cell cannot grow the
* notice bar without bound. A lone warning is kept as is; its code is at
* most 255 characters (admission refuses longer ones). Order is preserved.
*/
function foldWarnings(warnings) {
const formats = warnings.filter((warning) => String(warning).startsWith(UNSUPPORTED_FORMAT_PREFIX));
if (formats.length < 2) return warnings.slice();
const folded = [];
let placed = false;
for (const warning of warnings) {
if (!String(warning).startsWith(UNSUPPORTED_FORMAT_PREFIX)) folded.push(warning);
else if (!placed) {
folded.push(`${formats.length} unsupported number formats`);
placed = true;
}
}
return folded;
}
/**
* A cell's display text and any warning. The text is ALWAYS at most
* `LIMITS.maxCellTextChars` characters, whatever shape the value has.
@@ -622,7 +657,7 @@
const formatted = formatCellValueUncapped(serial, fallback, date1904);
return /^General$/i.test(code)
? formatted
: { text: formatted.text, warning: `Unsupported number format: ${code}` };
: { text: formatted.text, warning: `${UNSUPPORTED_FORMAT_PREFIX}${code}` };
}
if (typeof value === 'object') {
if (isFormulaValue(value)) {
@@ -642,7 +677,7 @@
}
const code = String(format || 'General');
if (typeof value !== 'number') return { text: String(value) };
if (/^General$/i.test(code)) return { text: String(value) };
if (/^General$/i.test(code)) return { text: generalNumber(value) };
if (DATE_FORMAT.test(code)) {
const date = excelDate(value, Boolean(date1904));
const yyyy = date.getUTCFullYear();
@@ -652,9 +687,16 @@
}
if (TIME_FORMAT.test(code)) {
const seconds = Math.round((value - Math.floor(value)) * 86400) % 86400;
const hh = String(Math.floor(seconds / 3600)).padStart(2, '0');
const hours = Math.floor(seconds / 3600);
const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
const ss = String(seconds % 60).padStart(2, '0');
if (/AM\/PM$/i.test(code)) {
const hour12 = String(hours % 12 || 12);
const hh = /hh/i.test(code) ? hour12.padStart(2, '0') : hour12;
const clock = /s/i.test(code) ? `${hh}:${mm}:${ss}` : `${hh}:${mm}`;
return { text: `${clock} ${hours < 12 ? 'AM' : 'PM'}` };
}
const hh = String(hours).padStart(2, '0');
return { text: `${hh}:${mm}:${ss}` };
}
if (DATE_TIME_FORMAT.test(code)) {
@@ -685,7 +727,7 @@
(percent ? '%' : ''),
};
}
return { text: String(value), warning: `Unsupported number format: ${code}` };
return { text: generalNumber(value), warning: `${UNSUPPORTED_FORMAT_PREFIX}${code}` };
}
// Colour resolution --------------------------------------------------------
@@ -923,6 +965,7 @@
computeViewport,
intersectingMerges,
formatCellValue,
foldWarnings,
DEFAULT_THEME_PALETTE,
INDEXED_PALETTE,
MIN_CONTRAST_RATIO,
+4
View File
@@ -19318,6 +19318,10 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
.spreadsheet-preview-notice {
flex: 0 0 auto;
/* A long warning list scrolls inside the bar instead of pushing the grid out of view. */
max-height: 4.5em;
overflow-y: auto;
overflow-wrap: anywhere;
padding: 5px 10px;
color: var(--warning, #f59e0b);
font-size: 12px;