コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
まずは5歳児にもわかるゲームから
この章で覚える真実は一つだけです。Asset は URL ではなく、読み込み、セキュリティ、ライフサイクル、エクスポートをまとめた完全なプロトコルです。
幼稚園の入口に「素材を借りる箱」があると想像してください。誰かが写真、動くビデオ箱、PDF の本、または <script> を含む偽装シールを渡してきます。管理人は送り主の住所を台帳へ書くだけではいけません。包みの重さを量り、封を調べ、中身を特定し、安全に開けるか確認し、小さなプレビューを作り、Stable ID を付け、仮の鍵をいつ返すか記録します。
先に予想してみてください。店の窓越しに隣人の写真が見えたら、自分の卒業アルバムへ入れてよいでしょうか。圧縮時は 2 MB でも、開くと10億ピクセルになる画像は「小さなファイル」でしょうか。Object URL が作った仮の鍵は、ページを閉じたあと誰が返すのでしょうか。
- まず荷物を検査種類、重さ、寸法
- 安全に開くscript、出所、decode
- 番号を付け thumbnail を作る復旧可能な状態
- 規則どおりに export意味、ピクセル、メモリ
Asset の失敗をすべて「画像が壊れた」と呼んではいけません。管理人は CORS 許可がない、decode に失敗した、ファイルがない、形式が未対応、安全規則が拒否した、export 用メモリが足りない、のどれかを正確に伝えます。エラーに型があって初めて、復旧ボタンが正しい約束をできます。
おもちゃを Canvas に置き換える
| 素材を借りる箱 | Asset システム | 保存または証明する事実 |
|---|---|---|
| 荷物番号 | Asset Stable ID | Shape は URL へ直接依存せず ID を参照 |
| 送り主の住所 | Source URL / storage key | 出所、期限、権限方針 |
| 配送状況 | Upload State | local、uploading、uploaded、failed |
| 開梱状況 | Decode State | pending、ready、failed |
| 重さと封 | byte size、MIME sniff、hash | 拡張子と File.type を信用しない |
| おもちゃの実寸 | Intrinsic Size | Shape の表示寸法と分離 |
| カタログの小画像 | Thumbnail | 本画像が未準備でも閲覧可能 |
| 仮の鍵 | Object URL | 作成者が revoke を担当 |
| 隣人の許可証 | CORS / Origin-Clean | ピクセルを読み戻し export できるか決定 |
| 安全検査規則 | SVG sanitizer / limits / CSP | 信頼できない Script、HTML、URL を実行しない |
| 梱包リスト | Export Plan | format、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 × 4bytes、さらに source buffer、ImageBitmap、Canvas、export copy があり得ます。 - 「表示できた画像なら
toBlobも必ず成功する。」 CORS 許可のない cross-origindrawImageを一度行うと 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)
- WHATWG HTML Canvas は origin-clean flag と、non-clean Canvas の
toBlob()/toDataURL()がSecurityErrorを投げることを定義します。MDN CORS enabled images は cross-origin image の taint と readback 制約を説明します。 - MDN
createImageBitmap()、ImageBitmap.close()、URL.createObjectURL()、URL.revokeObjectURL()が decode と一時 URL のライフサイクル境界です。 - MDN
HTMLCanvasElement.toBlob()は非同期 encode と format fallback を説明します。返却 Blob の実 MIME を確認します。 - MDN Content Security Policy、
img-src、media-srcは defense in depth を支えます。 - MDN SVG as an image は画像としての SVG の制限を説明します。編集可能 SVG の import は active content として扱い、厳格な subset を受け入れます。
- MDN Canvas color management で context attributes を検査できます。
colorSpace対応と encoded output は対象ブラウザで実測します。
この章で積み上げる実装
開始地点: 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 で SecurityError | origin URL と toBlob exception name | upload、same-origin proxy、正しい ACAO 後に clean redraw | 二つの test endpoint | clean source に交換、tainted bitmap を保持しない |
| SVG に Script/event attribute | code 実行または危険内容保存 | sanitizer rejection code | 厳格 allowlist または隔離 rasterize | 悪意ある corpus | 原子的拒否、DOM に付けない |
| 巨大画像寸法 / Image Bomb | tab memory 急増 | bytes、dimensions、pixel budget | decode 前後制限、隔離 server derivative | edge/pixel-limit fixture | bitmap close、URL revoke |
| Decode failure | 永久 loading | decode state/error | failed にして replace/retry 提供 | truncated JPEG | deterministic fallback を描く |
| Asset upload 成功、Document Save 失敗 | orphan asset | upload ID と Document revision | compensating delete または delayed GC | 2段階失敗 simulation | retry token/GC record を保持 |
| Document record あり、Asset missing | Renderer crash または blank | ASSET_MISSING と assetId | Missing placeholder と relink | 404 fixture | Shape を削除しない |
| Export が memory 超過 | tab crash または toBlob null | plan peak bytes と tile logs | tiles + stream/server、早期 MEMORY | 100k dimension plan test | tile Canvas/bitmap を解放 |
| page unload/交換後 URL 未解放 | retained Blob heap 増加 | URL registry と heap snapshot | ownership + idempotent release | 100回 open/close | 全 runtime handle を revoke |
証拠をもって合格する
| 失敗カテゴリ | 固有 error code | ユーザーが実行できる復旧 |
|---|---|---|
| cross-origin pixel を readback できない | CORS | copy を upload、または許可 source を選ぶ |
| 計画 pixel または1 tile が予算超過 | MEMORY | dimension/tile edge を下げる、または server 利用 |
| file を decode できない | DECODE | Document を変えず交換または retry |
| Asset 404 または record missing | ASSET_MISSING | Shape を保って relink |
| browser/pipeline が format 未対応 | FORMAT_UNSUPPORTED | PNG/sRGB など互換 format を選ぶ |
| SVG、dimension、MIME が policy 違反 | SECURITY_REJECTION | 正確な規則を示し、データを実行しない |
| network/CSP/CORS が fetch を不透明に阻止 | NETWORK | network、CSP、管理 CORS endpoint、server log を確認 |
| browser encoder が理由不明で null | ENCODE | size を下げる、encoder/server を変更し memory と誤報しない |
| 検査面 | 自動化された証拠 | 手動の証拠 |
|---|---|---|
| type と security boundary | magic/SVG corpus/fuzz/limit test | UI error が実行可能な行動を示すか確認 |
| CORS と format encode | 2 origin の Playwright endpoint と Blob MIME | 対象ブラウザで download し開く |
| lifecycle と memory | release 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」という言葉を使わずに答えてください。
- 隣人の写真を店の窓越しに見られても、自分の卒業アルバムへ入れられるとは限らないのはなぜでしょうか。
- とても軽い荷物でも、開くと部屋いっぱいになるのはなぜでしょうか。
- 仮の鍵は幼稚園が閉まるまで待たず、使い終えたら返すべきなのはなぜでしょうか。
- 新しい問題です。共有台帳に写真番号が残っているのに倉庫で荷物が見つかりません。ページ全体を消しますか、それとも説明カードを置きますか。