| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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]; |
|
|
| |
| export const EXPORT_LABELS: Record<ExportFormat, string> = { |
| csv: "CSV", |
| xlsx: "Excel", |
| pdf: "PDF", |
| json: "JSON", |
| }; |
|
|
| |
|
|
| |
| |
| |
| 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}`; |
| } |
|
|
| |
|
|
| 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, "&") |
| .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<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", |
| }) |
| ); |
| } |
| |