import { saveAs } from "file-saver"; import JSZip from "jszip"; function sniffImageMime(u8: Uint8Array): string | null { if (u8.length >= 3 && u8[0] === 0xff && u8[1] === 0xd8 && u8[2] === 0xff) { return "image/jpeg"; } if (u8.length >= 8 && u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4e && u8[3] === 0x47) { return "image/png"; } if (u8.length >= 12) { const a = String.fromCharCode(u8[0], u8[1], u8[2], u8[3]); const b = String.fromCharCode(u8[8], u8[9], u8[10], u8[11]); if (a === "RIFF" && b === "WEBP") { return "image/webp"; } } if (u8.length >= 6 && u8[0] === 0x47 && u8[1] === 0x49 && u8[2] === 0x46) { return "image/gif"; } return null; } /** * 经 fetch 拉取图片二进制;用 arrayBuffer + 魔数校验,避免把 JSON/HTML 当图片写入 zip。 * @param init 跨域带 Bearer 时不要 credentials: "include",与 Access-Control-Allow-Origin: * 不兼容。 */ export async function fetchImageBlob(url: string, init?: RequestInit): Promise { const r = await fetch(url, { credentials: "omit", ...init, }); const buf = await r.arrayBuffer(); const u8 = new Uint8Array(buf); if (!r.ok) { const text = new TextDecoder().decode(u8.slice(0, 400)); throw new Error(`加载图片失败(${r.status})${text.trim().slice(0, 120)}`); } const sniffed = sniffImageMime(u8); if (sniffed) { return new Blob([buf], { type: sniffed }); } const head = new TextDecoder("utf-8", { fatal: false }).decode(u8.slice(0, Math.min(120, u8.length))).trimStart(); if (head.startsWith("{") || head.startsWith("<")) { throw new Error( "接口返回的不是图片(可能是登录失效或错误页)。请重新登录后再试,并确认后端已部署 admin/v1/uploads/raw。" ); } const ct = (r.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase(); if (ct.startsWith("image/")) { return new Blob([buf], { type: ct }); } throw new Error("无法识别图片数据(魔数异常),请检查网络或联系管理员。"); } async function decodeImageToBitmap(blob: Blob): Promise { const tries: Array = [ { colorSpaceConversion: "none", premultiplyAlpha: "premultiply" }, { colorSpaceConversion: "none" }, undefined, ]; for (const opts of tries) { try { if (opts === undefined) { return await createImageBitmap(blob); } return await createImageBitmap(blob, opts); } catch { /* try next */ } } const url = URL.createObjectURL(blob); const img = new Image(); img.decoding = "async"; try { await new Promise((resolve, reject) => { img.onload = () => resolve(); img.onerror = () => reject(new Error("图片无法解码(格式不受支持或文件已损坏)")); img.src = url; }); return await createImageBitmap(img); } finally { URL.revokeObjectURL(url); } } /** 在图片右下角叠加白字描边(作者、日期);导出为 JPEG */ export async function blobWithWatermark( blob: Blob, line1: string, line2: string ): Promise { const bmp = await decodeImageToBitmap(blob); try { const canvas = document.createElement("canvas"); const width = bmp.width; const height = bmp.height; if (!width || !height) { throw new Error("图片尺寸无效"); } const maxSide = 8192; if (width > maxSide || height > maxSide) { throw new Error(`图片过大(${width}×${height}),超过浏览器可处理上限,请换用原图下载或压缩后再传。`); } canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); if (!ctx) { throw new Error("canvas"); } ctx.drawImage(bmp, 0, 0); const fs = Math.max(12, Math.round(width / 52)); const pad = Math.round(fs * 0.85); ctx.font = `${fs}px "PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif`; ctx.textBaseline = "bottom"; ctx.textAlign = "right"; const lines = [line1, line2].filter((s) => s.trim().length > 0); const maxTextWidth = lines.reduce((m, t) => Math.max(m, Math.ceil(ctx.measureText(t).width)), 0); const lineGap = Math.round(fs * 0.35); const panelPadX = Math.round(fs * 0.7); const panelPadY = Math.round(fs * 0.55); const panelW = maxTextWidth + panelPadX * 2; const panelH = lines.length * fs + (lines.length - 1) * lineGap + panelPadY * 2; const panelX = width - pad - panelW; const panelY = height - pad - panelH; if (lines.length > 0) { ctx.fillStyle = "rgba(30, 30, 30, 0.38)"; ctx.fillRect(panelX, panelY, panelW, panelH); } let y = height - pad; const x = width - pad; for (let i = lines.length - 1; i >= 0; i--) { const t = lines[i]; ctx.strokeStyle = "rgba(0,0,0,0.52)"; ctx.lineWidth = Math.max(1.5, fs / 9); ctx.fillStyle = "rgba(255,255,255,0.95)"; ctx.strokeText(t, x, y); ctx.fillText(t, x, y); y -= fs + lineGap; } return new Promise((resolve, reject) => { canvas.toBlob( (b) => (b ? resolve(b) : reject(new Error("导出失败"))), "image/jpeg", 0.92 ); }); } finally { bmp.close(); } } export function triggerSave(blob: Blob, filename: string) { saveAs(blob, filename); } export async function zipBlobs( entries: Array<{ path: string; blob: Blob }> ): Promise { const zip = new JSZip(); for (const { path, blob } of entries) { zip.file(path, blob); } return zip.generateAsync({ type: "blob" }); }