From 0a69192679b0b24eb9a1412cda99155a5c2c9628 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:46:41 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20pf=2096?= =?UTF-8?q?=EB=B0=94=ED=80=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017SBMKxYYSD2Dfjhnd4fgQJ From 2dd163a26d557eb60f8ddd603bf97a7909863356 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:57:24 +0000 Subject: [PATCH 2/5] =?UTF-8?q?web:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B3=A0=EC=B9=A8=20=EC=85=8B?= =?UTF-8?q?=EC=9D=98=20=EC=8B=9C=ED=97=98=EC=9D=84=20=EB=A8=BC=EC=A0=80=20?= =?UTF-8?q?=EB=91=94=EB=8B=A4=20(dev=20=EC=97=90=EC=84=9C=20=EB=B9=A8?= =?UTF-8?q?=EA=B0=95=209=EA=B0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017SBMKxYYSD2Dfjhnd4fgQJ --- .../app/pattern-lab/panels/TextPanel.test.tsx | 65 +++++++ web/src/lib/lab/textRaster.test.ts | 183 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 web/src/lib/lab/textRaster.test.ts 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/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)); + }); +}); From 8a3d020750a2b9f40afd6a3c22e722d331bc4e87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:59:53 +0000 Subject: [PATCH 3/5] =?UTF-8?q?web:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=EC=9D=98=20=EA=B8=80=EC=9D=84=20?= =?UTF-8?q?=EB=B9=84=EC=9A=B0=EB=A9=B4=20=EB=A0=88=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EB=8F=84=20=EB=B9=84=EA=B3=A0,=20=ED=9D=90=EB=A5=B4=EA=B8=B0?= =?UTF-8?q?=20source=20=EA=B0=80=20=ED=95=9C=20=EA=B3=B3=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=9E=90=EB=A6=AC=EB=A5=BC=20=EC=9E=A1=EC=95=84=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=97=B4=EC=96=B4=EB=8F=84=20=EC=82=B4=EA=B3=A0,?= =?UTF-8?q?=20=EA=B8=80=EA=BC=B4=EC=9D=84=20=EB=AA=BB=20=EB=82=A8=EA=B8=B0?= =?UTF-8?q?=EB=A9=B4=20=EA=B7=B8=EB=A0=87=EB=8B=A4=EA=B3=A0=20=EB=A7=90?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017SBMKxYYSD2Dfjhnd4fgQJ --- web/src/app/pattern-lab/panels/TextPanel.tsx | 8 ++-- web/src/lib/lab/serialize.ts | 23 +++++++---- web/src/lib/lab/store/shared.ts | 12 +++++- web/src/lib/lab/text.ts | 14 ++++--- web/src/lib/lab/textEffects.test.ts | 40 ++++++++++++++++++++ web/src/lib/lab/textEffects.ts | 34 +++++++++++++++++ web/src/lib/lab/textFonts.ts | 12 ++++-- web/src/lib/lab/textRaster.ts | 5 ++- web/src/lib/lab/types.ts | 3 ++ 9 files changed, 126 insertions(+), 25 deletions(-) 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.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 From 1e6eab681a9710ab6ecdc8e9e890ece631541ace Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:11:45 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=EB=B0=94=ED=80=B4=2096=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84:=20=EA=B8=80=EC=9D=84=20=EB=B9=84=EC=9A=B4=20?= =?UTF-8?q?=ED=9D=90=EB=A5=B4=EA=B8=B0=20=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=99=80=20=EB=AA=BB=20=EB=82=A8=EA=B8=B4=20=EA=B8=80=EA=BC=B4?= =?UTF-8?q?,=20dev=20=EC=99=80=20=EC=9D=B4=20=EB=B8=8C=EB=9E=9C=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017SBMKxYYSD2Dfjhnd4fgQJ --- round-shots/96-lab-text-after-1440.png | Bin 0 -> 115408 bytes round-shots/96-lab-text-after-390.png | Bin 0 -> 93971 bytes round-shots/96-lab-text-before-1440.png | Bin 0 -> 114024 bytes round-shots/96-lab-text-before-390.png | Bin 0 -> 96695 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 round-shots/96-lab-text-after-1440.png create mode 100644 round-shots/96-lab-text-after-390.png create mode 100644 round-shots/96-lab-text-before-1440.png create mode 100644 round-shots/96-lab-text-before-390.png diff --git a/round-shots/96-lab-text-after-1440.png b/round-shots/96-lab-text-after-1440.png new file mode 100644 index 0000000000000000000000000000000000000000..3c9f7b7d964aea1653e65e2cd2fdb5d129c5531d GIT binary patch literal 115408 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

XA;#Xm4%^_KsE77(G4x#N3?4{CTGQaACxv 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