diff --git a/web/src/app/pattern-lab/panels/TextPanel.test.tsx b/web/src/app/pattern-lab/panels/TextPanel.test.tsx
index 52da4b803..adedcab4d 100644
--- a/web/src/app/pattern-lab/panels/TextPanel.test.tsx
+++ b/web/src/app/pattern-lab/panels/TextPanel.test.tsx
@@ -67,6 +67,39 @@ function fontFile(name: string, firstByte = 1): File {
return Object.assign(file, { arrayBuffer: () => Promise.resolve(bytes.buffer) });
}
+/**
+ * IndexedDB as far as remembering a font goes, which jsdom lacks: a store
+ * that keeps what is put in it, or one whose write aborts, as a full disk
+ * or a private window's does.
+ */
+function fakeIndexedDb(outcome: "keeps" | "refuses") {
+ type Handlers = { oncomplete?: () => void; onerror?: () => void; onabort?: () => void };
+ const db = {
+ createObjectStore: () => ({}),
+ close: () => {},
+ transaction: () => {
+ const transaction: Handlers & { objectStore: () => { put: () => void } } = {
+ objectStore: () => ({
+ put: () => {
+ setTimeout(() => (outcome === "keeps" ? transaction.oncomplete?.() : transaction.onabort?.()));
+ },
+ }),
+ };
+ return transaction;
+ },
+ };
+ return {
+ open: () => {
+ const request: { result: typeof db; onsuccess?: () => void; onupgradeneeded?: () => void } = { result: db };
+ setTimeout(() => {
+ request.onupgradeneeded?.();
+ request.onsuccess?.();
+ });
+ return request;
+ },
+ };
+}
+
describe("TextPanel", () => {
it("offers to add a text layer when none is selected", () => {
render();
@@ -116,6 +149,38 @@ describe("TextPanel", () => {
expect(screen.getByRole("status")).toHaveTextContent("Brand Sans is loaded for this visit.");
});
+ it("says the font is kept for next time only when this browser kept it", async () => {
+ vi.stubGlobal("indexedDB", fakeIndexedDb("keeps"));
+ store().addTextLayer();
+ render();
+ fireEvent.click(screen.getByRole("checkbox", { name: "remember in this browser" }));
+ await act(async () => {
+ fireEvent.change(screen.getByLabelText("Font file"), { target: { files: [fontFile("Kept Serif.ttf")] } });
+ });
+ await waitFor(() => expect(text().spec.font).toBe("file:Kept Serif"));
+ expect(screen.getByRole("status")).toHaveTextContent("Kept Serif is kept in this browser for next time.");
+ expect(screen.getByRole("button", { name: "Forget it" })).toBeInTheDocument();
+ });
+
+ it.each([
+ ["has no IndexedDB", undefined],
+ ["refuses to store it", "refuses"],
+ ] as const)("says it is only for this visit when the browser %s", async (_, db) => {
+ vi.stubGlobal("indexedDB", db && fakeIndexedDb(db));
+ const name = db ? "Refused Mono" : "Unkept Sans";
+ store().addTextLayer();
+ render();
+ fireEvent.click(screen.getByRole("checkbox", { name: "remember in this browser" }));
+ await act(async () => {
+ fireEvent.change(screen.getByLabelText("Font file"), { target: { files: [fontFile(`${name}.woff2`)] } });
+ });
+ await waitFor(() => expect(text().spec.font).toBe(`file:${name}`));
+ expect(screen.getByRole("status")).toHaveTextContent(
+ `${name} is loaded for this visit. This browser wouldn't keep a copy for next time.`,
+ );
+ expect(screen.queryByRole("button", { name: "Forget it" })).toBeNull();
+ });
+
it("says so when a file is not a font, and keeps the font it had", async () => {
store().addTextLayer();
render();
diff --git a/web/src/app/pattern-lab/panels/TextPanel.tsx b/web/src/app/pattern-lab/panels/TextPanel.tsx
index 858e8d543..079fe3293 100644
--- a/web/src/app/pattern-lab/panels/TextPanel.tsx
+++ b/web/src/app/pattern-lab/panels/TextPanel.tsx
@@ -214,12 +214,14 @@ function TextEditor({ layer }: { layer: TextLayer }) {
setBusy(true);
setMessage(null);
try {
- const option = await addFontFile(file, remember);
+ const { option, remembered: kept } = await addFontFile(file, remember);
set({ font: option.id });
setMessage(
- remember
+ kept
? `${option.label} is kept in this browser for next time.`
- : `${option.label} is loaded for this visit.`,
+ : remember
+ ? `${option.label} is loaded for this visit. This browser wouldn't keep a copy for next time.`
+ : `${option.label} is loaded for this visit.`,
);
} catch (error) {
setMessage(error instanceof Error ? error.message : "That font couldn't be read.");
diff --git a/web/src/lib/lab/serialize.ts b/web/src/lib/lab/serialize.ts
index 532384ccd..06909aecb 100644
--- a/web/src/lib/lab/serialize.ts
+++ b/web/src/lib/lab/serialize.ts
@@ -36,10 +36,11 @@ import {
type LayerRole,
type PixelLayer,
type RampState,
+ type TextEffect,
type TextLayer,
type TextSource,
} from "./types";
-import { sourceColumnLimit } from "./textEffects";
+import { placeTextSource, sourceColumnLimit } from "./textEffects";
import { defaultKnobState, matchKnobsAnnotation } from "./annotations";
import { LAB_STORAGE, readStorage, removeStorage, writeStorage } from "./persist";
import { matchMatrixAnnotation } from "@/lib/pattern/matrix";
@@ -382,6 +383,7 @@ function readLayer(raw: unknown): Layer | null {
if (!data) return null;
if (raw.type === "text") {
const frame = { width, height };
+ const spec = normalizeTextSpec(isRecord(raw.spec) ? raw.spec : {}, defaultTextSpec(frame), frame);
const layer: TextLayer = {
...common,
type: "text",
@@ -389,9 +391,9 @@ function readLayer(raw: unknown): Layer | null {
height,
data,
rev: 0,
- spec: normalizeTextSpec(isRecord(raw.spec) ? raw.spec : {}, defaultTextSpec(frame), frame),
+ spec,
ramp: isRecord(raw.ramp) ? readRamp(raw.ramp) : cloneRampState(TEXT_RAMP_STATE),
- source: readTextSource(raw.src, width, height),
+ source: readTextSource(raw.src, spec.effect, width, height),
};
return layer;
}
@@ -409,15 +411,20 @@ function readLayer(raw: unknown): Layer | null {
return null;
}
-/** A saved text source, or null when it is missing or does not fit the frame. */
-function readTextSource(raw: unknown, width: number, height: number): TextSource | null {
+/**
+ * A saved text source, or null when it is missing, its effect reads none,
+ * or it holds more columns than the frame's source may. Its place goes
+ * through placeTextSource, as it did when it was drawn.
+ */
+function readTextSource(raw: unknown, effect: TextEffect, width: number, height: number): TextSource | null {
+ if (effect !== "scroll" && effect !== "ramp") return null;
if (!isRecord(raw) || typeof raw.px !== "string") return null;
const x = Number(raw.x);
const columns = Number(raw.w);
- if (!Number.isInteger(x) || !Number.isInteger(columns) || columns <= 0) return null;
- if (columns > sourceColumnLimit(width, height) || Math.abs(x) > width * 8 + columns) return null;
+ if (!Number.isSafeInteger(x) || !Number.isInteger(columns) || columns <= 0) return null;
+ if (columns > sourceColumnLimit(width, height)) return null;
const data = base64ToBytes(raw.px, columns * height * 4);
- return data ? { x, width: columns, data } : null;
+ return data ? placeTextSource({ x, width: columns, data }, effect, width, height) : null;
}
export function deserializeProject(json: string): (LabProject & { savedAt: number }) | null {
diff --git a/web/src/lib/lab/store/shared.ts b/web/src/lib/lab/store/shared.ts
index d5dc44eb9..017cbf72c 100644
--- a/web/src/lib/lab/store/shared.ts
+++ b/web/src/lib/lab/store/shared.ts
@@ -12,6 +12,7 @@ import { emptyShow } from "../director/types";
import { snapshotPixels } from "../pixelHistory";
import { resizeSurface } from "../pixelTools";
import { drawTextLayer } from "../text";
+import { placeTextSource } from "../textEffects";
import { DEFAULT_RAMP_STATE, cloneRampState, isRampLayer, layerId, type CodeLayer, type LabProject, type Layer, type RampLayer, type RampState, type RasterLayer } from "../types";
const labPresets = livePresets.filter((preset) => preset.labOnly);
export const initialLabPreset = labPresets[0] ?? livePresets[0];
@@ -142,15 +143,22 @@ export function resizePixelLayers(layers: Layer[], matrix: MatrixSize): Layer[]
y: resized.spec.y + Math.floor((matrix.height - layer.height) / 2),
};
// The source keeps its columns and moves by the same offset; its rows
- // are cut or padded the way the bitmap's are.
+ // are cut or padded the way the bitmap's are. A frame that holds fewer
+ // columns cuts it where text.ts would have (placeTextSource), so it
+ // saves and opens again as it is.
const source = resized.source;
- resized.source = source
+ const moved = source
? {
x: source.x + dx,
width: source.width,
data: resizeSurface(source.data, source.width, layer.height, source.width, matrix.height),
}
: null;
+ const effect = resized.spec.effect;
+ resized.source =
+ moved && (effect === "scroll" || effect === "ramp")
+ ? placeTextSource(moved, effect, matrix.width, matrix.height)
+ : moved;
}
return resized;
});
diff --git a/web/src/lib/lab/text.ts b/web/src/lib/lab/text.ts
index 83afd8411..bfe3455a5 100644
--- a/web/src/lib/lab/text.ts
+++ b/web/src/lib/lab/text.ts
@@ -15,7 +15,7 @@
import { buildRampLUTRGBA } from "@/lib/pattern/harness";
import { hexToRgb } from "@/lib/pattern/color";
import { rampStateToHarness } from "./engine";
-import { renderTextEffect, sourceColumnLimit } from "./textEffects";
+import { placeTextSource, renderTextEffect, sourceColumnLimit } from "./textEffects";
import { ensureFontReady, resolveFont } from "./textFonts";
import { textCoverage, textReach } from "./textRaster";
import type { RampState, TextLayer, TextSource, TextSpec } from "./types";
@@ -165,11 +165,13 @@ function cropToLetters(
}
/**
- * The scroll or ramp source for a spec, or null when no letter lands in it.
- * Undefined where there is no canvas to draw on.
+ * The scroll or ramp source for a spec, or null when no letter lands in it
+ * (or, for a ramp, none on the frame). Undefined where there is no canvas
+ * to draw on.
*/
function drawTextSource(
spec: TextSpec,
+ effect: "scroll" | "ramp",
ramp: RampState,
fontCss: string,
width: number,
@@ -184,10 +186,10 @@ function drawTextSource(
const letters = cropToLetters(coverage, spec.sharp, reach.left, columns, height);
if (!letters) return null;
const data =
- spec.effect === "ramp"
+ effect === "ramp"
? rampSource(letters.coverage, spec, letters.width, height)
: paintText(letters.coverage, spec, ramp, letters.width, height);
- return { x: letters.x, width: letters.width, data };
+ return placeTextSource({ x: letters.x, width: letters.width, data }, effect, width, height);
}
/**
@@ -204,7 +206,7 @@ export function drawTextLayer(layer: TextLayer): TextLayer {
const kept = layer.data.length === size ? layer.data : new Uint8ClampedArray(size);
if (spec.effect === "scroll" || spec.effect === "ramp") {
- const source = drawTextSource(spec, layer.ramp, font.css, width, height);
+ const source = drawTextSource(spec, spec.effect, layer.ramp, font.css, width, height);
if (source === undefined) return { ...layer, data: kept, rev: layer.rev + 1 };
const next: TextLayer = { ...layer, source, data: new Uint8ClampedArray(size), rev: layer.rev + 1 };
if (source) {
diff --git a/web/src/lib/lab/textEffects.test.ts b/web/src/lib/lab/textEffects.test.ts
index ca09e27d2..b1a6c3152 100644
--- a/web/src/lib/lab/textEffects.test.ts
+++ b/web/src/lib/lab/textEffects.test.ts
@@ -41,7 +41,9 @@ import {
TEXT_EFFECT_RATE,
activeTextEffect,
advanceTextPhase,
+ placeTextSource,
renderTextEffect,
+ sourceColumnLimit,
textEffectPeriod,
twinkleHash,
} from "./textEffects";
@@ -250,6 +252,44 @@ describe("a moving text saved and opened again", () => {
});
});
+describe("placing a source", () => {
+ const scrollFrames = (layer: TextLayer) =>
+ Array.from({ length: textEffectPeriod("scroll", layer) + 3 }, (_, phase) => {
+ const out = new Uint8ClampedArray(16 * 8 * 4);
+ renderTextEffect(layer, "scroll", phase, null, out);
+ return Array.from(out);
+ });
+
+ it("brings a scroll's x next to the frame and shows the same frames", () => {
+ const layer = textAt({ text: "PATTERNFLOW!", effect: "scroll" });
+ for (const x of [-5000, -41, -24, 17, 900]) {
+ const far = { ...layer, source: { ...layer.source!, x } };
+ const placed = placeTextSource(far.source, "scroll", 16, 8)!;
+ expect(placed.x).toBeGreaterThan(-placed.width);
+ expect(placed.x).toBeLessThanOrEqual(16);
+ expect(scrollFrames({ ...layer, source: placed })).toEqual(scrollFrames(far));
+ }
+ });
+
+ it("keeps a ramp where it is, and drops one wholly off the frame", () => {
+ const layer = textAt({ text: "HEY", effect: "ramp" });
+ const source = layer.source!;
+ expect(placeTextSource({ ...source, x: -3 }, "ramp", 16, 8)?.x).toBe(-3);
+ expect(placeTextSource({ ...source, x: -source.width }, "ramp", 16, 8)).toBeNull();
+ expect(placeTextSource({ ...source, x: 16 }, "ramp", 16, 8)).toBeNull();
+ });
+
+ it("cuts the columns past what the frame's source holds, keeping each row's start", () => {
+ const columns = sourceColumnLimit(16, 8) + 5;
+ const data = new Uint8ClampedArray(columns * 8 * 4);
+ for (let y = 0; y < 8; y++) data[(y * columns + 1) * 4 + 3] = y + 1;
+ const placed = placeTextSource({ x: 0, width: columns, data }, "scroll", 16, 8)!;
+ expect(placed.width).toBe(sourceColumnLimit(16, 8));
+ expect(placed.data.length).toBe(placed.width * 8 * 4);
+ for (let y = 0; y < 8; y++) expect(placed.data[(y * placed.width + 1) * 4 + 3]).toBe(y + 1);
+ });
+});
+
describe("a moving text on a resized frame", () => {
it("its source moves with the letters, columns kept and rows re-centred", () => {
const layer = textAt({ text: "PATTERNFLOW!", effect: "scroll" });
diff --git a/web/src/lib/lab/textEffects.ts b/web/src/lib/lab/textEffects.ts
index 223882da8..8a382975e 100644
--- a/web/src/lib/lab/textEffects.ts
+++ b/web/src/lib/lab/textEffects.ts
@@ -37,6 +37,40 @@ export function sourceColumnLimit(width: number, height: number): number {
return Math.max(width, Math.floor(TEXT_SOURCE_MAX_BYTES / (height * 4)));
}
+/**
+ * A source as every reader takes it, or null when the frame would read none
+ * of it. Columns past sourceColumnLimit are cut off, as text.ts leaves them
+ * out of a line too long to hold. A scroll reads x only modulo the source's
+ * width plus the frame's, so x is brought into one span that long, from the
+ * source's own width out to the left up to the frame's right edge: the same
+ * frames, and a number a reader can bound. A ramp reads x as it is, and one
+ * wholly off the frame lights nothing. text.ts, a resize (store/shared.ts)
+ * and serialize.ts all pass a source through here, so a save opens with the
+ * source it was saved with.
+ */
+export function placeTextSource(
+ source: TextSource,
+ effect: "scroll" | "ramp",
+ width: number,
+ height: number,
+): TextSource | null {
+ const columns = Math.min(source.width, sourceColumnLimit(width, height));
+ let data = source.data;
+ if (columns < source.width) {
+ data = new Uint8ClampedArray(columns * height * 4);
+ for (let y = 0; y < height; y++) {
+ const from = y * source.width * 4;
+ data.set(source.data.subarray(from, from + columns * 4), y * columns * 4);
+ }
+ }
+ if (effect === "ramp") {
+ return source.x <= -columns || source.x >= width ? null : { x: source.x, width: columns, data };
+ }
+ const period = columns + width;
+ const lead = (((width - source.x) % period) + period) % period;
+ return { x: width - lead, width: columns, data };
+}
+
const TWINKLE_PERIOD = 256;
const RAMP_PERIOD = 512;
diff --git a/web/src/lib/lab/textFonts.ts b/web/src/lib/lab/textFonts.ts
index 292a8b7fc..56f653c3e 100644
--- a/web/src/lib/lab/textFonts.ts
+++ b/web/src/lib/lab/textFonts.ts
@@ -175,16 +175,20 @@ async function registerFontBytes(label: string, bytes: ArrayBuffer, remembered:
return { id, label, group: "file" };
}
-/** Read a font file into this tab and, when asked, into this browser. */
-export async function addFontFile(file: File, remember: boolean): Promise {
+/**
+ * Read a font file into this tab and, when asked, into this browser.
+ * `remembered` is whether this browser kept it: false when it was not asked
+ * to, and when it has no IndexedDB or would not store the file.
+ */
+export async function addFontFile(file: File, remember: boolean): Promise<{ option: FontOption; remembered: boolean }> {
if (file.size > MAX_FONT_FILE_BYTES) {
throw new Error(`That font is ${Math.round(file.size / 1024 / 1024)} MB; the lab takes up to ${MAX_FONT_FILE_BYTES / 1024 / 1024} MB.`);
}
const bytes = await file.arrayBuffer();
const label = fontLabelFromFileName(file.name);
const option = await registerFontBytes(label, bytes, false);
- if (remember) await rememberFont(option.id, bytes);
- return option;
+ const remembered = remember ? await rememberFont(option.id, bytes) : false;
+ return { option, remembered };
}
// ── remembering: IndexedDB, this browser only ──
diff --git a/web/src/lib/lab/textRaster.test.ts b/web/src/lib/lab/textRaster.test.ts
new file mode 100644
index 000000000..c596165fe
--- /dev/null
+++ b/web/src/lib/lab/textRaster.test.ts
@@ -0,0 +1,183 @@
+// Text layers drawn through textRaster.ts itself, added 25 October 2026.
+// text.test.ts and textEffects.test.ts replace that file with a block per
+// character, which never says "no letters" and never measures a line past
+// what a source holds. Here the canvas is the fake one: it sets each
+// character as a solid block half the font size wide and the font size
+// tall, and a space as a gap. test/setup.ts's canvas measures nothing and
+// draws nothing, so it is swapped for this one around every test.
+
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { buildRampLUTRGBA } from "@/lib/pattern/harness";
+import { rampStateToHarness, LabEngine } from "./engine";
+import { buildPixelPatternCode } from "./pixelToCode";
+import { deserializeProject, serializeProject } from "./serialize";
+import { useLabStore } from "./store";
+import { activeTextEffect, renderTextEffect, sourceColumnLimit, textEffectPeriod } from "./textEffects";
+import type { TextLayer, TextSpec } from "./types";
+
+const advance = (size: number) => Math.ceil(size / 2);
+
+class LetterContext {
+ textAlign = "left";
+ textBaseline = "top";
+ fillStyle = "#ffffff";
+ private size = 10;
+ private lines: { text: string; x: number; y: number }[] = [];
+
+ set font(value: string) {
+ this.size = Number.parseFloat(value);
+ }
+
+ get font(): string {
+ return `${this.size}px sans-serif`;
+ }
+
+ measureText(line: string) {
+ const width = line.length * advance(this.size);
+ return { width, actualBoundingBoxLeft: 0, actualBoundingBoxRight: width };
+ }
+
+ clearRect() {
+ this.lines = [];
+ }
+
+ fillText(text: string, x: number, y: number) {
+ this.lines.push({ text, x, y });
+ }
+
+ getImageData(_x: number, _y: number, width: number, height: number) {
+ const data = new Uint8ClampedArray(width * height * 4);
+ const step = advance(this.size);
+ for (const line of this.lines) {
+ [...line.text].forEach((char, index) => {
+ if (char === " ") return;
+ const left = line.x + index * step;
+ for (let y = Math.max(0, line.y); y < Math.min(height, line.y + this.size); y++) {
+ for (let x = Math.max(0, left); x < Math.min(width, left + step - 1); x++) data[(y * width + x) * 4 + 3] = 255;
+ }
+ });
+ }
+ return { width, height, data };
+ }
+}
+
+let stubbed: PropertyDescriptor | undefined;
+
+beforeAll(() => {
+ stubbed = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "getContext");
+ Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
+ configurable: true,
+ value: () => new LetterContext(),
+ });
+});
+
+afterAll(() => {
+ if (stubbed) Object.defineProperty(HTMLCanvasElement.prototype, "getContext", stubbed);
+});
+
+const store = () => useLabStore.getState();
+
+const active = (): TextLayer => {
+ const layer = store().layers.find((entry) => entry.id === store().activeLayerId);
+ if (layer?.type !== "text") throw new Error("active layer is not text");
+ return layer;
+};
+
+function textLayer(spec: Partial): TextLayer {
+ store().addTextLayer();
+ store().setTextSpec(active().id, spec);
+ return active();
+}
+
+function litCount(data: Uint8ClampedArray): number {
+ let count = 0;
+ for (let i = 3; i < data.length; i += 4) if (data[i] > 0) count++;
+ return count;
+}
+
+/** The layer's frame at each of these phases, as the preview draws it. */
+function frames(layer: TextLayer, phases: number[]): number[][] {
+ const effect = activeTextEffect(layer);
+ if (!effect) throw new Error("the layer does not move");
+ const lut = effect === "ramp" ? buildRampLUTRGBA(rampStateToHarness(layer.ramp)) : null;
+ return phases.map((phase) => {
+ const out = new Uint8ClampedArray(layer.width * layer.height * 4);
+ renderTextEffect(layer, effect, phase, lut, out);
+ return Array.from(out);
+ });
+}
+
+const reopen = () => deserializeProject(serializeProject(store())!)!.layers[0] as TextLayer;
+
+beforeEach(() => {
+ store().discardProject();
+ store().setMatrix({ width: 32, height: 16 });
+});
+
+describe("a moving text whose words are deleted", () => {
+ it.each([
+ ["scroll", ""],
+ ["scroll", "\n"],
+ ["ramp", ""],
+ ["ramp", "\n"],
+ ] as const)("%s, with the text set to %j, lights nothing, as a still one does", (effect, emptied) => {
+ const layer = textLayer({ text: "HEY", size: 8, effect });
+ expect(layer.source).not.toBeNull();
+ expect(litCount(layer.data)).toBeGreaterThan(0);
+
+ store().setTextSpec(layer.id, { text: emptied });
+ const empty = active();
+ expect(empty.source).toBeNull();
+ expect(activeTextEffect(empty)).toBeNull();
+ expect(litCount(empty.data)).toBe(0);
+
+ // The preview, a second on at full speed, and Copy as code.
+ const engine = new LabEngine();
+ const frame = engine.render(
+ { matrix: { width: 32, height: 16 }, layers: [empty], activeLayerId: empty.id, knobs: [1, 1, 1, 1], ranges: [[0, 1], [0, 1], [0, 1], [0, 1]] },
+ 1,
+ 0,
+ );
+ expect(Array.from(frame.data).some((byte, i) => (i & 3) !== 3 && byte > 0)).toBe(false);
+ expect(buildPixelPatternCode(empty)).toBeNull();
+
+ store().setTextSpec(layer.id, { effect: "none" });
+ expect(Array.from(active().data)).toEqual(Array.from(empty.data));
+ });
+});
+
+describe("a line longer than its source holds", () => {
+ // 200 characters at 64 px set 6,400 pixels: past the 4,096 columns a
+ // 128 × 64 source holds.
+ const long = "PATTERNFLOW ".repeat(17).slice(0, 200);
+
+ it.each(["scroll", "ramp"] as const)(
+ "keeps its %s running after the panel turns from 128 × 64 to 64 × 128 and the project reopens",
+ (effect) => {
+ store().setMatrix({ width: 128, height: 64 });
+ const wide = textLayer({ text: long, size: 64, align: "left", x: 0, effect });
+ expect(wide.source!.width).toBeGreaterThan(sourceColumnLimit(64, 128));
+
+ store().setMatrix({ width: 64, height: 128 });
+ const turned = active();
+ expect(turned.source!.width).toBeLessThanOrEqual(sourceColumnLimit(64, 128));
+ expect(turned.source!.data.length).toBeLessThanOrEqual(512 * 512 * 4);
+
+ const back = reopen();
+ expect(activeTextEffect(back)).toBe(effect);
+ const phases = [0, 1, 63, 64, 300, textEffectPeriod(effect, turned) - 1];
+ expect(frames(back, phases)).toEqual(frames(turned, phases));
+ },
+ );
+
+ it("keeps scrolling after the project reopens when it is set right of its X", () => {
+ store().setMatrix({ width: 128, height: 64 });
+ const layer = textLayer({ text: long, size: 64, align: "right", x: 120, effect: "scroll" });
+ expect(activeTextEffect(layer)).toBe("scroll");
+
+ const back = reopen();
+ expect(activeTextEffect(back)).toBe("scroll");
+ const phases = [0, 1, 127, 128, 2000, textEffectPeriod("scroll", layer) - 1];
+ expect(frames(back, phases)).toEqual(frames(layer, phases));
+ });
+});
diff --git a/web/src/lib/lab/textRaster.ts b/web/src/lib/lab/textRaster.ts
index be5b23c47..f729bac3d 100644
--- a/web/src/lib/lab/textRaster.ts
+++ b/web/src/lib/lab/textRaster.ts
@@ -33,7 +33,8 @@ function lineLeft(spec: TextSpec, lineWidth: number): number {
/**
* The frame columns the lines reach, from the first to one past the last,
* with a pixel to spare on each side for a glyph that leans out of its box.
- * Null where there is no canvas, or no letters.
+ * Null where there is no canvas. A text with no letters reaches nowhere:
+ * left and right are both 0.
*/
export function textReach(spec: TextSpec, fontCss: string): { left: number; right: number } | null {
const context = context2d(1, 1);
@@ -48,7 +49,7 @@ export function textReach(spec: TextSpec, fontCss: string): { left: number; righ
left = Math.min(left, start - Math.max(0, metrics.actualBoundingBoxLeft || 0));
right = Math.max(right, start + Math.max(metrics.width, metrics.actualBoundingBoxRight || 0));
}
- if (!Number.isFinite(left) || !Number.isFinite(right)) return null;
+ if (!Number.isFinite(left) || !Number.isFinite(right)) return { left: 0, right: 0 };
return { left: Math.floor(left) - 1, right: Math.ceil(right) + 1 };
}
diff --git a/web/src/lib/lab/types.ts b/web/src/lib/lab/types.ts
index a8b1e85a1..2c420f3c1 100644
--- a/web/src/lib/lab/types.ts
+++ b/web/src/lib/lab/types.ts
@@ -150,6 +150,9 @@ export const TEXT_MAX_CHARS = 200;
* row of the frame, but only the columns the letters reach, `x` being the
* first of them in frame pixels. It may start left of the frame and run past
* its right edge, so a line longer than the panel scrolls through whole.
+ * Every source passes through textEffects.ts placeTextSource, which keeps
+ * its columns within what the frame's source holds and a scroll's x next to
+ * the frame.
* scroll the letters in colour
* ramp in each pixel, red is where it sits on the ramp (0..255) and
* alpha is how much of it the letters cover