loopable / web /src /customer-grid /export.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
dcdb685 verified
Raw
History Blame Contribute Delete
27.2 kB
// ---------------------------------------------------------------------------
// 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<ExportFormat, string> = {
csv: "CSV",
xlsx: "Excel",
pdf: "PDF",
json: "JSON",
};
// ------------------------------------------------------------------ filename
/** `<view or cohort name> - <today>.<ext>` — `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<string, unknown> = { 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
// 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<string, number>();
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) => `<numFmt numFmtId="${164 + i}" formatCode="${xmlEscape(c)}"/>`)
.join("");
const xfs = codes
.map((_, i) =>
`<xf numFmtId="${164 + i}" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>`)
.join("");
return (
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
(codes.length ? `<numFmts count="${codes.length}">${numFmts}</numFmts>` : "") +
`<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>` +
`<fills count="2"><fill><patternFill patternType="none"/></fill>` +
`<fill><patternFill patternType="gray125"/></fill></fills>` +
`<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>` +
`<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>` +
`<cellXfs count="${codes.length + 1}">` +
`<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>${xfs}</cellXfs>` +
`<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>` +
`</styleSheet>`
);
}
/** 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) =>
`<c t="inlineStr"><is><t xml:space="preserve">${xmlEscape(v)}</t></is></c>`;
/** A number Excel can sum, in style `s`. No `t` attribute: numeric is the default. */
const numCell = (n: number, s: number) =>
`<c${s ? ` s="${s}"` : ""}><v>${n}</v></c>`;
/** ⛔ 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) => `<c${s ? ` s="${s}"` : ""}/>`;
/** ⛔ 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 `<v>NaN</v>` 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[]) => `<row>${cells.join("")}</row>`;
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 =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
`<sheetData>${body}</sheetData></worksheet>`;
const workbook =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ` +
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
`<sheets><sheet name="${xmlEscape(sheetName(name))}" sheetId="1" r:id="rId1"/></sheets>` +
`</workbook>`;
const contentTypes =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
`<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +
// ⛔ 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.
`<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>` +
`</Types>`;
const rootRels =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>` +
`</Relationships>`;
const wbRels =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>` +
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>` +
`</Relationships>`;
// 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<string, number> = {
"…": 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",
})
);
}