Course progress Course outline 18 of 18 lessons available
Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates
Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools
- 05 Chapter 5: Give the Pixel World a Registry available now
- 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
- 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
- 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
- 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now
Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery
- 10 Chapter 10: Make the Editor Feel Right available now
- 11 Chapter 11: Drawn Text Is Not Editable Text available now
- 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules Current lesson
- 13 Chapter 13: Time Machines and Old Boxes available now
- 14 Chapter 14: Looking Correct Is Not Being Correct available now
Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI
Start with a game a five-year-old can understand
The one truth in this chapter: an Asset is not a URL. It is a complete loading, security, lifecycle, and export protocol.
Imagine an “asset borrowing box” at the kindergarten entrance. Someone hands over a photograph, a moving video box, a PDF book, or a disguised sticker containing <script>. The manager cannot merely write the sender’s address in a ledger. They must weigh the package, inspect the seal, identify its contents, confirm that it can be opened, make a small preview, assign a stable number, and record when its temporary key must be returned.
Predict what happens first. If you can see a neighbor’s photo through a shop window, does that mean you can put it into your own graduation collage? Is a 2 MB compressed image “small” if opening it produces one billion pixels? When an Object URL creates a temporary key, who returns it after the page closes?
- Inspect the package firsttype, weight, dimensions
- Open it safelyscripts, source, decoding
- Number it and make a thumbnailrecoverable state
- Pack exports by rulesemantics, pixels, memory
Asset failures cannot all be called “broken image.” The manager must tell the user exactly what happened: the source omitted CORS permission, decoding failed, the file is missing, the format is unsupported, a security rule rejected it, or export ran out of memory. Recovery controls can tell the truth only when errors have types.
Translate the toys into Canvas
| Borrowing box | Asset system | Fact to preserve or prove |
|---|---|---|
| Package number | Asset Stable ID | Shapes reference an ID rather than depending directly on a URL |
| Sender address | Source URL / storage key | Source, expiration, and permission policy |
| Delivery progress | Upload State | local, uploading, uploaded, failed |
| Unpacking progress | Decode State | pending, ready, failed |
| Weight and seal | Byte size, MIME sniff, hash | Trust neither extension nor File.type |
| Toy’s actual dimensions | Intrinsic Size | Separate from Shape display size |
| Catalog image | Thumbnail | Browse while the full image is not ready |
| Temporary key | Object URL | Its creator is responsible for revoke |
| Neighbor’s permission letter | CORS / Origin-Clean | Determines whether pixels can be read back or exported |
| Security rules | SVG sanitizer / limits / CSP | Do not execute untrusted Script, HTML, or URLs |
| Packing list | Export Plan | Format, color, dimensions, memory, and failure code |
The analogy has limits. CORS is neither copyright permission nor malware scanning; it is the browser’s cross-origin read protocol. An Object URL is not a permanent address after upload, only a temporary reference to a Blob in the current environment. SVG is a highly capable executable XML document and cannot be treated like PNG merely because it “looks like an image.” A hash verifies content identity but cannot prove that content is safe or lawful.
Kill the wrong instincts first
- “An Asset is a URL string.” URLs expire, need permission, may be impossible to export because of CORS, and carry no upload, decode, or missing state.
- “The browser validated
file.type.” It usually comes from client metadata. Check magic bytes, parse results, and an allowlist. - “The file is 5 MB, so it uses at most 5 MB of memory.” Decoded pixels require at least roughly
width × height × 4bytes, plus possible source buffers, ImageBitmap, Canvas, and export copies. - “The image displays, so
toBlobmust work.” One cross-origindrawImagewithout CORS permission makes the destination Canvas non-origin-clean; readback and export throwSecurityError. - “SVG is text, so deleting
<script>makes it safe.” Event attributes,foreignObject, external URLs, CSS, animation, and different namespaces remain. Accept a strict subset or use a mature sanitizer. - “Tiling always solves large-image export.” If the end still allocates a 100000×100000 Canvas to join the tiles, peak memory is not solved. Stream output, return a tile bundle, or use a server.
- “The browser handles unload, so revoke is unnecessary.” Replacing and deleting assets during a long session keeps retaining Blobs. The ownership protocol must release them explicitly.
Production backpack
Prerequisite contract
The Chapter 5 Document stores only assetId and AssetRecord, never ImageBitmap, DOM nodes, or Object URLs. The Chapter 11 DOM Overlay never inserts untrusted HTML. The Renderer draws deterministic placeholders for pending, missing, and failed assets. Commands and Asset uploads are two systems that can fail independently; this chapter defines compensation and garbage collection.
Formal knowledge
An Asset Record contains at least stable ID, kind, declared and sniffed MIME, byte size, hash, intrinsic width and height, storage key, source-URL policy, upload state, decode state, thumbnail ID, createdAt, and schema version. A Source URL can be a short-lived signed URL and must not serve as persistent identity; resolve it at runtime from the storage key. Keep Upload State and Decode State separate: successful server receipt does not mean the current browser can decode, and a local browser preview does not mean persistence succeeded.
The loading pipeline applies, in order: total-byte limit → magic bytes/type sniff → format allowlist → secure parse → intrinsic-dimension/pixel-count limit → decode → thumbnail. createImageBitmap() can asynchronously produce an ImageBitmap suitable for drawing, but it can fail and consume substantial memory. Call close() after use. Asset Runtime creates Object URLs for Blobs and tracks reference counts. Deleting a Shape need not immediately delete the server asset, but when no local references or upload tasks remain, revoke the local URL. Do not revoke too early while an <img> or download still uses it.
File Type Sniffing must fail closed. Check signatures for PNG, JPEG, WebP, and other allowed formats, then let the decoder confirm. Read SVG as limited UTF-8 text and parse it. Use a format-specific parser or decoder for PDF and video. Limits cover compressed bytes, width, height, pixel count, frame count, and duration. An Image Bomb can expand from a tiny compressed file into enormous pixels. Client limits are only the first layer; re-decode high-risk uploads and generate safe derivatives on an isolated server.
CORS determines whether scripts can read a cross-origin response. <img crossorigin="anonymous"> or fetch(...,{mode:'cors',credentials:'omit'}) still requires the remote server to return the correct Access-Control-Allow-Origin. Once a cross-origin image without permission is drawn into a Canvas, its origin-clean flag becomes false; getImageData/toBlob/toDataURL/captureStream throws SecurityError. There is an important diagnostic boundary: when fetch() encounters a network outage, DNS failure, CSP block, or CORS rejection, script usually receives similar TypeError values and cannot honestly label them all CORS. Differentiate them with the Network panel, controlled test endpoints, and server logs. By contrast, a SecurityError thrown while exporting an already-drawn Canvas is strong evidence of an origin-clean failure. No API can reliably “wash away contamination while preserving pixels.” Recover by using a permitted source or proxy/uploading into a controlled asset domain, then redraw from a clean Document.
Safe SVG Import parses without attaching to the live DOM, rejects parser errors, DOCTYPE, unknown namespaces, dangerous elements such as script/foreignObject/style, every on* attribute, external href, javascript: or data:text/html, CSS url(), and unapproved attributes. This course accepts a “small, safe SVG subset” and does not claim to import arbitrary SVG. If exact fidelity is mandatory, sanitize and rasterize on an isolated server with a maintained security library. Never give an unknown SVG to innerHTML.
Never insert Untrusted HTML directly as an image Overlay. CSP is defense in depth: tighten default-src, configure img-src, media-src, connect-src, and worker-src separately, prohibit object-src, and manage script/style through nonces or hashes. Allow blob: or data: only as narrowly as the actual Asset protocol requires. CSP does not replace a sanitizer because allowed same-origin content may still contain malicious data.
Asset Adapters produce a common frame and thumbnail for Image, Video, and PDF Background. Video records duration, poster, and frame availability; export either a fixed timestamp or an explicit unsupported result for motion. Cross-origin video has the same origin-cleanliness restrictions. PDF is not a native drawImage source. Rasterize pages with a controlled PDF renderer or retain a semantic attachment, while limiting pages, dimensions, and parse resources. Missing Asset is a normal runtime state: show its ID and retry/relink controls instead of crashing the Document.
PNG is lossless pixel export with alpha. JPEG is commonly used for photographs and does not preserve transparency, so lay down an explicit background before export. Detect WebP encoder support by checking the MIME returned from toBlob, not merely whether the method exists. SVG Export is semantic/vector export: generate controlled elements for Shapes, escape text, and embed permitted raster data for pixel effects that SVG cannot express. JSON Export stores the Document Schema and Asset references or manifest. It is Semantic Export, not a screenshot. Visual Export preserves visible pixels; Semantic Export preserves continued editability. Never pretend they are the same.
Before high-resolution export, calculate world bounds, scale, output pixels, and budget. Minimum pixel memory is approximately w*h*4, while multiplying by 2–4 better approximates peak Canvas, decoder, and encoder use. When over budget, use Tiled Export: redraw tiles with bleed, stream them to a supported encoder or server, or explicitly output a tile bundle plus manifest. Never assemble them into a giant frontend Canvas at the end. Error types distinguish CORS, MEMORY, DECODE, ASSET_MISSING, FORMAT_UNSUPPORTED, and SECURITY_REJECTION.
For color, declare the working space and delivery contract. Web delivery usually uses sRGB as the compatibility baseline. If Display-P3 is requested, feature-detect Canvas context colorSpace and encoder output, preserve or convert the profile, and provide an sRGB fallback. A vivid monitor alone does not prove that a file is P3. Export tests compare color patches and metadata.
Evidence and compatibility (verified 2026-08-29)
- WHATWG HTML Canvas defines the origin-clean flag and
SecurityErrorfromtoBlob()/toDataURL()on a non-clean Canvas. MDN CORS enabled images documents cross-origin image taint and readback restrictions. - MDN
createImageBitmap(),ImageBitmap.close(),URL.createObjectURL(), andURL.revokeObjectURL()define decoding and temporary-URL lifecycle boundaries. - MDN
HTMLCanvasElement.toBlob()documents asynchronous encoding and format fallback. Inspect the actual returned Blob MIME. - MDN Content Security Policy,
img-src, andmedia-srcsupport defense in depth. - MDN SVG as an image describes restrictions on SVG used as an image. Editable SVG import still treats it as active content and accepts a strict subset.
- MDN Canvas color management lets you inspect context attributes. Test
colorSpacesupport and encoded output in each target browser.
The engineering increment for this chapter
Starting point: An Image Shape stores an arbitrary URL, renders blank on failure, and export calls toDataURL() once. Finish line: A diagnosable Asset Pipeline supports Upload, Metadata, Thumbnail, Placeholder, Missing fallback, PNG/SVG/JSON, and memory-controlled tiled high-resolution export. Safe SVG and Object URL cleanup have tests.
Add these files:
src/engine/assets/AssetRecord.ts: persistent schema and states;src/engine/assets/AssetManager.ts: sniff, decode, runtime handles, and cleanup;src/engine/assets/sanitizeSvg.ts: strict allowed subset;src/engine/export/{plan,png,svg,json,tiles}.ts: export formats and budgets;src/engine/assets/__tests__/security.test.ts: signatures, SVG, limits, and cleanup;tests/browser/asset-cors-export.spec.ts: CORS, taint, toBlob, and format.
First define records and errors that route to accurate recovery actions:
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;
}>;
Below is a complete browser-side Image Asset Manager. It limits bytes, checks magic, decodes, and limits pixels. Every runtime handle releases its URL and ImageBitmap idempotently:
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 is not “delete script.” Accept only a describable subset. This complete implementation rejects the entire input on any unknown element or attribute, event attribute, or external reference:
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);
}
Plan export before allocating. tilePlan never creates a giant Canvas and records bleed plus final cropping in its 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 calls the deterministic Renderer for each tile. SVG Export generates allowed elements only from Document/Geometry and XML-escapes text. JSON Export emits {schemaVersion,document,assetManifest} and never embeds a Runtime URL. JPEG lays down a background first; WebP verifies the actual Blob MIME. If multiple tiles are required and the browser has no streaming encoder, return a zip/tile manifest or send work to a controlled server instead of pretending to have produced one enormous file.
Tests prove both secure rejection and resource release:
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);
});
});
Run pnpm vitest run src/engine/assets src/engine/export. Magic, dimensions, SVG allowlist, tile coverage, format, and cleanup should all pass. Run pnpm playwright test tests/browser/asset-cors-export.spec.ts. The endpoint without CORS should yield CORS on export, while the permitted endpoint should produce a PNG. Then run pnpm run build; Astro and Pagefind should succeed, and the chapter links should contain no destructive HTML.
Return to the borrowing box. AssetRecord is the package file, Runtime Handle is the temporary key, origin-clean is the seal recording whether the neighbor permits pixel repackaging, and Export Plan calculates box volume before packing. The manager does not say “it failed somehow.” They provide an accurate failure card and a recovery path.
Break it on purpose
| Injection | Symptom | Evidence | Fix | Regression test | Recovery |
|---|---|---|---|---|---|
| Cross-origin image without CORS | Visible on screen, but PNG export throws SecurityError | Origin URL and toBlob exception name | Upload, same-origin proxy, or correct ACAO, then clean redraw | Two test endpoints | Replace with clean source; do not retain tainted bitmap |
| SVG embeds Script or event attribute | Code executes or dangerous content is stored | Sanitizer rejection code | Strict allowlist or isolated rasterization | Malicious corpus | Reject atomically; never attach to DOM |
| Huge image dimensions / Image Bomb | Tab memory surges | Bytes, dimensions, pixel budget | Limits before and after decode; isolated server derivative | Edge and pixel-limit fixtures | Close bitmap and revoke URL |
| Decode failure | Loading forever | Decode state and error | Mark failed and offer replace/retry | Truncated JPEG | Draw deterministic fallback |
| Asset upload succeeds; Document Save fails | Orphaned asset | Upload ID and Document revision | Compensating delete or delayed GC | Two-phase failure simulation | Preserve retry token or GC record |
| Document record exists; Asset is missing | Renderer crashes or draws blank | ASSET_MISSING and assetId | Missing placeholder and relink | 404 fixture | Do not delete Shape |
| Export exceeds memory | Tab crash or toBlob null | Plan peak bytes and tile logs | Tiles plus streaming/server; early MEMORY | 100k-dimension plan test | Release tile Canvas and bitmap |
| URL remains after page unload or replacement | Retained Blob heap grows | URL registry and heap snapshot | Ownership plus idempotent release | Open and close 100 times | Revoke all runtime handles |
Pass with evidence
| Failure category | Unique error code | User-actionable recovery |
|---|---|---|
| Cross-origin pixels cannot be read back | CORS | Upload a copy or choose a permitted source |
| Planned pixels or one tile exceed budget | MEMORY | Reduce dimensions or tile edge, or use a server |
| File cannot decode | DECODE | Replace or retry without changing Document |
| Asset 404 or missing record | ASSET_MISSING | Relink while preserving Shape |
| Browser or pipeline lacks format support | FORMAT_UNSUPPORTED | Choose a compatible format such as PNG/sRGB |
| SVG, dimensions, or MIME violates policy | SECURITY_REJECTION | Show the exact rule and do not execute the data |
| Network, CSP, or CORS opaquely blocks fetch | NETWORK | Check network, CSP, a controlled CORS endpoint, and server logs |
| Browser encoder returns null with no reason | ENCODE | Reduce size or change encoder/server; do not misreport memory |
| Test surface | Automated evidence | Manual evidence |
|---|---|---|
| Type and security boundary | Magic/SVG corpus/fuzz/limit tests | Verify that UI errors suggest real actions |
| CORS and format encoding | Playwright endpoints on two origins and Blob MIME | Download and open exports in target browsers |
| Lifecycle and memory | Release counts, tile coverage, and heap budget | Replace assets in a long session and inspect a heap snapshot |
- AssetRecord contains URL/storage, Upload, Decode, Thumbnail, Intrinsic Size, hash, and version.
-
createImageBitmap, Object URL creation, andclose/revokehave one owner and idempotent cleanup. - CORS, Origin-Clean, and Tainted Canvas have real-browser tests.
- SVG, Untrusted HTML, MIME sniffing, byte/pixel/frame/page limits, and CSP form layered defenses.
- Image/Video/PDF Background adapters, missing state, and decode failure are defined.
- The protocol documents differences among PNG/JPEG/WebP, SVG, JSON, and Semantic/Visual Export.
- Tiled High-Resolution Export has a peak-memory budget and never rejoins tiles into an oversized Canvas.
- sRGB and Display-P3 have feature detection, fallback, and output validation.
- Export accurately distinguishes the six required domain error categories. Fetch and encoder failures that browsers cannot attribute use
NETWORK/ENCODErather than impersonating CORS or MEMORY.
Explain it to a five-year-old
Answer without saying “CORS,” “decode,” “SVG,” “memory,” or “URL”:
- Why can you see a neighbor’s photo through a shop window but not necessarily place it into your own graduation collage?
- Why can a very light package still fill the whole room after opening?
- Why should a temporary key be returned after use instead of waiting until the kindergarten closes?
- New question: the shared ledger still lists a photo number, but the warehouse cannot find the package. Should we delete the whole page or place an explanation card there?