JEPA4Japan · 教程

第 12 章:借来的图片不能毫无规矩地打包

6,759字 19分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

设计覆盖上传、解码、CORS、源清洁性、SVG 净化、内存预算和多格式导出的资产协议。

课程进度 课程大纲 已发布 18/18 课

从一个五岁孩子也能理解的游戏开始

本章唯一的真理:Asset 不是 URL。它是一套完整的加载、安全、生命周期和导出协议。

想象幼儿园门口有一个“资源借用箱”。有人交来一张照片、一个会动的视频盒子、一本 PDF 书,或者一张经过伪装且包含 <script> 的贴纸。管理员不能只在账本上写下发送者的地址。他们必须称量包裹、检查封条、识别内容、确认能否打开、制作一个小预览、分配一个稳定编号,并记录其临时钥匙应在何时归还。

预测一下首先会发生什么。如果你能透过商店橱窗看到邻居的照片,这是否意味着你可以把它放进自己的毕业纪念拼贴画?如果打开一张 2 MB 的压缩图像会产生十亿个像素,它还算“小”吗?当 Object URL 创建一把临时钥匙时,页面关闭后由谁归还它?

  1. 先检查包裹类型、重量、尺寸
  2. 安全地打开它脚本、来源、解码
  3. 为它编号并制作缩略图可恢复状态
  4. 按规则打包导出内容语义、像素、内存
先回答:显示权限和导出权限相同吗?不相同。跨源像素可以显示,但脚本可能被禁止读取和重新打包这些像素。

不能把所有 Asset 故障都称为“图像损坏”。管理员必须准确地告诉用户发生了什么:源未提供 CORS 权限、解码失败、文件缺失、格式不受支持、安全规则拒绝了它,或者导出时内存耗尽。只有错误具有明确类型时,恢复控件才能如实说明情况。

将玩具映射到 Canvas

借用箱Asset 系统需要保留或证明的事实
包裹编号Asset 稳定 IDShape 引用 ID,而不是直接依赖 URL
发送者地址源 URL / 存储键来源、过期时间和权限策略
配送进度上传状态本地、上传中、已上传、失败
拆包进度解码状态等待中、就绪、失败
重量和封条字节大小、MIME 嗅探、哈希既不要信任扩展名,也不要信任 File.type
玩具的实际尺寸固有尺寸与 Shape 的显示尺寸分开
图册图片缩略图完整图像尚未就绪时也能浏览
临时钥匙Object URL创建者负责撤销它
邻居的许可函CORS / Origin-Clean决定能否回读或导出像素
安全规则SVG 清理器 / 限制 / CSP不要执行不受信任的 Script、HTML 或 URL
装箱清单导出计划格式、颜色、尺寸、内存和失败代码

这个类比有其局限。CORS 既不是版权许可,也不是恶意软件扫描;它是浏览器的跨源读取协议。Object URL 在上传后并非永久地址,而只是当前环境中对 Blob 的临时引用。SVG 是一种功能强大且可执行的 XML 文档,不能仅仅因为它“看起来像图像”就把它当作 PNG。哈希可以验证内容身份,但无法证明内容安全或合法。

先消除错误直觉

  • “Asset 就是一个 URL 字符串。” URL 会过期、需要权限、可能因 CORS 而无法导出,并且不携带上传、解码或缺失状态。
  • “浏览器已经验证了 file.type。” 它通常来自客户端元数据。请检查魔数、解析结果和允许列表。
  • “文件大小是 5 MB,所以它最多只占用 5 MB 内存。” 解码后的像素至少大约需要 width × height × 4 字节,此外还可能有源缓冲区、ImageBitmap、Canvas 和导出副本。
  • “图像能显示,所以 toBlob 肯定能用。” 只要有一个未经 CORS 许可的跨源 drawImage,目标 Canvas 就会变成非源干净状态;回读和导出会抛出 SecurityError。
  • “SVG 是文本,所以删除 <script> 就能确保安全。” 事件属性、foreignObject、外部 URL、CSS、动画和不同的命名空间仍然存在。应只接受严格的子集,或使用成熟的清理器。
  • “分块总能解决大图像导出问题。” 如果最后仍要分配一个 100000×100000 的 Canvas 来拼接这些分块,峰值内存问题就没有解决。应流式输出、返回分块包,或使用服务器。
  • “浏览器会处理卸载,所以不需要撤销。” 在长时间会话中替换和删除 Asset,仍会持续保留 Blob。所有权协议必须显式释放它们。

