JEPA4Japan · チュートリアル

第12章:借りた画像を勝手に箱へ詰めてはいけない

6,639文字 21分で読めます #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Upload、Decode、CORS、Origin Clean、SVG Sanitization、Memory Budget、複数形式Exportを含むAssetプロトコルを設計します。

コース進捗 コース目次 18レッスン中 18件を公開中

第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標

  1. 01 第1章:まだ描かない——Canvasはプロダクト設計ではない 公開中
  2. 02 第2章:すぐに記憶を失うピクセルの紙 公開中
  3. 03 第3章:お絵描きを再現可能なレシピにする 公開中
  4. 04 第4章:4枚の地図と1台のカメラ 公開中

第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール

  1. 05 第5章:ピクセル世界に台帳を作る 公開中
  2. 06 第6章:ランプが点いたときだけ描き直す——Render SchedulerとReactの境界 公開中
  3. 07 第7章:マウス、指、ペンに同じ言葉を話してもらう 公開中
  4. 08 第8章:細い縁を調べる前に大きな箱を探す 公開中
  5. 09 第9章:ツールは信号機であり、Booleanの袋ではない 公開中

第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧

  1. 10 第10章:触って気持ちよいエディターにする 公開中
  2. 11 第11章:描かれた文字は編集できる文字ではない 公開中
  3. 12 第12章:借りた画像を勝手に箱へ詰めてはいけない 現在のレッスン
  4. 13 第13章:タイムマシンと古い箱 公開中
  5. 14 第14章:正しく見えることと、本当に正しいことは違う 公開中

第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI

  1. 15 第15章:1万人を一人ずつ探さない 公開中
  2. 16 第16章:受付を厨房へ入れない——WorkerとGPUへの更新 公開中
  3. 17 第17章:車を自作するか、実績あるシャーシを買うか 公開中
  4. 18 第18章:人とAIが同じ台帳を編集する 公開中

まずは5歳児にもわかるゲームから

この章で覚える真実は一つだけです。Asset は URL ではなく、読み込み、セキュリティ、ライフサイクル、エクスポートをまとめた完全なプロトコルです。

幼稚園の入口に「素材を借りる箱」があると想像してください。誰かが写真、動くビデオ箱、PDF の本、または <script> を含む偽装シールを渡してきます。管理人は送り主の住所を台帳へ書くだけではいけません。包みの重さを量り、封を調べ、中身を特定し、安全に開けるか確認し、小さなプレビューを作り、Stable ID を付け、仮の鍵をいつ返すか記録します。

先に予想してみてください。店の窓越しに隣人の写真が見えたら、自分の卒業アルバムへ入れてよいでしょうか。圧縮時は 2 MB でも、開くと10億ピクセルになる画像は「小さなファイル」でしょうか。Object URL が作った仮の鍵は、ページを閉じたあと誰が返すのでしょうか。

  1. まず荷物を検査種類、重さ、寸法
  2. 安全に開くscript、出所、decode
  3. 番号を付け thumbnail を作る復旧可能な状態
  4. 規則どおりに export意味、ピクセル、メモリ
まず答えてください。表示の許可と export の許可は同じでしょうか。同じではありません。cross-origin のピクセルは表示できても、script による読み取りと再梱包は禁止されることがあります。

Asset の失敗をすべて「画像が壊れた」と呼んではいけません。管理人は CORS 許可がない、decode に失敗した、ファイルがない、形式が未対応、安全規則が拒否した、export 用メモリが足りない、のどれかを正確に伝えます。エラーに型があって初めて、復旧ボタンが正しい約束をできます。

おもちゃを Canvas に置き換える

素材を借りる箱Asset システム保存または証明する事実
荷物番号Asset Stable IDShape は URL へ直接依存せず ID を参照
送り主の住所Source URL / storage key出所、期限、権限方針
配送状況Upload Statelocal、uploading、uploaded、failed
開梱状況Decode Statepending、ready、failed
重さと封byte size、MIME sniff、hash拡張子と File.type を信用しない
おもちゃの実寸Intrinsic SizeShape の表示寸法と分離
カタログの小画像Thumbnail本画像が未準備でも閲覧可能
仮の鍵Object URL作成者が revoke を担当
隣人の許可証CORS / Origin-Cleanピクセルを読み戻し export できるか決定
安全検査規則SVG sanitizer / limits / CSP信頼できない Script、HTML、URL を実行しない
梱包リストExport Planformat、color、dimensions、memory、failure code

