ai-chatbot/lib/client/canvas-image-download.ts

189 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<CanvasImageDownloadResult> {
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 };
}