生产级背包

前置契约

第 5 章的 Document 只存储 assetId 和 AssetRecord,绝不存储 ImageBitmap、DOM 节点或 Object URL。第 11 章的 DOM Overlay 绝不插入不受信任的 HTML。Renderer 会为等待中、缺失和失败的 Asset 绘制确定性的占位符。命令和 Asset 上传是两个可能独立失败的系统;本章定义补偿和垃圾回收机制。

正式知识

一条 Asset Record 至少包含稳定 ID、种类、声明及嗅探得到的 MIME、字节大小、哈希、固有宽度和高度、存储键、源 URL 策略、上传状态、解码状态、缩略图 ID、createdAt 和模式版本。源 URL 可以是短期有效的签名 URL,不能作为持久身份;应在运行时根据存储键解析它。上传状态和解码状态必须分开:服务器成功接收并不意味着当前浏览器能够解码,而本地浏览器能够预览也不意味着持久化成功。

加载流水线依次执行:总字节数限制 → 魔数字节/类型嗅探 → 格式允许列表 → 安全解析 → 固有尺寸/像素数限制 → 解码 → 缩略图。createImageBitmap() 可以异步生成适合绘制的 ImageBitmap,但它可能失败并消耗大量内存。使用后调用 close()。Asset Runtime 为 Blob 创建 Object URL,并跟踪引用计数。删除 Shape 不必立即删除服务器上的 Asset,但当本地引用和上传任务均已不存在时,应撤销本地 URL。不要在 <img> 或下载仍在使用它时过早撤销。

文件类型嗅探必须以失败即拒绝为原则。检查 PNG、JPEG、WebP 和其他允许格式的签名,然后让解码器进一步确认。将 SVG 作为受限的 UTF-8 文本读取并解析。对于 PDF 和视频,使用格式专用的解析器或解码器。限制范围包括压缩字节数、宽度、高度、像素数、帧数和时长。图像炸弹可以从一个很小的压缩文件膨胀成数量巨大的像素。客户端限制只是第一层防线;应在隔离的服务器上重新解码高风险上传内容并生成安全的衍生文件。

CORS 决定脚本能否读取跨源响应。<img crossorigin="anonymous"> 或 fetch(...,{mode:'cors',credentials:'omit'}) 仍然要求远程服务器返回正确的 Access-Control-Allow-Origin。一旦把未经许可的跨源图像绘制到 Canvas 中,其源干净标志就会变为 false;getImageData/toBlob/toDataURL/captureStream 会抛出 SecurityError。这里存在一条重要的诊断边界:当 fetch() 遇到网络中断、DNS 故障、CSP 阻止或 CORS 拒绝时,脚本通常会收到相似的 TypeError 值,因而无法诚实地将它们全都标记为 CORS。应通过 Network 面板、受控测试端点和服务器日志来区分它们。相比之下,在导出已经绘制好的 Canvas 时抛出的 SecurityError,是源干净状态失败的有力证据。没有任何 API 能可靠地“在保留像素的同时洗掉污染”。恢复方法是使用获准的来源,或者通过代理/上传将资源放入受控的 Asset 域,然后基于干净的 Document 重新绘制。

安全 SVG 导入会在不挂载到实时 DOM 的情况下进行解析,并拒绝解析器错误、DOCTYPE、未知命名空间、script/foreignObject/style 等危险元素、所有 on* 属性、外部 href、javascript: 或 data:text/html、CSS url() 以及未经批准的属性。本课程接受“较小且安全的 SVG 子集”,并不声称能够导入任意 SVG。如果必须实现完全一致的保真度,请在隔离的服务器上使用持续维护的安全库进行清理和栅格化。绝不要把未知 SVG 交给 innerHTML。

