fix(preview): address review of the XLSX preview

- Normalize the value shapes ExcelJS loads before formatting: Date cells are
  formatted from their serial (UTC), so they no longer render as a local-time
  string a day early at negative UTC offsets; rich text joins its runs,
  hyperlinks show their text, error values show the error, and formula and
  shared-formula results (including error results) recurse. Excel serials are
  rounded to whole milliseconds so 00:05 no longer shows as 00:04.
- sendTile() skips hidden rows and columns, and at the 2500-cell cap returns a
  truncated tile with a warning instead of failing the whole preview.
- ExcelJS now parses a STORE-only archive rebuilt from exactly the entries
  admitXlsx() inflated and counted, never the fetched bytes. Admission walks
  local headers while JSZip reads the central directory, so overlapping
  entries could show the two readers different sheets. A duplicate local
  entry name is refused. The theme fallback reads the admitted entry too.
- Row and column headings take their size from the same axis math as cells.
- Document the admission, worker-only loading and SPREADSHEET_ASSET_VERSION
  rules in architecture-invariants, and list .xlsx in the attachments panel
  help and the `codeman attach` error text (built from the accepted list).
This commit is contained in:
Aamer Akhter
2026-09-27 07:55:29 -04:00
parent 0b122e2c76
commit 4edb7b8f80
14 changed files with 557 additions and 46 deletions
+36
View File
@@ -11,6 +11,7 @@ type Core = {
XlsxPreviewError: new (code: string, message: string) => Error & { code: string };
inspectZipDirectory(bytes: Uint8Array, limits?: Record<string, number>): { entries: Array<{ name: string }> };
admitXlsx(bytes: Uint8Array, zip: typeof fflate, limits?: Record<string, number>): unknown;
buildAdmittedArchive(admission: unknown, zip: typeof fflate): Uint8Array;
parseCellRef(ref: string): { row: number; col: number } | null;
deriveExtent(cells: string[], merges: string[]): { rows: number; cols: number };
createSparseAxis(count: number, defaultSize: number, overrides: Array<[number, number]>): unknown;
@@ -110,6 +111,41 @@ describe('spreadsheet XLSX core', () => {
expect(result.features).toEqual(expect.arrayContaining(['charts', 'externalLinks']));
});
it('rebuilds a STORE-only archive from exactly the entries admission inflated', () => {
const zip = workbookZip();
const admission = core.admitXlsx(zip, fflate) as { entries: Record<string, Uint8Array>; inflatedBytes: number };
expect(Object.keys(admission.entries).sort()).toEqual(Object.keys(fflate.unzipSync(zip)).sort());
const rebuilt = core.buildAdmittedArchive(admission, fflate);
const directory = core.inspectZipDirectory(rebuilt).entries as Array<{
name: string;
compressedSize: number;
declaredSize: number;
}>;
for (const entry of directory) expect(entry.compressedSize).toBe(entry.declaredSize);
const roundTrip = fflate.unzipSync(rebuilt);
for (const [name, data] of Object.entries(admission.entries)) expect(roundTrip[name]).toEqual(data);
});
it('refuses a local entry name streamed twice, since the rebuilt archive could hold only one', () => {
const zip = workbookZip();
class DuplicateUnzip {
constructor(private readonly onFile: (file: any) => void) {}
register() {}
push(_bytes: Uint8Array, final: boolean) {
if (!final) return;
for (let i = 0; i < 2; i += 1) {
const file: Record<string, any> = {
name: 'xl/workbook.xml',
start: () => file.ondata(null, new Uint8Array(1), true),
};
this.onFile(file);
}
}
}
const duplicate = { Unzip: DuplicateUnzip, UnzipInflate: class {} } as unknown as typeof fflate;
expect(() => core.admitXlsx(zip, duplicate)).toThrowError(/duplicate/i);
});
it('derives bounded extents from real cells and merges', () => {
expect(core.parseCellRef('XFD1048576')).toEqual({ row: 1_048_576, col: 16_384 });
expect(core.parseCellRef('XFE1')).toBeNull();