// --------------------------------------------------------------------------- // customer-grid / export.ts // Wave-7 item W2 (contract C2) — CLIENT-side export of "the current view": // CSV · Excel · PDF · JSON, generated from exactly what the grid paints // (filtered + sorted rows, visible fields in display order) with no host // round-trip. // // ZERO DEPENDENCIES, deliberately. The contract allows SheetJS/jspdf, but both // together measure ~250 KB gzipped (~900 KB raw) against a single-file embed // the component reloads on every remount — and adding either means editing // package.json, which sits outside the CLIENT session's file ownership. So: // - .xlsx = a minimal Open XML spreadsheet in a STORE-only (uncompressed) // zip container, every cell an inline string — Excel and Google // Sheets open it natively; // - .pdf = a minimal PDF 1.4 table writer — landscape Letter, repeated // header row in brand navy, page numbers, Helvetica (WinAnsi). // Recorded in the wave-7 mailbox for HOST review. // // Value semantics (contract): CSV / Excel / PDF carry the grid's DISPLAY // strings (formatDisplay — formatted numbers, the '(none)' sentinels as they // read on screen); JSON carries RAW values as { fields: [...], rows: [...] }. // // Download mechanism (probed 2026-07-28): the grid runs inside Streamlit // 1.58.0's declared-component iframe, whose sandbox policy is // "allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox // allow-same-origin allow-scripts allow-downloads" (IFrameUtil, verified in // the installed build) — so a Blob URL + a[download] click works in the embed. // --------------------------------------------------------------------------- import type { Field, Row } from "./types"; import { formatDisplay, formulaIsBlank, formulaIsText, isBareDay, parseStamp, } from "./display"; export const EXPORT_FORMATS = ["csv", "xlsx", "pdf", "json"] as const; export type ExportFormat = (typeof EXPORT_FORMATS)[number]; /** The pane's display wording, in the contract's display order. */ export const EXPORT_LABELS: Record = { csv: "CSV", xlsx: "Excel", pdf: "PDF", json: "JSON", }; // ------------------------------------------------------------------ filename /** ` - .` — `today` is the PAYLOAD's string * verbatim (never the browser clock); absent = no date part. Characters a * filesystem refuses are replaced, nothing else is "cleaned". */ export function exportFilename( name: string, today: string | undefined, format: ExportFormat ): string { const safe = (name.trim() || "export").replace(/[\\/:*?"<>|]/g, "_"); return today ? `${safe} - ${today}.${format}` : `${safe}.${format}`; } // ----------------------------------------------------------------------- CSV function csvCell(v: string): string { return /[",\r\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; } /** RFC-4180 shape: CRLF rows, quoted only where needed. The caller prepends * the UTF-8 BOM at Blob time so Excel reads the encoding right. */ export function buildCsv(fields: Field[], rows: Row[]): string { const lines = [fields.map((f) => csvCell(f.label)).join(",")]; for (const r of rows) lines.push(fields.map((f) => csvCell(formatDisplay(f, r[f.key]))).join(",")); return lines.join("\r\n") + "\r\n"; } // ---------------------------------------------------------------------- JSON /** RAW values (contract): `{ fields: [{key,label,type}], rows: [...] }`, each * row an object of the visible fields' raw values plus its stable pid. */ export function buildJson(fields: Field[], rows: Row[]): string { return JSON.stringify( { fields: fields.map((f) => ({ key: f.key, label: f.label, type: f.type })), rows: rows.map((r) => { const out: Record = { pid: r.pid }; for (const f of fields) out[f.key] = r[f.key] ?? null; return out; }), }, null, 1 ); } // ------------------------------------------------------------- XLSX (zip) const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n += 1) { let c = n; for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })(); function crc32(bytes: Uint8Array): number { let c = 0xffffffff; for (let i = 0; i < bytes.length; i += 1) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } /** DOS date/time from the tenant's `today` (midnight) — deterministic, never * the browser clock. Unparseable/absent input pins 2020-01-01. */ function dosStamp(today: string | undefined): { date: number; time: number } { const m = today ? /^(\d{4})-(\d{2})-(\d{2})/.exec(today) : null; const y = m ? Number(m[1]) : 2020; const mo = m ? Number(m[2]) : 1; const d = m ? Number(m[3]) : 1; return { date: ((Math.max(1980, y) - 1980) << 9) | (mo << 5) | d, time: 0 }; } interface ZipEntry { name: string; data: Uint8Array; } /** STORE-only (uncompressed) zip — the container .xlsx requires. */ function buildZip(entries: ZipEntry[], today: string | undefined): Uint8Array { const enc = new TextEncoder(); const stamp = dosStamp(today); const chunks: Uint8Array[] = []; const central: Uint8Array[] = []; let offset = 0; const u16 = (v: number) => [v & 0xff, (v >> 8) & 0xff]; const u32 = (v: number) => [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff]; for (const e of entries) { const name = enc.encode(e.name); const crc = crc32(e.data); const local = new Uint8Array([ ...u32(0x04034b50), ...u16(20), ...u16(0x0800), ...u16(0), ...u16(stamp.time), ...u16(stamp.date), ...u32(crc), ...u32(e.data.length), ...u32(e.data.length), ...u16(name.length), ...u16(0), ]); chunks.push(local, name, e.data); central.push( new Uint8Array([ ...u32(0x02014b50), ...u16(20), ...u16(20), ...u16(0x0800), ...u16(0), ...u16(stamp.time), ...u16(stamp.date), ...u32(crc), ...u32(e.data.length), ...u32(e.data.length), ...u16(name.length), ...u16(0), ...u16(0), ...u16(0), ...u16(0), ...u32(0), ...u32(offset), ]), name ); offset += local.length + name.length + e.data.length; } const cdStart = offset; let cdSize = 0; for (const c of central) cdSize += c.length; const end = new Uint8Array([ ...u32(0x06054b50), ...u16(0), ...u16(0), ...u16(entries.length), ...u16(entries.length), ...u32(cdSize), ...u32(cdStart), ...u16(0), ]); const total = offset + cdSize + end.length; const out = new Uint8Array(total); let at = 0; for (const c of [...chunks, ...central, end]) { out.set(c, at); at += c.length; } return out; } function xmlEscape(v: string): string { return v .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) // Control chars are illegal in XML 1.0 — dropped rather than corrupting the sheet. // eslint-disable-next-line no-control-regex .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, ""); } /** Excel sheet names: ≤31 chars, none of []:*?/\ — replaced, not refused. */ function sheetName(name: string): string { const s = name.replace(/[[\]:*?/\\]/g, "_").trim(); return (s || "Export").slice(0, 31); } // -------------------------------------------------------- xlsx: real numbers // // ⭐ WAVE 29, OWNER ITEM 19 (W29-T30). Until now EVERY cell was `t="inlineStr"` // carrying `formatDisplay`'s output, so a revenue column arrived in Excel as the // TEXT "$1,234,567.50" and `=SUM()` over it returned 0. The owner's complaint was // that the download is not usable as a spreadsheet, and that was exactly right. // // ⛔ AND THE GATE CERTIFIED IT. `_test/export.test.ts` asserted the inline-string // shape as the PASSING condition, so the bug had a green check standing over it — // the third instance of that shape this wave. It is INVERTED there, not deleted: // a gate that greps a literal you just changed goes green forever after. /** Field kinds whose value is ARITHMETIC, so Excel must receive a number rather * than a pre-formatted string. W29-T30's `done-when` names exactly these. * ⚠ `rating` is deliberately NOT here: it renders "4 of 5", a sentence about a * scale rather than a quantity, and the ticket does not ask for it. */ const XLSX_NUMERIC_TYPES = new Set([ "int", "currency", "pct", "rollup", "formula", "metric", ]); /** Days from Excel's serial origin (1899-12-30) to the Unix epoch. * ⚠ 1899-12-30, not 12-31, absorbs Excel's phantom 1900-02-29 — correct for * every date after 1900-03-01, which is every date this product holds. */ const EXCEL_EPOCH_DAYS = 25569; /** A Date as an Excel serial, read on the stated clock. * ⛔ Excel has no timezone: a serial IS a wall clock. So the clock is chosen * the same way `dateTimeText` chooses it, or a stamp exports as a different day * than it renders — the off-by-a-day that `dateTimeText`'s own note records for * everyone west of Greenwich. */ function excelSerial(d: Date, utc: boolean): number { const ms = utc ? d.getTime() : Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds()); return ms / 86400000 + EXCEL_EPOCH_DAYS; } /** The Excel number-format code that makes a numeric cell READ the way the grid * paints it — `null` = General. Derived from the same `field.format` bag * `numberText` reads, so the file and the canvas agree by construction. * ⚠ `abbrev` has no Excel equivalent ("34.0M" is not a number format). The * NUMBER still ships — summable beats abbreviated in a spreadsheet — so an * abbreviated column reads in full here. Stated, not silent. */ function numFmtCode(f: Field): string | null { const fmt = f.format; const d = fmt && Number.isInteger(fmt.decimals) && (fmt.decimals as number) >= 0 && (fmt.decimals as number) <= 4 ? (fmt.decimals as number) : null; const grouped = fmt?.thousands !== false; const dec = (n: number) => (n > 0 ? "." + "0".repeat(n) : ""); if (f.type === "currency") // The done-when's own example: `$1,234.50`. Two places unless the column says otherwise. return `"$"${grouped ? "#,##0" : "0"}${dec(d ?? 2)}`; if (f.type === "pct") // ⛔ THE VALUE STAYS IN PERCENT UNITS AND THIS IS DELIBERATE. `display.ts` // renders `num(v).toFixed(1) + "%"`, i.e. 12.3 MEANS 12.3%. Pairing that // stored 12.3 with Excel's builtin `0.0%` would multiply by 100 and show // 1230.0%; dividing by 100 to suit the builtin would make this file // disagree with `buildJson`, which exports the RAW 12.3 — the same // one-cell-two-answers defect this ticket exists to end. So: raw value, and // a format whose `%` is a LITERAL suffix. return `0.0"%"`; if (d != null) return `${grouped ? "#,##0" : "0"}${dec(d)}`; // No declared decimals: `numberText` falls to `toLocaleString()`, which groups // and shows up to 3 fraction digits. `#,##0.###` is that, in Excel's dialect. return grouped ? "#,##0.###" : null; } /** Interns format codes and hands back a `cellXfs` index. Style 0 is General, * which the header row and every string cell use. */ function styleTable() { const byCode = new Map(); return { /** `null` code = General = style 0. */ idFor(code: string | null): number { if (code == null) return 0; const seen = byCode.get(code); if (seen != null) return seen; const id = byCode.size + 1; // 0 is General byCode.set(code, id); return id; }, codes(): string[] { return [...byCode.keys()]; }, }; } /** The stylesheet. ⚠ Excel is strict here in ways a reader would not guess: * `fills` must carry at least the two reserved entries (none + gray125) or the * file opens with a repair prompt, and custom `numFmtId`s must start at 164 * because everything below is reserved for builtins. */ function buildStyles(codes: string[]): string { const numFmts = codes .map((c, i) => ``) .join(""); const xfs = codes .map((_, i) => ``) .join(""); return ( `` + `` + (codes.length ? `${numFmts}` : "") + `` + `` + `` + `` + `` + `` + `${xfs}` + `` + `` ); } /** A minimal SpreadsheetML workbook: one sheet, NUMERIC kinds written as real * numbers with a number format that reproduces the grid's rendering, everything * else an inline string carrying the DISPLAY value (contract C2 — what the grid * shows is what the file says). */ export function buildXlsx( fields: Field[], rows: Row[], name: string, today: string | undefined ): Uint8Array { const enc = new TextEncoder(); const styles = styleTable(); const cell = (v: string) => `${xmlEscape(v)}`; /** A number Excel can sum, in style `s`. No `t` attribute: numeric is the default. */ const numCell = (n: number, s: number) => `${n}`; /** ⛔ BLANK STAYS BLANK — never `0`. A zero here would fabricate a measurement * the server refused to make (the codebase's standing law, and for a `date` a * 0 serial renders as a nonsense 1900 day). Styled but empty. */ const blankCell = (s: number) => ``; /** ⛔ VALUE-DRIVEN, NOT TYPE-DRIVEN. A `formula` may return TEXT and a `rollup` * fold may be `concatenate`/`latest`-over-text, so branching on the column's * type alone would emit `NaN` and Excel would open with a repair * prompt. Anything that is not a finite number falls back to the display * string, which is at least true. */ function dataCell(f: Field, raw: Row[string]): string { const isNumeric = XLSX_NUMERIC_TYPES.has(f.type); const isDate = f.type === "date" || f.type === "created_time"; if (!isNumeric && !isDate) return cell(formatDisplay(f, raw)); if (isDate) { const text = formatDisplay(f, raw); const s = String(raw ?? "").trim(); if (s === "") return blankCell(0); const d = parseStamp(s); if (!d) return cell(text); // unparseable: the raw string is true // ⛔ BOTH RULES ARE `dateTimeText`'s (`display.ts:245-246`), MIRRORED LINE // FOR LINE rather than re-derived. `time` defaults to true only for // `created_time` — a `date` defaults to NO time, and my first version had // that backwards. A bare YYYY-MM-DD is a calendar DAY and is read in UTC, // or it shows the previous day to everyone west of Greenwich. const withTime = f.format?.time ?? f.type === "created_time"; const utc = f.format?.tz === "utc" || isBareDay(raw); const sid = styles.idFor(withTime ? "mmm d, yyyy hh:mm" : "mmm d, yyyy"); return numCell(excelSerial(d, utc), sid); } // `formula`'s three states, in the order that makes them total. if (f.type === "formula") { if (formulaIsBlank(raw)) return blankCell(styles.idFor(numFmtCode(f))); if (formulaIsText(raw)) return cell(raw); } const str = String(raw ?? "").trim(); if (str === "") return blankCell(styles.idFor(numFmtCode(f))); const n = Number(str); if (!Number.isFinite(n)) return cell(formatDisplay(f, raw)); return numCell(n, styles.idFor(numFmtCode(f))); } const rowXml = (cells: string[]) => `${cells.join("")}`; const body = [ // The header row is LABELS — text, and it stays text. rowXml(fields.map((f) => cell(f.label))), ...rows.map((r) => rowXml(fields.map((f) => dataCell(f, r[f.key])))), ].join(""); const sheet = `` + `` + `${body}`; const workbook = `` + `` + `` + ``; const contentTypes = `` + `` + `` + `` + `` + `` + // ⛔ A stylesheet needs THREE registrations, not one: the zip part, this // Override, and a Relationship in workbook.xml.rels. Miss any and Excel // opens the file with a silent "we found a problem" repair — which a zip // signature check passes straight over. `` + ``; const rootRels = `` + `` + `` + ``; const wbRels = `` + `` + `` + `` + ``; // Built LAST: the format codes are interned while the body is written, so the // stylesheet can only be correct once every cell has asked for its style. const stylesXml = buildStyles(styles.codes()); return buildZip( [ { name: "[Content_Types].xml", data: enc.encode(contentTypes) }, { name: "_rels/.rels", data: enc.encode(rootRels) }, { name: "xl/workbook.xml", data: enc.encode(workbook) }, { name: "xl/_rels/workbook.xml.rels", data: enc.encode(wbRels) }, { name: "xl/styles.xml", data: enc.encode(stylesXml) }, { name: "xl/worksheets/sheet1.xml", data: enc.encode(sheet) }, ], today ); } // ----------------------------------------------------------------------- PDF // Landscape Letter, brand navy header (contract): repeated header row + page // numbers. Helvetica with WinAnsi encoding — the handful of typographic chars // the grid uses map to their WinAnsi bytes; anything else degrades to '?' // rather than corrupting the stream. const PAGE_W = 792; const PAGE_H = 612; const MARGIN = 36; const FONT_SIZE = 8; const ROW_H = 13; const HEADER_H = 17; const CHAR_W = FONT_SIZE * 0.52; // Helvetica average advance, close enough to size columns const WINANSI: Record = { "…": 0x85, "–": 0x96, "—": 0x97, "‘": 0x91, "’": 0x92, "“": 0x93, "”": 0x94, "·": 0xb7, "€": 0x80, "•": 0x95, }; function pdfText(v: string): string { let out = ""; for (const ch of v) { const code = ch.codePointAt(0) ?? 63; const mapped = code <= 0xff ? code : WINANSI[ch] ?? 63; // '?' const c = String.fromCharCode(mapped); out += c === "\\" ? "\\\\" : c === "(" ? "\\(" : c === ")" ? "\\)" : c; } return out; } function clip(v: string, maxChars: number): string { return v.length <= maxChars ? v : `${v.slice(0, Math.max(0, maxChars - 1))}…`; } export function buildPdf( fields: Field[], rows: Row[], title: string, today: string | undefined ): Uint8Array { // Column widths from content (header + a sample of rows), clamped and then // scaled to the content width — every column stays visible, long ones clip // per cell with an ellipsis rather than overrunning their neighbour. const contentW = PAGE_W - MARGIN * 2; const sample = rows.slice(0, 200); const widths = fields.map((f) => { let chars = f.label.length; for (const r of sample) chars = Math.max(chars, formatDisplay(f, r[f.key]).length); return Math.min(170, Math.max(42, chars * CHAR_W + 8)); }); const scale = Math.min(1, contentW / widths.reduce((a, b) => a + b, 0)); const colW = widths.map((w) => w * scale); const titleSpace = 26; const rowsPerPage = Math.max( 1, Math.floor((PAGE_H - MARGIN * 2 - titleSpace - HEADER_H - 14) / ROW_H) ); const pageCount = Math.max(1, Math.ceil(rows.length / rowsPerPage)); const heading = today ? `${title} — ${today}` : title; const pages: string[] = []; for (let p = 0; p < pageCount; p += 1) { const slice = rows.slice(p * rowsPerPage, (p + 1) * rowsPerPage); let s = ""; // Title line (every page — the file is a table, the table needs its name). s += `BT /F2 11 Tf 0.122 0.306 0.471 rg ${MARGIN} ${PAGE_H - MARGIN - 8} Td (${pdfText(clip(heading, 110))}) Tj ET\n`; const headTop = PAGE_H - MARGIN - titleSpace; // Header band in brand navy, then white bold labels. s += `0.122 0.306 0.471 rg ${MARGIN} ${headTop - HEADER_H} ${contentW} ${HEADER_H} re f\n`; let x = MARGIN; for (let c = 0; c < fields.length; c += 1) { const maxChars = Math.max(1, Math.floor((colW[c] - 6) / CHAR_W)); s += `BT /F2 ${FONT_SIZE} Tf 1 1 1 rg ${x + 3} ${headTop - HEADER_H + 5} Td (${pdfText(clip(fields[c].label, maxChars))}) Tj ET\n`; x += colW[c]; } // Body rows. let y = headTop - HEADER_H - ROW_H; for (const r of slice) { x = MARGIN; for (let c = 0; c < fields.length; c += 1) { const maxChars = Math.max(1, Math.floor((colW[c] - 6) / CHAR_W)); const v = formatDisplay(fields[c], r[fields[c].key]); if (v !== "") s += `BT /F1 ${FONT_SIZE} Tf 0.11 0.13 0.16 rg ${x + 3} ${y + 3} Td (${pdfText(clip(v, maxChars))}) Tj ET\n`; x += colW[c]; } // Hairline under each row. s += `0.89 0.9 0.92 RG 0.5 w ${MARGIN} ${y} m ${MARGIN + contentW} ${y} l S\n`; y -= ROW_H; } // Page number, bottom right. const pn = `Page ${p + 1} of ${pageCount}`; s += `BT /F1 8 Tf 0.48 0.5 0.53 rg ${PAGE_W - MARGIN - pn.length * CHAR_W} ${MARGIN - 14} Td (${pdfText(pn)}) Tj ET\n`; pages.push(s); } // Assemble the file with byte-accurate xref offsets. Streams are latin1 by // construction (pdfText maps everything into 0..255). const objects: string[] = []; const pageRefs = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" "); objects.push(`1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n`); objects.push( `2 0 obj\n<< /Type /Pages /Kids [${pageRefs}] /Count ${pages.length} >>\nendobj\n` ); objects.push( `3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n` ); objects.push( `4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n` ); pages.forEach((content, i) => { const pageNum = 5 + i * 2; objects.push( `${pageNum} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_W} ${PAGE_H}] ` + `/Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${pageNum + 1} 0 R >>\nendobj\n` ); objects.push( `${pageNum + 1} 0 obj\n<< /Length ${content.length} >>\nstream\n${content}endstream\nendobj\n` ); }); let file = "%PDF-1.4\n"; const offsets: number[] = []; for (const o of objects) { offsets.push(file.length); file += o; } const xrefAt = file.length; file += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; for (const at of offsets) file += `${String(at).padStart(10, "0")} 00000 n \n`; file += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n` + `startxref\n${xrefAt}\n%%EOF\n`; const bytes = new Uint8Array(file.length); for (let i = 0; i < file.length; i += 1) bytes[i] = file.charCodeAt(i) & 0xff; return bytes; } // ------------------------------------------------------------------ download /** Blob URL + a[download] click — works inside the Streamlit component iframe * (sandbox carries allow-downloads; see the header note). */ export function triggerDownload(filename: string, blob: Blob): void { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); // Revoked a tick later — revoking synchronously races the browser's fetch of // the blob in some engines. window.setTimeout(() => URL.revokeObjectURL(url), 4000); } /** One door for all four formats: build, wrap, download. */ export function runExport( format: ExportFormat, name: string, today: string | undefined, fields: Field[], rows: Row[] ): void { const filename = exportFilename(name, today, format); if (format === "csv") { // BOM so Excel decodes UTF-8 without an import wizard. triggerDownload( filename, new Blob(["", buildCsv(fields, rows)], { type: "text/csv;charset=utf-8" }) ); return; } if (format === "json") { triggerDownload( filename, new Blob([buildJson(fields, rows)], { type: "application/json" }) ); return; } if (format === "xlsx") { triggerDownload( filename, new Blob([buildXlsx(fields, rows, name, today).buffer as ArrayBuffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }) ); return; } triggerDownload( filename, new Blob([buildPdf(fields, rows, name, today).buffer as ArrayBuffer], { type: "application/pdf", }) ); }