/** * Pure download helpers — no React, no DOM lifecycle outside the link click. * Component layer in {@link ./download.tsx} orchestrates these. */ import html2canvas from 'html2canvas' export interface DownloadHandle { url: string revoke: () => void } // Leading characters that Excel / Sheets / Numbers interpret as a formula // when the CSV is opened. Prefixing the cell with a single quote forces the // spreadsheet app to render it as plain text. Tab and CR are included // because some tooling strips leading whitespace before formula evaluation. const CSV_FORMULA_PREFIX = /^[=+\-@\t\r]/ export function toCsvString(rows: readonly (readonly unknown[])[]): string { const escape = (cell: unknown): string => { if (cell == null) return '' let value = typeof cell === 'string' ? cell : typeof cell === 'number' || typeof cell === 'boolean' ? String(cell) : JSON.stringify(cell) if (CSV_FORMULA_PREFIX.test(value)) value = `'${value}` if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"` return value } return rows.map((row) => row.map(escape).join(',')).join('\n') } export function downloadToCSV( rows: readonly (readonly unknown[])[], ): DownloadHandle { const csv = toCsvString(rows) const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob) return { url, revoke: () => URL.revokeObjectURL(url) } } // Path separators, NUL, and ASCII control chars. Filenames containing these // can confuse the browser download stack and historically enabled // Content-Disposition-style trickery. // eslint-disable-next-line no-control-regex const UNSAFE_FILENAME_CHARS = /[\\/\x00-\x1f\x7f]/g const MAX_FILENAME_LENGTH = 200 export function sanitizeFilename(name: string): string { return ( (name ?? '') .replace(UNSAFE_FILENAME_CHARS, '_') .slice(0, MAX_FILENAME_LENGTH) || 'download' ) } /** * Triggers a browser download for the given URL by synthesizing an anchor, * clicking it, and removing it. Pure DOM — no React. The filename is run * through {@link sanitizeFilename} before being assigned to `a.download`. */ export function triggerLinkDownload(args: { url: string filename: string }): void { const { url, filename } = args const a = document.createElement('a') a.href = url a.download = sanitizeFilename(filename) a.style.display = 'none' document.body.appendChild(a) a.click() a.remove() } export interface DownloadDOMToPNGOptions { element: HTMLElement /** html2canvas `scale`. Default 2 — crisp on hi-DPI displays. */ pixelRatio?: number /** * html2canvas `backgroundColor`. Default `null` (transparent) so the PNG * inherits whatever surface it lands on when the user pastes it. */ backgroundColor?: string | null } /** * Rasterise an HTMLElement to PNG via `html2canvas` and wrap the result in * a `DownloadHandle`. The caller is responsible for calling `handle.revoke` * after the link click is dispatched. Used by per-widget download configs * to power the PNG menu item. */ export async function downloadDOMToPNG( opts: DownloadDOMToPNGOptions, ): Promise { const { element, pixelRatio = 2, backgroundColor = null } = opts const canvas = await html2canvas(element, { scale: pixelRatio, backgroundColor, useCORS: true, logging: false, }) return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (!blob) { reject(new Error('[widgets-v2] toBlob() returned null')) return } const url = URL.createObjectURL(blob) resolve({ url, revoke: () => URL.revokeObjectURL(url) }) }, 'image/png') }) }