この比喩には限界があります。CORS は著作権許可でも malware scan でもなく、ブラウザの cross-origin 読み取りプロトコルです。Object URL は upload 後の恒久住所ではなく、現在の環境から Blob を指す一時参照です。SVG は実行能力の高い XML 文書であり、「画像に見える」からと PNG と同一視できません。Hash は内容の一致を検証できますが、安全性や合法性を証明しません。

まず誤った直感を捨てる

  • 「Asset は URL 文字列だ。」 URL は期限切れになり、権限が必要で、CORS によって export できないことがあり、upload、decode、missing の状態もありません。
  • 「file.type はブラウザが検証済みだ。」 通常はクライアントのメタデータです。magic bytes、parse 結果、allowlist を確認します。
  • 「ファイルが 5 MB ならメモリも最大 5 MB だ。」 decode 後のピクセルだけで少なくとも約 width × height × 4 bytes、さらに source buffer、ImageBitmap、Canvas、export copy があり得ます。
  • 「表示できた画像なら toBlob も必ず成功する。」 CORS 許可のない cross-origin drawImage を一度行うと Canvas は non-origin-clean になり、readback と export が SecurityError を投げます。
  • 「SVG はテキストだから <script> を削除すれば安全だ。」 event attribute、foreignObject、外部 URL、CSS、animation、異なる namespace が残ります。厳格な subset または成熟した sanitizer を使います。
  • 「tile に分ければ大画像 export は必ず解決する。」 最後に 100000×100000 Canvas を作って結合したら peak memory は解決しません。stream 出力、tile bundle、server を使います。
  • 「ページ終了時にブラウザが片付けるから revoke は不要だ。」 長い session で Asset を交換・削除すると Blob が保持され続けます。所有権プロトコルで明示的に解放します。

Production backpack

前提となる契約

第5章の Document は assetId と AssetRecord だけを保存し、ImageBitmap、DOM node、Object URL は保存しません。第11章の DOM Overlay は信頼できない HTML を挿入しません。Renderer は pending、missing、failed に決定的な placeholder を描きます。Command と Asset upload は別々に失敗し得るシステムで、この章が補償と garbage collection を定義します。

正式な知識

Asset Record には最低限、Stable ID、kind、declared/sniffed MIME、byte size、hash、intrinsic width/height、storage key、source URL policy、upload state、decode state、thumbnail ID、createdAt、schema version を含めます。Source URL は短命な signed URL の場合があり、永続 ID として使えません。runtime で storage key から解決します。Upload State と Decode State は分離します。server が受信済みでも現在のブラウザが decode できるとは限らず、ローカル preview ができても永続化成功とは限りません。

読み込み pipeline は total byte limit → magic bytes/type sniff → format allowlist → secure parse → intrinsic dimension/pixel count limit → decode → thumbnail の順です。createImageBitmap() は描画に適した ImageBitmap を非同期生成できますが、失敗することも多量のメモリを使うこともあります。使用後は close() を呼びます。Asset Runtime は Blob の Object URL を作り reference count を記録します。Shape 削除時に server asset を即削除する必要はありませんが、ローカル参照と upload task がなくなったら revoke します。<img> や download が使用中なのに早すぎる revoke をしてはいけません。

File Type Sniffing は fail closed にします。PNG、JPEG、WebP などの signature を確認し、decoder に再確認させます。SVG は制限した UTF-8 text として読み parse します。PDF と video は専用 parser/decoder を使います。制限は compressed bytes、width、height、pixel count、frame count、duration をすべて見ます。Image Bomb は小さな圧縮ファイルから巨大なピクセルへ展開します。クライアント制限は第一層にすぎず、高リスク upload は隔離 server で再 decode して安全な派生画像を作ります。

