Skip to content
65 changes: 65 additions & 0 deletions web/src/app/pattern-lab/panels/TextPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TextPanel />);
Expand Down Expand Up @@ -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(<TextPanel />);
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(<TextPanel />);
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(<TextPanel />);
Expand Down
8 changes: 5 additions & 3 deletions web/src/app/pattern-lab/panels/TextPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
23 changes: 15 additions & 8 deletions web/src/lib/lab/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -382,16 +383,17 @@ 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",
width,
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;
}
Expand All @@ -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 {
Expand Down
12 changes: 10 additions & 2 deletions web/src/lib/lab/store/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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;
});
Expand Down
14 changes: 8 additions & 6 deletions web/src/lib/lab/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}

/**
Expand All @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions web/src/lib/lab/textEffects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ import {
TEXT_EFFECT_RATE,
activeTextEffect,
advanceTextPhase,
placeTextSource,
renderTextEffect,
sourceColumnLimit,
textEffectPeriod,
twinkleHash,
} from "./textEffects";
Expand Down Expand Up @@ -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" });
Expand Down
34 changes: 34 additions & 0 deletions web/src/lib/lab/textEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
12 changes: 8 additions & 4 deletions web/src/lib/lab/textFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FontOption> {
/**
* 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 ──
Expand Down
Loading
Loading