绝不要将不受信任的 HTML 直接作为图像 Overlay 插入。CSP 是纵深防御措施:收紧 default-src,分别配置 img-src、media-src、connect-src 和 worker-src,禁止 object-src,并通过 nonce 或哈希管理脚本和样式。仅在实际 Asset 协议所需的最小范围内允许 blob: 或 data:。CSP 不能替代清理器,因为获准的同源内容仍可能包含恶意数据。

Asset Adapter 为 Image、Video 和 PDF Background 生成统一的帧和缩略图。Video 会记录时长、海报帧和帧可用性;导出时,要么导出固定时间戳对应的帧,要么针对动态内容返回明确的不支持结果。跨源视频受到相同的源干净限制。PDF 不是原生的 drawImage 源。应使用受控的 PDF 渲染器栅格化页面,或将其保留为语义附件,同时限制页数、尺寸和解析资源。Asset 缺失是一种正常的运行时状态:应显示其 ID 以及重试/重新链接控件,而不是让 Document 崩溃。

PNG 是支持 alpha 的无损像素导出格式。JPEG 常用于照片且不保留透明度,因此导出前要铺设明确的背景。检测 WebP 编码器支持时,应检查 toBlob 返回的 MIME,而不能只检查该方法是否存在。SVG Export 属于语义/矢量导出:为 Shape 生成受控元素、转义文本,并为 SVG 无法表达的像素效果嵌入获准的栅格数据。JSON Export 存储 Document Schema 以及 Asset 引用或清单。它是 Semantic Export,而不是屏幕截图。Visual Export 保留可见像素;Semantic Export 保留继续编辑的能力。绝不要假装它们是同一回事。

在导出高分辨率图像之前,先计算世界边界、缩放比例、输出像素数和预算。最低像素内存约为 w*h*4,乘以 2–4 则能更准确地估算 Canvas、解码器和编码器的峰值用量。超出预算时,应使用分块导出:重新绘制带出血区的图块,将其流式传输到受支持的编码器或服务器,或者明确输出图块包及其清单。切勿在最后将它们拼装到一个巨大的前端 Canvas 中。错误类型应区分 CORS、MEMORY、DECODE、ASSET_MISSING、FORMAT_UNSUPPORTED 和 SECURITY_REJECTION。

对于颜色,请声明工作色彩空间和交付约定。Web 交付通常以 sRGB 作为兼容性基线。如果要求使用 Display-P3,请对 Canvas 上下文的 colorSpace 和编码器输出进行特性检测,保留或转换色彩配置文件,并提供 sRGB 回退方案。仅凭显示器色彩鲜艳,并不能证明文件采用了 P3。导出测试应比较色块和元数据。

依据与兼容性(已于 2026-08-29 验证)

本章的工程增量

**起点:**图像 Shape 存储任意 URL,失败时渲染为空白,导出仅调用一次 toDataURL()。**完成标准:**一个可诊断的资源流水线,支持上传、元数据、缩略图、占位符、缺失回退、PNG/SVG/JSON,以及受内存控制的分块高分辨率导出。安全 SVG 和对象 URL 清理均有测试。

添加以下文件:

  • src/engine/assets/AssetRecord.ts:持久化架构和状态;
  • src/engine/assets/AssetManager.ts:嗅探、解码、运行时句柄和清理;
  • src/engine/assets/sanitizeSvg.ts:严格允许的子集;
  • src/engine/export/{plan,png,svg,json,tiles}.ts:导出格式和预算;
  • src/engine/assets/__tests__/security.test.ts:签名、SVG、限制和清理;
  • tests/browser/asset-cors-export.spec.ts:CORS、污染、toBlob 和格式。

首先定义记录和错误,使其能够引导用户采取准确的恢复操作:

export type AssetFailureCode =
  | 'CORS'
  | 'MEMORY'
  | 'DECODE'
  | 'ASSET_MISSING'
  | 'FORMAT_UNSUPPORTED'
  | 'SECURITY_REJECTION'
  | 'NETWORK'
  | 'ENCODE';

export class AssetFailure extends Error {
  constructor(
    readonly code: AssetFailureCode,
    message: string,
    readonly assetId?: string,
  ) {
    super(message);
    this.name = 'AssetFailure';
  }
}

