feat: add first-class text layers
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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
13
core/text-layer.test.ts
Normal 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
44
core/text-layer.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user