import { buildImageProxyUrl } from "@/lib/image-delivery"; import { inferOriginalImageUrl } from "@/lib/image-url"; export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving"; export type CanvasImageDownloadProgress = { phase: CanvasImageDownloadPhase; message: string; loadedBytes?: number; totalBytes?: number; percent?: number; }; export type CanvasImageDownloadResult = { filename: string; bytes: number; }; type DownloadCanvasImageParams = { url: string; name?: string; candidates?: string[]; signal?: AbortSignal; onProgress?: (progress: CanvasImageDownloadProgress) => void; }; function parseAttachmentFilename(contentDisposition: string | null) { if (!contentDisposition) return ""; const encodedMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); if (encodedMatch?.[1]) { try { return decodeURIComponent(encodedMatch[1]); } catch { return encodedMatch[1]; } } const quotedMatch = contentDisposition.match(/filename="([^"]+)"/i); if (quotedMatch?.[1]) return quotedMatch[1]; const plainMatch = contentDisposition.match(/filename=([^;]+)/i); return plainMatch?.[1]?.trim() || ""; } function sanitizeDownloadName(value: string, fallback: string) { const cleaned = String(value || "") .replace(/\.[a-zA-Z0-9]{2,6}$/g, "") .replace(/[\\/:*?"<>|]+/g, "-") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") .trim() .slice(0, 120); return cleaned || fallback; } function inferDownloadNameFromUrl(url: string) { try { const pathname = new URL(url).pathname || ""; return pathname.split("/").pop() || ""; } catch { return String(url || "").split("/").pop()?.split("?")[0] || ""; } } function triggerBlobDownload(blob: Blob, filename: string) { const blobUrl = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = blobUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); } function toDownloadCandidates(url: string, candidates: string[] = []) { const raw = String(url || "").trim(); const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim(); return Array.from( new Set( [ originalUrl, raw, ...candidates, originalUrl ? buildImageProxyUrl(originalUrl, { delivery: "proxy" }) : "", ] .map((value) => String(value || "").trim()) .filter(Boolean) ) ); } async function readResponseBlobWithProgress( response: Response, onProgress?: (progress: CanvasImageDownloadProgress) => void ) { const totalBytes = Number(response.headers.get("content-length") || 0); if (!response.body) { const blob = await response.blob(); onProgress?.({ phase: "downloading", message: "正在下载原图...", loadedBytes: blob.size, totalBytes: totalBytes || blob.size, percent: 100, }); return blob; } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let loadedBytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; chunks.push(value); loadedBytes += value.byteLength; onProgress?.({ phase: "downloading", message: "正在下载原图...", loadedBytes, totalBytes: totalBytes || undefined, percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined, }); } return new Blob(chunks, { type: response.headers.get("content-type") || "image/png", }); } export async function downloadCanvasImageAsPng({ candidates, name, onProgress, signal, url, }: DownloadCanvasImageParams): Promise { const raw = String(url || "").trim(); if (!raw) { throw new Error("图片地址无效"); } const urls = toDownloadCandidates(raw, candidates); const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim(); const filenameBase = sanitizeDownloadName( name || inferDownloadNameFromUrl(originalUrl || raw), "canvas-image" ); onProgress?.({ phase: "preparing", message: "正在准备原图...", percent: 8, }); const response = await fetch("/api/canvas/download-image", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: urls[0], urls, name: filenameBase, }), signal, }); if (!response.ok) { const payload = (await response.json().catch(() => null)) as { error?: string } | null; throw new Error(payload?.error || `下载失败(${response.status})`); } const filename = parseAttachmentFilename(response.headers.get("content-disposition")) || `${filenameBase}.png`; const blob = await readResponseBlobWithProgress(response, onProgress); onProgress?.({ phase: "saving", message: "正在保存文件...", loadedBytes: blob.size, totalBytes: blob.size, percent: 100, }); triggerBlobDownload(blob, filename); return { filename, bytes: blob.size }; }