189 lines
5.1 KiB
TypeScript
189 lines
5.1 KiB
TypeScript
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 };
|
||
}
|