export type AssetRecord = Readonly<{
  id: string;
  schemaVersion: 1;
  kind: 'image' | 'video' | 'pdf' | 'svg';
  declaredMime: string;
  sniffedMime: string;
  byteSize: number;
  sha256: string;
  intrinsic: Readonly<{ width: number; height: number }>;
  storageKey: string | null;
  sourcePolicy: 'same-origin' | 'cors-anonymous' | 'uploaded';
  upload: 'local' | 'uploading' | 'uploaded' | 'failed';
  decode: 'pending' | 'ready' | 'failed';
  thumbnailAssetId: string | null;
  createdAt: string;
}>;

下面是一个完整的浏览器端图像资源管理器。它限制字节数、检查魔数、执行解码并限制像素数。每个运行时句柄都以幂等方式释放其 URL 和 ImageBitmap:

import { AssetFailure, type AssetFailureCode } from './AssetRecord';

const MAX_BYTES = 20 * 1024 * 1024;
const MAX_EDGE = 16_384;
const MAX_PIXELS = 40_000_000;

function sniffRaster(bytes: Uint8Array): 'image/png' | 'image/jpeg' | 'image/webp' | null {
  const png =
    bytes.length >= 8 && [137, 80, 78, 71, 13, 10, 26, 10].every((v, i) => bytes[i] === v);
  if (png) return 'image/png';
  if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
    return 'image/jpeg';
  const ascii = (from: number, to: number) => String.fromCharCode(...bytes.slice(from, to));
  if (bytes.length >= 12 && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return 'image/webp';
  return null;
}

export type RuntimeImage = Readonly<{
  mime: string;
  width: number;
  height: number;
  bitmap: ImageBitmap;
  objectUrl: string;
  release(): void;
}>;

export class AssetManager {
  async openRaster(blob: Blob): Promise<RuntimeImage> {
    if (blob.size <= 0 || blob.size > MAX_BYTES)
      throw new AssetFailure('SECURITY_REJECTION', `asset bytes ${blob.size} exceed policy`);
    const header = new Uint8Array(await blob.slice(0, 16).arrayBuffer());
    const mime = sniffRaster(header);
    if (!mime) throw new AssetFailure('FORMAT_UNSUPPORTED', 'unknown raster signature');
    let bitmap: ImageBitmap;
    try {
      bitmap = await createImageBitmap(blob);
    } catch (cause) {
      throw new AssetFailure('DECODE', `decoder rejected ${mime}: ${String(cause)}`);
    }
    if (
      bitmap.width > MAX_EDGE ||
      bitmap.height > MAX_EDGE ||
      bitmap.width * bitmap.height > MAX_PIXELS
    ) {
      bitmap.close();
      throw new AssetFailure(
        'SECURITY_REJECTION',
        `decoded dimensions ${bitmap.width}x${bitmap.height} exceed policy`,
      );
    }
    let objectUrl: string;
    try {
      objectUrl = URL.createObjectURL(blob);
    } catch (cause) {
      bitmap.close();
      throw new AssetFailure('MEMORY', `runtime URL allocation failed: ${String(cause)}`);
    }
    let released = false;
    return {
      mime,
      width: bitmap.width,
      height: bitmap.height,
      bitmap,
      objectUrl,
      release() {
        if (released) return;
        released = true;
        bitmap.close();
        URL.revokeObjectURL(objectUrl);
      },
    };
  }

  async fetchCors(url: URL): Promise<RuntimeImage> {
    if (url.protocol !== 'https:' && url.protocol !== 'http:')
      throw new AssetFailure('SECURITY_REJECTION', `asset URL protocol ${url.protocol} rejected`);
    let response: Response;
    try {
      response = await fetch(url, { mode: 'cors', credentials: 'omit' });
    } catch (cause) {
      throw new AssetFailure(
        'NETWORK',
        `fetch failed; browsers do not reveal whether transport, CSP, or CORS caused it: ${String(cause)}`,
      );
    }
    if (!response.ok) {
      const code: AssetFailureCode =
        response.status === 404 || response.status === 410 ? 'ASSET_MISSING' : 'NETWORK';
      throw new AssetFailure(code, `asset HTTP ${response.status}`);
    }
    let blob: Blob;
    try {
      blob = await response.blob();
    } catch (cause) {
      throw new AssetFailure('NETWORK', `asset body read failed: ${String(cause)}`);
    }
    return this.openRaster(blob);
  }
}

SVG 导入绝不是“删除脚本”这么简单。只接受可以明确描述的子集。以下完整实现会在遇到任何未知元素或属性、事件属性或外部引用时拒绝整个输入:

const SVG_NS = 'http://www.w3.org/2000/svg';
const XMLNS_NS = 'http://www.w3.org/2000/xmlns/';
const allowedElements = new Set([
  'svg',
  'g',
  'path',
  'rect',
  'circle',
  'ellipse',
  'line',
  'polyline',
  'polygon',
  'text',
  'tspan',
  'defs',
  'linearGradient',
  'radialGradient',
  'stop',
  'clipPath',
]);
const allowedAttributes = new Set([
  'viewBox',
  'width',
  'height',
  'x',
  'y',
  'x1',
  'x2',
  'y1',
  'y2',
  'cx',
  'cy',
  'r',
  'rx',
  'ry',
  'd',
  'points',
  'fill',
  'fill-opacity',
  'stroke',
  'stroke-width',
  'stroke-linecap',
  'stroke-linejoin',
  'opacity',
  'transform',
  'font-family',
  'font-size',
  'font-weight',
  'text-anchor',
  'offset',
  'stop-color',
  'stop-opacity',
  'clip-path',
  'id',
]);

export function sanitizeSvgSubset(source: string): string {
  if (new TextEncoder().encode(source).byteLength > 1_000_000 || /<!DOCTYPE/i.test(source))
    throw new AssetFailure('SECURITY_REJECTION', 'SVG size or doctype rejected');
  const doc = new DOMParser().parseFromString(source, 'image/svg+xml');
  if (doc.querySelector('parsererror') || doc.documentElement.localName !== 'svg')
    throw new AssetFailure('DECODE', 'invalid SVG XML');
  const nodes = [doc.documentElement, ...Array.from(doc.documentElement.querySelectorAll('*'))];
  const ids = new Set<string>(),
    references = new Set<string>();
  for (const node of nodes) {
    if (node.namespaceURI !== SVG_NS || !allowedElements.has(node.localName))
      throw new AssetFailure('SECURITY_REJECTION', `SVG element ${node.localName} rejected`);
    for (const attr of Array.from(node.attributes)) {
      const value = attr.value.trim();
      if (
        node === doc.documentElement &&
        attr.namespaceURI === XMLNS_NS &&
        attr.name === 'xmlns' &&
        value === SVG_NS
      )
        continue;
      if (attr.name.toLowerCase().startsWith('on') || !allowedAttributes.has(attr.name))
        throw new AssetFailure('SECURITY_REJECTION', `SVG attribute ${attr.name} rejected`);
      if (/javascript:|data:|https?:/i.test(value))
        throw new AssetFailure('SECURITY_REJECTION', `SVG external value rejected`);
      const localReference = /^url\(\s*#([A-Za-z_][\w.-]*)\s*\)$/.exec(value);
      if (/url\s*\(/i.test(value)) {
        if (!localReference || !['fill', 'stroke', 'clip-path'].includes(attr.name))
          throw new AssetFailure(
            'SECURITY_REJECTION',
            'only local SVG paint/clip references are allowed',
          );
        references.add(localReference[1]);
      }
      if (attr.name === 'clip-path' && !localReference)
        throw new AssetFailure('SECURITY_REJECTION', 'clip-path must be url(#local-id)');
      if (attr.name === 'id') {
        if (!/^[A-Za-z_][\w.-]*$/.test(value) || ids.has(value))
          throw new AssetFailure('SECURITY_REJECTION', 'SVG id invalid or duplicated');
        ids.add(value);
      }
    }
  }
  for (const reference of references) {
    if (!ids.has(reference))
      throw new AssetFailure('SECURITY_REJECTION', `SVG local reference #${reference} is missing`);
  }
  return new XMLSerializer().serializeToString(doc.documentElement);
}

在分配资源之前规划导出。tilePlan 永远不会创建巨大的 Canvas,并会在清单中记录出血区和最终裁剪信息:

export type ExportTile = Readonly<{
  x: number;
  y: number;
  width: number;
  height: number;
  bleed: number;
}>;
export type ExportPlan = Readonly<{
  width: number;
  height: number;
  colorSpace: 'srgb' | 'display-p3';
  fullFrameBytes: number;
  estimatedPeakBytes: number;
  tiles: readonly ExportTile[];
}>;

export function planExport(
  width: number,
  height: number,
  memoryBudgetBytes: number,
  tileEdge = 2048,
  bleed = 8,
  colorSpace: 'srgb' | 'display-p3' = 'srgb',
): ExportPlan {
  if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0)
    throw new AssetFailure('SECURITY_REJECTION', 'invalid export dimensions');
  if (
    !Number.isSafeInteger(memoryBudgetBytes) ||
    memoryBudgetBytes <= 0 ||
    !Number.isSafeInteger(tileEdge) ||
    tileEdge <= 0 ||
    !Number.isSafeInteger(bleed) ||
    bleed < 0
  )
    throw new AssetFailure('SECURITY_REJECTION', 'invalid export budget or tile settings');
  if (colorSpace !== 'srgb' && colorSpace !== 'display-p3')
    throw new AssetFailure('FORMAT_UNSUPPORTED', `color space ${String(colorSpace)} unsupported`);
  const fullFrameBytes = width * height * 4 * 3;
  if (!Number.isSafeInteger(fullFrameBytes))
    throw new AssetFailure('MEMORY', 'export size overflow');
  const tiled = fullFrameBytes > memoryBudgetBytes;
  const effectiveEdge = tiled ? tileEdge : Math.max(width, height);
  const tileBleed = tiled ? bleed : 0;
  const allocationWidth = Math.min(width, effectiveEdge) + tileBleed * 2;
  const allocationHeight = Math.min(height, effectiveEdge) + tileBleed * 2;
  const estimatedPeakBytes = allocationWidth * allocationHeight * 4 * 3;
  if (!Number.isSafeInteger(estimatedPeakBytes) || estimatedPeakBytes > memoryBudgetBytes)
    throw new AssetFailure('MEMORY', 'even one export tile exceeds the configured peak budget');
  const columns = Math.ceil(width / effectiveEdge),
    rows = Math.ceil(height / effectiveEdge);
  const tileCount = columns * rows;
  if (!Number.isSafeInteger(tileCount) || tileCount > 100_000)
    throw new AssetFailure('MEMORY', `export plan would allocate ${tileCount} tile records`);
  const tiles: ExportTile[] = [];
  for (let y = 0; y < height; y += effectiveEdge)
    for (let x = 0; x < width; x += effectiveEdge)
      tiles.push({
        x,
        y,
        width: Math.min(effectiveEdge, width - x),
        height: Math.min(effectiveEdge, height - y),
        bleed: tileBleed,
      });
  return { width, height, colorSpace, fullFrameBytes, estimatedPeakBytes, tiles };
}

export async function canvasToBlob(
  canvas: HTMLCanvasElement,
  mime: 'image/png' | 'image/jpeg' | 'image/webp',
  quality?: number,
) {
  let blob: Blob | null;
  try {
    blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, mime, quality));
  } catch (cause) {
    if (cause instanceof DOMException && cause.name === 'SecurityError')
      throw new AssetFailure('CORS', 'canvas is not origin-clean');
    throw cause;
  }
  if (!blob) throw new AssetFailure('ENCODE', 'encoder returned null without a diagnosable cause');
  if (blob.type !== mime)
    throw new AssetFailure('FORMAT_UNSUPPORTED', `${mime} encoder unavailable`);
  return blob;
}