CORS は script が cross-origin response を読めるかを決めます。<img crossorigin="anonymous"> や fetch(...,{mode:'cors',credentials:'omit'}) を使っても、remote server が正しい Access-Control-Allow-Origin を返す必要があります。許可のない cross-origin 画像を Canvas に描くと origin-clean flag が false になり、getImageData/toBlob/toDataURL/captureStream が SecurityError を投げます。診断上の重要な境界があります。fetch() が network outage、DNS failure、CSP block、CORS rejection に遭うと、script が得るのは多くの場合似た TypeError であり、すべてを正直に CORS と呼べません。Network panel、管理した test endpoint、server log で区別します。一方、描画済み Canvas の export 時に SecurityError が出れば origin-clean failure の強い証拠です。「汚染を洗い流してピクセルを保つ」信頼できる API はありません。許可済み source、proxy、管理 Asset domain への upload に切り替え、clean な Document から再描画します。

Safe SVG Import は live DOM へ付けずに parse し、parser error、DOCTYPE、未知 namespace、script/foreignObject/style などの危険 element、すべての on* attribute、外部 href、javascript:/data:text/html、CSS url()、未許可 attribute を拒否します。この教材は「小さく安全な SVG subset」を受け入れるだけで、任意 SVG の import を主張しません。完全な忠実度が必要なら、隔離 server で更新され続ける security library を用いて sanitize と rasterize を行います。未知 SVG を innerHTML へ渡してはいけません。

Untrusted HTML を画像 Overlay として直接挿入しません。CSP は defense in depth です。default-src を厳しくし、img-src、media-src、connect-src、worker-src を個別設定し、object-src を禁止し、nonce/hash で script/style を管理します。blob:/data: は実際の Asset protocol が必要とする最小範囲だけ許可します。CSP は sanitizer の代わりではありません。許可された same-origin content にも悪意あるデータがあり得ます。

Image、Video、PDF Background は Asset Adapter から共通 frame/thumbnail を生成します。Video は duration、poster、frame availability を記録し、固定時刻を export するか motion 未対応を明示します。cross-origin video も origin cleanliness の制約を受けます。PDF は native drawImage source ではありません。管理した PDF renderer でページごとに rasterize するか semantic attachment を保持し、page 数、寸法、parse resource を制限します。Missing Asset は通常の runtime state です。ID と retry/relink control を表示し、Document 全体を crash させません。

PNG は alpha 対応の lossless pixel export です。JPEG は写真向けで透明を保たないため、export 前に明示的背景を敷きます。WebP encoder 対応は toBlob の返却 MIME で検出し、method の存在だけを見ません。SVG Export は semantic/vector export です。Shape から管理 element を生成し text を escape し、表現できない pixel effect には許可済み raster を埋め込みます。JSON Export は Document Schema と Asset reference/manifest を保存する Semantic Export で、screenshot ではありません。Visual Export は見える pixel、Semantic Export は再編集可能性を保ちます。両者を同じものと見せてはいけません。

高解像度 export の前に world bounds、scale、output pixel、budget を計算します。最小 pixel memory は約 w*h*4 で、Canvas、decoder、encoder の peak を考えると実際には 2〜4 倍を見ます。超過時は Tiled Export で tile ごとに bleed を付けて再描画し、対応 encoder/server へ stream するか、tile bundle + manifest を明示的に出力します。最後に frontend で巨大 Canvas へ結合してはいけません。エラーは CORS、MEMORY、DECODE、ASSET_MISSING、FORMAT_UNSUPPORTED、SECURITY_REJECTION を区別します。

色について working space と delivery contract を明記します。Web 配信は通常 sRGB を互換基準にします。Display-P3 を要求されたら Canvas context の colorSpace と encoder output を feature detect し、profile を保持または変換し、sRGB fallback を用意します。画面が鮮やかに見えるだけで P3 file と主張してはいけません。export test で color patch と metadata を比較します。

根拠と互換性(確認日:2026-08-29)

