feat: add first-class text layers

This commit is contained in:
syntaxbullet
2026-07-11 12:38:34 +02:00
parent 606426c885
commit 37aa719047
33 changed files with 315 additions and 41 deletions

View File

@@ -22,3 +22,5 @@ export type { LayerMask } from "./layer-mask";
export { getLayerMask, hasLayerMask } from "./layer-mask-utils";
export type { LayerGroup } from "./layer-group";
export type { RasterLayer } from "./raster-layer";
export type { TextAlignment, TextFontFamily, TextFontStyle, TextFontWeight, TextLayer, TextStyle } from "./text-layer";
export { builtInTextFonts, isValidTextStyle, measureTextLayer } from "./text-layer";

View File

@@ -2,5 +2,6 @@ import type { ImageLayer } from "./image-layer";
import type { LayerGroup } from "./layer-group";
import type { RasterLayer } from "./raster-layer";
import type { AdjustmentLayer } from "./adjustment-layer";
import type { TextLayer } from "./text-layer";
export type Layer = ImageLayer | RasterLayer | LayerGroup | AdjustmentLayer;
export type Layer = ImageLayer | RasterLayer | LayerGroup | AdjustmentLayer | TextLayer;

13
core/text-layer.test.ts Normal file
View File

@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";
import { isValidTextStyle, measureTextLayer, type TextLayer } from "./text-layer";
const layer: TextLayer = { id: "text-1", type: "text", name: "Title", visible: true, locked: false, opacity: 1, transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 }, content: "Hello\nWorld", style: { fontFamily: "Arial", fontSize: 20, fontWeight: 400, fontStyle: "normal", color: "#ffffff", alignment: "left", lineHeight: 1.25 } };
describe("text layer metrics", () => {
test("uses deterministic multiline bounds", () => expect(measureTextLayer(layer)).toEqual({ w: 54, h: 50 }));
test("validates the closed built-in typography policy", () => {
expect(isValidTextStyle(layer.style)).toBeTrue();
expect(isValidTextStyle({ ...layer.style, fontFamily: "Remote Font" as never })).toBeFalse();
expect(isValidTextStyle({ ...layer.style, color: "red" })).toBeFalse();
});
});

44
core/text-layer.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { BaseLayer } from "./base-layer";
import type { Size } from "./geometry";
export const builtInTextFonts = ["Arial", "Georgia", "Courier New", "Trebuchet MS"] as const;
export type TextFontFamily = typeof builtInTextFonts[number];
export type TextAlignment = "left" | "center" | "right";
export type TextFontStyle = "normal" | "italic";
export type TextFontWeight = 400 | 700;
export type TextStyle = {
fontFamily: TextFontFamily;
fontSize: number;
fontWeight: TextFontWeight;
fontStyle: TextFontStyle;
color: string;
alignment: TextAlignment;
lineHeight: number;
};
export type TextLayer = BaseLayer & {
type: "text";
content: string;
style: TextStyle;
};
/** Deterministic layout box shared by selection, renderer and export. */
export function measureTextLayer(layer: Pick<TextLayer, "content" | "style">): Size {
const lines = layer.content.split("\n");
const weightFactor = layer.style.fontWeight === 700 ? 1.04 : 1;
const italicFactor = layer.style.fontStyle === "italic" ? 1.03 : 1;
const familyFactor = layer.style.fontFamily === "Courier New" ? 0.62 : layer.style.fontFamily === "Georgia" ? 0.56 : 0.54;
const longest = Math.max(1, ...lines.map((line) => [...line].length));
return {
w: Math.max(1, longest * layer.style.fontSize * familyFactor * weightFactor * italicFactor),
h: Math.max(1, lines.length * layer.style.fontSize * layer.style.lineHeight),
};
}
export function isValidTextStyle(style: TextStyle): boolean {
return builtInTextFonts.includes(style.fontFamily) && Number.isFinite(style.fontSize) && style.fontSize >= 1 && style.fontSize <= 1000
&& (style.fontWeight === 400 || style.fontWeight === 700) && (style.fontStyle === "normal" || style.fontStyle === "italic")
&& /^#[0-9a-f]{6}$/i.test(style.color) && (style.alignment === "left" || style.alignment === "center" || style.alignment === "right")
&& Number.isFinite(style.lineHeight) && style.lineHeight >= 0.5 && style.lineHeight <= 5;
}