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