この章で積み上げる実装

開始地点: Image Shape が任意 URL を直接保存し、読み込み失敗時は空白、export は toDataURL() を一度呼ぶだけです。完了地点: 診断可能な Asset Pipeline が Upload、Metadata、Thumbnail、Placeholder、Missing fallback、PNG/SVG/JSON、memory-controlled tiled high-resolution export を扱います。Safe SVG と Object URL cleanup にテストがあります。

次のファイルを追加します。

  • src/engine/assets/AssetRecord.ts:永続 schema と state。
  • src/engine/assets/AssetManager.ts:sniff、decode、runtime handle、cleanup。
  • src/engine/assets/sanitizeSvg.ts:厳格な allowed subset。
  • src/engine/export/{plan,png,svg,json,tiles}.ts:各 export と budget。
  • src/engine/assets/__tests__/security.test.ts:signature/SVG/limits/cleanup。
  • tests/browser/asset-cors-export.spec.ts:CORS/taint/toBlob/format。

まず、正確な復旧処理へ分岐できるエラーと record を定義します。

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

以下は完全なブラウザ側 Image Asset Manager です。byte を制限し、magic を確認し、decode 後の pixel も制限します。各 runtime handle は 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 Import は「script を削除する」ことではありません。説明可能な subset だけを受け入れます。以下の完全な実装は、未知 element/attribute、event attribute、外部参照が一つでもあれば全体を拒否します。

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);
}

割り当てる前に export を計画します。tilePlan は巨大 Canvas を作らず、bleed と最終 crop を manifest に記録します。

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 Export は deterministic Renderer を呼んで tile ごとに描画します。SVG Export は Document/Geometry から allowed element だけを作り XML-escape します。JSON Export は {schemaVersion,document,assetManifest} を出力し Runtime URL を入れません。JPEG は先に背景を敷き、WebP は実 Blob MIME を確認します。複数 tile が必要でブラウザに streaming encoder がなければ、単一巨大ファイルができたふりをせず zip/tile manifest を返すか管理 server へ送ります。

テストは安全拒否と resource 解放の両方を証明します。

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

