`
`getContext()` takes two context types.
@@ -128,18 +272,30 @@ grid.fillRect(0, 0, 20, 3);
grid.fillStyle = "#ffcc00";
grid.font = "bold";
grid.fillText("score: 42", 1, 1);
+grid.strokeStyle = "#5fafff";
+grid.lineJoin = "round";
+grid.strokeRect(0, 0, 20, 3);
document.body.append(canvas);
```
+Lines are box-drawing glyphs. Where two meet they join in a corner, a
+tee or a cross, as borders do. A canvas's lines join each other but
+not the page's borders around the canvas.
+
| Member | What it does |
| --- | --- |
| `fillStyle` | A CSS color. `currentcolor`, the default, is the terminal's foreground |
+| `strokeStyle` | The lines' color, the same way |
+| `lineStyle` | A CSS `border-style`: `solid`, the default, `double`, `dashed`, `dotted` and the rest |
+| `lineJoin` | `miter`, the default, or `round`, which curves `strokeRect()`'s corners |
| `font` | Keywords from the CSS `font` shorthand: `bold`, `italic`, `lighter` |
| `textAlign` | `left`, `right`, `center`, `start` or `end` |
| `textDecoration` | `none` or `underline` |
| `fillText(text, x, y, maxWidth?)` | Writes glyphs in the fill color, over the cells' backgrounds. A wide glyph takes two columns |
| `fillRect(x, y, w, h)` | Fills cells with the fill color as their background. `currentcolor` fills them in inverse video |
| `clearRect(x, y, w, h)` | Empties cells |
+| `strokeLine(x1, y1, x2, y2)` | A line from one cell to another, both included, along a row or a column. A slanted line draws nothing |
+| `strokeRect(x, y, w, h)` | A box around the cells `w` by `h` from (x, y) |
| `drawImage(image, ...)` | Draws an image's pixels, two to a cell. Without a size, the image takes its natural size in cells |
| `getCell(x, y)` | The cell's glyph, colors and attributes, or `null` |
| `measureText(text)` | The text's width in columns |
@@ -147,13 +303,13 @@ document.body.append(canvas);
## Text alone
-Pixels can also be written as text: an upper half block in a span
-whose `color` is the upper pixel and whose `background-color` is the
-lower one.
+Pixels can also be written as text: a lower half block in a span
+whose `color` is the lower pixel and whose `background-color` is the
+upper one.
```html
-▀▀▀
-▀▀▀
+▄▄▄
+▄▄▄
```
That stays aligned through scrolling, clipping and repaints, as any
diff --git a/examples/images.ts b/examples/images.ts
index 3b24df56c..e713987a5 100644
--- a/examples/images.ts
+++ b/examples/images.ts
@@ -1,3 +1,6 @@
+import {readFile} from "node:fs/promises";
+import {pathToFileURL} from "node:url";
+
import {TermDOM} from "@b9g/termdom";
// node examples/images.ts [path-or-url]
@@ -6,7 +9,22 @@ import {TermDOM} from "@b9g/termdom";
// to as a data: URL, so the example runs anywhere.
const source = typeof process === "undefined" ? undefined : process.argv[2];
-const term = new TermDOM();
+// Images load only what the Content Security Policy allows: here files,
+// data: URLs and the web. A fetch listener reads a file: URL from disk,
+// which Node's own fetch does not, and leaves the rest to the runtime.
+// The document's URL is the working directory, so a path given on the
+// command line resolves under it.
+const term = new TermDOM({
+ url: pathToFileURL(`${process.cwd()}/`).href,
+ csp: "img-src file: data: https:",
+});
+term.addEventListener("fetch", (event) => {
+ if (event.request.url.startsWith("file:")) {
+ event.respondWith(
+ readFile(new URL(event.request.url)).then((bytes) => new Response(bytes)),
+ );
+ }
+});
term.attach();
const {document} = term;
@@ -69,7 +87,7 @@ document.body.innerHTML = `
- A broken image shows its alt text:
+ A broken image is a box with its alt text:
`;
diff --git a/package.json b/package.json
index 9ed06c328..86c3094b9 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
],
"exports": {
".": "./src/index.ts",
+ "./decode-worker": "./src/decode-worker.ts",
"./package.json": "./package.json"
},
"files": [
diff --git a/scripts/support-matrix.ts b/scripts/support-matrix.ts
index b4bb385c0..fe6a75fa4 100644
--- a/scripts/support-matrix.ts
+++ b/scripts/support-matrix.ts
@@ -1065,7 +1065,10 @@ function apiProbe(
category,
async run() {
const terminal = new MockProcess({cols: 40, rows: 12});
- const dom = new TermDOM({transport: terminal.transport});
+ const dom = new TermDOM({
+ transport: terminal.transport,
+ csp: "img-src data: https:",
+ });
try {
return {supported: await run(dom), note};
} catch (_err) {
diff --git a/src/decode-worker.ts b/src/decode-worker.ts
new file mode 100644
index 000000000..b3803f104
--- /dev/null
+++ b/src/decode-worker.ts
@@ -0,0 +1,48 @@
+/**
+ * The thread images decode on, started as a web Worker. It takes
+ * `{id, bytes}` and answers `{id, width, height, naturalWidth,
+ * naturalHeight, data}` with the pixels' buffer transferred, kept at most
+ * MAX_KEPT_PIXELS, or `{id, error}`. It answers `{ready: true}` first, so
+ * the page can tell a worker that never started from one that failed on
+ * an image.
+ */
+import {decodeImageForPage} from "./internal/images.ts";
+
+interface WorkerScope {
+ postMessage(message: unknown, transfer?: Transferable[]): void;
+ addEventListener(
+ type: "message",
+ listener: (event: {data: unknown}) => void,
+ ): void;
+}
+
+const scope = globalThis as unknown as WorkerScope;
+
+async function answer(data: unknown): Promise {
+ const {id, bytes} = data as {id: number; bytes: ArrayBuffer};
+ try {
+ const bitmap = await decodeImageForPage(new Uint8Array(bytes));
+ const pixels = bitmap.data.buffer as ArrayBuffer;
+ scope.postMessage(
+ {
+ id,
+ width: bitmap.width,
+ height: bitmap.height,
+ naturalWidth: bitmap.naturalWidth,
+ naturalHeight: bitmap.naturalHeight,
+ data: pixels,
+ },
+ [pixels],
+ );
+ } catch (error) {
+ scope.postMessage({
+ id,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+}
+
+scope.addEventListener("message", (event) => {
+ answer(event.data).catch(() => {});
+});
+scope.postMessage({ready: true});
diff --git a/src/index.ts b/src/index.ts
index 95635d68a..b227241d3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,6 +7,7 @@ import {
getCellSize,
setCellSizeSource,
setColorSchemeSource,
+ setScreenCellSource,
UNIT_CELL,
} from "./internal/cssom.ts";
import {
@@ -43,12 +44,29 @@ import {
transportFromProcess,
} from "./internal/exchange.ts";
import {Framebuffer} from "./internal/framebuffer.ts";
+import {
+ releaseDecoder,
+ retainDecoder,
+ setDecodeWorkerURL,
+} from "./internal/images.ts";
import {Input} from "./internal/input.ts";
import {Layout} from "./internal/layout.ts";
import {Painter} from "./internal/painter.ts";
+import {
+ answerRequest,
+ type FetchEvent,
+ parseContentSecurityPolicies,
+ type RequestPolicy,
+ setResourceLoader,
+} from "./internal/resources.ts";
export type {CellSize} from "./internal/cssom.ts";
export {transportFromProcess} from "./internal/exchange.ts";
+export {
+ ExtendableEvent,
+ FetchEvent,
+ type FetchEventInit,
+} from "./internal/resources.ts";
export type {
ProcessLike,
TTYReadStream,
@@ -58,6 +76,19 @@ export type {
TerminalTransport,
} from "./internal/exchange.ts";
+// Images decode on the runtime's web Worker, running src/decode-worker.ts,
+// which builds to dist/decode-worker.js beside this file. A runtime with
+// no Worker, as Node is without --experimental-web-worker, and a CommonJS
+// build, which has no import.meta.url, decode on this thread instead.
+setDecodeWorkerURL(
+ typeof import.meta.url === "string"
+ ? new URL(
+ `./decode-worker.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`,
+ import.meta.url,
+ )
+ : null,
+);
+
export interface TermDOMOptions {
/** Defaults to the global process. */
@@ -81,8 +112,21 @@ export interface TermDOMOptions {
* - `{width, height}`: that size.
*/
cellSize?: "unit" | "auto" | CellSize;
+
+ /**
+ * The Content Security Policy the document's loads are checked against,
+ * as a `Content-Security-Policy` header would state it: an loads
+ * only what `img-src`, or `default-src` without it, allows. A page's own
+ * ` ` can narrow it, never
+ * widen it. Defaults to `"default-src 'none'"`, so markup loads nothing
+ * unless the program allows it. The empty string, like a header with
+ * no directives, sets no policy and allows every load.
+ */
+ csp?: string;
}
+const DEFAULT_CONTENT_SECURITY_POLICY = "default-src 'none'";
+
function getCellSizeSource(
option: TermDOMOptions["cellSize"],
measured: () => Readonly,
@@ -128,6 +172,8 @@ const kHoverReportingEnabled = Symbol("hoverReportingEnabled");
const kTransport = Symbol("transport");
const kExchange = Symbol("exchange");
const kStaticSibling = Symbol("staticSibling");
+const kLifetimes = Symbol("lifetimes");
+const kLoads = Symbol("loads");
const kAttachBegun = Symbol("attachBegun");
type Lifecycle = "detached" | "attaching" | "attached" | "disposed";
const kLifecycle = Symbol("lifecycle");
@@ -143,6 +189,11 @@ const kHeldErrors = Symbol("heldErrors");
const kUnwrittenErrors = Symbol("unwrittenErrors");
const HELD_ERROR_LIMIT = 50;
+/** The events a TermDOM dispatches, by type. */
+export interface TermDOMEventMap {
+ fetch: FetchEvent;
+}
+
export interface TermDOM {
[kFramebuffer]: Framebuffer;
[kLayout]: Layout;
@@ -188,18 +239,27 @@ export interface TermDOM {
// The engine behind renderANSI and print, rebuilt when the width, the
// cell or the screen's colors change.
[kStaticSibling]: TermDOM | null;
+ // What "fetch" listeners passed to waitUntil(). dispose() waits for it.
+ [kLifetimes]: Set>;
+ // Aborts the document's loads when the TermDOM is disposed.
+ [kLoads]: AbortController;
}
-export class TermDOM {
+/**
+ * A document drawn in a terminal. It is the target of a "fetch" event for
+ * each load its markup asks for that its policy allows (see FetchEvent).
+ */
+export class TermDOM extends EventTarget {
readonly document: Document;
readonly window: Window;
constructor(options: TermDOMOptions = {}) {
- const cellSize = getCellSizeSource(options.cellSize, () =>
+ super();
+ const screenCell = () =>
this[kTransport].interactive
? this[kFramebuffer].cellPixels
- : FALLBACK_CELL,
- );
+ : FALLBACK_CELL;
+ const cellSize = getCellSizeSource(options.cellSize, screenCell);
this[kSealed] = false;
this[kRenderQueued] = false;
@@ -219,6 +279,9 @@ export class TermDOM {
this[kAttachReady] = Promise.resolve();
this[kAttachBegun] = Promise.resolve();
this[kStaticSibling] = null;
+ this[kLifetimes] = new Set();
+ retainDecoder();
+ this[kLoads] = new AbortController();
this[kTransport] = options.transport ?? transportFromProcess();
this.window = createWindow(
@@ -227,6 +290,59 @@ export class TermDOM {
);
const document = this.document = this.window.document;
+ // The page's fetch resolves a relative URL against the document, as a
+ // browser's does, and goes to the runtime's.
+ this.window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) =>
+ fetch(
+ new Request(
+ typeof input === "string" ? new URL(input, document.baseURI) : input,
+ init,
+ ),
+ )) as typeof this.window.fetch;
+ const policy = options.csp;
+ let hinted = false;
+ const requestPolicy: RequestPolicy = {
+ policies: parseContentSecurityPolicies(
+ policy ?? DEFAULT_CONTENT_SECURITY_POLICY,
+ ),
+ signal: this[kLoads].signal,
+ extend: (promise) => {
+ const lifetime = promise.then(() => {}, () => {});
+ this[kLifetimes].add(lifetime);
+ void lifetime.then(() => this[kLifetimes].delete(lifetime));
+ },
+ violate: (at, init) => {
+ const window = this.window as unknown as typeof globalThis;
+ dispatchAsUserAgent(
+ at as never,
+ new window.SecurityPolicyViolationEvent(
+ "securitypolicyviolation",
+ init,
+ ) as never,
+ );
+ // A program that set no policy may not know markup loads
+ // nothing until it does.
+ if (policy === undefined && !hinted) {
+ hinted = true;
+ // A URL that is not HTTP(S) is reported as its scheme alone.
+ const blocked = init.blockedURI!.includes(":")
+ ? init.blockedURI
+ : `a ${init.blockedURI}: URL`;
+ reportUncaught(
+ this,
+ `TermDOM blocked ${blocked}. The document's markup ` +
+ "loads nothing until a Content Security Policy allows it: " +
+ "new TermDOM({csp: \"img-src data: https:\"}), for one.",
+ );
+ }
+ },
+ };
+ setResourceLoader(
+ document,
+ (request, context) =>
+ answerRequest(this, document, request, context, requestPolicy),
+ this[kLoads].signal,
+ );
this[kLayout] = new Layout(
this.window,
@@ -255,6 +371,7 @@ export class TermDOM {
this[kMeasuresCell] = options.cellSize === "auto";
setCellSizeSource(document, cellSize);
+ setScreenCellSource(document, screenCell);
attachDocument(
document,
@@ -475,6 +592,43 @@ export class TermDOM {
return this[kAttachReady];
}
+ // lib.dom's shape: the keyed overload first.
+ override addEventListener(
+ type: K,
+ listener: (this: TermDOM, event: TermDOMEventMap[K]) => unknown,
+ options?: boolean | AddEventListenerOptions,
+ ): void;
+ override addEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject | null,
+ options?: boolean | AddEventListenerOptions,
+ ): void;
+ override addEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject | null,
+ options?: boolean | AddEventListenerOptions,
+ ): void {
+ super.addEventListener(type, listener, options);
+ }
+
+ override removeEventListener(
+ type: K,
+ listener: (this: TermDOM, event: TermDOMEventMap[K]) => unknown,
+ options?: boolean | EventListenerOptions,
+ ): void;
+ override removeEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject | null,
+ options?: boolean | EventListenerOptions,
+ ): void;
+ override removeEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject | null,
+ options?: boolean | EventListenerOptions,
+ ): void {
+ super.removeEventListener(type, listener, options);
+ }
+
/**
* Render to ANSI at the transport's width: colors and line breaks, no
* cursor controls, no modes. Without an argument, the document as it
@@ -516,9 +670,10 @@ export class TermDOM {
/**
* Hand the terminal back. Resolves when every restore has reached the
- * transport. The process transport also restores the shell-critical
- * modes synchronously, so exiting without awaiting leaves the shell
- * usable.
+ * transport and what "fetch" listeners passed to waitUntil() has
+ * settled. Loads still in flight are aborted. The process transport
+ * also restores the shell-critical modes synchronously, so exiting
+ * without awaiting leaves the shell usable.
*/
dispose(): Promise {
if (this[kLifecycle] === "disposed") {
@@ -527,6 +682,8 @@ export class TermDOM {
const wasAttached = isAttached(this);
this[kLifecycle] = "disposed";
+ this[kLoads].abort();
+ releaseDecoder();
setDocumentVisible(this.document, false);
// Frames painted in place, so nothing reached the scrollback. Write the
@@ -573,7 +730,8 @@ export class TermDOM {
this[kLayout].dispose();
clearHighlights(this.document);
disconnectObservers(this.document);
- return this[kExchange].flush();
+ return Promise.all([this[kExchange].flush(), ...this[kLifetimes]])
+ .then(() => {});
}
}
diff --git a/src/internal/canvas.ts b/src/internal/canvas.ts
index 85b919c63..7ca33c3f7 100644
--- a/src/internal/canvas.ts
+++ b/src/internal/canvas.ts
@@ -13,16 +13,24 @@
* programs draw: one glyph, one color and one background a cell.
*/
import {parseColor, toCellColor} from "./cssvalues.ts";
+import {
+ type CellContext,
+ type CellStyle,
+ LINE_STYLES,
+ type LineStyle,
+ readCell,
+} from "./framebuffer.ts";
import {
averageBitmap,
type Bitmap,
- type CharacterGrid,
createBitmap,
encodePNG,
- type GridCell,
+ MAX_IMAGE_PIXELS,
+ naturalHeightOf,
+ naturalWidthOf,
sampleBitmap,
} from "./images.ts";
-import {getStringWidth} from "./text.ts";
+import {getStringWidth, graphemeSegmenter} from "./text.ts";
// ---------------------------------------------------------------------------
// What drawImage() and createPattern() take.
@@ -83,6 +91,11 @@ export class ImageData {
"The width and height must be positive",
);
}
+ if (width * height > MAX_IMAGE_PIXELS) {
+ throw new RangeError(
+ `ImageData is limited to ${MAX_IMAGE_PIXELS} pixels`,
+ );
+ }
data = new Uint8ClampedArray(width * height * 4);
} else {
data = dataOrWidth;
@@ -125,11 +138,13 @@ export class ImageBitmap {
}
get width(): number {
- return this[kBitmapData]?.width ?? 0;
+ const bitmap = this[kBitmapData];
+ return bitmap ? naturalWidthOf(bitmap) : 0;
}
get height(): number {
- return this[kBitmapData]?.height ?? 0;
+ const bitmap = this[kBitmapData];
+ return bitmap ? naturalHeightOf(bitmap) : 0;
}
close(): void {
@@ -154,14 +169,47 @@ export function createImageBitmapFrom(
}
const x = Math.floor(sx ?? 0);
const y = Math.floor(sy ?? 0);
- const width = Math.floor(sw ?? bitmap.width);
- const height = Math.floor(sh ?? bitmap.height);
+ const width = Math.floor(sw ?? naturalWidthOf(bitmap));
+ const height = Math.floor(sh ?? naturalHeightOf(bitmap));
if (width <= 0 || height <= 0) {
throw domException("IndexSizeError", "The crop rectangle is empty");
}
- const copy = createBitmap(width, height);
- copyRect(bitmap, x, y, copy, 0, 0, width, height);
- return new ImageBitmap(copy);
+ if (bitmap.width === naturalWidthOf(bitmap)) {
+ const copy = createBitmap(width, height);
+ copyRect(bitmap, x, y, copy, 0, 0, width, height);
+ return new ImageBitmap(copy);
+ }
+ // From an image kept smaller than it is, the copy is kept as small,
+ // and measures the crop. What the crop takes past the image's edge is
+ // transparent.
+ const keptX = bitmap.width / naturalWidthOf(bitmap);
+ const keptY = bitmap.height / naturalHeightOf(bitmap);
+ const copy = createBitmap(
+ Math.max(1, Math.round(width * keptX)),
+ Math.max(1, Math.round(height * keptY)),
+ );
+ const left = Math.max(x, 0);
+ const top = Math.max(y, 0);
+ const right = Math.min(x + width, naturalWidthOf(bitmap));
+ const bottom = Math.min(y + height, naturalHeightOf(bitmap));
+ const toLeft = Math.round((left - x) * keptX);
+ const toTop = Math.round((top - y) * keptY);
+ const toWidth = Math.round((right - x) * keptX) - toLeft;
+ const toHeight = Math.round((bottom - y) * keptY) - toTop;
+ if (toWidth > 0 && toHeight > 0) {
+ const part = {
+ width: toWidth,
+ height: toHeight,
+ data: sampleBitmap(bitmap, toWidth, toHeight, {
+ x: left,
+ y: top,
+ width: right - left,
+ height: bottom - top,
+ }),
+ };
+ copyRect(part, 0, 0, copy, toLeft, toTop, toWidth, toHeight);
+ }
+ return new ImageBitmap({...copy, naturalWidth: width, naturalHeight: height});
}
function copyRect(
@@ -1463,9 +1511,11 @@ export class CanvasPattern {
if (inverse !== null) {
[x, y] = apply(inverse, x, y);
}
- const {width, height, data} = this[kPatternBitmap];
- let px = Math.floor(x);
- let py = Math.floor(y);
+ const bitmap = this[kPatternBitmap];
+ const {width, height, data} = bitmap;
+ // The pattern tiles at the image's natural size.
+ let px = Math.floor((x * width) / naturalWidthOf(bitmap));
+ let py = Math.floor((y * height) / naturalHeightOf(bitmap));
if (this[kRepeatX]) {
px = ((px % width) + width) % width;
}
@@ -2096,6 +2146,8 @@ export class CanvasRenderingContext2D {
}
const copy = createBitmap(bitmap.width, bitmap.height);
copy.data.set(bitmap.data);
+ copy.naturalWidth = naturalWidthOf(bitmap);
+ copy.naturalHeight = naturalHeightOf(bitmap);
return new CanvasPattern(copy, mode);
}
@@ -2352,8 +2404,8 @@ export class CanvasRenderingContext2D {
}
let sx = 0;
let sy = 0;
- let sw = bitmap.width;
- let sh = bitmap.height;
+ let sw = naturalWidthOf(bitmap);
+ let sh = naturalHeightOf(bitmap);
let dx: number;
let dy: number;
let dw: number;
@@ -2390,8 +2442,8 @@ export class CanvasRenderingContext2D {
const scaleY = dh / sh;
const clippedX = Math.max(0, sx);
const clippedY = Math.max(0, sy);
- const clippedRight = Math.min(bitmap.width, sx + sw);
- const clippedBottom = Math.min(bitmap.height, sy + sh);
+ const clippedRight = Math.min(naturalWidthOf(bitmap), sx + sw);
+ const clippedBottom = Math.min(naturalHeightOf(bitmap), sy + sh);
if (clippedRight <= clippedX || clippedBottom <= clippedY) {
return;
}
@@ -2427,9 +2479,12 @@ export class CanvasRenderingContext2D {
const clip = this[kState].clip;
const smooth = this[kState].imageSmoothingEnabled;
const {width, height, data} = target;
+ // The source rectangle is in natural pixels. An image may keep fewer.
+ const keptX = source.width / naturalWidthOf(source);
+ const keptY = source.height / naturalHeightOf(source);
const sample = (u: number, v: number): RGBA => {
- const px = sx + ((u - dx) / dw) * sw;
- const py = sy + ((v - dy) / dh) * sh;
+ const px = (sx + ((u - dx) / dw) * sw) * keptX;
+ const py = (sy + ((v - dy) / dh) * sh) * keptY;
if (!smooth) {
const ix = Math.min(source.width - 1, Math.max(0, Math.floor(px)));
const iy = Math.min(source.height - 1, Math.max(0, Math.floor(py)));
@@ -2889,7 +2944,8 @@ export function canvasToPNG(bitmap: Bitmap): Uint8Array {
/** What the grid context needs from its canvas. */
export interface GridHost {
canvas: object;
- grid(): CharacterGrid;
+ // The canvas's cells, `width` columns by `height` rows.
+ cells(): CellContext;
changed(): void;
// Terminal pixels per cell, for drawing images at their natural size.
cellPixels(): {width: number; height: number};
@@ -2897,39 +2953,45 @@ export interface GridHost {
const kGridHost = Symbol("gridHost");
-/**
- * getContext("charactergrid"): the canvas as `width` columns by `height`
- * rows of cells. fillText() writes glyphs in the fill color over the
- * cells' backgrounds, fillRect() fills cells' backgrounds, drawImage()
- * draws an image two pixels a cell, and clearRect() empties cells.
- * Coordinates are cells. Colors are any CSS color, and `currentcolor`,
- * the default, is the terminal's own foreground.
- */
const kFillStyle = Symbol("fillStyle");
+const kGridStrokeStyle = Symbol("gridStrokeStyle");
+const kLineStyle = Symbol("lineStyle");
+const kLineJoin = Symbol("lineJoin");
const kFont = Symbol("font");
const kTextAlign = Symbol("textAlign");
const kTextDecoration = Symbol("textDecoration");
-const kColor = Symbol("color");
-const kCell = Symbol("cell");
-const kSet = Symbol("set");
-const kEmptyCell = Symbol("emptyCell");
const kRect = Symbol("rect");
export interface CanvasCharacterGridContext {
[kGridHost]: GridHost;
[kFillStyle]: string;
+ [kGridStrokeStyle]: string;
+ [kLineStyle]: LineStyle["style"];
+ [kLineJoin]: "miter" | "round";
[kFont]: string;
[kTextAlign]: string;
[kTextDecoration]: string;
}
+/**
+ * getContext("charactergrid"): the canvas as `width` columns by `height`
+ * rows of cells. fillText() writes glyphs in the fill color over the
+ * cells' backgrounds, fillRect() fills cells' backgrounds, strokeLine()
+ * and strokeRect() draw box-drawing lines that join where they meet,
+ * drawImage() draws an image two pixels a cell, and clearRect() empties
+ * cells. Coordinates are cells. Colors are any CSS color, and
+ * `currentcolor`, the default, is the terminal's own foreground.
+ */
export class CanvasCharacterGridContext {
constructor(host: GridHost) {
+ this[kGridHost] = host;
this[kFillStyle] = "currentcolor";
+ this[kGridStrokeStyle] = "currentcolor";
+ this[kLineStyle] = "solid";
+ this[kLineJoin] = "miter";
this[kFont] = "";
this[kTextAlign] = "left";
this[kTextDecoration] = "none";
- this[kGridHost] = host;
}
get canvas(): object {
@@ -2937,11 +2999,11 @@ export class CanvasCharacterGridContext {
}
get columns(): number {
- return this[kGridHost].grid().cols;
+ return this[kGridHost].cells().cols;
}
get rows(): number {
- return this[kGridHost].grid().rows;
+ return this[kGridHost].cells().rows;
}
get fillStyle(): string {
@@ -2949,14 +3011,37 @@ export class CanvasCharacterGridContext {
}
set fillStyle(value: string) {
- const text = String(value).trim().toLowerCase();
- if (text === "currentcolor") {
- this[kFillStyle] = "currentcolor";
- return;
+ this[kFillStyle] = readGridColor(value) ?? this[kFillStyle];
+ }
+
+ get strokeStyle(): string {
+ return this[kGridStrokeStyle];
+ }
+
+ set strokeStyle(value: string) {
+ this[kGridStrokeStyle] = readGridColor(value) ?? this[kGridStrokeStyle];
+ }
+
+ /** A CSS border style: solid, double, dashed, dotted and the rest. */
+ get lineStyle(): string {
+ return this[kLineStyle];
+ }
+
+ set lineStyle(value: string) {
+ const style = LINE_STYLES.find((name) => name === value);
+ if (style !== undefined) {
+ this[kLineStyle] = style;
}
- const color = parseCanvasColor(text);
- if (color !== null) {
- this[kFillStyle] = serializeColor(color);
+ }
+
+ /** miter, round. A round join curves strokeRect()'s corners. */
+ get lineJoin(): string {
+ return this[kLineJoin];
+ }
+
+ set lineJoin(value: string) {
+ if (value === "miter" || value === "round") {
+ this[kLineJoin] = value;
}
}
@@ -2992,21 +3077,14 @@ export class CanvasCharacterGridContext {
fillRect(x: number, y: number, w: number, h: number): void {
const rect = this[kRect](x, y, w, h);
- const color = this[kColor]();
+ const color = toGridColor(this[kFillStyle]);
if (rect === null || color === undefined) {
return;
}
const [x0, y0, x1, y1] = rect;
- for (let row = y0; row < y1; row++) {
- for (let col = x0; col < x1; col++) {
- const cell = this[kEmptyCell]();
- cell.char = " ";
- // The terminal's foreground as a background is its inverse.
- cell.bg = color;
- cell.inverse = color === null;
- this[kSet](col, row, cell);
- }
- }
+ // The terminal's foreground as a background is its inverse.
+ this[kGridHost].cells()
+ .drawRect(x0, y0, x1 - x0, y1 - y0, color ?? "inverse");
this[kGridHost].changed();
}
@@ -3015,11 +3093,11 @@ export class CanvasCharacterGridContext {
if (rect === null) {
return;
}
- const [x0, y0, x1, y1] = rect;
- for (let row = y0; row < y1; row++) {
- for (let col = x0; col < x1; col++) {
- this[kSet](col, row, null);
- }
+ const {grid, cols, rows} = this[kGridHost].cells();
+ const x0 = Math.max(0, rect[0]);
+ const x1 = Math.min(cols, rect[2]);
+ for (let row = Math.max(0, rect[1]); row < Math.min(rows, rect[3]); row++) {
+ grid.clearRange(row * cols + x0, row * cols + x1);
}
this[kGridHost].changed();
}
@@ -3028,13 +3106,13 @@ export class CanvasCharacterGridContext {
if (!isFiniteAll(x, y)) {
return;
}
- const color = this[kColor]();
+ const color = toGridColor(this[kFillStyle]);
if (color === undefined) {
return;
}
- const segmenter = new Intl.Segmenter(undefined, {granularity: "grapheme"});
+ const cells = this[kGridHost].cells();
const clusters = [
- ...segmenter.segment(String(text).replace(/[\t\n\f\r]/g, " ")),
+ ...graphemeSegmenter.segment(String(text).replace(/[\t\n\f\r]/g, " ")),
]
.map((segment) => segment.segment)
.filter((cluster) => getStringWidth(cluster) > 0);
@@ -3054,50 +3132,102 @@ export class CanvasCharacterGridContext {
? col + Math.max(0, Math.floor(maxWidth))
: Infinity;
const words = this[kFont].toLowerCase().split(/\s+/);
- const bold = words.includes("bold") || words.includes("bolder");
- const italic = words.includes("italic") || words.includes("oblique");
- const dim = words.includes("lighter");
+ const base: CellStyle = {
+ bold: words.includes("bold") || words.includes("bolder"),
+ italic: words.includes("italic") || words.includes("oblique"),
+ dim: words.includes("lighter"),
+ underline: this[kTextDecoration] === "underline",
+ };
+ const {grid} = cells;
for (const cluster of clusters) {
const columns = getStringWidth(cluster);
if (col + columns > limit) {
break;
}
- const previous = this[kCell](col, row);
+ const index = row * cells.cols + col;
+ const inside =
+ row >= 0 && row < cells.rows && col >= 0 && col < cells.cols;
// Over a cell filled with the terminal's foreground, which only
// inverse video can show, the cell stays inverse: its background
- // attribute is then what shows as the glyph's color.
- const inverse = previous?.inverse === true;
- const cell: GridCell = {
- char: cluster,
- fg: inverse ? null : color,
- bg: inverse ? color : previous?.bg ?? null,
- bold,
- italic,
- underline: this[kTextDecoration] === "underline",
- dim,
- inverse,
- };
- this[kSet](col, row, cell);
- // A wide glyph's second column is covered by it.
+ // is then what shows as the glyph's color.
+ const inverse =
+ inside &&
+ grid.cluster[index] !== 0 &&
+ readCell(grid, index)?.inverse === true;
+ cells.drawText(
+ cluster,
+ col,
+ row,
+ inverse ? {...base, inverse: true, bg: color} : {...base, fg: color},
+ );
+ // A wide glyph covers the columns after its first.
for (let extra = 1; extra < columns; extra++) {
- const next = this[kCell](col + extra, row);
- this[kSet](col + extra, row, {
- ...this[kEmptyCell](),
- char: "",
- bg: next?.bg ?? null,
- });
+ if (inside && col + extra < cells.cols) {
+ grid.cluster[index + extra] = 0;
+ }
}
col += columns;
}
this[kGridHost].changed();
}
- measureText(text: string): {width: number} {
- let width = 0;
- for (const char of String(text)) {
- width += Math.max(0, getStringWidth(char));
+ /**
+ * A line from the cell at (x1, y1) to the cell at (x2, y2), both
+ * included, in strokeStyle and lineStyle. A line meeting another
+ * joins it in a corner, a tee or a cross. Only rows and columns: a
+ * slanted line draws nothing.
+ */
+ strokeLine(x1: number, y1: number, x2: number, y2: number): void {
+ if (!isFiniteAll(x1, y1, x2, y2)) {
+ return;
+ }
+ const color = toGridColor(this[kGridStrokeStyle]);
+ if (color === undefined) {
+ return;
+ }
+ const [a, b, c, d] = [x1, y1, x2, y2].map(Math.round);
+ const line: LineStyle = {style: this[kLineStyle], color};
+ const cells = this[kGridHost].cells();
+ if (b === d) {
+ cells.drawLine(Math.min(a, c), b, Math.max(a, c) + 1, b, {
+ ...line,
+ startCap: a === c ? "square" : undefined,
+ endCap: a === c ? "square" : undefined,
+ });
+ } else if (a === c) {
+ cells.drawLine(a, Math.min(b, d), a, Math.max(b, d) + 1, line);
+ } else {
+ return;
}
- return {width};
+ this[kGridHost].changed();
+ }
+
+ /** A box on the cells `w` by `h` from (x, y), in strokeStyle and lineStyle. */
+ strokeRect(x: number, y: number, w: number, h: number): void {
+ const rect = this[kRect](x, y, w, h);
+ const color = toGridColor(this[kGridStrokeStyle]);
+ if (rect === null || color === undefined) {
+ return;
+ }
+ const [x0, y0, x1, y1] = rect;
+ const side: LineStyle = {style: this[kLineStyle], color};
+ const corner = this[kLineJoin] === "round" ? "round" : undefined;
+ this[kGridHost].cells()
+ .drawBox(x0, y0, x1 - x0, y1 - y0, {
+ top: side,
+ right: side,
+ bottom: side,
+ left: side,
+ topLeft: corner,
+ topRight: corner,
+ bottomRight: corner,
+ bottomLeft: corner,
+ });
+ this[kGridHost].changed();
+ }
+
+ measureText(text: string): {width: number} {
+ return this[kGridHost].cells().measureText(String(text));
}
/**
@@ -3113,8 +3243,8 @@ export class CanvasCharacterGridContext {
}
let sx = 0;
let sy = 0;
- let sw = bitmap.width;
- let sh = bitmap.height;
+ let sw = naturalWidthOf(bitmap);
+ let sh = naturalHeightOf(bitmap);
let dx: number;
let dy: number;
let dw: number;
@@ -3139,30 +3269,40 @@ export class CanvasCharacterGridContext {
if (cols <= 0 || rows <= 0) {
return;
}
- const pixels = sampleBitmap(bitmap, cols, rows * 2, {
- x: sx,
- y: sy,
- width: sw,
- height: sh,
- });
const left = Math.round(dx);
const top = Math.round(dy);
- for (let row = 0; row < rows; row++) {
- for (let col = 0; col < cols; col++) {
- const upper = (row * 2 * cols + col) * 4;
- const lower = ((row * 2 + 1) * cols + col) * 4;
- const previous = this[kCell](left + col, top + row);
- if (previous === undefined) {
- continue;
- }
- const cellOut = halfBlockCell(
+ const cells = this[kGridHost].cells();
+ const {grid} = cells;
+ // Only the cells on the grid are sampled, from the part of the
+ // source that lands on them.
+ const firstCol = Math.max(0, -left);
+ const firstRow = Math.max(0, -top);
+ const shownCols = Math.min(cols, cells.cols - left) - firstCol;
+ const shownRows = Math.min(rows, cells.rows - top) - firstRow;
+ if (shownCols <= 0 || shownRows <= 0) {
+ return;
+ }
+ const pixels = sampleBitmap(bitmap, shownCols, shownRows * 2, {
+ x: sx + (firstCol / cols) * sw,
+ y: sy + (firstRow / rows) * sh,
+ width: (shownCols / cols) * sw,
+ height: (shownRows / rows) * sh,
+ });
+ for (let row = 0; row < shownRows; row++) {
+ for (let col = 0; col < shownCols; col++) {
+ const x = left + firstCol + col;
+ const y = top + firstRow + row;
+ const index = y * cells.cols + x;
+ const half = halfBlockCell(
pixels,
- upper,
- lower,
- previous?.bg ?? null,
+ (row * 2 * shownCols + col) * 4,
+ ((row * 2 + 1) * shownCols + col) * 4,
+ grid.cluster[index] !== 0 && grid.bg[index] !== 0
+ ? grid.bg[index]
+ : null,
);
- if (cellOut !== null) {
- this[kSet](left + col, top + row, cellOut);
+ if (half !== null) {
+ cells.drawText(half.char, x, y, half.style);
}
}
}
@@ -3181,12 +3321,18 @@ export class CanvasCharacterGridContext {
italic: boolean;
underline: boolean;
} | null {
- const cell = this[kCell](Math.floor(x), Math.floor(y));
- if (cell == null) {
+ const {grid, cols, rows} = this[kGridHost].cells();
+ const col = Math.floor(x);
+ const row = Math.floor(y);
+ if (col < 0 || row < 0 || col >= cols || row >= rows) {
return null;
}
- const css = (value: number | null) => {
- if (value === null) {
+ const cell = readCell(grid, row * cols + col);
+ if (cell === null) {
+ return null;
+ }
+ const css = (value: number) => {
+ if (value === 0) {
return null;
}
const rgb = value & 0xffffff;
@@ -3198,7 +3344,7 @@ export class CanvasCharacterGridContext {
});
};
return {
- char: cell.char ?? "",
+ char: cell.char,
color: css(cell.fg),
background: css(cell.bg),
bold: cell.bold,
@@ -3208,57 +3354,17 @@ export class CanvasCharacterGridContext {
}
reset(): void {
- const grid = this[kGridHost].grid();
- grid.cells.fill(null);
+ this[kGridHost].cells().grid.clear();
this[kFillStyle] = "currentcolor";
+ this[kGridStrokeStyle] = "currentcolor";
+ this[kLineStyle] = "solid";
+ this[kLineJoin] = "miter";
this[kFont] = "";
this[kTextAlign] = "left";
this[kTextDecoration] = "none";
this[kGridHost].changed();
}
- // The fill color as a cell color, null for the terminal's foreground,
- // or undefined for a transparent color.
- [kColor](): number | null | undefined {
- if (this[kFillStyle] === "currentcolor") {
- return null;
- }
- const color = parseCanvasColor(this[kFillStyle])!;
- if (color.a === 0) {
- return undefined;
- }
- return toCellColor((color.r << 16) | (color.g << 8) | color.b);
- }
-
- [kCell](x: number, y: number): GridCell | null | undefined {
- const grid = this[kGridHost].grid();
- if (x < 0 || y < 0 || x >= grid.cols || y >= grid.rows) {
- return undefined;
- }
- return grid.cells[y * grid.cols + x];
- }
-
- [kSet](x: number, y: number, cell: GridCell | null): void {
- const grid = this[kGridHost].grid();
- if (x < 0 || y < 0 || x >= grid.cols || y >= grid.rows) {
- return;
- }
- grid.cells[y * grid.cols + x] = cell;
- }
-
- [kEmptyCell](): GridCell {
- return {
- char: null,
- fg: null,
- bg: null,
- bold: false,
- italic: false,
- underline: false,
- dim: false,
- inverse: false,
- };
- }
-
[kRect](
x: number,
y: number,
@@ -3280,20 +3386,52 @@ export class CanvasCharacterGridContext {
}
}
+// A color as the grid context keeps it, serialized, or null when the
+// value is not a color.
+function readGridColor(value: string): string | null {
+ const text = String(value).trim().toLowerCase();
+ if (text === "currentcolor") {
+ return "currentcolor";
+ }
+ const color = parseCanvasColor(text);
+ return color === null ? null : serializeColor(color);
+}
+
+// A kept color as a cell color: null for the terminal's foreground, or
+// undefined for a transparent one, which draws nothing.
+function toGridColor(value: string): number | null | undefined {
+ if (value === "currentcolor") {
+ return null;
+ }
+ const color = parseCanvasColor(value)!;
+ if (color.a === 0) {
+ return undefined;
+ }
+ return toCellColor((color.r << 16) | (color.g << 8) | color.b);
+}
+
/**
- * The cell two stacked pixels make: the upper half block in the upper
- * pixel's color over the lower pixel's, a lower half block when only the
- * lower pixel shows, a space in one color when both match, and null when
- * neither shows. A pixel at least half opaque shows; a partly
- * transparent one is first blended over `under` when there is a color
- * under it.
+ * The cell two stacked pixels make: the lower half block in the lower
+ * pixel's color over a background in the upper's, the upper half block
+ * when only the upper pixel shows, a space in one color when both match,
+ * and null when neither shows. A pixel at least half opaque shows; a
+ * partly transparent one is first blended over `under` when there is a
+ * color under it. A half block with no background keeps the one under it.
+ *
+ * Two colors go in the lower half block, not the upper, because a
+ * terminal that draws block characters from the font shows the font's
+ * glyph, and Menlo's and SF Mono's both sit low in the cell: the upper
+ * one leaves the cell's top in the wrong color and runs past the middle.
+ * The background fills whatever the glyph leaves, so with the lower half
+ * block only the seam is off. Terminals that draw the blocks themselves
+ * show either one exactly.
*/
export function halfBlockCell(
pixels: Uint8ClampedArray,
upper: number,
lower: number,
under: number | null,
-): GridCell | null {
+): {char: string; style: CellStyle} | null {
const read = (at: number): number | null => {
const alpha = pixels[at + 3] / 255;
let r = pixels[at];
@@ -3316,27 +3454,17 @@ export function halfBlockCell(
};
const top = read(upper);
const bottom = read(lower);
- const cell: GridCell = {
- char: "▀",
- fg: top,
- bg: bottom,
- bold: false,
- italic: false,
- underline: false,
- dim: false,
- inverse: false,
- };
if (top === null && bottom === null) {
return null;
}
if (top === null) {
- cell.char = "▄";
- cell.fg = bottom;
- cell.bg = null;
- } else if (top === bottom) {
- cell.char = " ";
- cell.fg = null;
- cell.bg = top;
- }
- return cell;
+ return {char: "▄", style: {fg: bottom}};
+ }
+ if (bottom === null) {
+ return {char: "▀", style: {fg: top}};
+ }
+ if (top === bottom) {
+ return {char: " ", style: {bg: top}};
+ }
+ return {char: "▄", style: {fg: bottom, bg: top}};
}
diff --git a/src/internal/cssom.ts b/src/internal/cssom.ts
index 420c7557c..441e4cddc 100644
--- a/src/internal/cssom.ts
+++ b/src/internal/cssom.ts
@@ -46,7 +46,7 @@ import {
isButtonInput,
MATHML_NAMESPACE,
} from "./dom.ts";
-import {LINE_STYLES, type LineStyle} from "./framebuffer.ts";
+import {FALLBACK_CELL, LINE_STYLES, type LineStyle} from "./framebuffer.ts";
import {
getPresentationalHints,
isHintAttribute,
@@ -71,11 +71,10 @@ export interface CellSize {
export const UNIT_CELL: Readonly = {width: 1, height: 1};
-// What "auto" takes when the terminal cannot say. Most terminal fonts
-// run about twice as tall as wide.
-export const FALLBACK_CELL: Readonly = {width: 8, height: 16};
+export {FALLBACK_CELL};
const cellSizeSources = new WeakMap Readonly>();
+const screenCellSources = new WeakMap Readonly>();
export function setCellSizeSource(
document: object,
@@ -92,6 +91,30 @@ export function getCellSize(node: Node | null | undefined): Readonly {
return cellSizeSources.get(document)?.() ?? UNIT_CELL;
}
+/** The cell the terminal draws, in its pixels. */
+export function setScreenCellSource(
+ document: object,
+ source: () => Readonly,
+): void {
+ screenCellSources.set(document, source);
+}
+
+/**
+ * The cell an image's pixels divide by. An image's pixels are CSS
+ * pixels, so this is the page's cell. Under the unit cell that would be
+ * a cell per pixel, so the screen's cell stands in.
+ */
+export function getImageCellSize(
+ node: Node | null | undefined,
+): Readonly {
+ const page = getCellSize(node);
+ if (page !== UNIT_CELL) {
+ return page;
+ }
+ const document = node?.ownerDocument ?? node;
+ return (document && screenCellSources.get(document)?.()) ?? FALLBACK_CELL;
+}
+
/**
* CSS pixels along one axis as cells, fractions kept. Layout rounds the
* edges of the boxes it places, not each length, so lengths that add up
diff --git a/src/internal/dom.ts b/src/internal/dom.ts
index f62c2019a..13e1c40ad 100644
--- a/src/internal/dom.ts
+++ b/src/internal/dom.ts
@@ -30,6 +30,7 @@ import {
getBoxModel,
getCellSize,
getComputedValue,
+ getImageCellSize,
getInlineStyle,
getStyleSheets,
getWhiteSpace,
@@ -50,7 +51,7 @@ import {
import * as CSSValues from "./cssvalues.ts";
import {getEditingHost, installEditing} from "./editing.ts";
import type {Exchange} from "./exchange.ts";
-import type {Framebuffer} from "./framebuffer.ts";
+import {CellContext, CellGrid, type Framebuffer} from "./framebuffer.ts";
import {
getInterface,
getReflections,
@@ -58,16 +59,24 @@ import {
} from "./htmlreflection.ts";
import {
type Bitmap,
- type CharacterGrid,
createBitmap,
- decodeImage,
- fetchImageBytes,
+ decodeImageOffThread,
+ IMAGE_ACCEPT,
+ MAX_IMAGE_PIXELS,
+ naturalHeightOf,
+ naturalWidthOf,
+ readImageResponse,
registerReplacedContent,
resolveImageURL,
} from "./images.ts";
import type {Layout} from "./layout.ts";
import {linearizeMath} from "./mathml.ts";
-import {getCellPixels, registerCellPixels} from "./replaced.ts";
+import {
+ addDocumentPolicy,
+ getLoadSignal,
+ loadResource,
+ setRequestDestination,
+} from "./resources.ts";
import {
getNextGraphemeBoundary,
getPreviousGraphemeBoundary,
@@ -2651,6 +2660,127 @@ Object.defineProperty(InputEvent.prototype, Symbol.toStringTag, {
configurable: true,
});
+// WebIDL's USVString: a lone surrogate becomes U+FFFD.
+function toUSVString(value: unknown): string {
+ return String(value).replace(
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?(
+ eventInitDict,
+ "A security policy violation event init",
+ );
+ const disposition = String(init.disposition ?? "enforce");
+ if (disposition !== "enforce" && disposition !== "report") {
+ throw new TypeError(
+ `"${disposition}" is not a security policy violation disposition`,
+ );
+ }
+ this[kViolation] = {
+ documentURI: toUSVString(init.documentURI ?? ""),
+ referrer: toUSVString(init.referrer ?? ""),
+ blockedURI: toUSVString(init.blockedURI ?? ""),
+ effectiveDirective: String(
+ init.effectiveDirective || init.violatedDirective || "",
+ ),
+ originalPolicy: String(init.originalPolicy ?? ""),
+ sourceFile: toUSVString(init.sourceFile ?? ""),
+ sample: String(init.sample ?? ""),
+ disposition,
+ statusCode: toUnsignedShort(init.statusCode ?? 0),
+ lineNumber: toUnsignedLong(init.lineNumber ?? 0),
+ columnNumber: toUnsignedLong(init.columnNumber ?? 0),
+ };
+ }
+
+ get documentURI(): string {
+ return this[kViolation].documentURI;
+ }
+
+ get referrer(): string {
+ return this[kViolation].referrer;
+ }
+
+ get blockedURI(): string {
+ return this[kViolation].blockedURI;
+ }
+
+ get effectiveDirective(): string {
+ return this[kViolation].effectiveDirective;
+ }
+
+ // A historical alias of effectiveDirective.
+ get violatedDirective(): string {
+ return this[kViolation].effectiveDirective;
+ }
+
+ get originalPolicy(): string {
+ return this[kViolation].originalPolicy;
+ }
+
+ get sourceFile(): string {
+ return this[kViolation].sourceFile;
+ }
+
+ get sample(): string {
+ return this[kViolation].sample;
+ }
+
+ get disposition(): globalThis.SecurityPolicyViolationEventDisposition {
+ return this[kViolation].disposition;
+ }
+
+ get statusCode(): number {
+ return this[kViolation].statusCode;
+ }
+
+ get lineNumber(): number {
+ return this[kViolation].lineNumber;
+ }
+
+ get columnNumber(): number {
+ return this[kViolation].columnNumber;
+ }
+}
+
+Object.defineProperty(
+ SecurityPolicyViolationEvent.prototype,
+ Symbol.toStringTag,
+ {value: "SecurityPolicyViolationEvent", configurable: true},
+);
+
// A transfer here carries text under format names and nothing else. There
// is no drag and drop in a terminal and no files to hand over, so
// `dropEffect`, `effectAllowed`, `setDragImage()` and `files` exist,
@@ -14275,7 +14405,7 @@ class HTMLCanvasElement extends HTMLElement {
declare height: globalThis.HTMLCanvasElement["height"];
declare width: globalThis.HTMLCanvasElement["width"];
[kCanvasBitmap]: Bitmap | null;
- [kCanvasGrid]: CharacterGrid | null;
+ [kCanvasGrid]: CellContext | null;
[kCanvasContext]: CanvasRenderingContext2D |
CanvasCharacterGridContext |
null;
@@ -14294,7 +14424,7 @@ class HTMLCanvasElement extends HTMLElement {
if (this[kCanvasContext] instanceof CanvasCharacterGridContext) {
return {
kind: "grid",
- grid: getCanvasGrid(this),
+ grid: getCanvasGrid(this).grid,
version: this[kCanvasVersion],
};
}
@@ -14384,16 +14514,16 @@ class HTMLCanvasElement extends HTMLElement {
if (rect && rect.width > 0 && rect.height > 0) {
return {x: this.width / rect.width, y: this.height / rect.height};
}
- const cell = getCellPixels(this);
+ const cell = getImageCellSize(this as unknown as Node);
return {x: cell.width, y: cell.height};
},
});
} else if (id === "charactergrid") {
this[kCanvasContext] = new CanvasCharacterGridContext({
canvas: this,
- grid: () => getCanvasGrid(this),
+ cells: () => getCanvasGrid(this),
changed,
- cellPixels: () => getCellPixels(this),
+ cellPixels: () => getImageCellSize(this as unknown as Node),
});
// The natural size is now in cells, not pixels.
invalidateReplaced(this, true);
@@ -14477,16 +14607,16 @@ class HTMLCanvasElement extends HTMLElement {
}
// The bitmap, made at the canvas's size when first needed.
-// Past this many pixels a canvas gets no bitmap and draws nothing, as a
+// Past MAX_IMAGE_PIXELS a canvas gets no bitmap and draws nothing, as a
// browser's canvas does past its own limit, rather than failing to
-// allocate.
-const CANVAS_PIXEL_LIMIT = 1 << 26;
+// allocate. A cell takes 18 bytes, so a character grid stops sooner.
+const GRID_CELL_LIMIT = 1 << 22;
// The bitmap, made at the canvas's size when first drawn on.
function getCanvasBitmap(canvas: HTMLCanvasElement): Bitmap {
const width = canvas.width;
const height = canvas.height;
- if (width * height > CANVAS_PIXEL_LIMIT) {
+ if (width * height > MAX_IMAGE_PIXELS) {
return createBitmap(0, 0);
}
let bitmap = canvas[kCanvasBitmap];
@@ -14496,17 +14626,17 @@ function getCanvasBitmap(canvas: HTMLCanvasElement): Bitmap {
return bitmap;
}
-function getCanvasGrid(canvas: HTMLCanvasElement): CharacterGrid {
- const fits = canvas.width * canvas.height <= CANVAS_PIXEL_LIMIT;
+function getCanvasGrid(canvas: HTMLCanvasElement): CellContext {
+ const fits = canvas.width * canvas.height <= GRID_CELL_LIMIT;
const cols = fits ? canvas.width : 0;
const rows = fits ? canvas.height : 0;
- let grid = canvas[kCanvasGrid];
- if (grid === null || grid.cols !== cols || grid.rows !== rows) {
- grid =
+ let cells = canvas[kCanvasGrid];
+ if (cells === null || cells.cols !== cols || cells.rows !== rows) {
+ cells =
canvas[kCanvasGrid] =
- {cols, rows, cells: new Array(cols * rows).fill(null)};
+ new CellContext(new CellGrid(rows, cols), rows, cols, 0);
}
- return grid;
+ return cells;
}
// Resizing a canvas resets its context to the default state (HTML
@@ -16468,6 +16598,8 @@ interface ImageState {
// Bumped with each new request, so a slow one that finishes after a
// newer one started is dropped.
generation: number;
+ // Cancels the request in flight when a newer one replaces it.
+ abort: AbortController | null;
version: number;
waiters: Array<{resolve: () => void; reject: (error: unknown) => void}>;
// A source set while the document had no window, loaded on insertion
@@ -16550,6 +16682,7 @@ class HTMLImageElement extends HTMLElement {
url: "",
bitmap: null,
generation: 0,
+ abort: null,
version: 0,
waiters: [],
pending: false,
@@ -16564,12 +16697,15 @@ class HTMLImageElement extends HTMLElement {
smooth: true,
};
}
- // Alt text stands in for an image that is not there.
+ // An image not showing, whether loading, broken or blocked, is a
+ // box with its alt text. An empty alt marks one that is only
+ // decoration, and with no source and no alt there is nothing to
+ // stand in for: both show nothing.
const alt = this.getAttribute("alt");
- if (alt !== null && alt !== "" && state.status !== "loading") {
- return {kind: "text", text: alt};
+ if (alt === "" || (alt === null && state.status === "unavailable")) {
+ return null;
}
- return null;
+ return {kind: "text", text: alt ?? ""};
});
registerDrawable(this, () => {
const state = this[kImageState];
@@ -16581,11 +16717,13 @@ class HTMLImageElement extends HTMLElement {
}
get naturalWidth(): number {
- return this[kImageState].bitmap?.width ?? 0;
+ const bitmap = this[kImageState].bitmap;
+ return bitmap === null ? 0 : naturalWidthOf(bitmap);
}
get naturalHeight(): number {
- return this[kImageState].bitmap?.height ?? 0;
+ const bitmap = this[kImageState].bitmap;
+ return bitmap === null ? 0 : naturalHeightOf(bitmap);
}
// The rendered width in cells when the image is rendered, which is
@@ -16715,6 +16853,8 @@ function readImageDimension(
function updateImageData(image: HTMLImageElement): void {
const state = image[kImageState];
const generation = ++state.generation;
+ state.abort?.abort();
+ state.abort = null;
// decode() calls waiting on the request this one replaces reject, as
// HTML's "update the image data" says.
for (const waiter of state.waiters.splice(0)) {
@@ -16787,12 +16927,43 @@ function updateImageData(image: HTMLImageElement): void {
// a src does not collapse the box in between.
state.status = "loading";
invalidateReplaced(image, false);
- fetchImageBytes(url)
- .then((bytes) => decodeImage(bytes))
- .then(
- (bitmap) => settle("complete", bitmap),
- (error) => settle("broken", null, error),
- );
+ const abort = state.abort = new AbortController();
+ const document = image.ownerDocument;
+ // As HTML's "update the image data" does, the request waits for the
+ // script that set the source to finish. A listener added just after
+ // the markup that holds the image sees it.
+ queueMicrotask(() => {
+ if (state.generation !== generation) {
+ return;
+ }
+ let request: Request;
+ try {
+ request = new Request(url, {
+ headers: {accept: IMAGE_ACCEPT},
+ signal: abort.signal,
+ });
+ } catch (error) {
+ settle("broken", null, error);
+ return;
+ }
+ setRequestDestination(request, "image");
+ // Disposing the TermDOM aborts the load, the request and a decode
+ // still waiting for its turn alike. Listened for only while the
+ // load runs, so the TermDOM holds no image it has finished with.
+ const ended = getLoadSignal(document);
+ const onEnded = () => abort.abort(ended!.reason);
+ ended?.addEventListener("abort", onEnded, {once: true});
+ loadResource(document, request, {
+ initiator: image as unknown as globalThis.Element,
+ })
+ .then((response) => readImageResponse(response))
+ .then((bytes) => decodeImageOffThread(bytes, abort.signal))
+ .finally(() => ended?.removeEventListener("abort", onEnded))
+ .then(
+ (bitmap) => settle("complete", bitmap),
+ (error) => settle("broken", null, error),
+ );
+ });
}
const kDirtyValue = Symbol("dirtyValue");
@@ -18498,6 +18669,24 @@ class HTMLMetaElement extends HTMLElement {
declare content: globalThis.HTMLMetaElement["content"];
declare httpEquiv: globalThis.HTMLMetaElement["httpEquiv"];
declare scheme: globalThis.HTMLMetaElement["scheme"];
+
+ // HTML §4.2.5.3: the Content-Security-Policy pragma runs when the
+ // element is inserted into a document as a child of its head.
+ override [kInsertionSteps](): void {
+ super[kInsertionSteps]();
+ const content = this.getAttribute("content");
+ if (
+ this.isConnected &&
+ this.parentNode?.nodeType === ELEMENT_NODE &&
+ (this.parentNode as Element).localName === "head" &&
+ (this.parentNode as Element).namespaceURI === HTML_NAMESPACE &&
+ this.getAttribute("http-equiv")?.toLowerCase() ===
+ "content-security-policy" &&
+ content
+ ) {
+ addDocumentPolicy(this.ownerDocument, content);
+ }
+ }
}
// A run of full blocks for the filled bar and a run of light shade for
@@ -33368,7 +33557,6 @@ export function attachDocument(
attached[kFramebuffer] = framebuffer;
attached[kPendingCaretReveal] = null;
attached[kPendingEditingReveal] = null;
- registerCellPixels(attached, () => framebuffer.cellPixels);
for (const type of ["input", "select", "change", "selectionchange"]) {
exchange.addEventListener(type, onTextControlEditEvent);
}
@@ -35541,7 +35729,7 @@ export class Window extends EventTarget {
// A Blob is decoded first; an image still loading is waited for.
if (typeof Blob !== "undefined" && image instanceof Blob) {
return image.arrayBuffer()
- .then((buffer) => decodeImage(new Uint8Array(buffer)))
+ .then((buffer) => decodeImageOffThread(new Uint8Array(buffer)))
.then((bitmap) => settle(new ImageBitmap(bitmap)));
}
if (image instanceof HTMLImageElement && !image.complete) {
@@ -35802,6 +35990,7 @@ const platform = {
SVGElement,
Screen,
ScreenOrientation,
+ SecurityPolicyViolationEvent,
Selection,
ShadowRoot,
StaticRange,
@@ -35873,9 +36062,26 @@ function buildWindow(document: Document): Window {
});
window.window = window;
window.self = window;
+ startPendingImages(document as unknown as globalThis.Document);
return window as unknown as Window;
}
+// Images the parser built before their document had a window wait for
+// one, and start here, once it does.
+function startPendingImages(
+ root: globalThis.Document | globalThis.ShadowRoot,
+): void {
+ for (const element of root.querySelectorAll("*")) {
+ if (element instanceof HTMLImageElement && element[kImageState].pending) {
+ updateImageData(element);
+ }
+ const shadow = getShadowRoot(element);
+ if (shadow !== null) {
+ startPendingImages(shadow);
+ }
+ }
+}
+
/**
* Parse a document from markup and display it in a window of its own. An
* XML content type parses as XML, which is what a frame whose src names a
@@ -35928,6 +36134,7 @@ export type {
CompositionEvent,
TextEvent,
InputEvent,
+ SecurityPolicyViolationEvent,
FileList,
DataTransferItem,
DataTransferItemList,
diff --git a/src/internal/framebuffer.ts b/src/internal/framebuffer.ts
index ea998cd5c..5113168ca 100644
--- a/src/internal/framebuffer.ts
+++ b/src/internal/framebuffer.ts
@@ -710,7 +710,7 @@ function getBorderChar(borderEncoding: number): string {
* two-column glyph covers. The glyph's width text control steps the emitter
* over it.
*/
-class CellGrid {
+export class CellGrid {
readonly rows: number;
readonly cols: number;
readonly cluster: Uint32Array;
@@ -768,6 +768,12 @@ class CellGrid {
this.border.set(source.border.subarray(start, end), to);
}
+ clone(): CellGrid {
+ const copy = new CellGrid(this.rows, this.cols);
+ copy.copyFrom(this, {to: 0, start: 0, end: this.rows * this.cols});
+ return copy;
+ }
+
bottomRows(rows: number): CellGrid {
const kept = new CellGrid(rows, this.cols);
kept.copyFrom(this, {
@@ -863,6 +869,52 @@ function meetEdges(existing: number, incoming: number): number {
return met;
}
+/**
+ * A grid's lines joined where they touch and drawn as their glyphs, so
+ * the grid copies into another without joining what is there.
+ */
+export function settleBorders(grid: CellGrid): void {
+ joinTouchingBorders(grid);
+ const {border, cluster} = grid;
+ for (let index = 0; index < border.length; index++) {
+ if (border[index] > 0) {
+ cluster[index] = encodeGrapheme(getBorderChar(border[index]));
+ border[index] = 0;
+ }
+ }
+}
+
+/** A cell's glyph, colors and attributes, or null for an empty cell. */
+export function readCell(
+ grid: CellGrid,
+ index: number,
+): {
+ char: string;
+ fg: number;
+ bg: number;
+ bold: boolean;
+ italic: boolean;
+ underline: boolean;
+ inverse: boolean;
+} | null {
+ if (grid.cluster[index] === 0) {
+ return null;
+ }
+ const attrs = grid.attrs[index];
+ const border = grid.border[index];
+ return {
+ char: border > 0
+ ? getBorderChar(border)
+ : decodeGrapheme(grid.cluster[index]),
+ fg: grid.fg[index],
+ bg: grid.bg[index],
+ bold: (attrs & ATTR.Bold) !== 0,
+ italic: (attrs & ATTR.Italic) !== 0,
+ underline: (attrs & (ATTR.Underline | ATTR.DoubleUnderline)) !== 0,
+ inverse: (attrs & ATTR.Inverse) !== 0,
+ };
+}
+
function joinTouchingBorders(grid: CellGrid): void {
const {rows, cols, border} = grid;
const painted = border.slice();
@@ -1119,6 +1171,37 @@ export class CellContext {
}
}
+ /**
+ * Another grid's cells with its top left at (x, y). An empty cell
+ * leaves what is under it, one with no background takes the
+ * background under it, and one in the default foreground takes
+ * `fallback.fg`.
+ */
+ drawGrid(source: CellGrid, x: number, y: number, fallback?: CellStyle): void {
+ const grid = this.grid;
+ const fg = (fallback?.fg ?? 0) & COLOR_MASK;
+ for (let row = 0; row < source.rows; row++) {
+ for (let col = 0; col < source.cols; col++) {
+ const from = row * source.cols + col;
+ if (source.cluster[from] === 0) {
+ continue;
+ }
+ const index = getGuardedWriteIndex(this, y + row, x + col);
+ if (index < 0) {
+ continue;
+ }
+ const under = grid.cluster[index] !== 0 ? grid.bg[index] : 0;
+ grid.setFrom(index, source, from);
+ if (grid.fg[index] === 0) {
+ grid.fg[index] = fg;
+ }
+ if (grid.bg[index] === 0) {
+ grid.bg[index] = under;
+ }
+ }
+ }
+ }
+
drawDecoration(x: number, y: number, width: number, style: CellStyle): void {
// An edge across existing glyphs, which drawText cannot draw because it
// overwrites. The style's fg is the row's default. A cell with its own
@@ -1790,9 +1873,12 @@ const kFrameScroll = Symbol("frameScroll");
const kDirty = Symbol("dirty");
const kDocumentTop = Symbol("documentTop");
const kCellPixels = Symbol("cellPixels");
-// What a cell measures until the terminal says. Most fonts run about
-// twice as tall as wide at any size.
-const DEFAULT_CELL_PIXELS = {width: 8, height: 16};
+// What a cell measures in pixels until the terminal says, or when it
+// cannot. Most fonts run about twice as tall as wide at any size.
+export const FALLBACK_CELL: Readonly<{width: number; height: number}> = {
+ width: 8,
+ height: 16,
+};
export interface Framebuffer {
[kPrev]: CellGrid | null;
@@ -1846,7 +1932,7 @@ export class Framebuffer {
this[kCols] = cols;
this[kScrollTop] = 0;
this[kDocumentTop] = 0;
- this[kCellPixels] = {...DEFAULT_CELL_PIXELS};
+ this[kCellPixels] = {...FALLBACK_CELL};
this[kFrameScroll] = 0;
this[kDirty] = true;
this[kWriter] = new FrameWriter(colorDepth);
diff --git a/src/internal/images.ts b/src/internal/images.ts
index 36c89bb21..59e6ba886 100644
--- a/src/internal/images.ts
+++ b/src/internal/images.ts
@@ -3,8 +3,8 @@
* encoding a bitmap as PNG, and sampling a bitmap down to the two
* pixels a cell shows.
*
- * A cell draws two pixels stacked: the upper half block (U+2580) in the
- * foreground color over the background color. Everything a terminal can
+ * A cell draws two pixels stacked: the lower half block (U+2584) in the
+ * foreground color under the background color. Everything a terminal can
* show of an image goes through cells that way, so an image scrolls,
* clips and diffs like any other text, over SSH and inside tmux too.
*
@@ -12,12 +12,117 @@
* and the canvas and the painter read it back through the registry at
* the bottom.
*/
+import type {CellGrid} from "./framebuffer.ts";
/** Straight (not premultiplied) RGBA, four bytes a pixel, rows top down. */
export interface Bitmap {
width: number;
height: number;
data: Uint8ClampedArray;
+ // The size the image is, when its pixels were kept smaller than that.
+ // Everything outside the pixels themselves measures this one.
+ naturalWidth?: number;
+ naturalHeight?: number;
+}
+
+export function naturalWidthOf(bitmap: Bitmap): number {
+ return bitmap.naturalWidth ?? bitmap.width;
+}
+
+export function naturalHeightOf(bitmap: Bitmap): number {
+ return bitmap.naturalHeight ?? bitmap.height;
+}
+
+/**
+ * The largest image anything here allocates: 32768 pixels to a side and
+ * 2^26 in all, 256 MB as RGBA. A file states its size before its
+ * pixels, so a few bytes can ask for any amount of memory. Like a
+ * browser, a decoder refuses a size past these before it allocates.
+ */
+const MAX_IMAGE_SIDE = 32768;
+export const MAX_IMAGE_PIXELS = 1 << 26;
+
+/** Throws for a size an image may not have. */
+function checkImageSize(width: number, height: number, format: string): void {
+ if (
+ !Number.isInteger(width) ||
+ !Number.isInteger(height) ||
+ width <= 0 ||
+ height <= 0
+ ) {
+ throw new Error(`The ${format} image has no size`);
+ }
+ if (
+ width > MAX_IMAGE_SIDE ||
+ height > MAX_IMAGE_SIDE ||
+ width * height > MAX_IMAGE_PIXELS
+ ) {
+ throw new Error(
+ `The ${format} image is ${width} by ${height} pixels, more than an image may be`,
+ );
+ }
+}
+
+// A browser decodes on another thread. On one, a decoder hands the event
+// loop a turn this often, so a large image does not stall the page
+// while it decodes.
+const DECODE_SLICE_MS = 16;
+
+// Returns a promise to await when a turn is due, and undefined between.
+// Past `deadline`, a time from Date.now(), the promise rejects instead.
+function createYielder(deadline: number): () => Promise | undefined {
+ let last = performance.now();
+ return () => {
+ if (performance.now() - last < DECODE_SLICE_MS) {
+ return undefined;
+ }
+ return new Promise((resolve, reject) => {
+ setTimeout(() => {
+ last = performance.now();
+ if (Date.now() > deadline) {
+ reject(new Error("The image took too long to decode"));
+ } else {
+ resolve();
+ }
+ }, 0);
+ });
+ };
+}
+
+/**
+ * The most pixels a decoded image keeps, 4 MB of them. A terminal shows a
+ * few hundred thousand at most, so a larger image is kept averaged down
+ * to this, and still reports its full size as its natural size.
+ */
+export const MAX_KEPT_PIXELS = 1 << 20;
+
+/** The image as a page keeps it: at most MAX_KEPT_PIXELS. */
+export function keepAtMost(bitmap: Bitmap): Bitmap {
+ const {width, height} = bitmap;
+ if (width * height <= MAX_KEPT_PIXELS) {
+ return bitmap;
+ }
+ const scale = Math.sqrt(MAX_KEPT_PIXELS / (width * height));
+ const keptWidth = Math.max(1, Math.floor(width * scale));
+ const keptHeight = Math.max(1, Math.floor(height * scale));
+ return {
+ width: keptWidth,
+ height: keptHeight,
+ data: sampleBitmap(bitmap, keptWidth, keptHeight),
+ naturalWidth: width,
+ naturalHeight: height,
+ };
+}
+
+/**
+ * Decode, and keep at most MAX_KEPT_PIXELS. A decode still running at
+ * `deadline` stops at its next yield.
+ */
+export async function decodeImageForPage(
+ bytes: Uint8Array,
+ deadline = Infinity,
+): Promise {
+ return keepAtMost(await decodeImage(bytes, deadline));
}
export function createBitmap(width: number, height: number): Bitmap {
@@ -57,12 +162,15 @@ export function sniffImageType(bytes: Uint8Array): string | null {
* sniffs it, not from a name or a content type. Rejects with the reason
* a format is unsupported or the data is damaged.
*/
-export async function decodeImage(bytes: Uint8Array): Promise {
+export async function decodeImage(
+ bytes: Uint8Array,
+ deadline = Infinity,
+): Promise {
switch (sniffImageType(bytes)) {
case "image/png":
- return await decodePNG(bytes);
+ return await decodePNG(bytes, deadline);
case "image/jpeg":
- return decodeJPEG(bytes);
+ return await decodeJPEG(bytes, deadline);
case "image/gif":
return decodeGIF(bytes);
case "image/bmp":
@@ -88,11 +196,26 @@ function readUint32(bytes: Uint8Array, offset: number): number {
// zlib, which is what CompressionStream calls "deflate". Node, Bun, Deno
// and browsers all have it, and nothing else here needs a platform API.
-async function inflate(data: Uint8Array): Promise {
- const stream = new Blob([data as BlobPart])
+// Reading stops at `limit` bytes: deflate shrinks a run of zeros a
+// thousandfold, so what a stream would inflate to is no bound at all.
+async function inflate(data: Uint8Array, limit: number): Promise {
+ const reader = new Blob([data as BlobPart])
.stream()
- .pipeThrough(new DecompressionStream("deflate"));
- return new Uint8Array(await new Response(stream).arrayBuffer());
+ .pipeThrough(new DecompressionStream("deflate"))
+ .getReader();
+ const out = new Uint8Array(limit);
+ let length = 0;
+ while (length < limit) {
+ const {done, value} = await reader.read();
+ if (done) {
+ break;
+ }
+ const take = Math.min(value.length, limit - length);
+ out.set(value.subarray(0, take), length);
+ length += take;
+ }
+ await reader.cancel().catch(() => {});
+ return out.subarray(0, length);
}
// Adam7: the start and step of each pass, columns then rows.
@@ -106,7 +229,29 @@ const ADAM7 = [
[0, 1, 1, 2],
];
-async function decodePNG(bytes: Uint8Array): Promise {
+// The bytes a PNG's image data inflates to: each row's filter byte and
+// samples, over each Adam7 pass when interlaced.
+function getPNGDataSize(
+ width: number,
+ height: number,
+ bitsPerPixel: number,
+ interlaced: boolean,
+): number {
+ const rowSize = (columns: number) =>
+ columns === 0 ? 0 : 1 + Math.ceil((columns * bitsPerPixel) / 8);
+ if (!interlaced) {
+ return height * rowSize(width);
+ }
+ let size = 0;
+ for (const [x0, y0, dx, dy] of ADAM7) {
+ const columns = Math.ceil(Math.max(0, width - x0) / dx);
+ const rows = Math.ceil(Math.max(0, height - y0) / dy);
+ size += rows * rowSize(columns);
+ }
+ return size;
+}
+
+async function decodePNG(bytes: Uint8Array, deadline: number): Promise {
for (let i = 0; i < 8; i++) {
if (bytes[i] !== PNG_SIGNATURE[i]) {
throw new Error("Not a PNG image");
@@ -177,6 +322,7 @@ async function decodePNG(bytes: Uint8Array): Promise {
if (channels === undefined || ![1, 2, 4, 8, 16].includes(depth)) {
throw new Error("The PNG image has an unknown color type or depth");
}
+ checkImageSize(width, height, "PNG");
let joined = parts[0];
if (parts.length > 1) {
joined = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
@@ -186,9 +332,12 @@ async function decodePNG(bytes: Uint8Array): Promise {
at += part.length;
}
}
- const raw = await inflate(joined);
- const bitmap = createBitmap(width, height);
const bitsPerPixel = channels * depth;
+ const raw = await inflate(
+ joined,
+ getPNGDataSize(width, height, bitsPerPixel, interlace === 1),
+ );
+ const bitmap = createBitmap(width, height);
const bytesPerPixel = Math.max(1, bitsPerPixel >> 3);
const max = (1 << depth) - 1;
@@ -247,7 +396,8 @@ async function decodePNG(bytes: Uint8Array): Promise {
};
let at = 0;
- const unfilter = (
+ const pause = createYielder(deadline);
+ const unfilter = async (
passWidth: number,
passHeight: number,
emit: (line: Uint8Array, y: number) => void,
@@ -290,6 +440,7 @@ async function decodePNG(bytes: Uint8Array): Promise {
}
emit(current, y);
[previous, current] = [current, previous];
+ await pause();
}
};
@@ -300,12 +451,12 @@ async function decodePNG(bytes: Uint8Array): Promise {
if (passWidth <= 0 || passHeight <= 0) {
continue;
}
- unfilter(passWidth, passHeight, (line, y) =>
+ await unfilter(passWidth, passHeight, (line, y) =>
store(line, passWidth, x0, dx, y0 + y * dy),
);
}
} else {
- unfilter(width, height, (line, y) => store(line, width, 0, 1, y));
+ await unfilter(width, height, (line, y) => store(line, width, 0, 1, y));
}
return bitmap;
}
@@ -408,6 +559,9 @@ export function encodePNG(bitmap: Bitmap): Uint8Array {
// factors, grayscale, YCbCr, and Adobe's RGB, CMYK and YCCK. Arithmetic
// coding and lossless JPEG are rare enough to be left out.
+// libjpeg-turbo's suggested limit. A progressive image has ten or so.
+const MAX_JPEG_SCANS = 500;
+
const ZIGZAG = new Int32Array([
0,
1,
@@ -666,7 +820,7 @@ class BitReader {
}
}
-function decodeScan(
+async function decodeScan(
data: Uint8Array,
offset: number,
frame: JPEGFrame,
@@ -676,7 +830,8 @@ function decodeScan(
spectralEnd: number,
successivePrev: number,
successive: number,
-): number {
+ pause: () => Promise | undefined,
+): Promise {
const reader = new BitReader(data, offset);
const progressive = frame.progressive;
let eobrun = 0;
@@ -842,6 +997,10 @@ function decodeScan(
eobrun = 0;
successiveState = 0;
for (let n = 0; n < interval && mcu < total; n++, mcu++) {
+ const turn = (mcu & 63) === 0 ? pause() : undefined;
+ if (turn !== undefined) {
+ await turn;
+ }
if (components.length === 1) {
const component = components[0];
const row = Math.floor(mcu / component.blocksPerLine);
@@ -978,12 +1137,14 @@ function readExifOrientation(
return 1;
}
-function decodeJPEG(data: Uint8Array): Bitmap {
+async function decodeJPEG(data: Uint8Array, deadline: number): Promise {
+ const pause = createYielder(deadline);
const quantTables: Uint16Array[] = [];
const dcTables: HuffmanTable[] = [];
const acTables: HuffmanTable[] = [];
let frame: JPEGFrame | null = null;
let resetInterval = 0;
+ let scans = 0;
let adobeTransform = -1;
let jfif = false;
let orientation = 1;
@@ -1058,6 +1219,12 @@ function decodeJPEG(data: Uint8Array): Bitmap {
const height = (data[start + 1] << 8) | data[start + 2];
const width = (data[start + 3] << 8) | data[start + 4];
const count = data[start + 5];
+ checkImageSize(width, height, "JPEG");
+ // Each component is a plane the size of the image, and an image
+ // is gray, YCbCr or CMYK.
+ if (count < 1 || count > 4) {
+ throw new Error("The JPEG image has more than four components");
+ }
const components: JPEGComponent[] = [];
let maxH = 1;
let maxV = 1;
@@ -1065,6 +1232,9 @@ function decodeJPEG(data: Uint8Array): Bitmap {
const at = start + 6 + i * 3;
const h = data[at + 1] >> 4 || 1;
const v = data[at + 1] & 15 || 1;
+ if (h > 4 || v > 4) {
+ throw new Error("The JPEG image samples a component past four");
+ }
maxH = Math.max(maxH, h);
maxV = Math.max(maxV, v);
components.push({
@@ -1081,9 +1251,6 @@ function decodeJPEG(data: Uint8Array): Bitmap {
pred: 0,
});
}
- if (width === 0 || height === 0) {
- throw new Error("The JPEG image has no size");
- }
const mcusPerLine = Math.ceil(width / (8 * maxH));
const mcusPerColumn = Math.ceil(height / (8 * maxV));
for (const component of components) {
@@ -1115,6 +1282,13 @@ function decodeJPEG(data: Uint8Array): Bitmap {
if (frame === null) {
throw new Error("The JPEG scan comes before its frame");
}
+ // Each scan passes over the whole image, so a file of a few
+ // kilobytes could ask for any number of passes.
+ if (++scans > MAX_JPEG_SCANS) {
+ throw new Error(
+ `The JPEG image has more than ${MAX_JPEG_SCANS} scans`,
+ );
+ }
const count = data[start];
const scanComponents: JPEGComponent[] = [];
for (let i = 0; i < count; i++) {
@@ -1129,7 +1303,7 @@ function decodeJPEG(data: Uint8Array): Bitmap {
scanComponents.push(component);
}
const at = start + 1 + count * 2;
- offset = decodeScan(
+ offset = await decodeScan(
data,
end,
frame,
@@ -1139,6 +1313,7 @@ function decodeJPEG(data: Uint8Array): Bitmap {
data[at + 1],
data[at + 2] >> 4,
data[at + 2] & 15,
+ pause,
);
continue;
}
@@ -1159,20 +1334,28 @@ function decodeJPEG(data: Uint8Array): Bitmap {
throw new Error("The JPEG image has no frame");
}
return orient(
- buildJPEGBitmap(frame, quantTables, adobeTransform, jfif),
+ await buildJPEGBitmap(frame, quantTables, adobeTransform, jfif, pause),
orientation,
);
}
-function buildJPEGBitmap(
+async function buildJPEGBitmap(
frame: JPEGFrame,
quantTables: Uint16Array[],
adobeTransform: number,
jfif: boolean,
-): Bitmap {
+ pause: () => Promise | undefined,
+): Promise {
const {width, height, components} = frame;
// Each component's samples at its own resolution.
- const planes = components.map((component) => {
+ const planes: Array<{
+ plane: Uint8ClampedArray;
+ planeWidth: number;
+ planeHeight: number;
+ scaleX: number;
+ scaleY: number;
+ }> = [];
+ for (const component of components) {
const planeWidth = component.blocksPerLineForMcu * 8;
const rows =
(component.blocks.length / 64 / component.blocksPerLineForMcu) * 8;
@@ -1186,6 +1369,7 @@ function buildJPEGBitmap(
const blocksPerRow = component.blocksPerLineForMcu;
const blockRows = rows / 8;
for (let blockRow = 0; blockRow < blockRows; blockRow++) {
+ await pause();
for (let blockCol = 0; blockCol < blocksPerRow; blockCol++) {
inverseDCT(
component.blocks,
@@ -1202,14 +1386,14 @@ function buildJPEGBitmap(
}
}
}
- return {
+ planes.push({
plane,
planeWidth,
planeHeight: rows,
scaleX: component.h / frame.maxH,
scaleY: component.v / frame.maxV,
- };
- });
+ });
+ }
const bitmap = createBitmap(width, height);
const data = bitmap.data;
@@ -1228,6 +1412,7 @@ function buildJPEGBitmap(
);
const sample = new Float32Array(4);
for (let y = 0; y < height; y++) {
+ await pause();
for (let x = 0; x < width; x++) {
for (let c = 0; c < count; c++) {
const {plane, planeWidth, planeHeight, scaleX, scaleY} = planes[c];
@@ -1413,20 +1598,28 @@ function decodeGIF(data: Uint8Array): Bitmap {
if (table === null) {
throw new Error("The GIF image has no color table");
}
+ checkImageSize(frameWidth, frameHeight, "GIF");
+ checkImageSize(width || frameWidth, height || frameHeight, "GIF");
const minCodeSize = data[offset++];
- const chunks: number[] = [];
+ if (minCodeSize < 2 || minCodeSize > 8) {
+ throw new Error("The GIF frame's code size is not 2 to 8 bits");
+ }
+ const chunks: Uint8Array[] = [];
+ let length = 0;
while (offset < data.length && data[offset] !== 0) {
const size = data[offset++];
- for (let i = 0; i < size; i++) {
- chunks.push(data[offset + i]);
- }
+ const chunk = data.subarray(offset, offset + size);
+ chunks.push(chunk);
+ length += chunk.length;
offset += size;
}
- const indices = decodeLZW(
- Uint8Array.from(chunks),
- minCodeSize,
- frameWidth * frameHeight,
- );
+ const joined = new Uint8Array(length);
+ let at = 0;
+ for (const chunk of chunks) {
+ joined.set(chunk, at);
+ at += chunk.length;
+ }
+ const indices = decodeLZW(joined, minCodeSize, frameWidth * frameHeight);
const bitmap = createBitmap(width || frameWidth, height || frameHeight);
const interlaced = (flags & 0x40) !== 0;
const rowOrder = interlaced ? interlacedRows(frameHeight) : null;
@@ -1554,9 +1747,7 @@ function decodeBMP(data: Uint8Array): Bitmap {
const compression = headerSize >= 40 ? view.getUint32(30, true) : 0;
const height = Math.abs(rawHeight);
const bottomUp = rawHeight > 0;
- if (width <= 0 || height === 0) {
- throw new Error("The BMP image has no size");
- }
+ checkImageSize(width, height, "BMP");
const rle =
(compression === 1 && bits === 8) || (compression === 2 && bits === 4);
if (compression !== 0 && compression !== 3 && !rle) {
@@ -1713,21 +1904,31 @@ export function sampleBitmap(
bitmap: Bitmap,
outWidth: number,
outHeight: number,
- source: {x: number; y: number; width: number; height: number} = {
+ natural: {x: number; y: number; width: number; height: number} = {
x: 0,
y: 0,
- width: bitmap.width,
- height: bitmap.height,
+ width: naturalWidthOf(bitmap),
+ height: naturalHeightOf(bitmap),
},
smooth = true,
): Uint8ClampedArray {
const out = new Uint8ClampedArray(Math.max(0, outWidth * outHeight * 4));
if (
- outWidth <= 0 || outHeight <= 0 || source.width <= 0 || source.height <= 0
+ outWidth <= 0 || outHeight <= 0 || natural.width <= 0 || natural.height <= 0
) {
return out;
}
const {width, height, data} = bitmap;
+ // The rectangle is in the image's natural pixels, and the pixels kept
+ // may be fewer.
+ const keptX = width / naturalWidthOf(bitmap);
+ const keptY = height / naturalHeightOf(bitmap);
+ const source = {
+ x: natural.x * keptX,
+ y: natural.y * keptY,
+ width: natural.width * keptX,
+ height: natural.height * keptY,
+ };
const scaleX = source.width / outWidth;
const scaleY = source.height / outHeight;
for (let oy = 0; oy < outHeight; oy++) {
@@ -1835,29 +2036,10 @@ export type ReplacedContent =
} |
// A canvas not drawn on yet: its size, and nothing to paint.
{kind: "blank"; width: number; height: number} |
- {kind: "grid"; grid: CharacterGrid; version: number} |
+ {kind: "grid"; grid: CellGrid; version: number} |
{kind: "text"; text: string} |
null;
-/** One cell of a character grid. A null char leaves the cell's text alone. */
-export interface GridCell {
- char: string | null;
- fg: number | null;
- bg: number | null;
- bold: boolean;
- italic: boolean;
- underline: boolean;
- dim: boolean;
- // Painted in the terminal's own colors, swapped.
- inverse: boolean;
-}
-
-export interface CharacterGrid {
- cols: number;
- rows: number;
- cells: Array;
-}
-
const replacedSources = new WeakMap ReplacedContent>();
export function registerReplacedContent(
@@ -1877,116 +2059,414 @@ export function isReplacedElement(owner: object): boolean {
}
// ---------------------------------------------------------------------------
-// Fetching what a src names.
+// Reading what a src names.
-/** The bytes of a data: URL, or null when it is not one. */
-export function readDataURL(url: string): Uint8Array | null {
- const match = /^data:([^,]*?)(;base64)?,(.*)$/is.exec(url);
- if (match === null) {
- return null;
+/**
+ * The URL a src names, against the document's URL, or null. A relative
+ * src in a document with no URL of its own, at about:blank, names
+ * nothing.
+ */
+export function resolveImageURL(src: string, base: string): string | null {
+ // A Windows path's drive letter would read as a URL scheme.
+ if (/^[a-z]:[\\/]/i.test(src)) {
+ return new URL(`file:///${src.replace(/\\/g, "/")}`).href;
}
- const body = percentDecode(match[3]);
- if (!match[2]) {
- return body;
+ return URL.canParse(src, base) ? new URL(src, base).href : null;
+}
+
+/** The most bytes an image file may have before it decodes. */
+export const MAX_IMAGE_BYTES = 1 << 26;
+
+function checkImageBytes(length: number): void {
+ if (length > MAX_IMAGE_BYTES) {
+ throw new Error(`The image file is more than ${MAX_IMAGE_BYTES} bytes`);
}
- const binary = atob(
- new TextDecoder("latin1").decode(body).replace(/\s+/g, ""),
- );
- return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
-// URL's percent-decode: each %XX is that byte, and the rest is UTF-8.
-function percentDecode(text: string): Uint8Array {
- const input = new TextEncoder().encode(text);
- const out = new Uint8Array(input.length);
+/**
+ * The image types the decoders read, for a request's Accept header, so a
+ * server that picks a format by it picks one of these.
+ */
+export const IMAGE_ACCEPT =
+ "image/png,image/jpeg,image/gif,image/bmp;q=0.9,*/*;q=0.5";
+
+/**
+ * A response's bytes, up to MAX_IMAGE_BYTES: by its declared length when
+ * it has one, and while reading when it has not.
+ */
+export async function readImageResponse(
+ response: Response,
+): Promise {
+ // A body left unread holds its connection open.
+ const discard = () => response.body?.cancel().catch(() => {});
+ if (!response.ok) {
+ await discard();
+ throw new Error(`The image request failed with status ${response.status}`);
+ }
+ const declared = Number(response.headers.get("content-length") ?? NaN);
+ if (Number.isFinite(declared) && declared > MAX_IMAGE_BYTES) {
+ await discard();
+ checkImageBytes(declared);
+ }
+ if (response.body === null) {
+ return new Uint8Array(0);
+ }
+ // The length a server declares is not a promise. The body is read
+ // a piece at a time and given up past the limit.
+ const reader = response.body.getReader();
+ const pieces: Uint8Array[] = [];
let length = 0;
- for (let i = 0; i < input.length; i++) {
- const hex = String.fromCharCode(input[i + 1] ?? 0, input[i + 2] ?? 0);
- if (input[i] === 0x25 && /^[0-9a-f]{2}$/i.test(hex)) {
- out[length++] = parseInt(hex, 16);
- i += 2;
- } else {
- out[length++] = input[i];
+ for (;;) {
+ const {done, value} = await reader.read();
+ if (done) {
+ break;
+ }
+ length += value.length;
+ if (length > MAX_IMAGE_BYTES) {
+ await reader.cancel().catch(() => {});
+ checkImageBytes(length);
}
+ pieces.push(value);
}
- return out.subarray(0, length);
+ const bytes = new Uint8Array(length);
+ let at = 0;
+ for (const piece of pieces) {
+ bytes.set(piece, at);
+ at += piece.length;
+ }
+ return bytes;
+}
+
+// ---------------------------------------------------------------------------
+// Decoding off the page's thread.
+
+/**
+ * How long an image may take to decode. Past it, the worker is ended,
+ * which ends the decode with it, or the decode here stops at its next
+ * yield, and the image fails.
+ */
+let decodeTimeout = 10_000;
+// A worker with nothing to do is ended, so it keeps the runtime alive for
+// a second at most.
+const WORKER_IDLE_MS = 1_000;
+
+let workerURL: URL | null = null;
+
+interface DecodeWorker {
+ post(message: unknown, transfer: Transferable[]): void;
+ terminate(): void;
+}
+
+interface Job {
+ bytes: Uint8Array;
+ resolve(bitmap: Bitmap): void;
+ reject(error: unknown): void;
+ // Called when the job leaves the line to decode.
+ started(): void;
}
-interface ProcessWithBuiltins {
- cwd?: () => string;
- getBuiltinModule?: (name: string) => unknown;
+interface WorkerState {
+ worker: DecodeWorker;
+ // Until the worker says it started, a failure means it cannot start.
+ ready: boolean;
+ idle: ReturnType | null;
}
-function getProcess(): ProcessWithBuiltins | undefined {
- return (globalThis as {process?: ProcessWithBuiltins}).process;
+// Images decode one at a time, in the order they ask. A decode's time
+// limit then counts its own work and not the line ahead of it, and one
+// decode's memory is all the decoding a page holds at once.
+const waiting: Job[] = [];
+let running: {
+ job: Job;
+ id: number;
+ timer: ReturnType | null;
+} |
+ null = null;
+let current: WorkerState | null = null;
+// False for good once a worker fails to start: decoding stays here.
+let workersWork = true;
+let nextRequest = 0;
+
+/** The module src/decode-worker.ts builds to, which decodes off-thread. */
+export function setDecodeWorkerURL(url: URL | null): void {
+ if (current !== null) {
+ endWorker(current);
+ }
+ workerURL = url;
+ workersWork = url !== null;
+ // An image the ended worker had goes to whatever decodes next.
+ if (running !== null && running.timer !== null) {
+ clearTimeout(running.timer);
+ waiting.unshift(running.job);
+ running = null;
+ startNextDecode();
+ }
+}
+
+/** How long a decode may take before it is stopped. */
+export function setDecodeTimeout(ms: number): void {
+ decodeTimeout = ms;
}
/**
- * Where a relative src points. A document at about:blank has no base,
- * and a program in a terminal works in its working directory, so a
- * relative path there is a file under it.
+ * Decode on a worker thread, where the runtime has one, so a large image
+ * does not stall the page. Where no worker starts, the decode runs here,
+ * a slice at a time. Either way it stops past decodeTimeout.
*/
-export function resolveImageURL(src: string, base: string): string | null {
- // A Windows path's drive letter would read as a URL scheme.
- if (/^[a-z]:[\\/]/i.test(src)) {
- return new URL(`file:///${src.replace(/\\/g, "/")}`).href;
+export function decodeImageOffThread(
+ bytes: Uint8Array,
+ signal?: AbortSignal,
+): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(signal.reason);
+ return;
+ }
+ const job: Job = {bytes, resolve, reject, started: () => {}};
+ // A decode that has not started is dropped, with its bytes. One
+ // that has runs to its end or its time limit.
+ if (signal !== undefined) {
+ const onAbort = () => {
+ const index = waiting.indexOf(job);
+ if (index !== -1) {
+ waiting.splice(index, 1);
+ reject(signal.reason);
+ }
+ };
+ signal.addEventListener("abort", onAbort, {once: true});
+ job.started = () => signal.removeEventListener("abort", onAbort);
+ }
+ waiting.push(job);
+ startNextDecode();
+ });
+}
+
+function startNextDecode(): void {
+ if (running !== null) {
+ return;
+ }
+ const job = waiting.shift();
+ job?.started();
+ if (job === undefined) {
+ const idle = current;
+ if (idle !== null && idle.idle === null) {
+ idle.idle = setTimeout(() => endWorker(idle), WORKER_IDLE_MS);
+ }
+ return;
+ }
+ const id = nextRequest++;
+ const state = workersWork ? getWorker() : null;
+ if (state === null) {
+ running = {job, id, timer: null};
+ decodeImageForPage(job.bytes, Date.now() + decodeTimeout)
+ .then(job.resolve, job.reject)
+ .finally(() => {
+ running = null;
+ startNextDecode();
+ });
+ return;
+ }
+ if (state.idle !== null) {
+ clearTimeout(state.idle);
+ state.idle = null;
+ }
+ running = {job, id, timer: startDecodeTimer(id, state)};
+ // A copy the worker can take, whatever buffer the bytes sit in. The
+ // job keeps its own, in case the worker never starts.
+ const copy = job.bytes.slice();
+ state.worker.post({id, bytes: copy.buffer}, [copy.buffer]);
+}
+
+// The running decode's time limit, counted again once the worker is
+// ready, so a worker's start does not count against its first image.
+function startDecodeTimer(
+ id: number,
+ state: WorkerState,
+): ReturnType {
+ return setTimeout(() => {
+ if (running?.id !== id) {
+ return;
+ }
+ const {job} = running;
+ running = null;
+ job.reject(
+ new Error(`The image took more than ${decodeTimeout} ms to decode`),
+ );
+ // Ending the worker ends the decode. A new one takes the next image.
+ endWorker(state);
+ startNextDecode();
+ }, decodeTimeout);
+}
+
+function finishDecode(id: number, settle: (job: Job) => void): void {
+ if (running?.id !== id) {
+ return;
+ }
+ const {job, timer} = running;
+ if (timer !== null) {
+ clearTimeout(timer);
+ }
+ running = null;
+ settle(job);
+ startNextDecode();
+}
+
+function getWorker(): WorkerState | null {
+ if (current !== null) {
+ return current;
}
- if (URL.canParse(src, base)) {
- return new URL(src, base).href;
+ const url = workerURL;
+ if (url === null) {
+ return null;
}
- const process = getProcess();
- if (process?.cwd === undefined) {
+ let state: WorkerState | null = null;
+ const onMessage = (data: unknown) => {
+ if (state === null || current !== state) {
+ return;
+ }
+ const message = data as {
+ ready?: boolean;
+ id?: number;
+ width?: number;
+ height?: number;
+ naturalWidth?: number;
+ naturalHeight?: number;
+ data?: ArrayBuffer;
+ error?: string;
+ };
+ if (message.ready) {
+ state.ready = true;
+ if (running !== null && running.timer !== null) {
+ clearTimeout(running.timer);
+ running.timer = startDecodeTimer(running.id, state);
+ }
+ return;
+ }
+ finishDecode(message.id!, (job) => {
+ if (message.error !== undefined) {
+ job.reject(new Error(message.error));
+ } else {
+ job.resolve({
+ width: message.width!,
+ height: message.height!,
+ data: new Uint8ClampedArray(message.data!),
+ naturalWidth: message.naturalWidth,
+ naturalHeight: message.naturalHeight,
+ });
+ }
+ });
+ };
+ const onError = (error: unknown) => {
+ if (state === null || current !== state) {
+ return;
+ }
+ const started = state.ready;
+ endWorker(state);
+ if (!started) {
+ // It never started: the image in hand decodes here, and so does
+ // every one after it.
+ workersWork = false;
+ if (running !== null) {
+ const {job, timer} = running;
+ if (timer !== null) {
+ clearTimeout(timer);
+ }
+ running = null;
+ waiting.unshift(job);
+ }
+ startNextDecode();
+ return;
+ }
+ // It started and then failed on this image. The image fails, and a
+ // new worker takes the next.
+ if (running !== null) {
+ finishDecode(running.id, (job) =>
+ job.reject(
+ new Error(
+ (error as {message?: string} | null)?.message ||
+ "The image decoder failed",
+ ),
+ ),
+ );
+ }
+ };
+ const worker = startWorker(url, onMessage, onError);
+ if (worker === null) {
+ workersWork = false;
return null;
}
- const dir = process.cwd().replace(/\\/g, "/");
- const root = `file://${dir.startsWith("/") ? "" : "/"}${dir}/`;
- return URL.canParse(src, root) ? new URL(src, root).href : null;
+ state = {worker, ready: false, idle: null};
+ current = state;
+ return state;
}
-interface FileSystem {
- readFile(path: URL): Promise;
+function endWorker(state: WorkerState): void {
+ if (current === state) {
+ current = null;
+ }
+ if (state.idle !== null) {
+ clearTimeout(state.idle);
+ state.idle = null;
+ }
+ state.worker.terminate();
}
-// Node's file system, which Bun and Deno provide too. getBuiltinModule
-// is synchronous and needs no import a bundler would follow; Node before
-// 20.16 lacks it and gets the dynamic import, whose specifier is built at
-// run time for the same reason.
-async function getFileSystem(): Promise {
- const process = getProcess();
- if (process === undefined) {
- return undefined;
- }
- if (process.getBuiltinModule !== undefined) {
- return process.getBuiltinModule("node:fs/promises") as FileSystem |
- undefined;
+interface WebWorker {
+ postMessage(message: unknown, transfer: Transferable[]): void;
+ addEventListener(type: string, listener: (event: any) => void): void;
+ terminate(): void;
+}
+
+// The runtime's own web Worker, where it has one: Bun, Deno, a browser,
+// and Node from 26.9 with --experimental-web-worker. Without one, images
+// decode on this thread.
+function startWorker(
+ url: URL,
+ onMessage: (data: unknown) => void,
+ onError: (error: unknown) => void,
+): DecodeWorker | null {
+ const Worker = (globalThis as {Worker?: new (
+ url: URL,
+ options: {type: "module"},
+ ) => WebWorker;}).Worker;
+ if (Worker === undefined) {
+ return null;
}
- const specifier = ["node:fs", "promises"].join("/");
try {
- return await import(specifier) as FileSystem;
+ const worker = new Worker(url, {type: "module"});
+ worker.addEventListener("message", (event) => onMessage(event.data));
+ worker.addEventListener("error", (event) => {
+ event.preventDefault?.();
+ onError(event.error ?? event);
+ });
+ return {
+ post: (message, transfer) => worker.postMessage(message, transfer),
+ terminate: () => worker.terminate(),
+ };
} catch (_error) {
- return undefined;
+ return null;
}
}
-/**
- * The bytes a URL names. data: is read here. file: goes through the
- * runtime's file system where it has one (Node's fetch does not read
- * files), and anything else through fetch.
- */
-export async function fetchImageBytes(url: string): Promise {
- const inline = readDataURL(url);
- if (inline !== null) {
- return inline;
- }
- if (url.startsWith("file:")) {
- const fs = await getFileSystem();
- if (fs !== undefined) {
- return new Uint8Array(await fs.readFile(new URL(url)));
- }
+// The TermDOMs alive that may decode. A running worker keeps the runtime
+// alive, so when the last is disposed the worker ends with it, and with
+// it any decode still running.
+let decoderUsers = 0;
+
+export function retainDecoder(): void {
+ decoderUsers++;
+}
+
+export function releaseDecoder(): void {
+ decoderUsers = Math.max(0, decoderUsers - 1);
+ if (decoderUsers > 0 || current === null) {
+ return;
}
- const response = await fetch(url);
- if (!response.ok) {
- throw new Error(`The image request failed with status ${response.status}`);
+ if (running !== null && running.timer !== null) {
+ clearTimeout(running.timer);
+ const {job} = running;
+ running = null;
+ job.reject(new DOMException("The decoder was closed", "AbortError"));
}
- return new Uint8Array(await response.arrayBuffer());
+ endWorker(current);
}
diff --git a/src/internal/replaced.ts b/src/internal/replaced.ts
index d642e776e..72700863c 100644
--- a/src/internal/replaced.ts
+++ b/src/internal/replaced.ts
@@ -14,17 +14,24 @@
import {type CanvasTextRun, halfBlockCell} from "./canvas.ts";
import {
getBoxModel,
- getCellSize,
getComputedValue,
+ getImageCellSize,
toCellLength,
} from "./cssom.ts";
import * as CSSValues from "./cssvalues.ts";
-import type {CellContext, CellStyle} from "./framebuffer.ts";
+import {
+ CellContext,
+ CellGrid,
+ type CellStyle,
+ settleBorders,
+} from "./framebuffer.ts";
import {
type Bitmap,
getReplacedContent,
- type GridCell,
isReplacedElement,
+ MAX_IMAGE_PIXELS,
+ naturalHeightOf,
+ naturalWidthOf,
type ReplacedContent,
sampleBitmap,
} from "./images.ts";
@@ -32,34 +39,6 @@ import {getStringWidth} from "./text.ts";
export {isReplacedElement};
-const DEFAULT_CELL_PIXELS = {width: 8, height: 16};
-
-const cellPixelSources = new WeakMap<
- object,
- () => {width: number; height: number}
->();
-
-/** Where a document's cell size comes from, once it has a screen. */
-export function registerCellPixels(
- document: object,
- read: () => {width: number; height: number},
-): void {
- cellPixelSources.set(document, read);
-}
-
-export function getCellPixels(element: Element): {
- width: number;
- height: number;
-} {
- const page = getCellSize(element as unknown as Node);
- if (page.width !== 1 || page.height !== 1) {
- return page;
- }
- const document = element.ownerDocument;
- return (document && cellPixelSources.get(document)?.()) ??
- DEFAULT_CELL_PIXELS;
-}
-
/** The natural size in cells, or null for content with none. */
export function getNaturalSize(
element: Element,
@@ -72,13 +51,20 @@ export function getNaturalSize(
return {width: content.grid.cols, height: content.grid.rows};
}
if (content.kind === "text") {
- return {width: textWidth(content.text), height: content.text ? 1 : 0};
+ // The alt text on one line inside a border, or an empty cell
+ // inside one when there is none.
+ return {width: Math.max(1, textWidth(content.text)) + 2, height: 3};
}
- const {width, height} = content.kind === "blank" ? content : content.bitmap;
+ const {width, height} = content.kind === "blank"
+ ? content
+ : {
+ width: naturalWidthOf(content.bitmap),
+ height: naturalHeightOf(content.bitmap),
+ };
if (width === 0 || height === 0) {
return {width: 0, height: 0};
}
- const cell = getCellPixels(element);
+ const cell = getImageCellSize(element as unknown as Node);
return {
width: Math.max(1, Math.round(width / cell.width)),
height: Math.max(1, Math.round(height / cell.height)),
@@ -96,11 +82,16 @@ export function getNaturalRatio(
if (content.kind === "grid") {
return content.grid.rows > 0 ? content.grid.cols / content.grid.rows : NaN;
}
- const {width, height} = content.kind === "blank" ? content : content.bitmap;
+ const {width, height} = content.kind === "blank"
+ ? content
+ : {
+ width: naturalWidthOf(content.bitmap),
+ height: naturalHeightOf(content.bitmap),
+ };
if (width === 0 || height === 0) {
return NaN;
}
- const cell = getCellPixels(element);
+ const cell = getImageCellSize(element as unknown as Node);
return (width / cell.width) / (height / cell.height);
}
@@ -355,7 +346,7 @@ function placeImage(
interface CellCache {
key: string;
- cells: Array;
+ cells: CellGrid;
}
const bitmapCells = new WeakMap();
@@ -368,11 +359,11 @@ function getBitmapCells(
cols: number,
rows: number,
under: number | null,
-): Array {
+): CellGrid {
const {bitmap} = content;
- const cell = getCellPixels(element);
- const naturalWidth = bitmap.width / cell.width;
- const naturalHeight = bitmap.height / cell.height;
+ const cell = getImageCellSize(element as unknown as Node);
+ const naturalWidth = naturalWidthOf(bitmap) / cell.width;
+ const naturalHeight = naturalHeightOf(bitmap) / cell.height;
const place = placeImage(element, cols, rows, naturalWidth, naturalHeight);
const rendering = getComputedValue(element, "image-rendering")
.trim()
@@ -402,8 +393,8 @@ function getBitmapCells(
const bottom = Math.min(rows * 2, Math.round(place.y + place.height));
const pixels = new Uint8ClampedArray(cols * rows * 2 * 4);
if (right > left && bottom > top && place.width > 0 && place.height > 0) {
- const scaleX = bitmap.width / place.width;
- const scaleY = bitmap.height / place.height;
+ const scaleX = naturalWidthOf(bitmap) / place.width;
+ const scaleY = naturalHeightOf(bitmap) / place.height;
const sampled = sampleBitmap(
bitmap,
right - left,
@@ -424,49 +415,39 @@ function getBitmapCells(
);
}
}
- const cells: Array = [];
+ const cells = new CellContext(new CellGrid(rows, cols), rows, cols, 0);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
- cells.push(
- halfBlockCell(
- pixels,
- (row * 2 * cols + col) * 4,
- ((row * 2 + 1) * cols + col) * 4,
- under,
- ),
+ const half = halfBlockCell(
+ pixels,
+ (row * 2 * cols + col) * 4,
+ ((row * 2 + 1) * cols + col) * 4,
+ under,
);
+ if (half !== null) {
+ cells.drawText(half.char, col, row, half.style);
+ }
}
}
- bitmapCells.set(bitmap, {key, cells});
- return cells;
+ bitmapCells.set(bitmap, {key, cells: cells.grid});
+ return cells.grid;
}
-function drawCell(
- ctx: CellContext,
- cell: GridCell,
- x: number,
- y: number,
- fallback: CellStyle,
-): void {
- // The second column of a wide glyph belongs to the glyph before it.
- if (cell.char === "") {
- return;
- }
- if (cell.char === null) {
- if (cell.bg !== null) {
- ctx.drawRect(x, y, 1, 1, cell.bg);
- }
- return;
+const settledGrids = new WeakMap();
+
+// A charactergrid's cells with its lines joined among themselves and
+// drawn, kept until the canvas draws again.
+function getSettledGrid(
+ content: Extract,
+): CellGrid {
+ const cached = settledGrids.get(content.grid);
+ if (cached?.version === content.version) {
+ return cached.grid;
}
- ctx.drawText(cell.char, x, y, {
- fg: cell.fg ?? fallback.fg,
- bg: cell.bg ?? undefined,
- bold: cell.bold || undefined,
- italic: cell.italic || undefined,
- underline: cell.underline || undefined,
- dim: cell.dim || undefined,
- inverse: cell.inverse || undefined,
- });
+ const grid = content.grid.clone();
+ settleBorders(grid);
+ settledGrids.set(content.grid, {version: content.version, grid});
+ return grid;
}
/**
@@ -528,27 +509,18 @@ export function renderReplaced(
};
try {
if (content.kind === "text") {
- ctx.drawText(content.text, left, top, style);
+ drawPlaceholder(content.text, left, top, cols, rows, ctx, style);
} else if (content.kind === "grid") {
- const {grid} = content;
- for (let row = 0; row < Math.min(rows, grid.rows); row++) {
- for (let col = 0; col < Math.min(cols, grid.cols); col++) {
- const cell = grid.cells[row * grid.cols + col];
- if (cell !== null) {
- drawCell(ctx, cell, left + col, top + row, style);
- }
- }
- }
- } else {
- const cells = getBitmapCells(element, content, cols, rows, under);
- for (let row = 0; row < rows; row++) {
- for (let col = 0; col < cols; col++) {
- const cell = cells[row * cols + col];
- if (cell !== null) {
- drawCell(ctx, cell, left + col, top + row, style);
- }
- }
- }
+ ctx.drawGrid(getSettledGrid(content), left, top, style);
+ } else if (cols * rows * 2 <= MAX_IMAGE_PIXELS) {
+ // Past the limit the pixels a box samples are more than an image
+ // may hold, and like a canvas past it, the box shows nothing.
+ ctx.drawGrid(
+ getBitmapCells(element, content, cols, rows, under),
+ left,
+ top,
+ style,
+ );
if (textRuns.length > 0) {
paintTextRuns(
element,
@@ -567,6 +539,55 @@ export function renderReplaced(
}
}
+/**
+ * An image that is not showing: a box around the content box with the
+ * alt text inside it, cut to fit. A box too small for a border holds
+ * the text alone.
+ */
+function drawPlaceholder(
+ text: string,
+ left: number,
+ top: number,
+ cols: number,
+ rows: number,
+ ctx: CellContext,
+ style: CellStyle,
+): void {
+ if (cols < 3 || rows < 3) {
+ ctx.drawText(fitText(text, cols), left, top, style);
+ return;
+ }
+ const side = {style: "solid" as const, color: style.fg};
+ ctx.drawBox(left, top, cols, rows, {
+ top: side,
+ right: side,
+ bottom: side,
+ left: side,
+ });
+ ctx.drawText(fitText(text, cols - 2), left + 1, top + 1, style);
+}
+
+// The text cut to `width` columns, with an ellipsis where it was cut.
+function fitText(text: string, width: number): string {
+ if (textWidth(text) <= width) {
+ return text;
+ }
+ if (width <= 0) {
+ return "";
+ }
+ let out = "";
+ let used = 0;
+ for (const char of text) {
+ const columns = Math.max(0, getStringWidth(char));
+ if (used + columns > width - 1) {
+ break;
+ }
+ out += char;
+ used += columns;
+ }
+ return out + "…";
+}
+
// A 2d context's text at the cells its anchors fall in, through the same
// placement as the pixels.
function paintTextRuns(
@@ -579,7 +600,7 @@ function paintTextRuns(
rows: number,
ctx: CellContext,
): void {
- const cell = getCellPixels(element);
+ const cell = getImageCellSize(element as unknown as Node);
const place = placeImage(
element,
cols,
diff --git a/src/internal/resources.ts b/src/internal/resources.ts
new file mode 100644
index 000000000..a07d40293
--- /dev/null
+++ b/src/internal/resources.ts
@@ -0,0 +1,687 @@
+/**
+ * Loading what a document's markup asks for, such as an 's source,
+ * in the order Fetch takes a browser's loads. The document's Content
+ * Security Policy decides whether a load may be made at all. One it
+ * allows goes to the TermDOM's "fetch" listeners as a FetchEvent, which
+ * answer it as a Service Worker does, and to the network when none does.
+ * Each redirect is checked against the policy again and followed on the
+ * network. Only a document that is itself a file: URL loads file: URLs.
+ */
+
+const kExtend = Symbol("extend");
+const kPending = Symbol("pending");
+
+export interface ExtendableEvent {
+ // Where waitUntil()'s promises go: the TermDOM that dispatched the
+ // event. Null for an event a program made, which nothing waits on.
+ [kExtend]: ((promise: Promise) => void) | null;
+ [kPending]: number;
+}
+
+/** An event whose listeners can ask the TermDOM to wait for their work. */
+export class ExtendableEvent extends Event {
+ constructor(type: string, init?: EventInit) {
+ super(type, init);
+ this[kExtend] = null;
+ this[kPending] = 0;
+ }
+
+ /** Keeps the TermDOM's dispose() waiting until `promise` settles. */
+ waitUntil(promise: PromiseLike): void {
+ const lifetime = Promise.resolve(promise);
+ addLifetimePromise(this, lifetime);
+ this[kExtend]!(lifetime);
+ }
+}
+
+// Service Workers §4.4.1: an event is active while it is dispatched, or
+// while a promise it was given is pending.
+function addLifetimePromise(
+ event: ExtendableEvent,
+ promise: Promise,
+): void {
+ if (event[kExtend] === null) {
+ throw new DOMException(
+ "Only an event TermDOM dispatches can be extended",
+ "InvalidStateError",
+ );
+ }
+ if (event.eventPhase === Event.NONE && event[kPending] === 0) {
+ throw new DOMException(
+ "The event is no longer active",
+ "InvalidStateError",
+ );
+ }
+ event[kPending]++;
+ const settled = () => {
+ queueMicrotask(() => event[kPending]--);
+ };
+ promise.then(settled, settled);
+}
+
+export interface FetchEventInit extends EventInit {
+ request: Request;
+ preloadResponse?: Promise;
+ clientId?: string;
+ resultingClientId?: string;
+ replacesClientId?: string;
+ handled?: Promise;
+}
+
+const kResponse = Symbol("response");
+
+export interface FetchEvent {
+ [kResponse]: Promise | null;
+}
+
+/**
+ * A load the document's markup asks for, dispatched at the TermDOM as a
+ * Service Worker's fetch event is at its global scope. A listener answers
+ * it with respondWith(), which stops the other listeners. One that calls
+ * preventDefault() without answering fails the load.
+ */
+export class FetchEvent extends ExtendableEvent {
+ readonly request: Request;
+ readonly preloadResponse: Promise;
+ readonly clientId: string;
+ readonly resultingClientId: string;
+ readonly replacesClientId: string;
+ // Settles once the load is answered: rejects with a NetworkError when
+ // it fails.
+ readonly handled: Promise;
+
+ constructor(type: string, init: FetchEventInit) {
+ super(type, init);
+ this.request = init.request;
+ this.preloadResponse = init.preloadResponse ?? Promise.resolve(undefined);
+ this.clientId = init.clientId ?? "";
+ this.resultingClientId = init.resultingClientId ?? "";
+ this.replacesClientId = init.replacesClientId ?? "";
+ this.handled = init.handled ?? new Promise(() => {});
+ this[kResponse] = null;
+ }
+
+ /**
+ * Answer the request, during the event. Response.error(), a promise
+ * that rejects, or a value that is not a Response fails it as a
+ * network error does.
+ */
+ respondWith(response: Response | PromiseLike): void {
+ if (this.eventPhase === Event.NONE) {
+ throw new DOMException(
+ "respondWith() is only called while the event is dispatched",
+ "InvalidStateError",
+ );
+ }
+ if (this[kResponse] !== null) {
+ throw new DOMException(
+ "The request was already answered",
+ "InvalidStateError",
+ );
+ }
+ const answer = Promise.resolve(response);
+ addLifetimePromise(this, answer);
+ this.stopImmediatePropagation();
+ this[kResponse] = answer.then((value) => {
+ if (!(value instanceof Response)) {
+ throw new TypeError("respondWith() takes a Response");
+ }
+ return value;
+ });
+ }
+}
+
+/**
+ * Gives a request its Fetch destination, such as "image", which a
+ * runtime's Request leaves empty.
+ */
+export function setRequestDestination(
+ request: Request,
+ destination: RequestDestination,
+): void {
+ Object.defineProperty(request, "destination", {
+ value: destination,
+ enumerable: true,
+ configurable: true,
+ });
+}
+
+/** What a load knows of what asked for it. */
+export interface RequestContext {
+ // The element that asked, where a policy violation is reported.
+ initiator: Element | null;
+}
+
+/** How a document's loads are answered, set by the TermDOM that owns it. */
+type ResourceLoader = (
+ request: Request,
+ context: RequestContext,
+) => Promise;
+
+const loaders = new WeakMap<
+ object,
+ {loader: ResourceLoader; signal: AbortSignal}
+>();
+
+/** `signal` aborts when the TermDOM that owns the document is disposed. */
+export function setResourceLoader(
+ document: object,
+ loader: ResourceLoader,
+ signal: AbortSignal,
+): void {
+ loaders.set(document, {loader, signal});
+}
+
+/**
+ * Aborts when the document's loads end for good, or null for a document
+ * no TermDOM owns.
+ */
+export function getLoadSignal(document: object): AbortSignal | null {
+ return loaders.get(document)?.signal ?? null;
+}
+
+/**
+ * The response to a load the document's markup asks for. A document no
+ * TermDOM owns, such as DOMParser's, loads nothing.
+ */
+export function loadResource(
+ document: object,
+ request: Request,
+ context: RequestContext,
+): Promise {
+ const entry = loaders.get(document);
+ if (entry === undefined) {
+ return Promise.reject(new TypeError("This document loads nothing"));
+ }
+ return entry.loader(request, context);
+}
+
+/** What a TermDOM decides a document's loads by. */
+export interface RequestPolicy {
+ // The program's policy, or the default that allows nothing. A page's
+ // own meta tags are read with each request and can only narrow it.
+ policies: readonly ContentSecurityPolicy[];
+ // Tells of a load a policy blocked.
+ violate(at: EventTarget, init: SecurityPolicyViolationEventInit): void;
+ // Takes the work a listener asked the TermDOM to wait for.
+ extend(promise: Promise): void;
+ // Aborted when the TermDOM is disposed, after which nothing loads.
+ signal: AbortSignal;
+}
+
+const REDIRECT_LIMIT = 20;
+const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
+
+/**
+ * Settle a load the document's markup asks for: checked against the
+ * document's policies, offered to `target`'s "fetch" listeners, and sent
+ * to the network when none answers. A redirect, from a listener or the
+ * network, is checked again and followed on the network, as Fetch follows
+ * one without its Service Worker.
+ */
+export async function answerRequest(
+ target: EventTarget,
+ document: Document,
+ request: Request,
+ context: RequestContext,
+ policy: RequestPolicy,
+): Promise {
+ policy.signal.throwIfAborted();
+ const destination = request.destination;
+ checkRequest(document, request, new URL(request.url), 0, context, policy);
+ let response = await dispatchFetchEvent(target, request, policy);
+ if (response === null) {
+ response = await fetch(new Request(request, {redirect: "manual"}));
+ } else if (response.url !== "" && response.url !== request.url) {
+ // A listener's response that came from somewhere, such as its own
+ // fetch(event.request), is checked where it ended up, as Fetch checks
+ // a Service Worker's response against the page's policy.
+ const final = new URL(response.url);
+ try {
+ if (response.redirected) {
+ checkRedirectScheme(request, final);
+ }
+ checkRequest(
+ document,
+ request,
+ final,
+ response.redirected ? 1 : 0,
+ context,
+ policy,
+ );
+ } catch (error) {
+ await response.body?.cancel().catch(() => {});
+ throw error;
+ }
+ }
+ let current = new URL(response.url || request.url);
+ for (let redirects = 1; isRedirect(response); redirects++) {
+ await response.body?.cancel().catch(() => {});
+ if (request.redirect !== "follow") {
+ throw new TypeError(`${request.url} redirects`);
+ }
+ if (redirects > REDIRECT_LIMIT) {
+ throw new TypeError(`${request.url} redirects too many times`);
+ }
+ current = new URL(response.headers.get("location")!, current);
+ checkRedirectScheme(request, current);
+ checkRequest(document, request, current, redirects, context, policy);
+ const next = new Request(current, {
+ headers: request.headers,
+ signal: request.signal,
+ redirect: "manual",
+ });
+ setRequestDestination(next, destination);
+ response = await fetch(next);
+ }
+ if (response.type === "error") {
+ throw new TypeError("The request was refused");
+ }
+ return response;
+}
+
+// Fetch's "HTTP-redirect fetch": a redirect goes only to another HTTP(S)
+// URL, never to a file, data: or anything the network does not serve.
+function checkRedirectScheme(request: Request, url: URL): void {
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new TypeError(
+ `${request.url} redirects to a URL that is not HTTP(S)`,
+ );
+ }
+}
+
+function isRedirect(response: Response): boolean {
+ return REDIRECT_STATUSES.has(response.status) &&
+ response.headers.has("location");
+}
+
+// Fetch's "should request be blocked", for the request's URL or a URL it
+// was redirected to. As in a browser, a document that is not a file loads
+// no file, about:blank included.
+function checkRequest(
+ document: Document,
+ request: Request,
+ url: URL,
+ redirects: number,
+ context: RequestContext,
+ policy: RequestPolicy,
+): void {
+ const self = new URL(document.URL);
+ if (url.protocol === "file:" && self.protocol !== "file:") {
+ throw new TypeError("A document that is not a file cannot load a file");
+ }
+ const blocked = getBlockingDirective([
+ ...policy.policies,
+ ...getDocumentPolicies(document),
+ ], url, request.destination, self, redirects);
+ if (blocked === null) {
+ return;
+ }
+ const at = context.initiator?.isConnected ? context.initiator : document;
+ // The URL the document asked for, never where it was redirected
+ // (CSP3 §2.4.2), and only as much of it as a report may carry.
+ policy.violate(at, {
+ documentURI: stripURLForReport(new URL(document.URL)),
+ blockedURI: stripURLForReport(new URL(request.url)),
+ effectiveDirective: blocked.directive,
+ originalPolicy: blocked.policy.text,
+ disposition: "enforce",
+ bubbles: true,
+ composed: true,
+ });
+ throw new TypeError(
+ `The Content Security Policy directive "${blocked.directive}" ` +
+ `blocked ${stripURLForReport(new URL(request.url))}`,
+ );
+}
+
+// CSP3 §5.4: a URL that is not HTTP(S) reports as its scheme alone, and
+// one that is reports without its fragment and credentials.
+function stripURLForReport(url: URL): string {
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ return url.protocol.slice(0, -1);
+ }
+ const stripped = new URL(url);
+ stripped.hash = "";
+ stripped.username = "";
+ stripped.password = "";
+ return stripped.href;
+}
+
+// Service Workers' Handle Fetch, as far as a page's load needs it: the
+// listener's response, or null when none answered and the load goes to
+// the network.
+async function dispatchFetchEvent(
+ target: EventTarget,
+ request: Request,
+ policy: RequestPolicy,
+): Promise {
+ let resolveHandled!: () => void;
+ let rejectHandled!: (error: DOMException) => void;
+ const handled = new Promise((resolve, reject) => {
+ resolveHandled = () => resolve(undefined);
+ rejectHandled = reject;
+ });
+ handled.catch(() => {});
+ const event = new FetchEvent("fetch", {request, handled, cancelable: true});
+ event[kExtend] = (promise) => {
+ policy.extend(promise);
+ };
+ target.dispatchEvent(event);
+ const answer = event[kResponse];
+ if (answer === null) {
+ if (event.defaultPrevented) {
+ rejectHandled(new DOMException("The load was canceled", "NetworkError"));
+ throw new TypeError("A fetch listener canceled the load");
+ }
+ resolveHandled();
+ return null;
+ }
+ try {
+ const response = await whileNotAborted(answer, request.signal);
+ resolveHandled();
+ return response;
+ } catch (error) {
+ rejectHandled(new DOMException("The load failed", "NetworkError"));
+ throw error;
+ }
+}
+
+// An answer still pending when its request is aborted, by a new source or
+// the TermDOM's disposal, ends the load then, as Fetch ends a fetch
+// whatever its Service Worker is doing.
+function whileNotAborted(
+ promise: Promise,
+ signal: AbortSignal,
+): Promise {
+ if (signal.aborted) {
+ return Promise.reject(signal.reason);
+ }
+ return new Promise((resolve, reject) => {
+ const onAbort = () => reject(signal.reason);
+ signal.addEventListener("abort", onAbort, {once: true});
+ promise.then(resolve, reject).finally(() => {
+ signal.removeEventListener("abort", onAbort);
+ });
+ });
+}
+
+// The policies a page stated for itself with in its head.
+const documentPolicies = new WeakMap();
+
+/**
+ * A ` ` inserted as a child of
+ * a head (HTML §4.2.5.3): its policy holds for the document from then on,
+ * whatever later becomes of the element. A meta policy cannot report,
+ * frame or sandbox, so those directives are dropped.
+ */
+export function addDocumentPolicy(document: object, content: string): void {
+ const policy = parseContentSecurityPolicy(content);
+ for (const directive of ["report-uri", "frame-ancestors", "sandbox"]) {
+ policy.directives.delete(directive);
+ }
+ const policies = documentPolicies.get(document);
+ if (policies === undefined) {
+ documentPolicies.set(document, [policy]);
+ } else {
+ policies.push(policy);
+ }
+}
+
+function getDocumentPolicies(document: object): ContentSecurityPolicy[] {
+ return documentPolicies.get(document) ?? [];
+}
+
+// ---------------------------------------------------------------------------
+// Content Security Policy (CSP3): which loads a document may make.
+
+/** One parsed policy: each directive's source expressions, by name. */
+export interface ContentSecurityPolicy {
+ text: string;
+ directives: Map;
+}
+
+/**
+ * A serialized policy list, as a header or a meta tag carries it: policies
+ * separated by commas, directives by semicolons (CSP3 §2.2.1). A directive
+ * named twice keeps its first value.
+ */
+export function parseContentSecurityPolicies(
+ serialized: string,
+): ContentSecurityPolicy[] {
+ return serialized.split(",").map(parseContentSecurityPolicy);
+}
+
+/** One serialized policy, as a meta tag carries it (CSP3 §2.2.1). */
+function parseContentSecurityPolicy(text: string): ContentSecurityPolicy {
+ const directives = new Map();
+ for (const token of text.split(";")) {
+ const [name, ...values] = token.trim().split(/[\t\n\f\r ]+/);
+ if (!name || !/^[a-zA-Z0-9-]+$/.test(name)) {
+ continue;
+ }
+ const key = name.toLowerCase();
+ if (!directives.has(key)) {
+ directives.set(key, values.filter(Boolean));
+ }
+ }
+ return {text: text.trim(), directives};
+}
+
+// The directive that governs a destination, and the one it falls back to.
+const FETCH_DIRECTIVES: Partial> = {
+ image: "img-src",
+ style: "style-src",
+ font: "font-src",
+ audio: "media-src",
+ video: "media-src",
+ track: "media-src",
+};
+
+/** The directive a policy enforces for a load, or null when none applies. */
+function getEffectiveDirective(
+ policy: ContentSecurityPolicy,
+ destination: RequestDestination,
+): string | null {
+ const directive = FETCH_DIRECTIVES[destination];
+ if (directive !== undefined && policy.directives.has(directive)) {
+ return directive;
+ }
+ return policy.directives.has("default-src") ? "default-src" : null;
+}
+
+/**
+ * The first policy and directive that block a load of `url` for
+ * `destination`, from a document at `self`, or null when every policy
+ * allows it. `redirects` counts the redirects that led to `url`.
+ */
+export function getBlockingDirective(
+ policies: readonly ContentSecurityPolicy[],
+ url: URL,
+ destination: RequestDestination,
+ self: URL,
+ redirects = 0,
+): {policy: ContentSecurityPolicy; directive: string} | null {
+ for (const policy of policies) {
+ const directive = getEffectiveDirective(policy, destination);
+ if (
+ directive !== null &&
+ !matchesSourceList(
+ policy.directives.get(directive)!,
+ url,
+ self,
+ redirects,
+ )
+ ) {
+ return {policy, directive};
+ }
+ }
+ return null;
+}
+
+// CSP3 §6.7.2.6. An empty list, or one that is just 'none', matches nothing.
+function matchesSourceList(
+ sources: string[],
+ url: URL,
+ self: URL,
+ redirects: number,
+): boolean {
+ return sources.some((source) => matchesSource(source, url, self, redirects));
+}
+
+const NETWORK_SCHEMES = new Set(["http", "https", "ws", "wss"]);
+
+const SCHEME_SOURCE = /^([a-zA-Z][a-zA-Z0-9+.-]*):$/;
+const HOST_SOURCE =
+ /^(?:([a-zA-Z][a-zA-Z0-9+.-]*):\/\/)?(\*|(?:\*\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)(?::(\*|\d+))?(\/[^?#]*)?$/;
+
+function matchesSource(
+ source: string,
+ url: URL,
+ self: URL,
+ redirects: number,
+): boolean {
+ const scheme = url.protocol.slice(0, -1);
+ const selfScheme = self.protocol.slice(0, -1);
+ const lower = source.toLowerCase();
+ if (lower === "'self'") {
+ return matchesSelf(url, self);
+ }
+ // Keywords, nonces and hashes govern scripts and styles, never a load.
+ if (lower.startsWith("'")) {
+ return false;
+ }
+ // A network scheme, or the document's own (§6.7.2.7, step 1).
+ if (lower === "*") {
+ return NETWORK_SCHEMES.has(scheme) || scheme === selfScheme;
+ }
+ const schemeSource = SCHEME_SOURCE.exec(source);
+ if (schemeSource !== null) {
+ return schemePartMatches(schemeSource[1].toLowerCase(), scheme);
+ }
+ const hostSource = HOST_SOURCE.exec(source);
+ if (hostSource === null || url.host === "") {
+ return false;
+ }
+ const [, sourceScheme, host, port, path] = hostSource;
+ // Without a scheme, the document's own (§6.7.2.7, step 4). A document
+ // not served over the network, as about:blank and file: are not, has no
+ // network scheme to lend, so its host sources mean the network ones.
+ if (sourceScheme !== undefined) {
+ if (!schemePartMatches(sourceScheme.toLowerCase(), scheme)) {
+ return false;
+ }
+ } else if (
+ !schemePartMatches(selfScheme, scheme) &&
+ !(!NETWORK_SCHEMES.has(selfScheme) && NETWORK_SCHEMES.has(scheme))
+ ) {
+ return false;
+ }
+ if (!hostPartMatches(host.toLowerCase(), url.hostname.toLowerCase())) {
+ return false;
+ }
+ if (!portPartMatches(port, url)) {
+ return false;
+ }
+ // A redirect's target is matched without its path, which would tell
+ // the page where a cross-origin redirect went (§6.7.2.5).
+ return path === undefined || redirects > 0 || pathPartMatches(path, url);
+}
+
+// §6.7.2.8: a scheme matches itself and its secure counterpart, and ws
+// matches the http schemes too.
+function schemePartMatches(expression: string, scheme: string): boolean {
+ return (
+ expression === scheme ||
+ (expression === "http" && scheme === "https") ||
+ (expression === "ws" &&
+ (scheme === "wss" || scheme === "http" || scheme === "https")) ||
+ (expression === "wss" && scheme === "https")
+ );
+}
+
+// §6.7.2.9: `*.example.com` matches a subdomain, never example.com.
+function hostPartMatches(expression: string, host: string): boolean {
+ if (expression === "*") {
+ return true;
+ }
+ if (expression.startsWith("*.")) {
+ return host.endsWith(expression.slice(1));
+ }
+ return expression === host;
+}
+
+const DEFAULT_PORTS: Record = {
+ http: "80",
+ https: "443",
+ ws: "80",
+ wss: "443",
+};
+
+// §6.7.2.10: no port means the scheme's default, which the URL parser
+// leaves empty. Port 80 also matches 443, an upgrade to https.
+function portPartMatches(port: string | undefined, url: URL): boolean {
+ if (port === "*") {
+ return true;
+ }
+ if (port === undefined) {
+ return url.port === "";
+ }
+ const scheme = url.protocol.slice(0, -1);
+ const urlPort = url.port === "" ? DEFAULT_PORTS[scheme] : url.port;
+ return port === urlPort || (port === "80" && urlPort === "443");
+}
+
+// §6.7.2.11: a path ending in a slash matches what is under it, and any
+// other the one resource it names, compared piece by piece once each
+// piece is percent-decoded.
+function pathPartMatches(path: string, url: URL): boolean {
+ if (path === "" || (path === "/" && url.pathname === "")) {
+ return true;
+ }
+ const exact = !path.endsWith("/");
+ const expected = path.split("/");
+ const actual = url.pathname.split("/");
+ if (expected.length > actual.length) {
+ return false;
+ }
+ if (exact && expected.length !== actual.length) {
+ return false;
+ }
+ if (!exact) {
+ expected.pop();
+ }
+ return expected.every((piece, i) =>
+ percentDecode(piece) === percentDecode(actual[i]),
+ );
+}
+
+// The URL Standard's percent-decode, which leaves a malformed sequence as
+// it is, comparing as bytes.
+function percentDecode(text: string): string {
+ const bytes = String.fromCharCode(...new TextEncoder().encode(text));
+ return bytes.replace(/%([0-9A-Fa-f]{2})/g, (_, hex: string) =>
+ String.fromCharCode(parseInt(hex, 16)),
+ );
+}
+
+// §6.7.2.7: the document's own origin, or the same host and port, over
+// https or wss, or over http or ws from an http document. The URL parser
+// leaves a scheme's default port empty, so two defaults compare equal.
+function matchesSelf(url: URL, self: URL): boolean {
+ if (self.origin === "null" || url.origin === "null") {
+ return false;
+ }
+ if (url.origin === self.origin) {
+ return true;
+ }
+ const scheme = url.protocol.slice(0, -1);
+ const selfScheme = self.protocol.slice(0, -1);
+ return (
+ url.hostname === self.hostname &&
+ url.port === self.port &&
+ (scheme === "https" ||
+ scheme === "wss" ||
+ (selfScheme === "http" && (scheme === "http" || scheme === "ws")))
+ );
+}
diff --git a/tests/canvas.test.ts b/tests/canvas.test.ts
index be3a0cfea..8dc6b71cb 100644
--- a/tests/canvas.test.ts
+++ b/tests/canvas.test.ts
@@ -13,6 +13,7 @@
import {expect, test} from "@b9g/libuild/test";
import {TermDOM} from "../src/index.ts";
+import {createImageBitmapFrom, ImageBitmap} from "../src/internal/canvas.ts";
import {decodeImage, encodePNG} from "../src/internal/images.ts";
import {MockProcess, nextFrame, until} from "./test-utils.ts";
@@ -50,7 +51,10 @@ async function mount(
rows = 12,
): Promise<{dom: TermDOM; terminal: MockProcess; document: Document}> {
const terminal = new MockProcess({cols, rows});
- const dom = new TermDOM({transport: terminal.transport});
+ const dom = new TermDOM({
+ transport: terminal.transport,
+ csp: "img-src data: https:",
+ });
dom.document.body.innerHTML = html;
await nextFrame(dom);
return {dom, terminal, document: dom.document};
@@ -128,14 +132,14 @@ test("drawing repaints the canvas's cells without any DOM change", async () => {
ctx.fillRect(0, 8, 16, 8);
await nextFrame(dom);
expect(cell(terminal, 0, 0)).toMatchObject({
- char: "▀",
- fg: 0xff0000,
- bg: 0x0000ff,
+ char: "▄",
+ fg: 0x0000ff,
+ bg: 0xff0000,
});
expect(cell(terminal, 1, 0)).toMatchObject({
- char: "▀",
- fg: 0xff0000,
- bg: 0x0000ff,
+ char: "▄",
+ fg: 0x0000ff,
+ bg: 0xff0000,
});
// The second row of cells is still empty.
expect(cell(terminal, 0, 1).char).toBe(" ");
@@ -690,14 +694,14 @@ test("drawImage draws pixels into cells, two a cell", async () => {
ctx.drawImage(document.getElementById("src"), 1, 0, 2, 1);
await nextFrame(dom);
expect(cell(terminal, 1, 0)).toMatchObject({
- char: "▀",
- fg: 0xff0000,
- bg: 0x00ff00,
+ char: "▄",
+ fg: 0x00ff00,
+ bg: 0xff0000,
});
expect(cell(terminal, 2, 0)).toMatchObject({
- char: "▀",
- fg: 0xff0000,
- bg: 0x00ff00,
+ char: "▄",
+ fg: 0x00ff00,
+ bg: 0xff0000,
});
expect(cell(terminal, 0, 0).char).toBe(" ");
dom.dispose();
@@ -849,3 +853,116 @@ test("text over a currentcolor fill stays in inverse video", async () => {
expect(cell(terminal, 0, 0)).toMatchObject({char: " ", inverse: true});
dom.dispose();
});
+
+test("strokeRect draws a box and strokeLine joins it as tees", async () => {
+ const {dom, terminal, document} =
+ await mount(" ");
+ const ctx = grid(document);
+ ctx.strokeRect(0, 0, 6, 4);
+ ctx.strokeLine(0, 2, 5, 2);
+ ctx.strokeLine(3, 0, 3, 2);
+ await nextFrame(dom);
+ expect(rowText(terminal, 0)).toBe("┌──┬─┐");
+ expect(rowText(terminal, 1)).toBe("│ │ │");
+ expect(rowText(terminal, 2)).toBe("├──┴─┤");
+ expect(rowText(terminal, 3)).toBe("└────┘");
+ expect(ctx.getCell(3, 2).char).toBe("┴");
+ dom.dispose();
+});
+
+test("lineStyle, lineJoin and strokeStyle set how lines draw", async () => {
+ const {dom, terminal, document} =
+ await mount(" ");
+ const ctx = grid(document);
+ ctx.lineStyle = "double";
+ ctx.strokeRect(0, 0, 4, 3);
+ ctx.lineStyle = "solid";
+ ctx.lineJoin = "round";
+ ctx.strokeStyle = "#ff0000";
+ ctx.strokeRect(0, 3, 4, 3);
+ ctx.lineStyle = "wavy";
+ expect(ctx.lineStyle).toBe("solid");
+ await nextFrame(dom);
+ expect(rowText(terminal, 0)).toBe("╔══╗");
+ expect(rowText(terminal, 2)).toBe("╚══╝");
+ expect(rowText(terminal, 3)).toBe("╭──╮");
+ expect(cell(terminal, 1, 3)).toMatchObject({char: "─", fg: 0xff0000});
+ expect(cell(terminal, 1, 0).fg).toBe(-1);
+ dom.dispose();
+});
+
+test("a slanted line draws nothing", async () => {
+ const {dom, document} =
+ await mount(" ");
+ const ctx = grid(document);
+ ctx.strokeLine(0, 0, 3, 3);
+ expect(ctx.getCell(0, 0)).toBeNull();
+ dom.dispose();
+});
+
+test("a canvas's lines do not join the page's borders beside it", async () => {
+ const {dom, terminal, document} = await mount(
+ "" +
+ "
",
+ );
+ const ctx = grid(document);
+ ctx.strokeLine(0, 1, 0, 1);
+ await nextFrame(dom);
+ expect(rowText(terminal, 2).slice(0, 2)).toBe("│─");
+ dom.dispose();
+});
+
+test("ImageData past the pixel limit throws, and so does getImageData", async () => {
+ const {dom, document} =
+ await mount(" ");
+ const ctx =
+ (document.getElementById("c") as HTMLCanvasElement).getContext("2d")!;
+ expect(() => ctx.createImageData(1 << 14, 1 << 13)).toThrow(RangeError);
+ expect(() => ctx.getImageData(0, 0, 1 << 14, 1 << 13)).toThrow(RangeError);
+ dom.dispose();
+});
+
+test("a charactergrid samples only the cells an image lands on, however large", async () => {
+ const {dom, document} = await mount(
+ " ",
+ );
+ const source =
+ (document.getElementById("src") as HTMLCanvasElement).getContext("2d")!;
+ source.fillStyle = "#ff0000";
+ source.fillRect(0, 0, 1, 2);
+ source.fillStyle = "#0000ff";
+ source.fillRect(1, 0, 1, 2);
+ const ctx = grid(document);
+ ctx.drawImage(document.getElementById("src"), -1e6 + 2, 0, 2e6, 1e6);
+ expect(ctx.getCell(1, 0).background).toBe("#ff0000");
+ expect(ctx.getCell(2, 0).background).toBe("#0000ff");
+ expect(ctx.getCell(3, 1).background).toBe("#0000ff");
+ dom.dispose();
+});
+
+test("createImageBitmap from a kept-down image crops by each side's ratio, and past the edge is clear", async () => {
+ const data = new Uint8ClampedArray(4 * 2 * 4);
+ for (let i = 0; i < data.length; i += 4) {
+ data.set([255, 0, 0, 255], i);
+ }
+ const image = new ImageBitmap({
+ width: 4,
+ height: 2,
+ data,
+ naturalWidth: 8,
+ naturalHeight: 8,
+ });
+ const copy = createImageBitmapFrom(image, 4, 4, 8, 8);
+ expect([copy.width, copy.height]).toEqual([8, 8]);
+ const {dom, document} =
+ await mount(" ");
+ const ctx =
+ (document.getElementById("c") as HTMLCanvasElement).getContext("2d")!;
+ ctx.imageSmoothingEnabled = false;
+ ctx.drawImage(copy as unknown as CanvasImageSource, 0, 0);
+ const alphaAt =
+ (x: number, y: number) => ctx.getImageData(x, y, 1, 1).data[3];
+ expect([alphaAt(1, 1), alphaAt(3, 3)]).toEqual([255, 255]);
+ expect([alphaAt(6, 1), alphaAt(1, 6), alphaAt(6, 6)]).toEqual([0, 0, 0]);
+ dom.dispose();
+});
diff --git a/tests/content-security-policy.test.ts b/tests/content-security-policy.test.ts
new file mode 100644
index 000000000..b53e600fd
--- /dev/null
+++ b/tests/content-security-policy.test.ts
@@ -0,0 +1,232 @@
+/**
+ * The policy a document's loads are checked against: CSP3's directives,
+ * source lists and matching, as a program and a page's own meta tags set
+ * it.
+ */
+import {expect, test} from "@b9g/libuild/test";
+
+import {
+ getBlockingDirective,
+ parseContentSecurityPolicies,
+} from "../src/internal/resources.ts";
+
+function allows(
+ policy: string,
+ url: string,
+ self = "https://mail.example/inbox",
+ destination: RequestDestination = "image",
+): boolean {
+ return getBlockingDirective(
+ parseContentSecurityPolicies(policy),
+ new URL(url),
+ destination,
+ new URL(self),
+ ) === null;
+}
+
+test("img-src governs an image, and default-src stands in for it", () => {
+ expect(allows("img-src https:", "https://cdn.example/a.png")).toBe(true);
+ expect(allows("img-src https:", "http://cdn.example/a.png")).toBe(false);
+ expect(allows("default-src data:", "data:image/png;base64,AAAA")).toBe(true);
+ expect(allows("default-src data:", "https://cdn.example/a.png")).toBe(false);
+ // img-src replaces default-src for images; it does not add to it.
+ expect(allows("default-src *; img-src cid:", "https://cdn.example/a.png"))
+ .toBe(false);
+ // A policy that names neither governs nothing an image does.
+ expect(allows("style-src 'none'", "https://cdn.example/a.png")).toBe(true);
+});
+
+test("'none', an empty list and keywords match nothing", () => {
+ expect(allows("img-src 'none'", "data:image/png;base64,AAAA")).toBe(false);
+ expect(allows("img-src", "https://cdn.example/a.png")).toBe(false);
+ expect(
+ allows("img-src 'unsafe-inline' 'nonce-abc'", "https://cdn.example/a.png"),
+ )
+ .toBe(false);
+});
+
+test("* matches the network schemes and the document's own, not data: or cid:", () => {
+ expect(allows("img-src *", "https://cdn.example/a.png")).toBe(true);
+ expect(allows("img-src *", "http://cdn.example/a.png")).toBe(true);
+ expect(allows("img-src *", "data:image/png;base64,AAAA")).toBe(false);
+ expect(allows("img-src *", "cid:logo@example.com")).toBe(false);
+ expect(allows("img-src *", "file:///a.png", "file:///home/me/")).toBe(true);
+});
+
+test("a scheme matches itself and its secure counterpart", () => {
+ expect(allows("img-src cid:", "cid:logo@example.com")).toBe(true);
+ expect(allows("img-src CID:", "cid:logo@example.com")).toBe(true);
+ expect(allows("img-src http:", "https://cdn.example/a.png")).toBe(true);
+ expect(allows("img-src https:", "http://cdn.example/a.png")).toBe(false);
+ // ws also matches the http schemes.
+ expect(allows("img-src ws:", "http://cdn.example/a.png")).toBe(true);
+ expect(allows("img-src ws:", "https://cdn.example/a.png")).toBe(true);
+ expect(allows("img-src wss:", "http://cdn.example/a.png")).toBe(false);
+});
+
+test("a path is compared piece by piece, each piece percent-decoded", () => {
+ expect(
+ allows(
+ "img-src https://cdn.example/a%20b/",
+ "https://cdn.example/a b/c.png",
+ ),
+ )
+ .toBe(true);
+ // An encoded slash is part of a piece, not a separator.
+ expect(allows("img-src https://c.example/a/b", "https://c.example/a%2Fb"))
+ .toBe(false);
+ // A malformed escape compares as itself rather than failing.
+ expect(
+ allows(
+ "img-src https://cdn.example/images/",
+ "https://cdn.example/images/%E0.png",
+ ),
+ )
+ .toBe(true);
+ expect(allows("img-src https://cdn.example/%E0", "https://cdn.example/%E0"))
+ .toBe(true);
+ expect(allows("img-src https://cdn.example/", "https://cdn.example/a/b.png"))
+ .toBe(true);
+});
+
+test("a redirect's target is matched without its path", () => {
+ const policies =
+ parseContentSecurityPolicies("img-src https://cdn.example/img/");
+ const self = new URL("https://mail.example/");
+ const elsewhere = new URL("https://cdn.example/other/a.png");
+ expect(getBlockingDirective(policies, elsewhere, "image", self, 0))
+ .not.toBeNull();
+ expect(getBlockingDirective(policies, elsewhere, "image", self, 1))
+ .toBeNull();
+ expect(
+ getBlockingDirective(
+ policies,
+ new URL("https://evil.example/img/a.png"),
+ "image",
+ self,
+ 1,
+ ),
+ )
+ .not.toBeNull();
+});
+
+test("a host matches itself, a wildcard its subdomains, and a port or path narrows it", () => {
+ expect(allows("img-src https://cdn.example", "https://cdn.example/a.png"))
+ .toBe(true);
+ expect(allows("img-src https://CDN.example", "https://cdn.example/a.png"))
+ .toBe(true);
+ expect(allows("img-src https://*.example", "https://cdn.example/a.png"))
+ .toBe(true);
+ expect(allows("img-src https://*.example", "https://example/a.png"))
+ .toBe(false);
+ expect(
+ allows("img-src https://cdn.example", "https://cdn.example:8443/a.png"),
+ )
+ .toBe(false);
+ expect(
+ allows("img-src https://cdn.example:*", "https://cdn.example:8443/a.png"),
+ )
+ .toBe(true);
+ expect(
+ allows("img-src https://cdn.example/img/", "https://cdn.example/img/a.png"),
+ )
+ .toBe(true);
+ expect(
+ allows("img-src https://cdn.example/img/", "https://cdn.example/css/a.png"),
+ )
+ .toBe(false);
+ expect(
+ allows(
+ "img-src https://cdn.example/img/a.png",
+ "https://cdn.example/img/a.png",
+ ),
+ )
+ .toBe(true);
+ expect(
+ allows(
+ "img-src https://cdn.example/img/a.png",
+ "https://cdn.example/img/b.png",
+ ),
+ )
+ .toBe(false);
+});
+
+test("a host without a scheme takes the document's, and the network's for a document not served from it", () => {
+ expect(allows("img-src cdn.example", "https://cdn.example/a.png")).toBe(true);
+ expect(
+ allows("img-src cdn.example", "https://cdn.example/a.png", "about:blank"),
+ )
+ .toBe(true);
+ expect(
+ allows(
+ "img-src cdn.example",
+ "http://cdn.example/a.png",
+ "https://mail.example/",
+ ),
+ )
+ .toBe(false);
+});
+
+test("'self' matches the document's origin and its upgrade, and nothing for an opaque one", () => {
+ expect(allows("img-src 'self'", "https://mail.example/logo.png")).toBe(true);
+ expect(allows("img-src 'self'", "https://cdn.example/logo.png")).toBe(false);
+ expect(
+ allows(
+ "img-src 'self'",
+ "https://mail.example/a.png",
+ "http://mail.example/",
+ ),
+ )
+ .toBe(true);
+ expect(allows("img-src 'self'", "file:///a.png", "file:///home/me/")).toBe(
+ false,
+ );
+ // The same host over wss, and the port must agree.
+ expect(
+ allows("img-src 'self'", "wss://mail.example/a", "https://mail.example/"),
+ )
+ .toBe(true);
+ expect(
+ allows(
+ "img-src 'self'",
+ "https://mail.example/a.png",
+ "http://mail.example:8080/",
+ ),
+ )
+ .toBe(false);
+ expect(
+ allows(
+ "img-src 'self'",
+ "https://mail.example:8080/a.png",
+ "http://mail.example:8080/",
+ ),
+ )
+ .toBe(true);
+ // An https document does not allow http from its own host.
+ expect(allows("img-src 'self'", "http://mail.example/a.png")).toBe(false);
+});
+
+test("every policy in a list must allow a load", () => {
+ expect(
+ allows(
+ "img-src https:, img-src https://cdn.example",
+ "https://cdn.example/a.png",
+ ),
+ )
+ .toBe(true);
+ expect(
+ allows(
+ "img-src https:, img-src https://cdn.example",
+ "https://other.example/a.png",
+ ),
+ )
+ .toBe(false);
+ const blocked = getBlockingDirective(
+ parseContentSecurityPolicies("img-src *, default-src 'self'"),
+ new URL("https://cdn.example/a.png"),
+ "image",
+ new URL("https://mail.example/"),
+ );
+ expect([blocked?.directive, blocked?.policy.text])
+ .toEqual(["default-src", "default-src 'self'"]);
+});
diff --git a/tests/images.test.ts b/tests/images.test.ts
index a5feaf401..6429ccbd4 100644
--- a/tests/images.test.ts
+++ b/tests/images.test.ts
@@ -13,10 +13,18 @@ import {expect, test} from "@b9g/libuild/test";
import {
type Bitmap,
decodeImage,
+ decodeImageOffThread,
encodePNG,
- readDataURL,
+ keepAtMost,
+ MAX_IMAGE_BYTES,
+ MAX_KEPT_PIXELS,
+ readImageResponse,
+ releaseDecoder,
resolveImageURL,
+ retainDecoder,
sampleBitmap,
+ setDecodeTimeout,
+ setDecodeWorkerURL,
sniffImageType,
} from "../src/internal/images.ts";
import {IMAGES} from "./fixtures/images.ts";
@@ -290,33 +298,359 @@ test("sampling averages the pixels a cell half covers, weighted by alpha", () =>
.toEqual([0, 0, 255, 255]);
});
-test("data: URLs decode percent-encoded bytes, binary ones included", () => {
- const bytes = readDataURL("data:application/octet-stream,%89PNG%0d%0a");
- expect(Array.from(bytes!)).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
- expect(Array.from(readDataURL("data:;base64,AQ%3D%3D")!)).toEqual([1]);
-});
-
test("a Windows path is a file URL, not a URL with a drive-letter scheme", () => {
expect(resolveImageURL("C:\\art\\cover.png", "about:blank")).toBe(
"file:///C:/art/cover.png",
);
});
-test("data: URLs decode base64 and percent-encoding", () => {
- expect(Array.from(readDataURL("data:image/png;base64,AQID")!)).toEqual([
- 1,
- 2,
- 3,
- ]);
- expect(new TextDecoder().decode(readDataURL("data:,a%20b")!)).toBe("a b");
- expect(readDataURL("https://example.com/a.png")).toBeNull();
-});
-
-test("a relative src resolves against the document, or the working directory", () => {
+test("a relative src resolves against the document's URL, and at about:blank to nothing", () => {
expect(resolveImageURL("b.png", "https://example.com/a/")).toBe(
"https://example.com/a/b.png",
);
- const local = resolveImageURL("art/cover.png", "about:blank");
- expect(local?.startsWith("file:///")).toBe(true);
- expect(local?.endsWith("/art/cover.png")).toBe(true);
+ expect(resolveImageURL("art/cover.png", "file:///home/me/")).toBe(
+ "file:///home/me/art/cover.png",
+ );
+ expect(resolveImageURL("art/cover.png", "about:blank")).toBeNull();
+});
+
+// Images that ask for more than they are: a header's size, a stream
+// that inflates past its image, more scans or components than an image
+// has, or more bytes than an image file may have.
+
+function patched(name: string, at: number, ...values: number[]): Uint8Array {
+ const bytes = bytesOf(name).slice();
+ bytes.set(values, at);
+ return bytes;
+}
+
+function findMarker(bytes: Uint8Array, marker: number): number {
+ for (let i = 2; i < bytes.length - 1; i++) {
+ if (bytes[i] === 0xff && bytes[i + 1] === marker) {
+ return i;
+ }
+ }
+ throw new Error(`no marker ${marker.toString(16)}`);
+}
+
+test("a size no image may have rejects before it is allocated", async () => {
+ const big = [0x00, 0x01, 0x86, 0xa0];
+ await expect(decodeImage(patched("png-rgb.png", 16, ...big, ...big)))
+ .rejects.toThrow("more than an image may be");
+ await expect(decodeImage(patched("png-rgb.png", 16, 0, 0, 0x9c, 0x40)))
+ .rejects.toThrow("more than an image may be");
+ const sof = findMarker(bytesOf("jpeg-444.jpg"), 0xc0);
+ await expect(
+ decodeImage(patched("jpeg-444.jpg", sof + 5, 0xff, 0xff, 0xff, 0xff)),
+ ).rejects.toThrow("more than an image may be");
+ await expect(decodeImage(patched("gif.gif", 6, 0xff, 0xff, 0xff, 0xff)))
+ .rejects.toThrow("more than an image may be");
+ await expect(
+ decodeImage(
+ patched("bmp-24.bmp", 18, 0xa0, 0x86, 0x01, 0x00, 0xa0, 0x86, 0x01, 0x00),
+ ),
+ ).rejects.toThrow("more than an image may be");
+});
+
+test("a JPEG with more components, sampling or scans than an image has rejects", async () => {
+ const sof = findMarker(bytesOf("jpeg-444.jpg"), 0xc0);
+ await expect(decodeImage(patched("jpeg-444.jpg", sof + 9, 5)))
+ .rejects.toThrow("more than four components");
+ await expect(decodeImage(patched("jpeg-444.jpg", sof + 11, 0x55)))
+ .rejects.toThrow("past four");
+ // The last scan again and again, ahead of the end marker.
+ const bytes = bytesOf("jpeg-progressive.jpg");
+ let last = -1;
+ for (let at = findMarker(bytes, 0xda); at !== -1;) {
+ last = at;
+ let next = -1;
+ for (let i = at + 2; i < bytes.length - 1; i++) {
+ if (bytes[i] === 0xff && bytes[i + 1] === 0xda) {
+ next = i;
+ break;
+ }
+ }
+ at = next;
+ }
+ const end = bytes.length - 2;
+ const scan = bytes.subarray(last, end);
+ const many = new Uint8Array(last + scan.length * 501 + 2);
+ many.set(bytes.subarray(0, last));
+ for (let i = 0; i < 501; i++) {
+ many.set(scan, last + i * scan.length);
+ }
+ many.set([0xff, 0xd9], many.length - 2);
+ await expect(decodeImage(many)).rejects.toThrow("more than 500 scans");
+});
+
+test("a GIF frame's code size past 8 bits rejects", async () => {
+ const bytes = bytesOf("gif.gif");
+ const descriptor = bytes.indexOf(0x2c, 13);
+ const flags = bytes[descriptor + 9];
+ const table = flags & 0x80 ? 3 * (1 << ((flags & 7) + 1)) : 0;
+ await expect(decodeImage(patched("gif.gif", descriptor + 10 + table, 12)))
+ .rejects.toThrow("code size");
+});
+
+test("PNG data that inflates past its image is read only as far as the image", async () => {
+ // 16 by 16 gray, filter byte 0 and a ramp on each row, and then
+ // megabytes of zeros the image has no room for.
+ const raw = new Uint8Array(16 * 17 + (1 << 24));
+ for (let y = 0; y < 16; y++) {
+ for (let x = 0; x < 16; x++) {
+ raw[y * 17 + 1 + x] = x * 16;
+ }
+ }
+ const deflated = new Uint8Array(
+ await new Response(
+ new Blob([raw]).stream().pipeThrough(new CompressionStream("deflate")),
+ ).arrayBuffer(),
+ );
+ const header = new Uint8Array(13);
+ header.set([0, 0, 0, 16, 0, 0, 0, 16, 8, 0, 0, 0, 0]);
+ const chunk = (type: string, data: Uint8Array) => {
+ const out = new Uint8Array(12 + data.length);
+ new DataView(out.buffer).setUint32(0, data.length);
+ out.set([...type].map((c) => c.charCodeAt(0)), 4);
+ out.set(data, 8);
+ return out;
+ };
+ const parts = [
+ new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ chunk("IHDR", header),
+ chunk("IDAT", deflated),
+ chunk("IEND", new Uint8Array(0)),
+ ];
+ const png = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
+ let at = 0;
+ for (const part of parts) {
+ png.set(part, at);
+ at += part.length;
+ }
+ const bitmap = await decodeImage(png);
+ expect([bitmap.width, bitmap.height]).toEqual([16, 16]);
+ expect(pixel(bitmap, 15, 15)).toEqual([240, 240, 240, 255]);
+});
+
+test("an image response past the byte limit is not read whole", async () => {
+ const piece = new Uint8Array(1 << 20);
+ const declared = new Response(piece, {
+ headers: {"content-length": String(MAX_IMAGE_BYTES + 1)},
+ });
+ await expect(readImageResponse(declared)).rejects.toThrow("more than");
+ let pulled = 0;
+ const endless = new Response(
+ new ReadableStream({
+ pull(controller) {
+ pulled++;
+ controller.enqueue(piece);
+ },
+ }),
+ );
+ await expect(readImageResponse(endless)).rejects.toThrow("more than");
+ expect(pulled).toBeLessThanOrEqual(MAX_IMAGE_BYTES / piece.length + 2);
+ await expect(readImageResponse(new Response(null, {status: 404})))
+ .rejects.toThrow("status 404");
+});
+
+// A worker from source text: it reports ready, then answers each
+// request as `reply` says, or never.
+function fakeWorker(reply: string): URL {
+ const source =
+ "const port = globalThis;" +
+ "const answer = (data) => {" +
+ reply +
+ "};" +
+ "addEventListener(\"message\", (e) => answer(e.data));" +
+ "postMessage({ready: true});";
+ return new URL(`data:text/javascript,${encodeURIComponent(source)}`);
+}
+
+// These need the runtime's own Worker, which Node has only with
+// --experimental-web-worker. Without one, images decode on this thread,
+// which the tests after them cover.
+const withWorker =
+ typeof (globalThis as {Worker?: unknown}).Worker ===
+ "function"
+ ? test
+ : test.skip;
+
+// A one-byte image stands for a slow or broken one in these workers.
+const STAND_IN = new Uint8Array([0]);
+
+// How each decode ended: its width, or its error.
+function outcomes(decodes: Array>): Promise {
+ return Promise.all(
+ decodes.map((decode) =>
+ decode.then((bitmap) =>
+ `${bitmap.width} wide`, (error: Error) => error.message),
+ ),
+ );
+}
+
+const ANSWER =
+ "port.postMessage({id: data.id, width: 1, height: 1, data: new ArrayBuffer(4)})";
+
+withWorker(
+ "an image past its time fails alone, and the images behind it decode",
+ async () => {
+ try {
+ setDecodeTimeout(1000);
+ setDecodeWorkerURL(
+ fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`),
+ );
+ expect(
+ await outcomes([
+ decodeImageOffThread(STAND_IN),
+ decodeImageOffThread(bytesOf("png-rgb.png")),
+ decodeImageOffThread(bytesOf("png-rgb.png")),
+ ]),
+ ).toEqual([
+ "The image took more than 1000 ms to decode",
+ "1 wide",
+ "1 wide",
+ ]);
+ } finally {
+ setDecodeTimeout(10_000);
+ setDecodeWorkerURL(null);
+ }
+ },
+);
+
+withWorker(
+ "a decode aborted while it waits is dropped, and the line moves on",
+ async () => {
+ try {
+ setDecodeTimeout(1000);
+ setDecodeWorkerURL(
+ fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`),
+ );
+ const abort = new AbortController();
+ const started = Date.now();
+ let droppedAfter = -1;
+ // The stand-in, ahead of it, holds the worker until its time is up.
+ const stalled = decodeImageOffThread(STAND_IN);
+ const dropped = decodeImageOffThread(bytesOf("png-rgb.png"), abort.signal)
+ .finally(() => {
+ droppedAfter = Date.now() - started;
+ });
+ const decodes = [
+ stalled,
+ dropped,
+ decodeImageOffThread(bytesOf("png-rgb.png")),
+ ];
+ abort.abort(new Error("The source changed"));
+ expect(await outcomes(decodes)).toEqual([
+ "The image took more than 1000 ms to decode",
+ "The source changed",
+ "1 wide",
+ ]);
+ expect(droppedAfter).toBeLessThan(500);
+ await expect(
+ decodeImageOffThread(bytesOf("png-rgb.png"), abort.signal),
+ ).rejects.toThrow("The source changed");
+ } finally {
+ setDecodeTimeout(10_000);
+ setDecodeWorkerURL(null);
+ }
+ },
+);
+
+withWorker(
+ "a worker that fails on an image fails that image alone",
+ async () => {
+ try {
+ setDecodeWorkerURL(
+ fakeWorker(
+ `if (data.bytes.byteLength === 1) throw new Error("broke"); ${ANSWER};`,
+ ),
+ );
+ const [before, broken, after] = await outcomes([
+ decodeImageOffThread(bytesOf("png-rgb.png")),
+ decodeImageOffThread(STAND_IN),
+ decodeImageOffThread(bytesOf("png-rgb.png")),
+ ]);
+ expect([before, after]).toEqual(["1 wide", "1 wide"]);
+ expect(broken).toContain("broke");
+ setDecodeWorkerURL(
+ fakeWorker(
+ "port.postMessage({id: data.id, error: \"from the worker\"})",
+ ),
+ );
+ await expect(decodeImageOffThread(bytesOf("png-rgb.png")))
+ .rejects.toThrow("from the worker");
+ } finally {
+ setDecodeWorkerURL(null);
+ }
+ },
+);
+
+withWorker(
+ "the last TermDOM disposed ends the worker, and the decode it ran",
+ async () => {
+ try {
+ setDecodeWorkerURL(fakeWorker(""));
+ retainDecoder();
+ const decode = decodeImageOffThread(bytesOf("png-rgb.png"));
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ releaseDecoder();
+ const outcome = await decode.then(() => "decoded", (error: Error) =>
+ error.name);
+ expect(outcome).toBe("AbortError");
+ } finally {
+ setDecodeWorkerURL(null);
+ }
+ },
+);
+
+test("where no worker starts, every image decodes on this thread", async () => {
+ try {
+ setDecodeWorkerURL(
+ new URL(
+ `data:text/javascript,${encodeURIComponent("throw new Error(\"no\");")}`,
+ ),
+ );
+ const bitmaps = await Promise.all(
+ [0, 1, 2].map(() => decodeImageOffThread(bytesOf("png-rgb.png"))),
+ );
+ expect(bitmaps.map((bitmap) => [bitmap.width, bitmap.height]))
+ .toEqual([[16, 16], [16, 16], [16, 16]]);
+ } finally {
+ setDecodeWorkerURL(null);
+ }
+});
+
+test("a decode on this thread stops past its time", async () => {
+ const width = 1500;
+ const data = new Uint8ClampedArray(width * width * 4);
+ for (let i = 0; i < data.length; i++) {
+ data[i] = (i * 2654435761) >>> 24;
+ }
+ const png = encodePNG({width, height: width, data});
+ try {
+ setDecodeTimeout(0);
+ await expect(decodeImageOffThread(png)).rejects.toThrow("too long");
+ setDecodeTimeout(10_000);
+ const bitmap = await decodeImageOffThread(png);
+ expect(bitmap.naturalWidth ?? bitmap.width).toBe(width);
+ } finally {
+ setDecodeTimeout(10_000);
+ }
+});
+
+test("an image past MAX_KEPT_PIXELS is kept averaged down, with its natural size", () => {
+ const width = 2000;
+ const height = 1000;
+ const data = new Uint8ClampedArray(width * height * 4);
+ for (let i = 0; i < width * height; i++) {
+ data.set(i % width < 1000 ? [255, 0, 0, 255] : [0, 0, 255, 255], i * 4);
+ }
+ const kept = keepAtMost({width, height, data});
+ expect(kept.width * kept.height).toBeLessThanOrEqual(MAX_KEPT_PIXELS);
+ expect([kept.naturalWidth, kept.naturalHeight]).toEqual([2000, 1000]);
+ expect(kept.width / kept.height).toBeCloseTo(2, 1);
+ expect(pixel(kept, 0, 0)).toEqual([255, 0, 0, 255]);
+ expect(pixel(kept, kept.width - 1, 0)).toEqual([0, 0, 255, 255]);
+ const small = {width: 10, height: 10, data: new Uint8ClampedArray(400)};
+ expect(keepAtMost(small)).toBe(small);
});
diff --git a/tests/img.test.ts b/tests/img.test.ts
index 240ea554f..fc5530a16 100644
--- a/tests/img.test.ts
+++ b/tests/img.test.ts
@@ -93,7 +93,10 @@ async function mount(
{ask: "\x1b[16t", reply: `\x1b[6;${height};${width}t`},
]);
}
- const dom = new TermDOM({transport: terminal.transport});
+ const dom = new TermDOM({
+ transport: terminal.transport,
+ csp: "img-src data: https:",
+ });
dom.document.body.innerHTML = html;
await nextFrame(dom);
return {dom, terminal, document: dom.document};
@@ -138,9 +141,9 @@ test("an image paints two pixels a cell in their own colors", async () => {
[1, 1, BLUE, WHITE],
] as const) {
const painted = cell(terminal, col, row);
- expect(painted.char).toBe("▀");
- expect(painted.fg).toBe(hex(top));
- expect(painted.bg).toBe(hex(bottom));
+ expect(painted.char).toBe("▄");
+ expect(painted.fg).toBe(hex(bottom));
+ expect(painted.bg).toBe(hex(top));
}
// Two columns wide and no more.
expect(cell(terminal, 2, 0).char).toBe(" ");
@@ -263,7 +266,9 @@ test("an image sits on a line with the text around it", async () => {
await mount(`before after
`);
await loaded(document.querySelector("img")!);
await nextFrame(dom);
- expect(rowText(terminal, 0)).toBe("before ▀▀ after");
+ // A frame drawn while it loaded showed its wider loading box, whose
+ // last cell was erased.
+ expect(rowText(terminal, 0).trimEnd()).toBe("before ▄▄ after");
// The image's computed display stays inline, as in a browser.
expect(
dom.window.getComputedStyle(document.querySelector("img")!).display,
@@ -305,7 +310,7 @@ test("object-fit: contain centers the image and leaves the rest alone", async ()
dom.dispose();
});
-test("a broken image fires error, rejects decode() and shows its alt text", async () => {
+test("a broken image fires error, rejects decode() and shows a box with its alt text", async () => {
const {dom, terminal, document} = await mount("
");
const image = document.createElement("img");
image.alt = "cover art";
@@ -325,7 +330,9 @@ test("a broken image fires error, rejects decode() and shows its alt text", asyn
});
expect((rejected as Error | null)?.name).toBe("EncodingError");
await nextFrame(dom);
- expect(rowText(terminal, 0)).toBe("[cover art]");
+ expect(rowText(terminal, 0)).toBe("[┌─────────┐]");
+ expect(rowText(terminal, 1)).toBe(" │cover art│");
+ expect(rowText(terminal, 2)).toBe(" └─────────┘");
dom.dispose();
});
@@ -455,9 +462,9 @@ test("an image keeps its own height on a line with a taller neighbor", async ()
// tall, and the pixels are not stretched to fill it.
expect(image.getBoundingClientRect().height).toBe(2);
expect(cell(terminal, 0, 1)).toEqual({
- char: "▀",
- fg: hex(BLUE),
- bg: hex(WHITE),
+ char: "▄",
+ fg: hex(WHITE),
+ bg: hex(BLUE),
});
expect(cell(terminal, 0, 2).char).toBe(" ");
dom.dispose();
@@ -579,7 +586,8 @@ test("decode() settles when src changes, and the current image stays up meanwhil
release = resolve;
});
globalThis.fetch = (async (input: RequestInfo | URL) => {
- if (String(input) === "https://images.test/bands.png") {
+ const url = input instanceof Request ? input.url : String(input);
+ if (url === "https://images.test/bands.png") {
await held;
return new Response(bands);
}
@@ -608,6 +616,7 @@ test("with a cell size, an image's pixels and lengths are CSS pixels", async ()
const dom = new TermDOM({
transport: terminal.transport,
cellSize: {width: 7, height: 15},
+ csp: "img-src data: https:",
});
dom.document.body.innerHTML =
` ` +
@@ -628,3 +637,109 @@ test("with a cell size, an image's pixels and lengths are CSS pixels", async ()
expect(cells("ratio")).toEqual([15, 3]);
dom.dispose();
});
+
+test("a new src cancels the request the old one started", async () => {
+ const {dom, document} = await mount(" ");
+ const signals: AbortSignal[] = [];
+ dom.addEventListener("fetch", (event) => {
+ const {signal} = event.request;
+ signals.push(signal);
+ event.respondWith(
+ new Promise((_resolve, reject) => {
+ signal.addEventListener("abort", () => reject(signal.reason));
+ }),
+ );
+ });
+ const image = document.getElementById("i") as HTMLImageElement;
+ image.src = "https://example.com/first.png";
+ await until(() => signals.length === 1);
+ image.src = "https://example.com/second.png";
+ await until(() => signals.length === 2);
+ expect(signals.map((signal) => signal.aborted)).toEqual([true, false]);
+ dom.dispose();
+});
+
+test("an image whose box is past the pixel limit paints nothing", async () => {
+ const square = png(4, 4, () => RED);
+ const {dom, terminal, document} = await mount(
+ ` `,
+ );
+ await loaded(document.querySelector("img")!);
+ await nextFrame(dom);
+ expect(cell(terminal, 0, 0).bg).toBe(-1);
+ dom.dispose();
+});
+
+test("an image that is not showing is a box, sized as the image is", async () => {
+ const {dom, terminal, document} = await mount(
+ "" +
+ "|
" +
+ " " +
+ " ",
+ );
+ await until(() =>
+ [...document.querySelectorAll("img")].every((image) => image.complete),
+ );
+ await nextFrame(dom);
+ expect([0, 1, 2].map((row) => rowText(terminal, row))).toEqual([
+ "┌─┐",
+ "│ │",
+ "└─┘",
+ ]);
+ expect(rowText(terminal, 3)).toBe("|");
+ expect([4, 5, 6, 7].map((row) => rowText(terminal, row))).toEqual([
+ "┌────────┐",
+ "│the ban…│",
+ "│ │",
+ "└────────┘",
+ ]);
+ expect(rowText(terminal, 8)).toBe("lo…");
+ dom.dispose();
+});
+
+test("an image is a box while it loads", async () => {
+ const terminal = new MockProcess({cols: 40, rows: 12});
+ const dom = new TermDOM({transport: terminal.transport});
+ dom.addEventListener("fetch", (event) => {
+ event.respondWith(new Promise(() => {}));
+ });
+ dom.document.body.innerHTML =
+ " ";
+ await nextFrame(dom);
+ await nextFrame(dom);
+ expect(rowText(terminal, 1)).toBe("│a│");
+ dom.dispose();
+});
+
+test("an image larger than a page keeps measures as large as it is", async () => {
+ // 1500 by 800 is past MAX_KEPT_PIXELS, so fewer pixels are kept. Red
+ // on the left half, blue on the right.
+ const wide = png(1500, 800, (x) => x < 750 ? RED : BLUE);
+ const {dom, document} = await mount(
+ ` ` +
+ " ",
+ );
+ const image = document.getElementById("a") as HTMLImageElement;
+ await loaded(image);
+ await nextFrame(dom);
+ expect([image.naturalWidth, image.naturalHeight]).toEqual([1500, 800]);
+ // 1500 by 800 pixels under the 8 by 16 guess is 187.5 by 50 cells.
+ const box = image.getBoundingClientRect();
+ expect(box.height).toBe(Math.round(40 * 50 / 187.5));
+
+ const canvas = document.getElementById("c") as HTMLCanvasElement;
+ const context = canvas.getContext("2d")!;
+ // A source rectangle in the image's own pixels: its right half.
+ context.drawImage(image, 750, 0, 750, 800, 0, 0, 10, 10);
+ expect([...context.getImageData(5, 5, 1, 1).data]).toEqual(BLUE);
+
+ const bitmap = await dom.window.createImageBitmap(image);
+ expect([bitmap.width, bitmap.height]).toEqual([1500, 800]);
+
+ const pattern = context.createPattern(image, "repeat")!;
+ context.fillStyle = pattern;
+ context.fillRect(0, 0, 1500, 800);
+ expect([...context.getImageData(100, 400, 1, 1).data]).toEqual(RED);
+ expect([...context.getImageData(1400, 400, 1, 1).data]).toEqual(BLUE);
+ dom.dispose();
+});
diff --git a/tests/memory.test.ts b/tests/memory.test.ts
index 58a95f716..d860a5654 100644
--- a/tests/memory.test.ts
+++ b/tests/memory.test.ts
@@ -6,6 +6,7 @@
import {expect, test} from "@b9g/libuild/test";
import {TermDOM} from "../src/index.ts";
+import {encodePNG} from "../src/internal/images.ts";
import {findCollector, MockProcess, nextFrame} from "./test-utils.js";
const collect = await findCollector();
@@ -196,3 +197,72 @@ async function growth(html: (round: number) => string): Promise {
expect(grown).toBeLessThan(MB);
},
);
+
+// Pixels sit in array buffers, which the heap does not count.
+async function heldMemory(): Promise {
+ let lowest = Infinity;
+ for (let i = 0; i < 3; i++) {
+ await settle();
+ const usage = process.memoryUsage();
+ lowest = Math.min(lowest, usage.heapUsed + (usage.external ?? 0));
+ }
+ return lowest;
+}
+
+// A 256 by 256 image is 256 KB decoded, so a page that held its images
+// after they went would hold some 100 MB here.
+const IMAGE = (() => {
+ const bytes = encodePNG({
+ width: 256,
+ height: 256,
+ data: new Uint8ClampedArray(256 * 256 * 4).fill(200),
+ });
+ let binary = "";
+ for (let i = 0; i < bytes.length; i += 0x8000) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
+ }
+ return `data:image/png;base64,${btoa(binary)}`;
+})();
+
+async function showImages(dom: TermDOM, round: number): Promise {
+ const host = dom.document.createElement("div");
+ host.innerHTML =
+ ` `.repeat(4) +
+ " ".repeat(2);
+ dom.document.body.append(host);
+ for (const canvas of host.querySelectorAll("canvas")) {
+ const context = canvas.getContext("2d")!;
+ context.fillStyle = `#${(round * 997 % 0xffffff).toString(16).padStart(6, "0")}`;
+ context.fillRect(0, 0, 256, 256);
+ }
+ // Every other round, the images go before they load.
+ if (round % 2 === 0) {
+ const images = [...host.querySelectorAll("img")];
+ while (!images.every((image) => image.complete)) {
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ }
+ }
+ await nextFrame(dom);
+ host.remove();
+ await nextFrame(dom);
+}
+
+(collect === null ? test.skip : test)(
+ "showing and removing images and canvases does not grow memory",
+ async () => {
+ const dom = new TermDOM({
+ transport: new MockProcess({cols: 100, rows: 40}).transport,
+ csp: "img-src data: https:",
+ });
+ for (let round = 0; round < WARM_UP; round++) {
+ await showImages(dom, round);
+ }
+ const before = await heldMemory();
+ for (let round = WARM_UP; round < WARM_UP + ROUNDS; round++) {
+ await showImages(dom, round);
+ }
+ const grown = (await heldMemory() - before) / 1e6;
+ dom.dispose();
+ expect(grown).toBeLessThan(MB);
+ },
+);
diff --git a/tests/requests.test.ts b/tests/requests.test.ts
new file mode 100644
index 000000000..e2da53fdb
--- /dev/null
+++ b/tests/requests.test.ts
@@ -0,0 +1,568 @@
+/**
+ * What a document's markup loads is first checked against its Content
+ * Security Policy. What the policy allows goes to the TermDOM as a
+ * "fetch" event, as a Service Worker hears it, and to the network when no
+ * listener answers. A redirect is checked against the policy again and
+ * followed on the network.
+ */
+import {mkdtempSync, rmSync, writeFileSync} from "node:fs";
+import {createServer} from "node:http";
+import type {AddressInfo} from "node:net";
+import {tmpdir} from "node:os";
+import {join} from "node:path";
+import {pathToFileURL} from "node:url";
+
+import {expect, test} from "@b9g/libuild/test";
+
+import {ExtendableEvent, FetchEvent, TermDOM} from "../src/index.ts";
+import {encodePNG} from "../src/internal/images.ts";
+import {MockProcess, until} from "./test-utils.ts";
+
+const PNG = encodePNG({
+ width: 2,
+ height: 2,
+ data: new Uint8ClampedArray(16).fill(255),
+}) as Uint8Array;
+
+function create(
+ options: {html?: string; url?: string; csp?: string} = {},
+): TermDOM {
+ return new TermDOM({
+ transport: new MockProcess({cols: 40, rows: 12}).transport,
+ ...options,
+ });
+}
+
+async function settled(image: HTMLImageElement): Promise {
+ await until(() => image.complete);
+ return image.naturalWidth > 0 ? "loaded" : "broken";
+}
+
+interface Server {
+ origin: string;
+ port: number;
+ // Each request the server heard, as "METHOD /path".
+ heard: string[];
+ close(): Promise;
+}
+
+// A server that answers /redirect?to=URL with a 302 to it, /hop/N with a
+// 302 to /hop/N-1 until /hop/0, and any other path with the PNG.
+async function serve(): Promise {
+ const heard: string[] = [];
+ const server = createServer((request, response) => {
+ heard.push(`${request.method} ${request.url}`);
+ const url = new URL(request.url!, "http://localhost");
+ if (url.pathname === "/redirect") {
+ response.writeHead(302, {location: url.searchParams.get("to")!});
+ response.end();
+ return;
+ }
+ const hop = /^\/hop\/(\d+)$/.exec(url.pathname);
+ if (hop !== null && hop[1] !== "0") {
+ response.writeHead(302, {location: `/hop/${Number(hop[1]) - 1}`});
+ response.end();
+ return;
+ }
+ response.writeHead(200, {"content-type": "image/png"});
+ response.end(PNG);
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const {port} = server.address() as AddressInfo;
+ return {
+ origin: `http://127.0.0.1:${port}`,
+ port,
+ heard,
+ close: () => new Promise((resolve) => {
+ server.closeAllConnections?.();
+ server.close(() => resolve());
+ }),
+ };
+}
+
+test("without a policy, markup loads nothing, and the block is reported", async () => {
+ const dom = create();
+ let heard = 0;
+ dom.addEventListener("fetch", () => {
+ heard++;
+ });
+ const violations: Array> = [];
+ dom.document.addEventListener("securitypolicyviolation", (event) => {
+ violations.push(event as SecurityPolicyViolationEvent);
+ });
+ dom.document.body.innerHTML =
+ " " +
+ " ";
+ const images = [...dom.document.querySelectorAll("img")];
+ expect(await Promise.all(images.map(settled))).toEqual(["broken", "broken"]);
+ expect(heard).toBe(0);
+ expect(violations).toHaveLength(2);
+ const [violation] = violations;
+ expect(violation.target).toBe(images[0]);
+ // A report carries no fragment, and only the scheme of a URL that is
+ // not HTTP(S).
+ expect([
+ violation.blockedURI,
+ violations[1].blockedURI,
+ violation.effectiveDirective,
+ violation.originalPolicy,
+ violation.disposition,
+ violation.isTrusted,
+ ]).toEqual([
+ "https://example.com/a.png",
+ "data",
+ "default-src",
+ "default-src 'none'",
+ "enforce",
+ true,
+ ]);
+ dom.dispose();
+});
+
+test("a listener answers what the policy allows, as a Service Worker does", async () => {
+ const dom = create({csp: "img-src cid:"});
+ const seen: FetchEvent[] = [];
+ dom.addEventListener("fetch", (event) => {
+ seen.push(event);
+ event.respondWith(new Response(PNG));
+ });
+ dom.document.body.innerHTML =
+ " ";
+ const a = dom.document.getElementById("a") as HTMLImageElement;
+ const b = dom.document.getElementById("b") as HTMLImageElement;
+ expect([await settled(a), await settled(b)]).toEqual(["loaded", "broken"]);
+ expect(seen).toHaveLength(1);
+ const [event] = seen;
+ expect(event).toBeInstanceOf(FetchEvent);
+ expect(event).toBeInstanceOf(ExtendableEvent);
+ expect([
+ event.type,
+ event.cancelable,
+ event.request.url,
+ event.request.destination,
+ event.clientId,
+ event.resultingClientId,
+ event.replacesClientId,
+ ]).toEqual(["fetch", true, "cid:logo@example.com", "image", "", "", ""]);
+ expect(event.request.headers.get("accept")).toContain("image/png");
+ expect(await event.preloadResponse).toBeUndefined();
+ expect(await event.handled).toBeUndefined();
+ dom.dispose();
+});
+
+test("an allowed request no listener answers goes to the network", async () => {
+ const server = await serve();
+ const dom = create({csp: `img-src ${server.origin}`});
+ let heard = 0;
+ dom.addEventListener("fetch", () => {
+ heard++;
+ });
+ dom.document.body.innerHTML = ` `;
+ expect(await settled(dom.document.querySelector("img")!)).toBe("loaded");
+ expect(heard).toBe(1);
+ expect(server.heard).toEqual(["GET /a.png"]);
+ dom.dispose();
+ await server.close();
+
+ const data = create({csp: "img-src data:"});
+ data.document.body.innerHTML =
+ ` `;
+ expect(await settled(data.document.querySelector("img")!)).toBe("loaded");
+ data.dispose();
+});
+
+test("a redirect is checked against the policy again, and reported as the URL asked for", async () => {
+ const server = await serve();
+ const elsewhere = `http://localhost:${server.port}/tracker.png`;
+ const dom = create({csp: `img-src ${server.origin}`});
+ const violations: SecurityPolicyViolationEvent[] = [];
+ dom.document.addEventListener("securitypolicyviolation", (event) => {
+ violations.push(event as SecurityPolicyViolationEvent);
+ });
+ const away = `${server.origin}/redirect?to=${encodeURIComponent(elsewhere)}`;
+ const home = `${server.origin}/redirect?to=%2Fhome.png`;
+ dom.document.body.innerHTML = ` `;
+ const a = dom.document.getElementById("a") as HTMLImageElement;
+ const b = dom.document.getElementById("b") as HTMLImageElement;
+ expect([await settled(a), await settled(b)]).toEqual(["broken", "loaded"]);
+ expect(server.heard).not.toContain("GET /tracker.png");
+ expect(server.heard).toContain("GET /home.png");
+ expect(violations.map((violation) => violation.blockedURI)).toEqual([away]);
+ dom.dispose();
+ await server.close();
+});
+
+test("a redirect goes only to another HTTP(S) URL", async () => {
+ const server = await serve();
+ const folder = mkdtempSync(join(tmpdir(), "termdom-redirect-"));
+ try {
+ writeFileSync(join(folder, "secret.png"), PNG);
+ const file = pathToFileURL(join(folder, "secret.png")).href;
+ const data = `data:image/png;base64,${Buffer.from(PNG).toString("base64")}`;
+ const dom = create({
+ url: pathToFileURL(`${folder}/`).href,
+ csp: `img-src file: data: cid: ${server.origin}`,
+ });
+ // The file loads when the page asks for it, through the listener,
+ // which a redirect never reaches.
+ const answered: string[] = [];
+ dom.addEventListener("fetch", (event) => {
+ if (event.request.url.startsWith("file:")) {
+ answered.push(event.request.url);
+ event.respondWith(new Response(PNG));
+ }
+ });
+ const to = (target: string) =>
+ `${server.origin}/redirect?to=${encodeURIComponent(target)}`;
+ dom.document.body.innerHTML =
+ ` ` +
+ ` `;
+ const images = [...dom.document.querySelectorAll("img")];
+ expect(await Promise.all(images.map(settled)))
+ .toEqual(["broken", "broken", "broken", "loaded"]);
+ expect(answered).toEqual([file]);
+ dom.dispose();
+ } finally {
+ await server.close();
+ rmSync(folder, {recursive: true});
+ }
+});
+
+test("twenty redirects are followed, and a twenty-first fails", async () => {
+ const server = await serve();
+ const dom = create({csp: `img-src ${server.origin}`});
+ dom.document.body.innerHTML =
+ ` `;
+ const a = dom.document.getElementById("a") as HTMLImageElement;
+ const b = dom.document.getElementById("b") as HTMLImageElement;
+ expect([await settled(a), await settled(b)]).toEqual(["loaded", "broken"]);
+ dom.dispose();
+ await server.close();
+});
+
+test("a listener that passes the load on is checked where the load ended up", async () => {
+ const server = await serve();
+ const elsewhere = `http://localhost:${server.port}/secret.png`;
+ const dom = create({csp: `img-src ${server.origin}`});
+ dom.addEventListener("fetch", (event) => {
+ event.respondWith(fetch(event.request));
+ });
+ const violations: SecurityPolicyViolationEvent[] = [];
+ dom.document.addEventListener("securitypolicyviolation", (event) => {
+ violations.push(event as SecurityPolicyViolationEvent);
+ });
+ const away = `${server.origin}/redirect?to=${encodeURIComponent(elsewhere)}`;
+ dom.document.body.innerHTML =
+ ` `;
+ const a = dom.document.getElementById("a") as HTMLImageElement;
+ const b = dom.document.getElementById("b") as HTMLImageElement;
+ expect([await settled(a), await settled(b)]).toEqual(["broken", "loaded"]);
+ expect(violations.map((violation) => violation.blockedURI)).toEqual([away]);
+ dom.dispose();
+ await server.close();
+});
+
+test("an answer still pending ends when the source changes or the TermDOM is disposed", async () => {
+ const dom = create({csp: "img-src https:"});
+ const handled: Array> = [];
+ dom.addEventListener("fetch", (event) => {
+ handled.push(event.handled);
+ event.respondWith(new Promise(() => {}));
+ });
+ dom.document.body.innerHTML = " ";
+ const image = dom.document.querySelector("img")!;
+ await until(() => handled.length === 1);
+ image.src = "https://example.com/b.png";
+ await until(() => handled.length === 2);
+ const first = await handled[0].then(() => "resolved", (error: DOMException) =>
+ error.name);
+ expect(first).toBe("NetworkError");
+ void dom.dispose();
+ expect(await settled(image)).toBe("broken");
+ const second = await handled[1].then(
+ () => "resolved",
+ (error: DOMException) =>
+ error.name,
+ );
+ expect(second).toBe("NetworkError");
+});
+
+test("a listener's redirect is followed on the network, and checked", async () => {
+ const server = await serve();
+ const dom = create({csp: `img-src cid: ${server.origin}`});
+ let heard = 0;
+ dom.addEventListener("fetch", (event) => {
+ heard++;
+ const to = event.request.url === "cid:a"
+ ? `${server.origin}/a.png`
+ : "https://elsewhere.example/b.png";
+ event.respondWith(Response.redirect(to, 302));
+ });
+ dom.document.body.innerHTML = " ";
+ const a = dom.document.getElementById("a") as HTMLImageElement;
+ const b = dom.document.getElementById("b") as HTMLImageElement;
+ expect([await settled(a), await settled(b)]).toEqual(["loaded", "broken"]);
+ // The redirect goes to the network, not back to the listener.
+ expect(heard).toBe(2);
+ expect(server.heard).toEqual(["GET /a.png"]);
+ dom.dispose();
+ await server.close();
+});
+
+test("a page's own meta policy narrows the program's, and never widens it", async () => {
+ const dom = create({csp: "img-src https:"});
+ dom.addEventListener("fetch", (event) => {
+ event.respondWith(new Response(PNG));
+ });
+ dom.document.head.innerHTML =
+ " ";
+ dom.document.body.innerHTML =
+ " " +
+ " " +
+ " ";
+ const images = ["a", "b", "c"].map((id) =>
+ dom.document.getElementById(id) as HTMLImageElement);
+ expect(await Promise.all(images.map(settled)))
+ .toEqual(["loaded", "broken", "broken"]);
+ dom.dispose();
+});
+
+test("a meta policy holds from its insertion into the head, whatever becomes of the element", async () => {
+ const dom = create({csp: "img-src https:"});
+ dom.addEventListener("fetch", (event) => {
+ event.respondWith(new Response(PNG));
+ });
+ const load = async (src: string): Promise => {
+ const image = dom.document.createElement("img");
+ image.src = src;
+ dom.document.body.append(image);
+ return settled(image);
+ };
+ const meta = dom.document.createElement("meta");
+ meta.httpEquiv = "Content-Security-Policy";
+ meta.content = "img-src https://cdn.example";
+ dom.document.body.append(meta.cloneNode());
+ expect(await load("https://other.example/a.png")).toBe("loaded");
+ dom.document.head.append(meta);
+ expect(await load("https://other.example/b.png")).toBe("broken");
+ meta.content = "img-src https:";
+ meta.remove();
+ expect(await load("https://other.example/c.png")).toBe("broken");
+ expect(await load("https://cdn.example/d.png")).toBe("loaded");
+ dom.dispose();
+});
+
+test("Response.error(), a rejection, or preventDefault() without an answer fails the load", async () => {
+ const dom = create({csp: "img-src https:"});
+ const handled: Array> = [];
+ dom.addEventListener("fetch", (event) => {
+ handled.push(event.handled);
+ const name = event.request.url.slice(-5);
+ if (name === "a.png") {
+ event.respondWith(Response.error());
+ } else if (name === "b.png") {
+ event.respondWith(Promise.reject(new Error("no")));
+ } else {
+ event.preventDefault();
+ }
+ });
+ dom.document.body.innerHTML =
+ " " +
+ " " +
+ " ";
+ const images = ["a", "b", "c"].map((id) =>
+ dom.document.getElementById(id) as HTMLImageElement);
+ expect(await Promise.all(images.map(settled)))
+ .toEqual(["broken", "broken", "broken"]);
+ const outcomes = await Promise.all(
+ handled.map((promise) =>
+ promise.then(() => "resolved", (error: DOMException) => error.name)),
+ );
+ expect(outcomes).toEqual(["resolved", "NetworkError", "NetworkError"]);
+ dom.dispose();
+});
+
+test("respondWith() is called once, during the event, and stops the other listeners", async () => {
+ const dom = create({csp: "img-src https:"});
+ let late: FetchEvent | null = null;
+ const errors: string[] = [];
+ dom.addEventListener("fetch", (event) => {
+ late = event;
+ event.respondWith(new Response(PNG));
+ try {
+ event.respondWith(new Response(PNG));
+ } catch (error) {
+ errors.push((error as DOMException).name);
+ }
+ });
+ dom.addEventListener("fetch", () => {
+ errors.push("second listener ran");
+ });
+ dom.document.body.innerHTML = " ";
+ expect(await settled(dom.document.querySelector("img")!)).toBe("loaded");
+ expect(() => late!.respondWith(new Response(PNG))).toThrow();
+ expect(errors).toEqual(["InvalidStateError"]);
+ dom.dispose();
+});
+
+test("waitUntil() keeps dispose() waiting, and dispose() aborts loads in flight", async () => {
+ const dom = create({csp: "img-src https:"});
+ let finish!: () => void;
+ let signal!: AbortSignal;
+ dom.addEventListener("fetch", (event) => {
+ signal = event.request.signal;
+ event.waitUntil(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ event.respondWith(new Promise(() => {}));
+ });
+ dom.document.body.innerHTML = " ";
+ await until(() => signal !== undefined);
+ let disposed = false;
+ const disposing = dom.dispose().then(() => {
+ disposed = true;
+ });
+ expect(signal.aborted).toBe(true);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(disposed).toBe(false);
+ finish();
+ await disposing;
+ expect(disposed).toBe(true);
+});
+
+test("a listener added after the constructor's markup hears its images", async () => {
+ const dom = create({html: " ", csp: "img-src cid:"});
+ dom.addEventListener("fetch", (event) => {
+ event.respondWith(new Response(PNG));
+ });
+ expect(await settled(dom.document.querySelector("img")!)).toBe("loaded");
+ dom.dispose();
+});
+
+test("only a document that is a file loads files, whatever the policy", async () => {
+ let heard = 0;
+ const dom = create({url: "https://mail.example/message/1", csp: "img-src *"});
+ dom.addEventListener("fetch", (event) => {
+ heard++;
+ event.respondWith(new Response(PNG));
+ });
+ dom.document.body.innerHTML = " ";
+ expect(await settled(dom.document.querySelector("img")!)).toBe("broken");
+ expect(heard).toBe(0);
+ dom.dispose();
+
+ const asked: string[] = [];
+ const local = create({url: "file:///home/me/art/", csp: "img-src file:"});
+ local.addEventListener("fetch", (event) => {
+ asked.push(event.request.url);
+ event.respondWith(new Response(PNG));
+ });
+ local.document.body.innerHTML = " ";
+ expect(await settled(local.document.querySelector("img")!)).toBe("loaded");
+ expect(asked).toEqual(["file:///home/me/art/cover.png"]);
+ local.dispose();
+});
+
+test("window.fetch is the runtime's, resolved against the document, and no policy or listener governs it", async () => {
+ const server = await serve();
+ const dom = create({url: `${server.origin}/app/`});
+ let heard = 0;
+ dom.addEventListener("fetch", () => {
+ heard++;
+ });
+ const answer = await dom.window.fetch("data", {method: "POST", body: "x"});
+ expect(answer.status).toBe(200);
+ await answer.arrayBuffer();
+ expect(server.heard).toEqual(["POST /app/data"]);
+ expect(heard).toBe(0);
+ dom.dispose();
+ await server.close();
+});
+
+test("a FetchEvent can be made by hand, and only TermDOM's can be answered or extended", () => {
+ const request = new Request("https://example.com/");
+ const event = new FetchEvent("fetch", {request});
+ expect([
+ event.request,
+ event.clientId,
+ event.resultingClientId,
+ event.replacesClientId,
+ event.cancelable,
+ ]).toEqual([request, "", "", "", false]);
+ expect(event.handled).toBeInstanceOf(Promise);
+ expect(() => event.respondWith(new Response(""))).toThrow();
+ expect(() => event.waitUntil(Promise.resolve())).toThrow();
+ const target = new EventTarget();
+ const errors: string[] = [];
+ target.addEventListener("fetch", (dispatched) => {
+ try {
+ (dispatched as FetchEvent).respondWith(new Response(""));
+ } catch (error) {
+ errors.push((error as DOMException).name);
+ }
+ });
+ target.dispatchEvent(new FetchEvent("fetch", {request}));
+ expect(errors).toEqual(["InvalidStateError"]);
+});
+
+test("a SecurityPolicyViolationEvent takes its init, with violatedDirective an alias", () => {
+ const dom = create();
+ const Violation = (dom.window as unknown as typeof globalThis)
+ .SecurityPolicyViolationEvent;
+ const event = new Violation("securitypolicyviolation", {
+ blockedURI: "https://example.com/a.png",
+ effectiveDirective: "img-src",
+ originalPolicy: "img-src 'none'",
+ statusCode: 200,
+ });
+ expect([
+ event.blockedURI,
+ event.violatedDirective,
+ event.disposition,
+ event.statusCode,
+ String(event),
+ ]).toEqual([
+ "https://example.com/a.png",
+ "img-src",
+ "enforce",
+ 200,
+ "[object SecurityPolicyViolationEvent]",
+ ]);
+ expect(() =>
+ new Violation("x", {
+ disposition: "bogus" as SecurityPolicyViolationEventDisposition,
+ }))
+ .toThrow(TypeError);
+ dom.dispose();
+});
+
+test("a program with no policy hears once that markup loads nothing until one allows it", async () => {
+ const logged: string[] = [];
+ const transport = {
+ ...new MockProcess({cols: 40, rows: 12}).transport,
+ logError: (text: string) => {
+ logged.push(text);
+ return true;
+ },
+ };
+ const dom = new TermDOM({transport});
+ dom.document.body.innerHTML =
+ " ";
+ const images = [...dom.document.querySelectorAll("img")];
+ await Promise.all(images.map(settled));
+ expect(logged).toHaveLength(1);
+ expect(logged[0]).toContain("csp");
+ dom.dispose();
+
+ const quiet: string[] = [];
+ const set = new TermDOM({
+ transport: {...transport, logError: (text: string) => quiet.push(text) > 0},
+ csp: "img-src 'none'",
+ });
+ set.document.body.innerHTML = " ";
+ await settled(set.document.querySelector("img")!);
+ expect(quiet).toEqual([]);
+ set.dispose();
+});