diff --git a/commands/document.ts b/commands/document.ts index c3b8ca8..0efa8ea 100644 --- a/commands/document.ts +++ b/commands/document.ts @@ -9,6 +9,8 @@ import type { RasterLayer } from "@core/raster-layer"; import type { Layer } from "@core/layer"; import type { LayerGroup } from "@core/layer-group"; import type { AdjustmentLayer, ColorAdjustment } from "@core/adjustment-layer"; +import type { TextLayer, TextStyle } from "@core/text-layer"; +import { isValidTextStyle } from "@core/text-layer"; import type { Command } from "./command"; import { commandIds } from "./ids"; @@ -76,6 +78,8 @@ export type DocumentAddGroupLayerPayload = { }; export type DocumentAddAdjustmentLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: AdjustmentLayer }; export type DocumentSetAdjustmentPayload = { layerId: LayerId; adjustment: ColorAdjustment }; +export type DocumentAddTextLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: TextLayer }; +export type DocumentSetTextLayerPayload = { layerId: LayerId; content: string; style: TextStyle }; export type DocumentMoveLayerPayload = { layerId: LayerId; @@ -365,6 +369,26 @@ export const documentAddAdjustmentLayerCommand: Command = { + id: commandIds.documentAddTextLayer, + name: "Add text layer", + execute({ state }, payload) { + if (!payload.layer.content.trim() || !isValidTextStyle(payload.layer.style)) return state; + const document = insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer); + return { ...state, document, editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.layer.id] } } }; + }, +}; + +export const documentSetTextLayerCommand: Command = { + id: commandIds.documentSetTextLayer, + name: "Edit text layer", + execute({ state }, payload) { + const location = findLayerLocation(state.document, payload.layerId); + if (!location || location.layer.type !== "text" || location.layer.locked || !payload.content.trim() || !isValidTextStyle(payload.style)) return state; + return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => layer.type === "text" ? { ...layer, content: payload.content, style: { ...payload.style } } : layer) }; + }, +}; + export const documentSetAdjustmentCommand: Command = { id: commandIds.documentSetAdjustment, name: "Edit adjustment layer", @@ -488,14 +512,14 @@ export const documentSetLayerSourceRectCommand: Command candidate.id === leaf.assetId); if (!asset) return state; const sourceRect = payload.sourceRect ? clampSourceRect(payload.sourceRect, asset.intrinsicSize.w, asset.intrinsicSize.h) : undefined; if (payload.sourceRect && !sourceRect) return state; return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => { - if (layer.type === "group" || layer.type === "adjustment") return layer; + if (layer.type !== "image" && layer.type !== "raster") return layer; if (sourceRect) return { ...layer, sourceRect }; const uncropped = { ...layer }; delete uncropped.sourceRect; @@ -574,7 +598,7 @@ export const documentSetLayerClippingMaskCommand: Command = name: "Add layer mask", execute({ state }, payload) { const targetLocation = findLayerLocation(state.document, payload.layerId); - if (!targetLocation || targetLocation.layer.type === "group" || targetLocation.layer.type === "adjustment") return state; + if (!targetLocation || (targetLocation.layer.type !== "image" && targetLocation.layer.type !== "raster")) return state; const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId; if (existingMaskId) { @@ -664,7 +688,7 @@ export const documentApplyLayerMaskOperationCommand: Command { + test("creates, selects and edits authoritative text", () => { + const base = documentAddArtboardCommand.execute({ state: createInitialAppState("Test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } }); + const added = documentAddTextLayerCommand.execute({ state: base }, { artboardId: "board", layer: text }); + expect(added.editor.selection.layerIds).toEqual(["text"]); + const edited = documentSetTextLayerCommand.execute({ state: added }, { layerId: "text", content: "Changed", style: { ...text.style, fontWeight: 700 } }); + expect(edited.document.artboards[0]?.layers[0]).toMatchObject({ type: "text", content: "Changed", style: { fontWeight: 700 } }); + }); + + test("can be grouped as a real tree node", () => { + const base = documentAddArtboardCommand.execute({ state: createInitialAppState("Test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } }); + const added = documentAddTextLayerCommand.execute({ state: base }, { artboardId: "board", layer: text }); + const grouped = documentGroupLayersCommand.execute({ state: added }, { artboardId: "board", layerIds: ["text"], group: { id: "group", type: "group", name: "Group", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, children: [] } }); + expect(grouped.document.artboards[0]?.layers[0]).toMatchObject({ type: "group", children: [{ id: "text", type: "text" }] }); + }); +}); diff --git a/commands/transform-document.ts b/commands/transform-document.ts index e4af0a7..b4bc5f8 100644 --- a/commands/transform-document.ts +++ b/commands/transform-document.ts @@ -5,6 +5,7 @@ import type { Layer } from "@core/layer"; import { getLayerMask } from "@core/layer-mask-utils"; import { resolveTransformTargetBounds } from "@editor/transform-targets"; import type { TransformTarget } from "@editor/transform"; +import { measureTextLayer } from "@core/text-layer"; export function applyTransformTargetBounds(document: ImageDocument, target: TransformTarget, bounds: Rect): ImageDocument { if (target.type === "artboard") return { ...document, artboards: document.artboards.map((artboard) => artboard.id === target.id ? { ...artboard, bounds: { ...bounds } } : artboard) }; @@ -40,16 +41,16 @@ function mapGroupBounds(document: ImageDocument, layers: Layer[], groupId: Layer function scaleSubtree(document: ImageDocument, layer: Layer, initial: Rect, bounds: Rect, scale: { x: number; y: number }): Layer { if (layer.type === "group") return { ...layer, children: layer.children.map((child) => scaleSubtree(document, child, initial, bounds, scale)) }; if (layer.type === "adjustment") return layer; - if (!document.assets.some((asset) => asset.id === layer.assetId)) return layer; + if (layer.type !== "text" && !document.assets.some((asset) => asset.id === layer.assetId)) return layer; return { ...layer, transform: { ...layer.transform, position: { x: bounds.x + (layer.transform.position.x - initial.x) * scale.x, y: bounds.y + (layer.transform.position.y - initial.y) * scale.y }, scale: { x: layer.transform.scale.x * scale.x, y: layer.transform.scale.y * scale.y } } }; } function mapLeafBounds(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] { return layers.map((layer) => { if (layer.id === layerId && layer.type !== "group" && layer.type !== "adjustment") { - const asset = document.assets.find((candidate) => candidate.id === layer.assetId); - if (!asset) return layer; - const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize }; + const asset = layer.type === "text" ? undefined : document.assets.find((candidate) => candidate.id === layer.assetId); + if (layer.type !== "text" && !asset) return layer; + const source = layer.type === "text" ? { x: 0, y: 0, ...measureTextLayer(layer) } : layer.sourceRect ?? { x: 0, y: 0, ...asset!.intrinsicSize }; const scale = { x: bounds.w / source.w, y: bounds.h / source.h }; return { ...layer, transform: { ...layer.transform, position: { x: bounds.x - source.x * scale.x, y: bounds.y - source.y * scale.y }, scale } }; } diff --git a/core/index.ts b/core/index.ts index 13d9953..6d6312d 100644 --- a/core/index.ts +++ b/core/index.ts @@ -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"; diff --git a/core/layer.ts b/core/layer.ts index 4738ef8..c04f63b 100644 --- a/core/layer.ts +++ b/core/layer.ts @@ -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; diff --git a/core/text-layer.test.ts b/core/text-layer.test.ts new file mode 100644 index 0000000..9b397aa --- /dev/null +++ b/core/text-layer.test.ts @@ -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(); + }); +}); diff --git a/core/text-layer.ts b/core/text-layer.ts new file mode 100644 index 0000000..b7cce70 --- /dev/null +++ b/core/text-layer.ts @@ -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): 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; +} diff --git a/editor/document-indexes.ts b/editor/document-indexes.ts index ee7bfd0..41c5987 100644 --- a/editor/document-indexes.ts +++ b/editor/document-indexes.ts @@ -4,6 +4,7 @@ import type { Rect } from "@core/geometry"; import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { Layer } from "@core/layer"; import { getLayerMask } from "@core/layer-mask-utils"; +import { measureTextLayer } from "@core/text-layer"; export type IndexedLayerInfo = { artboardId: ArtboardId; @@ -77,6 +78,10 @@ export function resolveIndexedLayerBounds(index: DocumentReadIndex, layerOrId: L return unionLayerBounds(index, layer.children); case "adjustment": return undefined; + case "text": { + const size = measureTextLayer(layer); + return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y }; + } case "image": case "raster": { const asset = index.assetById.get(layer.assetId); diff --git a/editor/transform-targets.ts b/editor/transform-targets.ts index e76fe94..51b81af 100644 --- a/editor/transform-targets.ts +++ b/editor/transform-targets.ts @@ -3,6 +3,7 @@ import type { Rect } from "@core/geometry"; import type { Layer } from "@core/layer"; import type { ArtboardId, LayerId } from "@core/id"; import type { TransformTarget } from "./transform"; +import { measureTextLayer } from "@core/text-layer"; export function resolveTransformTargetBounds(document: ImageDocument, target: TransformTarget): Rect | undefined { switch (target.type) { @@ -62,6 +63,12 @@ function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undef h: source.h * layer.transform.scale.y, }; } + case "text": { + const size = measureTextLayer(layer); + return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y }; + } + case "adjustment": + return undefined; } } diff --git a/input/document-geometry.ts b/input/document-geometry.ts index 281a20b..5d693fb 100644 --- a/input/document-geometry.ts +++ b/input/document-geometry.ts @@ -1,5 +1,6 @@ import type { Rect, Size, Vec2D } from "@core/geometry"; import type { InputArtboardId, InputDocument, InputLayer, InputLayerId } from "./read-model"; +import { measureTextLayer } from "@core/text-layer"; export type InputViewportState = { center: Vec2D; @@ -90,6 +91,12 @@ function resolveLayerBounds(document: InputDocument, layer: InputLayer): Rect | h: asset.intrinsicSize.h * layer.transform.scale.y, }; } + case "text": { + const size = measureTextLayer(layer); + return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y }; + } + case "adjustment": + return undefined; } } diff --git a/input/read-model.ts b/input/read-model.ts index 3c39f4e..a7f290b 100644 --- a/input/read-model.ts +++ b/input/read-model.ts @@ -1,4 +1,5 @@ import type { Rect, Size, Transform } from "@core/geometry"; +import type { TextStyle } from "@core/text-layer"; export type InputLayerId = string; export type InputArtboardId = string; @@ -17,6 +18,7 @@ type InputBaseLayer = { export type InputLayer = | (InputBaseLayer & { type: "group"; children: InputLayer[] }) | (InputBaseLayer & { type: "adjustment" }) + | (InputBaseLayer & { type: "text"; content: string; style: TextStyle }) | (InputBaseLayer & { type: "image" | "raster"; assetId: string }); export type InputDocument = { diff --git a/operations/document/layerActions.ts b/operations/document/layerActions.ts index 713f19d..2559bfd 100644 --- a/operations/document/layerActions.ts +++ b/operations/document/layerActions.ts @@ -67,6 +67,16 @@ export function addAdjustmentLayer(artboardId: ArtboardId, dispatch: AppStore["d dispatch(commandIds.documentAddAdjustmentLayer, { artboardId, layer: { id: crypto.randomUUID(), type: "adjustment", name: "Color adjustment", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, adjustment: { ...neutralColorAdjustment, colorBalance: { ...neutralColorAdjustment.colorBalance } } } }); } +export function addTextLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) { + const artboard = document.artboards.find((candidate) => candidate.id === artboardId); + if (!artboard) return; + dispatch(commandIds.documentAddTextLayer, { artboardId, parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined, layer: { + id: crypto.randomUUID(), type: "text", name: "Text", visible: true, locked: false, opacity: 1, + transform: { position: { x: artboard.bounds.x + 40, y: artboard.bounds.y + 40 }, scale: { x: 1, y: 1 }, rotation: 0 }, + content: "Text", style: { fontFamily: "Arial", fontSize: 48, fontWeight: 400, fontStyle: "normal", color: "#ffffff", alignment: "left", lineHeight: 1.2 }, + } }); +} + export function groupLayers(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) { dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") }); } @@ -110,7 +120,7 @@ export function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerIn export function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) { const layer = layerInfo.layer; - if (layer.type === "group" || layer.type === "adjustment") return; + if (layer.type === "group" || layer.type === "adjustment" || layer.type === "text") return; const asset = documentIndex.assetById.get(layer.assetId); const bounds = resolveIndexedLayerBounds(documentIndex, layer); diff --git a/operations/generation/inpaintPrep.ts b/operations/generation/inpaintPrep.ts index 261c02e..248890c 100644 --- a/operations/generation/inpaintPrep.ts +++ b/operations/generation/inpaintPrep.ts @@ -134,7 +134,7 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState const documentIndex = createDocumentReadIndex(document); const layerInfo = documentIndex.layerInfoById.get(selection.layerIds[0]); - if (!layerInfo || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") throw new Error("Select one image or raster layer to inpaint."); + if (!layerInfo || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) throw new Error("Select one image or raster layer to inpaint. Text must be rasterized first."); const asset = documentIndex.assetById.get(layerInfo.layer.assetId); if (!asset) throw new Error("The selected layer is missing its source image."); @@ -142,7 +142,7 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState if (!layerMask?.enabled) throw new Error("Add a layer mask before running inpaint."); const maskLayer = documentIndex.layerById.get(layerMask.maskLayerId); - if (!maskLayer || maskLayer.type === "group" || maskLayer.type === "adjustment") throw new Error("The selected layer mask is missing."); + if (!maskLayer || (maskLayer.type !== "image" && maskLayer.type !== "raster")) throw new Error("The selected layer mask is missing."); const maskAsset = documentIndex.assetById.get(maskLayer.assetId); if (!maskAsset) throw new Error("The selected layer mask is missing its image data."); diff --git a/operations/generation/outputPlacement.ts b/operations/generation/outputPlacement.ts index c48dbb3..84de1ba 100644 --- a/operations/generation/outputPlacement.ts +++ b/operations/generation/outputPlacement.ts @@ -85,7 +85,7 @@ function resolveTarget(document: ImageDocument, selection: SelectionState, artbo const layerId = selection.layerIds[0]; const index = createDocumentReadIndex(document); const layerInfo = layerId ? index.layerInfoById.get(layerId) : undefined; - if (!layerInfo || layerInfo.artboardId !== artboardId || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") { + if (!layerInfo || layerInfo.artboardId !== artboardId || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) { throw new Error("Select one image or raster layer before placing generated output."); } const asset = index.assetById.get(layerInfo.layer.assetId); diff --git a/operations/generation/preconditions.ts b/operations/generation/preconditions.ts index 3faa6ab..a0e0563 100644 --- a/operations/generation/preconditions.ts +++ b/operations/generation/preconditions.ts @@ -27,7 +27,7 @@ export function checkGenerationPreconditions( const index = createDocumentReadIndex(document); const layerInfo = index.layerInfoById.get(selection.layerIds[0]); - if (!layerInfo || layerInfo.artboardId !== artboard.id || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") { + if (!layerInfo || layerInfo.artboardId !== artboard.id || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) { return missing(modeSelectionMessage(settings.mode)); } @@ -47,7 +47,7 @@ export function checkGenerationPreconditions( const layerMask = getLayerMask(layerInfo.layer); if (!layerMask?.enabled) return missing("Paint a mask over the area you want AI to replace.", "add-mask"); const maskLayer = index.layerById.get(layerMask.maskLayerId); - if (!maskLayer || maskLayer.type === "group" || maskLayer.type === "adjustment") return missing("The selected layer mask is missing."); + if (!maskLayer || (maskLayer.type !== "image" && maskLayer.type !== "raster")) return missing("The selected layer mask is missing."); const maskAsset = index.assetById.get(maskLayer.assetId); if (!maskAsset) return missing("The selected layer mask is missing its image data."); if (Math.round(asset.intrinsicSize.w) !== Math.round(maskAsset.intrinsicSize.w) || Math.round(asset.intrinsicSize.h) !== Math.round(maskAsset.intrinsicSize.h)) { diff --git a/operations/generation/runGenerate.ts b/operations/generation/runGenerate.ts index e2c1c2a..294f8a6 100644 --- a/operations/generation/runGenerate.ts +++ b/operations/generation/runGenerate.ts @@ -210,11 +210,11 @@ function resolveSelectedImage(document: ImageDocument, selection: SelectionState const layerId = selection.layerIds[0]; if (!layerId) return undefined; const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId); - if (!layer || layer.type === "group" || layer.type === "adjustment") return undefined; + if (!layer || (layer.type !== "image" && layer.type !== "raster")) return undefined; const asset = document.assets.find((candidate) => candidate.id === layer.assetId); const layerMask = getLayerMask(layer); const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined; - const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; + const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; return asset ? { layer, asset, maskAsset } : undefined; } diff --git a/operations/generation/workflow.ts b/operations/generation/workflow.ts index fad9afe..11e2890 100644 --- a/operations/generation/workflow.ts +++ b/operations/generation/workflow.ts @@ -57,7 +57,7 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati if (!layerId) return; const artboard = state.document.artboards.find((candidate) => candidate.id === state.editor.selection.artboardId); const layer = artboard ? findLayer(artboard.layers, layerId) : undefined; - if (!layer || layer.type === "group" || layer.type === "adjustment") return; + if (!layer || (layer.type !== "image" && layer.type !== "raster")) return; const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId); if (!asset) return; const source = await dependencies.createRefinementMask(asset.intrinsicSize.w, asset.intrinsicSize.h); diff --git a/operations/masks/chromaKey.ts b/operations/masks/chromaKey.ts index 1571da7..7f83703 100644 --- a/operations/masks/chromaKey.ts +++ b/operations/masks/chromaKey.ts @@ -14,19 +14,19 @@ export function resolveChromaKeyTarget(document: ImageDocument, selection: Selec const layerId = selection.layerIds[0]; if (selection.layerIds.length !== 1 || !layerId) return undefined; const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId); - if (!layer || layer.type === "group" || layer.type === "adjustment") return undefined; + if (!layer || (layer.type !== "image" && layer.type !== "raster")) return undefined; const asset = document.assets.find((candidate) => candidate.id === layer.assetId); const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); const layerMask = getLayerMask(layer); const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined; - const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; + const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; } export async function applyChromaKeyMask(target: NonNullable>, settings: ChromaKeySettings, dispatch: AppStore["dispatch"]) { const source = await createChromaKeyMask(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings); dispatch(commandIds.toolSetBrushStrokePreview, undefined); - if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group" && target.maskLayer.type !== "adjustment") { + if (target.maskAsset && target.maskLayer && (target.maskLayer.type === "image" || target.maskLayer.type === "raster")) { dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "chromaKey" } }); dispatch(commandIds.workspaceSetPanel, { panel: "none" }); return; diff --git a/operations/masks/magic-wand.ts b/operations/masks/magic-wand.ts index 54dcae7..4f57484 100644 --- a/operations/masks/magic-wand.ts +++ b/operations/masks/magic-wand.ts @@ -17,7 +17,7 @@ export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverri const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y)); if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true; const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode }); - if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group" && target.maskLayer.type !== "adjustment") { + if (target.maskAsset && target.maskLayer && (target.maskLayer.type === "image" || target.maskLayer.type === "raster")) { store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "magicWand" } }); return true; } @@ -39,12 +39,12 @@ function resolveTarget(document: ImageDocument, editor: EditorState) { const layerId = editor.selection.layerIds[0]; if (!layerId || editor.selection.layerIds.length !== 1) return undefined; const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId); - if (!layer || layer.type === "group" || layer.type === "adjustment") return undefined; + if (!layer || (layer.type !== "image" && layer.type !== "raster")) return undefined; const asset = document.assets.find((candidate) => candidate.id === layer.assetId); const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); const layerMask = getLayerMask(layer); const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined; - const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; + const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; } diff --git a/operations/project/format.test.ts b/operations/project/format.test.ts index c067eec..4826d62 100644 --- a/operations/project/format.test.ts +++ b/operations/project/format.test.ts @@ -56,6 +56,14 @@ describe("project format", () => { expect(() => parseProject(serializeProject(document))).toThrow("artboard level"); }); + test("round-trips valid text and rejects unsupported typography", () => { + const document = projectDocument(); + document.artboards[0]!.layers.unshift({ id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: .8, transform: { position: { x: 1, y: 2 }, scale: { x: 1, y: 1 }, rotation: .2 }, content: "Hello", style: { fontFamily: "Arial", fontSize: 20, fontWeight: 700, fontStyle: "italic", color: "#123456", alignment: "right", lineHeight: 1.2 } }); + expect(parseProject(serializeProject(document)).document.artboards[0]?.layers[0]?.type).toBe("text"); + const invalid = JSON.parse(serializeProject(document)); invalid.document.artboards[0].layers[0].style.fontFamily = "Web Font"; + expect(() => parseProject(JSON.stringify(invalid))).toThrow("invalid typography"); + }); + test("creates safe project file names", () => { expect(projectFileName(" Summer / Study ")).toBe("Summer-Study.image-studio.json"); expect(projectFileName("***")).toBe("untitled.image-studio.json"); diff --git a/operations/project/format.ts b/operations/project/format.ts index 7a7f1f3..756d74b 100644 --- a/operations/project/format.ts +++ b/operations/project/format.ts @@ -1,6 +1,7 @@ import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; import type { Rect } from "@core/geometry"; +import { isValidTextStyle, type TextStyle } from "@core/text-layer"; export const PROJECT_FORMAT = "image-studio-project"; export const CURRENT_PROJECT_VERSION = 1; @@ -115,11 +116,14 @@ function assertLayers(value: unknown[], nested: boolean): asserts value is Layer if (nested) throw new Error("Adjustment layers must stay at artboard level."); if (!isAdjustment(layer.adjustment)) throw new Error("A project adjustment layer contains invalid settings."); if (layer.layerMask !== undefined || layer.clippingMask !== undefined) throw new Error("Adjustment layers do not support masks."); + } else if (layer.type === "text") { + if (typeof layer.content !== "string" || !layer.content.trim() || !isRecord(layer.style) || !isValidTextStyle(layer.style as TextStyle)) throw new Error("A project text layer contains invalid typography."); + if (layer.layerMask !== undefined || layer.clippingMask !== undefined) throw new Error("Text layers do not support masks. Rasterize text before masking."); } else if ((layer.type === "image" || layer.type === "raster") && typeof layer.assetId !== "string") { throw new Error("A project layer is missing its asset reference."); } else if ((layer.type === "image" || layer.type === "raster") && layer.sourceRect !== undefined && (!isRect(layer.sourceRect) || layer.sourceRect.w < 1 || layer.sourceRect.h < 1)) { throw new Error("A project layer contains an invalid source crop."); - } else if (layer.type !== "image" && layer.type !== "raster") { + } else if (layer.type !== "image" && layer.type !== "raster" && layer.type !== "text") { throw new Error(`Unsupported layer type: ${layer.type}.`); } } diff --git a/platform/browser/exportArtboardPng.test.ts b/platform/browser/exportArtboardPng.test.ts index e4de366..4bdefce 100644 --- a/platform/browser/exportArtboardPng.test.ts +++ b/platform/browser/exportArtboardPng.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { resolveLayerDrawImage } from "./exportArtboardPng"; +import { resolveLayerDrawImage, resolveTextLayerDraw } from "./exportArtboardPng"; describe("artboard PNG export", () => { test("draws only cropped source pixels at their retained document position", () => { @@ -14,4 +14,11 @@ describe("artboard PNG export", () => { destination: { x: 120, y: 65, w: 80, h: 60 }, }); }); + + test("uses deterministic text data centered for renderer rotation parity", () => { + const draw = resolveTextLayerDraw({ id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: 1, transform: { position: { x: 10, y: 20 }, scale: { x: 2, y: 3 }, rotation: 1 }, content: "AB", style: { fontFamily: "Arial", fontSize: 10, fontWeight: 400, fontStyle: "normal", color: "#ffffff", alignment: "center", lineHeight: 1.2 } }); + expect(draw.size).toEqual({ w: 10.8, h: 12 }); + expect(draw.center).toEqual({ x: 20.8, y: 38 }); + expect(draw.textX).toBe(0); + }); }); diff --git a/platform/browser/exportArtboardPng.ts b/platform/browser/exportArtboardPng.ts index b52cbed..7b95dd2 100644 --- a/platform/browser/exportArtboardPng.ts +++ b/platform/browser/exportArtboardPng.ts @@ -4,6 +4,7 @@ import type { Rect } from "@core/geometry"; import type { Layer } from "@core/layer"; import type { Size } from "@core/geometry"; import { getLayerMask } from "@core/layer-mask-utils"; +import { measureTextLayer } from "@core/text-layer"; export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) { const width = Math.max(1, Math.round(artboard.bounds.w)); @@ -71,6 +72,12 @@ async function drawLayer( return; } + if (layer.type === "text") { + drawTextLayer(context, layer); + context.restore(); + return; + } + const asset = assets.find((candidate) => candidate.id === layer.assetId); if (!asset) { context.restore(); @@ -84,6 +91,29 @@ async function drawLayer( context.restore(); } +export function drawTextLayer(context: CanvasRenderingContext2D, layer: Extract) { + const draw = resolveTextLayerDraw(layer); + const { size } = draw; + context.translate(draw.center.x, draw.center.y); + context.rotate(layer.transform.rotation); + context.scale(layer.transform.scale.x, layer.transform.scale.y); + context.fillStyle = layer.style.color; + context.font = `${layer.style.fontStyle} ${layer.style.fontWeight} ${layer.style.fontSize}px ${JSON.stringify(layer.style.fontFamily)}, Arial, sans-serif`; + context.textAlign = layer.style.alignment; + context.textBaseline = "alphabetic"; + const x = draw.textX; + layer.content.split("\n").forEach((line, index) => context.fillText(line, x, (index + 0.82) * layer.style.fontSize * layer.style.lineHeight - size.h / 2)); +} + +export function resolveTextLayerDraw(layer: Extract) { + const size = measureTextLayer(layer); + return { + size, + center: { x: layer.transform.position.x + size.w * layer.transform.scale.x / 2, y: layer.transform.position.y + size.h * layer.transform.scale.y / 2 }, + textX: (layer.style.alignment === "left" ? 0 : layer.style.alignment === "center" ? size.w / 2 : size.w) - size.w / 2, + }; +} + export function resolveLayerDrawImage(layer: Extract, intrinsicSize: Size) { const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize }; return { diff --git a/renderer/layers.ts b/renderer/layers.ts index 0c71f02..57ecf58 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -10,6 +10,7 @@ import type { ImageTextureRenderer } from "./image-textures"; import { documentRectToScreenRect } from "./screen-rect"; import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types"; import type { AdjustmentPass } from "./adjustment-pass"; +import { textLayerRenderAsset } from "./text-asset"; const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1]; const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1]; @@ -63,10 +64,22 @@ function renderLayerTree( adjustmentPass.apply(layer.adjustment, effectiveOpacity, effectiveClipRect); continue; } - renderLeafLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity); + if (layer.type === "text") { + renderTextLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity); + } else { + renderLeafLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity); + } } } +function renderTextLayer(context: WebGlRendererContext, documentIndex: DocumentReadIndex, editor: EditorState, layer: Extract, imageTextureRenderer: ImageTextureRenderer, clipRect: ScreenRect, opacity: number) { + if (editor.maskEdit?.viewMode && isIsolatedMaskView(editor.maskEdit.viewMode)) return; + const bounds = resolveIndexedLayerBounds(documentIndex, layer); + if (!bounds) return; + const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport); + imageTextureRenderer.render(textLayerRenderAsset(layer), rect, clipRect, opacity, layer.transform.rotation); +} + function renderLeafLayer( context: WebGlRendererContext, documentIndex: DocumentReadIndex, @@ -88,7 +101,7 @@ function renderLeafLayer( const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor); const layerMask = getLayerMask(layer); const maskLayer = !editingMaskLayer && layerMask?.enabled ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined; - const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor); + const maskAsset = assetWithBrushStrokePreview(maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor); const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined; const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined; const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId); diff --git a/renderer/text-asset.test.ts b/renderer/text-asset.test.ts new file mode 100644 index 0000000..e7a6ef2 --- /dev/null +++ b/renderer/text-asset.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test"; +import { textLayerRenderAsset } from "./text-asset"; + +describe("text render data", () => { + test("creates an owned SVG texture with matching deterministic dimensions", () => { + const asset = textLayerRenderAsset({ id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, content: "A&B", style: { fontFamily: "Georgia", fontSize: 10, fontWeight: 700, fontStyle: "italic", color: "#abcdef", alignment: "center", lineHeight: 1.2 } }); + expect(asset.mimeType).toBe("image/svg+xml"); + expect(asset.source).toStartWith("data:image/svg+xml,"); + expect(decodeURIComponent(asset.source)).toContain("A&B"); + expect(asset.intrinsicSize.w).toBeGreaterThan(0); + }); +}); diff --git a/renderer/text-asset.ts b/renderer/text-asset.ts new file mode 100644 index 0000000..fce35fa --- /dev/null +++ b/renderer/text-asset.ts @@ -0,0 +1,19 @@ +import type { Asset } from "@core/asset"; +import type { TextLayer } from "@core/text-layer"; +import { measureTextLayer } from "@core/text-layer"; + +/** Produces a derived SVG texture; text content remains the only authoritative state. */ +export function textLayerRenderAsset(layer: TextLayer): Asset { + const size = measureTextLayer(layer); + const lines = layer.content.split("\n"); + const anchor = layer.style.alignment === "left" ? "start" : layer.style.alignment === "center" ? "middle" : "end"; + const x = layer.style.alignment === "left" ? 0 : layer.style.alignment === "center" ? size.w / 2 : size.w; + const family = escapeXml(layer.style.fontFamily); + const tspans = lines.map((line, index) => `${escapeXml(line || " ")}`).join(""); + const svg = `${tspans}`; + return { id: `text-render:${layer.id}`, name: layer.name, mimeType: "image/svg+xml", source: `data:image/svg+xml,${encodeURIComponent(svg)}`, intrinsicSize: size }; +} + +function escapeXml(value: string) { + return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!); +} diff --git a/view/LayersSheet.tsx b/view/LayersSheet.tsx index 7389cf1..9ce4a17 100644 --- a/view/LayersSheet.tsx +++ b/view/LayersSheet.tsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react"; -import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, SlidersHorizontal, Stack, Trash } from "@phosphor-icons/react"; +import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, SlidersHorizontal, Stack, Trash, TextT } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; import type { ColorAdjustment } from "@core/adjustment-layer"; +import type { TextLayer, TextStyle } from "@core/text-layer"; +import { builtInTextFonts } from "@core/text-layer"; import { getLayerMask } from "@core/layer-mask-utils"; import type { ArtboardId } from "@core/id"; import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/document-indexes"; @@ -11,7 +13,7 @@ import type { MaskEditState, SelectionState } from "@editor/state"; import type { AppStore } from "@editor/store"; import { resolveLayerDrop } from "@input/index"; import type { DocumentActions } from "@app/document-actions"; -import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions"; +import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, addTextLayer, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions"; import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls"; import { LayerThumbnail } from "./layers/LayerThumbnail"; import { createLayerThumbnailIndex, type LayerThumbnailModel } from "./layers/thumbnailModel"; @@ -95,6 +97,7 @@ function LayersSheetBody({ +
@@ -115,6 +118,7 @@ function LayersSheetBody({
{selectedLayer?.layer.type === "adjustment" ? : null} + {selectedLayer?.layer.type === "text" ? : null}
{document.artboards.map((artboard) => { const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0; @@ -204,6 +208,28 @@ function LayersSheetBody({ ); } +function TextInspector({ layer, dispatch }: { layer: TextLayer; dispatch: AppStore["dispatch"] }) { + const [content, setContent] = useState(layer.content); + const [style, setStyle] = useState({ ...layer.style }); + useEffect(() => { setContent(layer.content); setStyle({ ...layer.style }); }, [layer.id, layer.content, layer.style]); + const commit = () => dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style }); + return
+