PNG 导出会为每个图块调用确定性渲染器。SVG 导出只根据 Document/Geometry 生成允许的元素,并对文本进行 XML 转义。JSON 导出会生成 {schemaVersion,document,assetManifest},且绝不嵌入运行时 URL。JPEG 会先铺设背景;WebP 则验证实际的 Blob MIME。如果需要多个图块,而浏览器没有流式编码器,就返回 zip/图块清单,或将工作发送到受控服务器,而不是假装已经生成了一个巨大的单文件。

测试既要证明能够安全拒绝,也要证明资源得到释放:

import { describe, expect, it, vi } from 'vitest';
import { AssetFailure, planExport, sanitizeSvgSubset } from '../assets';

describe('asset boundary', () => {
  it('rejects SVG event attributes and foreignObject', () => {
    expect(() =>
      sanitizeSvgSubset('<svg xmlns="http://www.w3.org/2000/svg"><rect onload="alert(1)"/></svg>'),
    ).toThrowError(AssetFailure);
    expect(() =>
      sanitizeSvgSubset('<svg xmlns="http://www.w3.org/2000/svg"><foreignObject/></svg>'),
    ).toThrowError(/element foreignObject rejected/);
  });

  it('allows parsed local paint references and rejects missing references', () => {
    const valid =
      '<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="g">' +
      '<stop offset="0" stop-color="#fff"/></linearGradient></defs><rect fill="url(#g)"/></svg>';
    expect(sanitizeSvgSubset(valid)).toContain('url(#g)');
    expect(() =>
      sanitizeSvgSubset(
        '<svg xmlns="http://www.w3.org/2000/svg"><rect fill="url(#missing)"/></svg>',
      ),
    ).toThrowError(/reference #missing is missing/);
  });

  it('creates tiles that cover the complete output when over budget', () => {
    const plan = planExport(5000, 3000, 64 * 1024 * 1024, 2048);
    expect(plan.tiles).toHaveLength(6);
    expect(plan.fullFrameBytes).toBeGreaterThan(64 * 1024 * 1024);
    expect(plan.estimatedPeakBytes).toBeLessThanOrEqual(64 * 1024 * 1024);
    expect(Math.max(...plan.tiles.map((tile) => tile.x + tile.width))).toBe(5000);
    expect(Math.max(...plan.tiles.map((tile) => tile.y + tile.height))).toBe(3000);
  });

  it('requires runtime release to be idempotent', async () => {
    const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
    const close = vi.fn();
    const handle = {
      objectUrl: 'blob:test',
      bitmap: { close },
      release() {
        if (close.mock.calls.length) return;
        close();
        URL.revokeObjectURL(this.objectUrl);
      },
    };
    handle.release();
    handle.release();
    expect(close).toHaveBeenCalledTimes(1);
    expect(revoke).toHaveBeenCalledTimes(1);
  });
});

运行 pnpm vitest run src/engine/assets src/engine/export。魔数、尺寸、SVG 允许列表、图块覆盖范围、格式和清理测试应全部通过。运行 pnpm playwright test tests/browser/asset-cors-export.spec.ts。不支持 CORS 的端点应在导出时产生 CORS,而获准的端点应生成 PNG。然后运行 pnpm run build;Astro 和 Pagefind 应成功完成,并且章节链接中不应包含破坏性 HTML。

回到借用箱的比喻。AssetRecord 是包裹档案,Runtime Handle 是临时钥匙,origin-clean 是记录邻居是否允许重新打包像素的封条,而 Export Plan 会在打包前计算箱子的容积。管理器不会只说“反正失败了”。它会提供准确的故障卡片和恢复路径。

有意破坏它

注入项症状证据修复回归测试恢复
没有 CORS 的跨源图像屏幕上可见,但导出 PNG 时抛出 SecurityError源 URL 和 toBlob 异常名称上传、使用同源代理或配置正确的 ACAO,然后重新进行干净绘制两个测试端点替换为干净来源;不要保留已污染的位图
SVG 嵌入 Script 或事件属性代码被执行,或危险内容被存储清理器拒绝代码使用严格允许列表或隔离栅格化恶意样本库以原子方式拒绝;绝不附加到 DOM
巨大的图像尺寸/图像炸弹标签页内存用量激增字节数、尺寸和像素预算在解码前后实施限制;由隔离的服务器生成衍生图像边界和像素限制夹具关闭位图并撤销 URL
解码失败永远处于加载状态解码状态和错误标记为失败,并提供替换/重试选项截断的 JPEG绘制确定性回退内容
资源上传成功;Document 保存失败出现孤立资源上传 ID 和 Document 修订版本补偿性删除或延迟 GC两阶段失败模拟保留重试令牌或 GC 记录
Document 记录存在;资源缺失渲染器崩溃或绘制为空白ASSET_MISSING 和 assetId显示缺失占位符并重新链接404 夹具不要删除 Shape
导出超出内存预算标签页崩溃或 toBlob 返回 null计划峰值字节数和图块日志使用图块加流式处理/服务器;尽早返回 MEMORY10 万尺寸规划测试释放图块 Canvas 和位图
页面卸载或替换后 URL 仍然存在保留的 Blob 堆持续增长URL 注册表和堆快照明确所有权并进行幂等释放打开和关闭 100 次撤销所有运行时句柄

用证据证明通过

故障类别唯一错误代码用户可执行的恢复操作
无法回读跨源像素CORS上传副本或选择获准的来源
规划的像素数或单个图块超出预算MEMORY减小尺寸或图块边长,或者使用服务器
文件无法解码DECODE替换或重试,无需更改 Document
资源返回 404 或记录缺失ASSET_MISSING保留 Shape 并重新链接
浏览器或流水线不支持该格式FORMAT_UNSUPPORTED选择兼容格式,例如 PNG/sRGB
SVG、尺寸或 MIME 违反策略SECURITY_REJECTION显示违反的确切规则,并且不要执行该数据
网络、CSP 或 CORS 以不透明方式阻止获取NETWORK检查网络、CSP、受控 CORS 端点和服务器日志
浏览器编码器无故返回 nullENCODE减小尺寸或更换编码器/服务器;不要误报为内存问题
测试面自动化证据手动证据
类型和安全边界魔数/SVG 样本库/模糊测试/限制测试验证 UI 错误是否建议了切实可行的操作
CORS 和格式编码两个源上的 Playwright 端点和 Blob MIME在目标浏览器中下载并打开导出文件
生命周期和内存释放次数、图块覆盖范围和堆预算在长时间会话中替换资源并检查堆快照
  • AssetRecord 包含 URL/存储、上传、解码、缩略图、固有尺寸、哈希和版本。
  • createImageBitmap、对象 URL 创建和 close/revoke 具有唯一所有者和幂等清理机制。
  • CORS、Origin-Clean 和 Tainted Canvas 具有真实浏览器测试。
  • SVG、不受信任的 HTML、MIME 嗅探、字节/像素/帧/页数限制以及 CSP 构成分层防御。
  • 已定义图像/视频/PDF 背景适配器、缺失状态和解码失败状态。
  • 协议记录了 PNG/JPEG/WebP、SVG、JSON 以及语义/视觉导出之间的差异。
  • 分块高分辨率导出具有峰值内存预算,并且绝不将图块重新拼接到超大 Canvas 中。
  • sRGB 和 Display-P3 具备特性检测、回退和输出验证。
  • 导出能够准确区分所需的六类领域错误。对于浏览器无法归因的获取和编码器故障,应使用 NETWORK/ENCODE,而不是冒充 CORS 或 MEMORY 错误。

给五岁小朋友解释

回答时不要说“CORS”“解码”“SVG”“内存”或“URL”:

  1. 为什么你能透过商店橱窗看到邻居的照片,却不一定能把它放进自己的毕业纪念拼贴画里?
  2. 为什么一个很轻的包裹打开后仍可能塞满整个房间?
  3. 为什么临时钥匙用完后就应该归还,而不是等到幼儿园关门时再还?
  4. 新问题:共享账本里仍然列着一个照片编号,但仓库找不到包裹。我们应该删除整页,还是在那里放一张说明卡片?
显示参考答案 邻居可能允许你隔着玻璃观看,却不允许你取走像素并重新打包。你应取得明确许可,或者向自己的管理人员提供一份合法副本。压缩包裹就像真空包装的羽绒被:称起来很轻,展开后却非常庞大,因此既要检查包裹重量,也要检查打开后的大小。临时钥匙会持续占用仓库资源,并在长时间的编辑会话和反复替换中不断累积,所以一旦不再使用,就应登记归还。包裹丢失并不意味着账本中记录的位置、尺寸和关系也应该消失。保留一张带编号的说明卡片,让用户可以重试或重新链接。