import { assertBoundedText, assertPositiveSafeInteger } from "./limits"; export interface ObjectUrlLease { readonly url: string; readonly revoked: boolean; revoke(): void; } export function sanitizeDownloadFilename( input: string, fallback = "download.bin", maximumLength = 180, ): string { assertPositiveSafeInteger(maximumLength, "Maximum filename length"); const cleaned = cleanFilename(input); const fallbackName = cleanFilename(fallback) || "download.bin"; const safe = cleaned || fallbackName; if (safe.length <= maximumLength) return safe; const dot = safe.lastIndexOf("."); const extension = dot > 0 && safe.length - dot <= 16 ? safe.slice(dot) : ""; let stem = safe.slice(0, Math.max(1, maximumLength - extension.length)); const finalCodeUnit = stem.charCodeAt(stem.length - 1); if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) stem = stem.slice(0, -1); return `${stem || "_"}${extension}`; } function cleanFilename(input: string): string { const normalized = assertBoundedText( input, 4096, "Filename length", ).normalize("NFC"); const cleaned = Array.from(normalized, (character) => { const codePoint = character.codePointAt(0) ?? 0; return codePoint <= 31 || (codePoint >= 127 && codePoint <= 159) || (codePoint >= 0x202a && codePoint <= 0x202e) || (codePoint >= 0x2066 && codePoint <= 0x2069) || '/\\:*?"<>|'.includes(character) ? "_" : character; }) .join("") .replace(/\s+/gu, " ") .replace(/^\.+|[. ]+$/gu, "") .trim(); return /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(cleaned) ? `_${cleaned}` : cleaned; } export function createObjectUrlLease( blob: Blob, urlApi: Pick = URL, ): ObjectUrlLease { const url = urlApi.createObjectURL(blob); let revoked = false; return { url, get revoked() { return revoked; }, revoke() { if (revoked) return; revoked = true; urlApi.revokeObjectURL(url); }, }; } export function triggerBlobDownload( blob: Blob, filename: string, ownerDocument: Document = document, ): ObjectUrlLease { const lease = createObjectUrlLease(blob); const anchor = ownerDocument.createElement("a"); anchor.href = lease.url; anchor.download = sanitizeDownloadFilename(filename); anchor.rel = "noopener"; anchor.hidden = true; ownerDocument.body.append(anchor); try { anchor.click(); } finally { anchor.remove(); globalThis.setTimeout(() => lease.revoke(), 0); } return lease; }