watermarkDownload.ts 5.5 KB
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<Blob> {
  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<ImageBitmap> {
  const tries: Array<ImageBitmapOptions | undefined> = [
    { 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<void>((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<Blob> {
  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<Blob> {
  const zip = new JSZip();
  for (const { path, blob } of entries) {
    zip.file(path, blob);
  }
  return zip.generateAsync({ type: "blob" });
}