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; export type ProjectFile = { format: typeof PROJECT_FORMAT; version: typeof CURRENT_PROJECT_VERSION; savedAt: string; document: ImageDocument; }; export function serializeProject(document: ImageDocument, savedAt = new Date().toISOString()): string { assertDocumentAssetOwnership(document); return JSON.stringify({ format: PROJECT_FORMAT, version: CURRENT_PROJECT_VERSION, savedAt, document } satisfies ProjectFile); } export function parseProject(source: string): ProjectFile { let value: unknown; try { value = JSON.parse(source); } catch { throw new Error("The selected file is not valid JSON."); } const migrated = migrateProject(value); assertImageDocument(migrated.document); assertDocumentAssetOwnership(migrated.document); return migrated; } export function projectFileName(documentName: string): string { const base = documentName.trim().replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "") || "untitled"; return `${base}.image-studio.json`; } function migrateProject(value: unknown): ProjectFile { if (!isRecord(value)) throw new Error("The project file must contain an object."); if (value.format === PROJECT_FORMAT) { if (value.version !== CURRENT_PROJECT_VERSION) { throw new Error(`Unsupported project version: ${String(value.version)}.`); } if (typeof value.savedAt !== "string") throw new Error("The project is missing its save timestamp."); return value as ProjectFile; } // Legacy exports stored ImageDocument directly. Loading upgrades them to v1. if (looksLikeDocument(value)) { return { format: PROJECT_FORMAT, version: CURRENT_PROJECT_VERSION, savedAt: new Date(0).toISOString(), document: value as ImageDocument, }; } throw new Error("This is not an Image Studio project file."); } function assertImageDocument(value: unknown): asserts value is ImageDocument { if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string" || typeof value.version !== "number") { throw new Error("The project contains an invalid document."); } if (!Array.isArray(value.artboards) || !Array.isArray(value.assets)) throw new Error("The project document is incomplete."); for (const asset of value.assets) { if (!isRecord(asset) || typeof asset.id !== "string" || typeof asset.name !== "string" || typeof asset.mimeType !== "string" || typeof asset.source !== "string" || !isSize(asset.intrinsicSize)) { throw new Error("The project contains an invalid asset."); } } for (const artboard of value.artboards) { if (!isRecord(artboard) || typeof artboard.id !== "string" || typeof artboard.name !== "string" || !isRect(artboard.bounds) || !Array.isArray(artboard.layers)) { throw new Error("The project contains an invalid artboard."); } assertLayers(artboard.layers, false); } } export function assertDocumentAssetOwnership(document: ImageDocument): void { const assetIds = new Set(); for (const asset of document.assets) { if (assetIds.has(asset.id)) throw new Error(`Duplicate asset id: ${asset.id}.`); assetIds.add(asset.id); if (!isOwnedAssetSource(asset.source)) throw new Error(`Asset ${asset.name} is not embedded in the project.`); } const layerIds = new Set(); for (const artboard of document.artboards) { walkLayers(artboard.layers, (layer) => { if (layerIds.has(layer.id)) throw new Error(`Duplicate layer id: ${layer.id}.`); layerIds.add(layer.id); if ((layer.type === "image" || layer.type === "raster") && !assetIds.has(layer.assetId)) { throw new Error(`Layer ${layer.name} references missing asset ${layer.assetId}.`); } }); } } function isOwnedAssetSource(source: string): boolean { return source.startsWith("data:"); } function assertLayers(value: unknown[], nested: boolean): asserts value is Layer[] { for (const layer of value) { if (!isRecord(layer) || typeof layer.id !== "string" || typeof layer.name !== "string" || typeof layer.type !== "string" || typeof layer.visible !== "boolean" || typeof layer.locked !== "boolean" || typeof layer.opacity !== "number" || !isTransform(layer.transform)) { throw new Error("The project contains an invalid layer."); } if (layer.type === "group") { if (!Array.isArray(layer.children)) throw new Error("A project group is missing its children."); assertLayers(layer.children, true); } else if (layer.type === "adjustment") { 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" && layer.type !== "text") { throw new Error(`Unsupported layer type: ${layer.type}.`); } } } function isAdjustment(value: unknown): boolean { if (!isRecord(value) || !isRecord(value.colorBalance)) return false; return [value.brightness, value.contrast, value.saturation, value.colorBalance.red, value.colorBalance.green, value.colorBalance.blue] .every((number) => isFiniteNumber(number) && number >= -1 && number <= 1); } function walkLayers(layers: Layer[], visit: (layer: Layer) => void): void { for (const layer of layers) { visit(layer); if (layer.type === "group") walkLayers(layer.children, visit); } } function looksLikeDocument(value: Record): boolean { return typeof value.id === "string" && Array.isArray(value.artboards) && Array.isArray(value.assets); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isSize(value: unknown): boolean { return isRecord(value) && isFiniteNumber(value.w) && isFiniteNumber(value.h) && value.w >= 0 && value.h >= 0; } function isRect(value: unknown): value is Rect { return isRecord(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y) && isFiniteNumber(value.w) && isFiniteNumber(value.h); } function isTransform(value: unknown): boolean { return isRecord(value) && isRecord(value.position) && isFiniteNumber(value.position.x) && isFiniteNumber(value.position.y) && isRecord(value.scale) && isFiniteNumber(value.scale.x) && isFiniteNumber(value.scale.y) && isFiniteNumber(value.rotation); } function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); }