JEPA4Japan · tutorials

Chapter 12: Borrowed Images Cannot Be Packed Without Rules

4,450 words 21 min read #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Design an asset protocol covering upload, decode, CORS, origin cleanliness, SVG sanitization, memory budgets, and multi-format export.

Course progress Course outline 18 of 18 lessons available

Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates

  1. 01 Chapter 1: Do Not Draw Yet—Canvas Is Not a Product Architecture available now
  2. 02 Chapter 2: A Sheet of Pixels That Forgets available now
  3. 03 Chapter 3: Turn Drawing into a Replayable Recipe available now
  4. 04 Chapter 4: Four Maps and a Camera available now

Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools

  1. 05 Chapter 5: Give the Pixel World a Registry available now
  2. 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
  3. 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
  4. 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
  5. 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

  1. 10 Chapter 10: Make the Editor Feel Right available now
  2. 11 Chapter 11: Drawn Text Is Not Editable Text available now
  3. 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules Current lesson
  4. 13 Chapter 13: Time Machines and Old Boxes available now
  5. 14 Chapter 14: Looking Correct Is Not Being Correct available now

Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI

  1. 15 Chapter 15: Do Not Search Ten Thousand Children One by One available now
  2. 16 Chapter 16: Keep the Front Desk Out of the Kitchen—Worker and GPU Upgrades available now
  3. 17 Chapter 17: Build the Car or Buy a Proven Chassis? available now
  4. 18 Chapter 18: People and AI Edit the Same Ledger available now

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?

  1. Inspect the package firsttype, weight, dimensions
  2. Open it safelyscripts, source, decoding
  3. Number it and make a thumbnailrecoverable state
  4. Pack exports by rulesemantics, pixels, memory
Answer first: are permission to display and permission to export the same? No. Cross-origin pixels may be shown while scripts are forbidden from reading and repackaging them.

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 boxAsset systemFact to preserve or prove
Package numberAsset Stable IDShapes reference an ID rather than depending directly on a URL
Sender addressSource URL / storage keySource, expiration, and permission policy
Delivery progressUpload Statelocal, uploading, uploaded, failed
Unpacking progressDecode Statepending, ready, failed
Weight and sealByte size, MIME sniff, hashTrust neither extension nor File.type
Toy’s actual dimensionsIntrinsic SizeSeparate from Shape display size
Catalog imageThumbnailBrowse while the full image is not ready
Temporary keyObject URLIts creator is responsible for revoke
Neighbor’s permission letterCORS / Origin-CleanDetermines whether pixels can be read back or exported
Security rulesSVG sanitizer / limits / CSPDo not execute untrusted Script, HTML, or URLs
Packing listExport PlanFormat, 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 × 4 bytes, plus possible source buffers, ImageBitmap, Canvas, and export copies.
  • “The image displays, so toBlob must work.” One cross-origin drawImage without CORS permission makes the destination Canvas non-origin-clean; readback and export throw SecurityError.
  • “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)

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

InjectionSymptomEvidenceFixRegression testRecovery
Cross-origin image without CORSVisible on screen, but PNG export throws SecurityErrorOrigin URL and toBlob exception nameUpload, same-origin proxy, or correct ACAO, then clean redrawTwo test endpointsReplace with clean source; do not retain tainted bitmap
SVG embeds Script or event attributeCode executes or dangerous content is storedSanitizer rejection codeStrict allowlist or isolated rasterizationMalicious corpusReject atomically; never attach to DOM
Huge image dimensions / Image BombTab memory surgesBytes, dimensions, pixel budgetLimits before and after decode; isolated server derivativeEdge and pixel-limit fixturesClose bitmap and revoke URL
Decode failureLoading foreverDecode state and errorMark failed and offer replace/retryTruncated JPEGDraw deterministic fallback
Asset upload succeeds; Document Save failsOrphaned assetUpload ID and Document revisionCompensating delete or delayed GCTwo-phase failure simulationPreserve retry token or GC record
Document record exists; Asset is missingRenderer crashes or draws blankASSET_MISSING and assetIdMissing placeholder and relink404 fixtureDo not delete Shape
Export exceeds memoryTab crash or toBlob nullPlan peak bytes and tile logsTiles plus streaming/server; early MEMORY100k-dimension plan testRelease tile Canvas and bitmap
URL remains after page unload or replacementRetained Blob heap growsURL registry and heap snapshotOwnership plus idempotent releaseOpen and close 100 timesRevoke all runtime handles

Pass with evidence

Failure categoryUnique error codeUser-actionable recovery
Cross-origin pixels cannot be read backCORSUpload a copy or choose a permitted source
Planned pixels or one tile exceed budgetMEMORYReduce dimensions or tile edge, or use a server
File cannot decodeDECODEReplace or retry without changing Document
Asset 404 or missing recordASSET_MISSINGRelink while preserving Shape
Browser or pipeline lacks format supportFORMAT_UNSUPPORTEDChoose a compatible format such as PNG/sRGB
SVG, dimensions, or MIME violates policySECURITY_REJECTIONShow the exact rule and do not execute the data
Network, CSP, or CORS opaquely blocks fetchNETWORKCheck network, CSP, a controlled CORS endpoint, and server logs
Browser encoder returns null with no reasonENCODEReduce size or change encoder/server; do not misreport memory
Test surfaceAutomated evidenceManual evidence
Type and security boundaryMagic/SVG corpus/fuzz/limit testsVerify that UI errors suggest real actions
CORS and format encodingPlaywright endpoints on two origins and Blob MIMEDownload and open exports in target browsers
Lifecycle and memoryRelease counts, tile coverage, and heap budgetReplace 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, and close/revoke have 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/ENCODE rather than impersonating CORS or MEMORY.

Explain it to a five-year-old

Answer without saying “CORS,” “decode,” “SVG,” “memory,” or “URL”:

  1. Why can you see a neighbor’s photo through a shop window but not necessarily place it into your own graduation collage?
  2. Why can a very light package still fill the whole room after opening?
  3. Why should a temporary key be returned after use instead of waiting until the kindergarten closes?
  4. 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?
Show the reference answer The neighbor may permit viewing through glass without permitting the pixels to be removed and repacked. Obtain explicit permission or give a lawful copy to your own manager. A compressed package is like a vacuum-packed duvet: light when weighed, enormous when expanded, so check both package weight and opened size. Temporary keys continue occupying warehouse resources and accumulate during long editing sessions and replacements, so register their return as soon as they are no longer used. A missing package does not mean the position, dimensions, and relationships recorded in the ledger should disappear. Keep a numbered explanation card so the user can retry or relink it.