describe('asset boundary', () => {
  it('SVG の event attribute と 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('解析済み local paint reference を許可し、存在しない reference を拒否する', () => {
    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('予算超過時に出力全体を覆う tiles を生成する', () => {
    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('runtime release は冪等でなければならない', 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 を実行し、magic、dimension、SVG allowlist、tile coverage、format、cleanup がすべて通ることを確認します。pnpm playwright test tests/browser/asset-cors-export.spec.ts を実行し、CORS なし endpoint の export が CORS、許可 endpoint が PNG を生成することを確認します。続いて pnpm run build を実行し、Astro と Pagefind が成功し、この章のリンクに破壊的 HTML がないことを確認します。

素材を借りる箱へ戻りましょう。AssetRecord は荷物の記録、Runtime Handle は仮の鍵、origin-clean は隣人が pixel の再梱包を許したかを記す封、Export Plan は梱包前に箱の体積を計算する作業です。管理人は「とにかく失敗した」とは言わず、正確な失敗カードと復旧手順を渡します。

わざと壊してみる

注入する障害症状証拠修正回帰テスト復旧
CORS なし cross-origin image画面表示できるが PNG export で SecurityErrororigin URL と toBlob exception nameupload、same-origin proxy、正しい ACAO 後に clean redraw二つの test endpointclean source に交換、tainted bitmap を保持しない
SVG に Script/event attributecode 実行または危険内容保存sanitizer rejection code厳格 allowlist または隔離 rasterize悪意ある corpus原子的拒否、DOM に付けない
巨大画像寸法 / Image Bombtab memory 急増bytes、dimensions、pixel budgetdecode 前後制限、隔離 server derivativeedge/pixel-limit fixturebitmap close、URL revoke
Decode failure永久 loadingdecode state/errorfailed にして replace/retry 提供truncated JPEGdeterministic fallback を描く
Asset upload 成功、Document Save 失敗orphan assetupload ID と Document revisioncompensating delete または delayed GC2段階失敗 simulationretry token/GC record を保持
Document record あり、Asset missingRenderer crash または blankASSET_MISSING と assetIdMissing placeholder と relink404 fixtureShape を削除しない
Export が memory 超過tab crash または toBlob nullplan peak bytes と tile logstiles + stream/server、早期 MEMORY100k dimension plan testtile Canvas/bitmap を解放
page unload/交換後 URL 未解放retained Blob heap 増加URL registry と heap snapshotownership + idempotent release100回 open/close全 runtime handle を revoke

証拠をもって合格する

失敗カテゴリ固有 error codeユーザーが実行できる復旧
cross-origin pixel を readback できないCORScopy を upload、または許可 source を選ぶ
計画 pixel または1 tile が予算超過MEMORYdimension/tile edge を下げる、または server 利用
file を decode できないDECODEDocument を変えず交換または retry
Asset 404 または record missingASSET_MISSINGShape を保って relink
browser/pipeline が format 未対応FORMAT_UNSUPPORTEDPNG/sRGB など互換 format を選ぶ
SVG、dimension、MIME が policy 違反SECURITY_REJECTION正確な規則を示し、データを実行しない
network/CSP/CORS が fetch を不透明に阻止NETWORKnetwork、CSP、管理 CORS endpoint、server log を確認
browser encoder が理由不明で nullENCODEsize を下げる、encoder/server を変更し memory と誤報しない
検査面自動化された証拠手動の証拠
type と security boundarymagic/SVG corpus/fuzz/limit testUI error が実行可能な行動を示すか確認
CORS と format encode2 origin の Playwright endpoint と Blob MIME対象ブラウザで download し開く
lifecycle と memoryrelease count、tile coverage、heap budget長い session で Asset 交換し heap snapshot 確認
  • AssetRecord が URL/storage、Upload、Decode、Thumbnail、Intrinsic Size、hash、version を含む。
  • createImageBitmap、Object URL 作成、close/revoke に一人の owner と冪等 cleanup がある。
  • CORS、Origin-Clean、Tainted Canvas に実ブラウザテストがある。
  • SVG、Untrusted HTML、MIME sniff、byte/pixel/frame/page limits、CSP が多層防御を構成する。
  • Image/Video/PDF Background adapter、missing state、decode failure が定義されている。
  • PNG/JPEG/WebP、SVG、JSON、Semantic/Visual Export の差が protocol に記載されている。
  • Tiled High-Resolution Export に peak memory budget があり、巨大 Canvas へ再結合しない。
  • sRGB と Display-P3 に feature detection、fallback、output validation がある。
  • Export が要求された6種の domain error category を正確に区別し、browser が帰属できない fetch/encoder failure は CORS や MEMORY を装わず NETWORK/ENCODE を使う。

5歳児に説明する

「CORS」「デコード」「SVG」「メモリ」「URL」という言葉を使わずに答えてください。

  1. 隣人の写真を店の窓越しに見られても、自分の卒業アルバムへ入れられるとは限らないのはなぜでしょうか。
  2. とても軽い荷物でも、開くと部屋いっぱいになるのはなぜでしょうか。
  3. 仮の鍵は幼稚園が閉まるまで待たず、使い終えたら返すべきなのはなぜでしょうか。
  4. 新しい問題です。共有台帳に写真番号が残っているのに倉庫で荷物が見つかりません。ページ全体を消しますか、それとも説明カードを置きますか。
参考解答を見る 隣人はガラス越しに見ることを許しても、pixel を取り外して再梱包することまで許していないかもしれません。明示的な許可を得るか、合法な copy を自分の管理人へ渡します。圧縮荷物は真空パックの布団のように、量ると軽くても広げると巨大なので、荷物の重さと開いた寸法の両方を調べます。仮の鍵は倉庫の資源を占め続け、画像交換や長時間編集で積み上がるため、使わなくなったらすぐ返却を記録します。荷物が一時的にないからといって、台帳の位置、寸法、関係まで消すべきではありません。番号付き説明カードを残し、retry または relink できるようにします。