This commit is contained in:
@@ -27,5 +27,7 @@ jobs:
|
||||
with tarfile.open(fileobj=io.BytesIO(data),mode='r:gz') as archive:archive.extractall(directory,filter='data')
|
||||
shutil.copytree(next(Path(directory).iterdir()),Path.cwd(),dirs_exist_ok=True)
|
||||
PY
|
||||
- run: npm ci
|
||||
- run: npm run check
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tgz
|
||||
coverage/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @entroia/poster-editor
|
||||
|
||||
跨 Web 与小程序的 Schema 驱动图片编辑器核心。仓库名是工程容器,实际包名为 `@entroia/poster-editor`。
|
||||
跨 Web 与小程序的 Schema 驱动图片编辑器核心。仓库名是工程容器,实际包名为 `@entroia/poster-editor`。公共源码、渲染器、小程序类型入口和声明文件均由 TypeScript 构建,包同时提供 ESM、CommonJS 与 `.d.ts`。
|
||||
|
||||
核心采用非破坏性 `PosterDocument`:背景、分组、图片、矢量、文字、贴纸和笔刷均为可独立绑定工具与效果的图层。业务图谱先转换为通用 vector/group 节点,编辑器不包含 PK 规则。
|
||||
|
||||
@@ -11,3 +11,5 @@
|
||||
花字按 `documentSeed + layerId + 字符逻辑位置` 哈希出受 `maxAngle` 限制的角度,因此重绘、导出和服务端复现时保持一致;背景色块可直接随测量宽度增长,图片背景使用 `capInsets` 九宫格拉伸,边角和装饰不会变形。
|
||||
|
||||
花花笔刷属于 Stamp Brush:沿采样后的路径重复绘制透明花朵素材,而不是一条特殊颜色的线。每个落点可保存尺寸、角度和素材 ID,因此撤销、重放与跨端导出是确定的。
|
||||
|
||||
开发时使用 `npm install`、`npm run check`、`npm test` 和 `npm run build`。构建产物位于 `dist/`;现有小程序若尚未接入包构建流程,可继续使用 `./xhs-legacy` 兼容入口。
|
||||
|
||||
Generated
+2045
File diff suppressed because it is too large
Load Diff
+18
-8
@@ -3,18 +3,28 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Schema-driven, layer-based poster and image editor core for Web and mini apps",
|
||||
"type": "module",
|
||||
"main": "./src/index.js",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./renderer": "./src/renderer.js",
|
||||
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" },
|
||||
"./renderer": { "types": "./dist/renderer.d.ts", "import": "./dist/renderer.js", "require": "./dist/renderer.cjs" },
|
||||
"./schema": "./schema/poster-document.schema.json",
|
||||
"./xhs": "./xhs/index.cjs"
|
||||
"./xhs": { "types": "./dist/xhs.d.ts", "import": "./dist/xhs.js", "require": "./dist/xhs.cjs" },
|
||||
"./xhs-legacy": "./xhs/index.cjs"
|
||||
},
|
||||
"files": ["src", "schema", "xhs"],
|
||||
"files": ["dist", "schema", "xhs"],
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"check": "node --check src/index.js && node --check src/renderer.js && node --check xhs/index.cjs"
|
||||
"build": "tsup src/index.ts src/renderer.ts src/xhs.ts --format esm,cjs --dts --sourcemap --clean",
|
||||
"test": "npm run build && node --test test/*.test.mjs",
|
||||
"check": "tsc --noEmit && node --check xhs/index.cjs",
|
||||
"prepack": "npm run check && npm test && npm run build"
|
||||
},
|
||||
"engines": { "node": ">=20.9" },
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.0",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": { "node": ">=20" },
|
||||
"license": "UNLICENSED"
|
||||
}
|
||||
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
export const DOCUMENT_VERSION = 1;
|
||||
|
||||
export const LAYER_TYPES = Object.freeze([
|
||||
'background', 'group', 'image', 'vector', 'text', 'sticker', 'brush'
|
||||
]);
|
||||
|
||||
export const ADJUSTMENTS = Object.freeze([
|
||||
'brightness', 'contrast', 'shadows', 'highlights', 'temperature',
|
||||
'hue', 'saturation', 'vignette', 'sharpen', 'grain'
|
||||
]);
|
||||
|
||||
export const LAYOUT_PRESETS = Object.freeze([
|
||||
{ id: 'two-equal-columns', count: 2, tags: ['2', 'equal'], slots: [[0,0,0.5,1],[0.5,0,0.5,1]] },
|
||||
{ id: 'two-equal-rows', count: 2, tags: ['2', 'equal'], slots: [[0,0,1,0.5],[0,0.5,1,0.5]] },
|
||||
{ id: 'two-top-large', count: 2, tags: ['2', 'top-large'], slots: [[0,0,1,0.66],[0,0.66,1,0.34]] },
|
||||
{ id: 'two-bottom-large', count: 2, tags: ['2', 'bottom-large'], slots: [[0,0,1,0.34],[0,0.34,1,0.66]] },
|
||||
{ id: 'two-left-large', count: 2, tags: ['2', 'left-large'], slots: [[0,0,0.66,1],[0.66,0,0.34,1]] },
|
||||
{ id: 'two-right-large', count: 2, tags: ['2', 'right-large'], slots: [[0,0,0.34,1],[0.34,0,0.66,1]] },
|
||||
{ id: 'three-hero-left', count: 3, tags: ['3', 'hero'], slots: [[0,0,0.62,1],[0.62,0,0.38,0.5],[0.62,0.5,0.38,0.5]] },
|
||||
{ id: 'four-grid', count: 4, tags: ['4', 'grid'], slots: [[0,0,0.5,0.5],[0.5,0,0.5,0.5],[0,0.5,0.5,0.5],[0.5,0.5,0.5,0.5]] },
|
||||
{ id: 'five-hero', count: 5, tags: ['5', 'hero'], slots: [[0,0,1,0.56],[0,0.56,0.25,0.44],[0.25,0.56,0.25,0.44],[0.5,0.56,0.25,0.44],[0.75,0.56,0.25,0.44]] }
|
||||
]);
|
||||
|
||||
export const BRUSH_PRESETS = Object.freeze([
|
||||
{ id: 'round', engine: 'stroke', spacing: 0.08, jitter: 0 },
|
||||
{ id: 'marker', engine: 'stroke', spacing: 0.05, opacity: 0.55 },
|
||||
{ id: 'pencil', engine: 'texture', spacing: 0.06, texture: 'pencil-grain' },
|
||||
{ id: 'chalk', engine: 'texture', spacing: 0.08, texture: 'chalk-grain', jitter: 0.08 },
|
||||
{ id: 'spray', engine: 'particles', density: 18, jitter: 0.8 },
|
||||
{ id: 'neon', engine: 'stroke', glow: 0.8 },
|
||||
{ id: 'flowers', engine: 'stamp', stamp: 'flower', spacing: 0.7, angleJitter: 18, sizeJitter: 0.18 }
|
||||
]);
|
||||
|
||||
export const TOOL_DEFINITIONS = Object.freeze({
|
||||
filter: { accepts: ['image', 'background'], writes: ['effects'] },
|
||||
text: { accepts: ['text'], writes: ['text', 'style'] },
|
||||
sticker: { accepts: ['group'], creates: 'sticker' },
|
||||
crop: { accepts: ['image', 'background'], writes: ['crop', 'mask'] },
|
||||
adjust: { accepts: ['image', 'background'], writes: ['adjustments'] },
|
||||
layout: { accepts: ['group'], writes: ['layout'] },
|
||||
brush: { accepts: ['brush'], writes: ['strokes'] }
|
||||
});
|
||||
|
||||
export function createDocument(options = {}) {
|
||||
return {
|
||||
schema: 'entroia.poster-document', version: DOCUMENT_VERSION,
|
||||
id: options.id || cryptoId(), seed: options.seed || cryptoId(),
|
||||
canvas: { width: options.width || 1080, height: options.height || 1440, background: options.background || '#ffffff' },
|
||||
layers: options.layers ? structuredCloneSafe(options.layers) : [],
|
||||
bindings: options.bindings ? structuredCloneSafe(options.bindings) : {},
|
||||
meta: { title: options.title || '', createdAt: new Date().toISOString(), ...(options.meta || {}) }
|
||||
};
|
||||
}
|
||||
|
||||
export function createLayer(type, props = {}) {
|
||||
if (!LAYER_TYPES.includes(type)) throw new Error(`Unsupported layer type: ${type}`);
|
||||
return {
|
||||
id: props.id || cryptoId(), type, name: props.name || type,
|
||||
visible: props.visible !== false, locked: Boolean(props.locked), opacity: props.opacity ?? 1,
|
||||
blendMode: props.blendMode || 'source-over', transform: props.transform || { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 },
|
||||
effects: props.effects || [], tags: props.tags || [], ...props
|
||||
};
|
||||
}
|
||||
|
||||
export function stableHash(value) {
|
||||
let hash = 2166136261;
|
||||
for (const char of String(value)) { hash ^= char.codePointAt(0); hash = Math.imul(hash, 16777619); }
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function deterministicGlyphAngle({ documentSeed, layerId, index, glyph, maxAngle = 7, mode = 'position' }) {
|
||||
const key = mode === 'glyph' ? `${documentSeed}:${layerId}:${glyph}` : `${documentSeed}:${layerId}:${index}`;
|
||||
return ((stableHash(key) / 0xffffffff) * 2 - 1) * Math.abs(maxAngle);
|
||||
}
|
||||
|
||||
export function layoutDecoratedText(text, measure, style = {}) {
|
||||
const glyphs = Array.from(text);
|
||||
let cursor = 0;
|
||||
const runs = glyphs.map((glyph, index) => {
|
||||
const width = measure(glyph, style);
|
||||
const run = { glyph, index, x: cursor + width / 2, width,
|
||||
rotation: deterministicGlyphAngle({ documentSeed: style.documentSeed || '', layerId: style.layerId || '', index, glyph, maxAngle: style.maxAngle || 0, mode: style.rotationMode }) };
|
||||
cursor += width + (style.letterSpacing || 0);
|
||||
return run;
|
||||
});
|
||||
const paddingX = style.background?.paddingX ?? 20;
|
||||
const paddingY = style.background?.paddingY ?? 12;
|
||||
return { glyphs: runs, width: Math.max(0, cursor - (style.letterSpacing || 0)),
|
||||
background: style.background ? { ...style.background, width: Math.max(0, cursor) + paddingX * 2, height: (style.lineHeight || style.fontSize || 48) + paddingY * 2, resizeMode: style.background.resizeMode || 'nine-slice' } : null };
|
||||
}
|
||||
|
||||
export function resolveBinding(template, context) {
|
||||
if (typeof template !== 'string') return template;
|
||||
return template.replace(/\{([^}]+)\}/g, (_, path) => path.split('.').reduce((value, key) => value?.[key], context) ?? '');
|
||||
}
|
||||
|
||||
export function resolveToolTargets(document, toolId, context = {}) {
|
||||
const binding = document.bindings?.tools?.[toolId];
|
||||
if (!binding) return [];
|
||||
const ids = new Set((binding.layerIds || []).map(id => resolveBinding(id, context)));
|
||||
return flattenLayers(document.layers).filter(layer =>
|
||||
ids.has(layer.id) || (binding.tags || []).some(tag => layer.tags?.includes(resolveBinding(tag, context)))
|
||||
);
|
||||
}
|
||||
|
||||
export function selectCatalogItems(items, query = {}) {
|
||||
const all = new Set(query.all || []), any = new Set(query.any || []), none = new Set(query.none || []);
|
||||
return items.filter(item => {
|
||||
const tags = new Set(item.tags || []);
|
||||
return [...all].every(tag => tags.has(tag)) && (!any.size || [...any].some(tag => tags.has(tag))) && ![...none].some(tag => tags.has(tag));
|
||||
});
|
||||
}
|
||||
|
||||
export function applyLayout(presetId, group, gap = 0) {
|
||||
const preset = LAYOUT_PRESETS.find(item => item.id === presetId);
|
||||
if (!preset) throw new Error(`Unknown layout: ${presetId}`);
|
||||
return preset.slots.map(([x,y,width,height], index) => ({ index, x: x + gap / 2, y: y + gap / 2, width: Math.max(0, width - gap), height: Math.max(0, height - gap), mask: group?.mask || 'rect' }));
|
||||
}
|
||||
|
||||
export function validateDocument(document) {
|
||||
const errors = [];
|
||||
if (document?.schema !== 'entroia.poster-document') errors.push('schema must be entroia.poster-document');
|
||||
if (document?.version !== DOCUMENT_VERSION) errors.push(`unsupported version: ${document?.version}`);
|
||||
if (!(document?.canvas?.width > 0 && document?.canvas?.height > 0)) errors.push('canvas dimensions must be positive');
|
||||
const ids = new Set();
|
||||
for (const layer of document?.layers || []) {
|
||||
if (!LAYER_TYPES.includes(layer.type)) errors.push(`unsupported layer type: ${layer.type}`);
|
||||
if (!layer.id || ids.has(layer.id)) errors.push(`invalid or duplicate layer id: ${layer.id}`);
|
||||
ids.add(layer.id);
|
||||
}
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
function cryptoId() { return globalThis.crypto?.randomUUID?.() || `layer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; }
|
||||
function structuredCloneSafe(value) { return globalThis.structuredClone ? structuredClone(value) : JSON.parse(JSON.stringify(value)); }
|
||||
function flattenLayers(layers) { return (layers || []).flatMap(layer => [layer, ...flattenLayers(layer.children)]); }
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BrushPreset, CatalogItem, CatalogQuery, CreateDocumentOptions, DecoratedTextLayout, DecoratedTextStyle, DocumentBindings, LayerProps, LayerType, LayoutBox, LayoutPreset, PosterDocument, PosterLayer, ValidationResult } from './types.js';
|
||||
export type * from './types.js';
|
||||
|
||||
export const DOCUMENT_VERSION = 1 as const;
|
||||
export const LAYER_TYPES = Object.freeze(['background','group','image','vector','text','sticker','brush'] as const);
|
||||
export const ADJUSTMENTS = Object.freeze(['brightness','contrast','shadows','highlights','temperature','hue','saturation','vignette','sharpen','grain'] as const);
|
||||
export const LAYOUT_PRESETS: readonly LayoutPreset[] = Object.freeze([
|
||||
{id:'two-equal-columns',count:2,tags:['2','equal'],slots:[[0,0,.5,1],[.5,0,.5,1]]},{id:'two-equal-rows',count:2,tags:['2','equal'],slots:[[0,0,1,.5],[0,.5,1,.5]]},
|
||||
{id:'two-top-large',count:2,tags:['2','top-large'],slots:[[0,0,1,.66],[0,.66,1,.34]]},{id:'two-bottom-large',count:2,tags:['2','bottom-large'],slots:[[0,0,1,.34],[0,.34,1,.66]]},
|
||||
{id:'two-left-large',count:2,tags:['2','left-large'],slots:[[0,0,.66,1],[.66,0,.34,1]]},{id:'two-right-large',count:2,tags:['2','right-large'],slots:[[0,0,.34,1],[.34,0,.66,1]]},
|
||||
{id:'three-hero-left',count:3,tags:['3','hero'],slots:[[0,0,.62,1],[.62,0,.38,.5],[.62,.5,.38,.5]]},{id:'four-grid',count:4,tags:['4','grid'],slots:[[0,0,.5,.5],[.5,0,.5,.5],[0,.5,.5,.5],[.5,.5,.5,.5]]},
|
||||
{id:'five-hero',count:5,tags:['5','hero'],slots:[[0,0,1,.56],[0,.56,.25,.44],[.25,.56,.25,.44],[.5,.56,.25,.44],[.75,.56,.25,.44]]}
|
||||
]);
|
||||
export const BRUSH_PRESETS: readonly BrushPreset[] = Object.freeze([
|
||||
{id:'round',engine:'stroke',spacing:.08,jitter:0},{id:'marker',engine:'stroke',spacing:.05,opacity:.55},{id:'pencil',engine:'texture',spacing:.06,texture:'pencil-grain'},
|
||||
{id:'chalk',engine:'texture',spacing:.08,texture:'chalk-grain',jitter:.08},{id:'spray',engine:'particles',density:18,jitter:.8},{id:'neon',engine:'stroke',glow:.8},
|
||||
{id:'flowers',engine:'stamp',stamp:'flower',spacing:.7,angleJitter:18,sizeJitter:.18}
|
||||
]);
|
||||
export const TOOL_DEFINITIONS = Object.freeze({filter:{accepts:['image','background'],writes:['effects']},text:{accepts:['text'],writes:['text','style']},sticker:{accepts:['group'],creates:'sticker'},crop:{accepts:['image','background'],writes:['crop','mask']},adjust:{accepts:['image','background'],writes:['adjustments']},layout:{accepts:['group'],writes:['layout']},brush:{accepts:['brush'],writes:['strokes']}});
|
||||
|
||||
export function createDocument(options: CreateDocumentOptions = {}): PosterDocument { return {schema:'entroia.poster-document',version:DOCUMENT_VERSION,id:options.id??cryptoId(),seed:options.seed??cryptoId(),canvas:{width:options.width??1080,height:options.height??1440,background:options.background??'#ffffff'},layers:options.layers?clone(options.layers):[],bindings:options.bindings?clone<DocumentBindings>(options.bindings):{},meta:{title:options.title??'',createdAt:new Date().toISOString(),...(options.meta??{})}}; }
|
||||
export function createLayer(type: LayerType, props: LayerProps = {}): PosterLayer { if(!(LAYER_TYPES as readonly string[]).includes(type))throw new Error(`Unsupported layer type: ${type}`);const layer:PosterLayer={id:props.id??cryptoId(),type,name:props.name??type,visible:props.visible!==false,locked:Boolean(props.locked),opacity:props.opacity??1,blendMode:props.blendMode??'source-over',transform:props.transform??{x:0,y:0,scaleX:1,scaleY:1,rotation:0},effects:props.effects??[],tags:props.tags??[]};return Object.assign(layer,props); }
|
||||
export function stableHash(value: unknown): number { let hash=2166136261;for(const char of String(value)){hash^=char.codePointAt(0)??0;hash=Math.imul(hash,16777619);}return hash>>>0; }
|
||||
export function deterministicGlyphAngle({documentSeed,layerId,index,glyph,maxAngle=7,mode='position'}:{documentSeed:string;layerId:string;index:number;glyph:string;maxAngle?:number;mode?:'position'|'glyph'}):number { const key=mode==='glyph'?`${documentSeed}:${layerId}:${glyph}`:`${documentSeed}:${layerId}:${index}`;return((stableHash(key)/0xffffffff)*2-1)*Math.abs(maxAngle); }
|
||||
export function layoutDecoratedText(text:string,measure:(glyph:string,style:DecoratedTextStyle)=>number,style:DecoratedTextStyle={}):DecoratedTextLayout { let cursor=0;const glyphs=Array.from(text).map((glyph,index)=>{const width=measure(glyph,style),run={glyph,index,x:cursor+width/2,width,rotation:deterministicGlyphAngle({documentSeed:style.documentSeed??'',layerId:style.layerId??'',index,glyph,maxAngle:style.maxAngle??0,mode:style.rotationMode??'position'})};cursor+=width+(style.letterSpacing??0);return run;});const paddingX=style.background?.paddingX??20,paddingY=style.background?.paddingY??12;return{glyphs,width:Math.max(0,cursor-(style.letterSpacing??0)),background:style.background?{...style.background,paddingX,paddingY,width:Math.max(0,cursor)+paddingX*2,height:(style.lineHeight??style.fontSize??48)+paddingY*2,resizeMode:style.background.resizeMode??'nine-slice'}:null}; }
|
||||
export function resolveBinding(template:unknown,context:Record<string,unknown>):unknown { if(typeof template!=='string')return template;return template.replace(/\{([^}]+)\}/g,(_,path:string)=>String(path.split('.').reduce<unknown>((value,key)=>isRecord(value)?value[key]:undefined,context)??'')); }
|
||||
export function resolveToolTargets(document:PosterDocument,toolId:string,context:Record<string,unknown>={}):PosterLayer[]{const binding=document.bindings.tools?.[toolId];if(!binding)return[];const ids=new Set((binding.layerIds??[]).map(id=>String(resolveBinding(id,context))));return flattenLayers(document.layers).filter(layer=>ids.has(layer.id)||(binding.tags??[]).some(tag=>layer.tags.includes(String(resolveBinding(tag,context)))));}
|
||||
export function selectCatalogItems<T extends CatalogItem>(items:readonly T[],query:CatalogQuery={}):T[]{const all=new Set(query.all??[]),any=new Set(query.any??[]),none=new Set(query.none??[]);return items.filter(item=>{const tags=new Set(item.tags??[]);return[...all].every(tag=>tags.has(tag))&&(!any.size||[...any].some(tag=>tags.has(tag)))&&![...none].some(tag=>tags.has(tag));});}
|
||||
export function applyLayout(presetId:string,group?:{mask?:unknown},gap=0):LayoutBox[]{const preset=LAYOUT_PRESETS.find(item=>item.id===presetId);if(!preset)throw new Error(`Unknown layout: ${presetId}`);return preset.slots.map(([x,y,width,height],index)=>({index,x:x+gap/2,y:y+gap/2,width:Math.max(0,width-gap),height:Math.max(0,height-gap),mask:group?.mask??'rect'}));}
|
||||
export function validateDocument(document:unknown):ValidationResult {const errors:string[]=[];if(!isRecord(document)||document.schema!=='entroia.poster-document')errors.push('schema must be entroia.poster-document');if(!isRecord(document)||document.version!==DOCUMENT_VERSION)errors.push(`unsupported version: ${isRecord(document)?String(document.version):'undefined'}`);const canvas=isRecord(document)&&isRecord(document.canvas)?document.canvas:null;if(!(canvas&&typeof canvas.width==='number'&&canvas.width>0&&typeof canvas.height==='number'&&canvas.height>0))errors.push('canvas dimensions must be positive');const layers=isRecord(document)&&Array.isArray(document.layers)?document.layers:[],ids=new Set<string>();for(const value of layers){if(!isRecord(value))continue;const type=String(value.type),id=String(value.id??'');if(!(LAYER_TYPES as readonly string[]).includes(type))errors.push(`unsupported layer type: ${type}`);if(!id||ids.has(id))errors.push(`invalid or duplicate layer id: ${id}`);ids.add(id);}return{valid:errors.length===0,errors};}
|
||||
function cryptoId():string{const cryptoApi=globalThis.crypto as {randomUUID?:()=>string}|undefined;return cryptoApi?.randomUUID?.()??`layer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;}function clone<T>(value:T):T{const cloneFn=(globalThis as {structuredClone?:<U>(input:U)=>U}).structuredClone;return cloneFn?cloneFn(value):JSON.parse(JSON.stringify(value)) as T;}function flattenLayers(layers:PosterLayer[]=[]):PosterLayer[]{return layers.flatMap(layer=>[layer,...flattenLayers(layer.children)]);}function isRecord(value:unknown):value is Record<string,unknown>{return typeof value==='object'&&value!==null;}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { layoutDecoratedText } from './index.js';
|
||||
|
||||
export class CanvasRenderer {
|
||||
constructor(context, assets = {}) { this.ctx = context; this.assets = assets; }
|
||||
render(document) {
|
||||
const ctx = this.ctx;
|
||||
ctx.save(); ctx.fillStyle = document.canvas.background || '#fff'; ctx.fillRect(0, 0, document.canvas.width, document.canvas.height);
|
||||
for (const layer of document.layers) this.renderLayer(layer, document);
|
||||
ctx.restore();
|
||||
}
|
||||
renderLayer(layer, document) {
|
||||
if (!layer.visible) return;
|
||||
const ctx = this.ctx, t = layer.transform || {};
|
||||
ctx.save(); ctx.globalAlpha *= layer.opacity ?? 1; ctx.globalCompositeOperation = layer.blendMode || 'source-over';
|
||||
ctx.translate(t.x || 0, t.y || 0); ctx.rotate((t.rotation || 0) * Math.PI / 180); ctx.scale(t.scaleX ?? 1, t.scaleY ?? 1);
|
||||
if (layer.type === 'background' || layer.type === 'vector') this.drawVector(layer);
|
||||
if (layer.type === 'text') this.drawText(layer, document);
|
||||
if (layer.type === 'image' || layer.type === 'sticker') this.drawImage(layer);
|
||||
if (layer.type === 'brush') this.drawBrush(layer);
|
||||
for (const child of layer.children || []) this.renderLayer(child, document);
|
||||
ctx.restore();
|
||||
}
|
||||
drawVector(layer) {
|
||||
const ctx = this.ctx;
|
||||
for (const command of layer.commands || []) {
|
||||
ctx.beginPath(); ctx.strokeStyle = command.stroke || 'transparent'; ctx.fillStyle = command.fill || 'transparent'; ctx.lineWidth = command.strokeWidth || 1;
|
||||
if (command.kind === 'line') { ctx.moveTo(command.x1, command.y1); ctx.lineTo(command.x2, command.y2); ctx.stroke(); }
|
||||
if (command.kind === 'rect') { if (command.radius && ctx.roundRect) ctx.roundRect(command.x, command.y, command.width, command.height, command.radius); else ctx.rect(command.x, command.y, command.width, command.height); if (command.fill) ctx.fill(); if (command.stroke) ctx.stroke(); }
|
||||
if (command.kind === 'text') { ctx.font = command.font || '24px sans-serif'; ctx.textAlign = command.align || 'left'; ctx.fillText(command.text, command.x, command.y); }
|
||||
}
|
||||
}
|
||||
drawText(layer, document) {
|
||||
const ctx = this.ctx, style = layer.style || {};
|
||||
ctx.font = `${style.fontWeight || 700} ${style.fontSize || 48}px ${style.fontFamily || 'sans-serif'}`;
|
||||
const measured = layoutDecoratedText(layer.text || '', glyph => ctx.measureText(glyph).width, { ...style, documentSeed: document.seed, layerId: layer.id });
|
||||
if (measured.background) this.drawStretchBackground(measured.background, style.background);
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = style.fill || '#111'; ctx.strokeStyle = style.stroke || 'transparent'; ctx.lineWidth = style.strokeWidth || 0;
|
||||
for (const glyph of measured.glyphs) { ctx.save(); ctx.translate(glyph.x + (measured.background?.paddingX || 0), (style.lineHeight || style.fontSize || 48) / 2 + (measured.background?.paddingY || 0)); ctx.rotate(glyph.rotation * Math.PI / 180); if (style.strokeWidth) ctx.strokeText(glyph.glyph, 0, 0); ctx.fillText(glyph.glyph, 0, 0); ctx.restore(); }
|
||||
}
|
||||
drawStretchBackground(box, style = {}) {
|
||||
const ctx = this.ctx; ctx.save(); ctx.fillStyle = style.fill || '#ffe36e';
|
||||
const image = style.assetId && this.assets[style.assetId];
|
||||
if (image && style.capInsets) this.drawNineSlice(image, box.width, box.height, style.capInsets);
|
||||
else if (style.shape === 'pill') { ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(0, 0, box.width, box.height, box.height / 2); else ctx.rect(0, 0, box.width, box.height); ctx.fill(); }
|
||||
else ctx.fillRect(0, 0, box.width, box.height);
|
||||
ctx.restore();
|
||||
}
|
||||
drawNineSlice(image, width, height, cap) {
|
||||
const ctx = this.ctx, left = cap.left, right = cap.right, top = cap.top, bottom = cap.bottom;
|
||||
const sx = [0, left, image.width - right], sy = [0, top, image.height - bottom];
|
||||
const sw = [left, image.width - left - right, right], sh = [top, image.height - top - bottom, bottom];
|
||||
const dx = [0, left, width - right], dy = [0, top, height - bottom];
|
||||
const dw = [left, Math.max(0, width - left - right), right], dh = [top, Math.max(0, height - top - bottom), bottom];
|
||||
for (let y = 0; y < 3; y += 1) for (let x = 0; x < 3; x += 1) ctx.drawImage(image, sx[x], sy[y], sw[x], sh[y], dx[x], dy[y], dw[x], dh[y]);
|
||||
}
|
||||
drawImage(layer) { const image = this.assets[layer.assetId]; if (image) this.ctx.drawImage(image, 0, 0, layer.width || image.width, layer.height || image.height); }
|
||||
drawBrush(layer) {
|
||||
const ctx = this.ctx;
|
||||
for (const stroke of layer.strokes || []) {
|
||||
const points = stroke.points || []; if (!points.length) continue;
|
||||
if (stroke.engine === 'stamp') { for (let i = 0; i < points.length; i += Math.max(1, stroke.step || 3)) { const p = points[i], stamp = stroke.assetId && this.assets[stroke.assetId]; ctx.save(); ctx.translate(p.x,p.y); ctx.rotate((p.angle || 0)*Math.PI/180); if (stamp) { const size = p.size || stroke.size || 28; ctx.drawImage(stamp,-size/2,-size/2,size,size); } else { ctx.font = `${stroke.size || 28}px sans-serif`; ctx.fillText(stroke.stampGlyph || '✿',0,0); } ctx.restore(); } }
|
||||
else { ctx.beginPath(); ctx.strokeStyle = stroke.color || '#111'; ctx.globalAlpha *= stroke.opacity ?? 1; ctx.lineWidth = stroke.size || 8; ctx.lineCap = 'round'; ctx.moveTo(points[0].x,points[0].y); for (const p of points.slice(1)) ctx.lineTo(p.x,p.y); ctx.stroke(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { layoutDecoratedText } from './index.js';
|
||||
import type { PosterDocument, PosterLayer, TextBackgroundStyle } from './types.js';
|
||||
|
||||
export type AssetMap = Record<string, CanvasImageSource & { width: number; height: number }>;
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
export class CanvasRenderer {
|
||||
constructor(public readonly ctx: CanvasRenderingContext2D, public readonly assets: AssetMap = {}) {}
|
||||
render(document: PosterDocument): void { const{ctx}=this;ctx.save();ctx.fillStyle=document.canvas.background||'#fff';ctx.fillRect(0,0,document.canvas.width,document.canvas.height);for(const layer of document.layers)this.renderLayer(layer,document);ctx.restore(); }
|
||||
renderLayer(layer:PosterLayer,document:PosterDocument):void {if(!layer.visible)return;const ctx=this.ctx,t=layer.transform;ctx.save();ctx.globalAlpha*=layer.opacity;ctx.globalCompositeOperation=layer.blendMode;ctx.translate(t.x,t.y);ctx.rotate(t.rotation*Math.PI/180);ctx.scale(t.scaleX,t.scaleY);if(layer.type==='background'||layer.type==='vector')this.drawVector(layer as PosterLayer&AnyRecord);if(layer.type==='text')this.drawText(layer as PosterLayer&AnyRecord,document);if(layer.type==='image'||layer.type==='sticker')this.drawImage(layer as PosterLayer&AnyRecord);if(layer.type==='brush')this.drawBrush(layer as PosterLayer&AnyRecord);for(const child of layer.children??[])this.renderLayer(child,document);ctx.restore();}
|
||||
drawVector(layer:PosterLayer&AnyRecord):void {for(const command of layer.commands??[]){const ctx=this.ctx;ctx.beginPath();ctx.strokeStyle=command.stroke??'transparent';ctx.fillStyle=command.fill??'transparent';ctx.lineWidth=command.strokeWidth??1;if(command.kind==='line'){ctx.moveTo(command.x1,command.y1);ctx.lineTo(command.x2,command.y2);ctx.stroke();}if(command.kind==='rect'){command.radius?ctx.roundRect(command.x,command.y,command.width,command.height,command.radius):ctx.rect(command.x,command.y,command.width,command.height);if(command.fill)ctx.fill();if(command.stroke)ctx.stroke();}if(command.kind==='text'){ctx.font=command.font??'24px sans-serif';ctx.textAlign=command.align??'left';ctx.fillText(command.text,command.x,command.y);}}}
|
||||
drawText(layer:PosterLayer&AnyRecord,document:PosterDocument):void {const ctx=this.ctx,style=(layer.style??{}) as AnyRecord;ctx.font=`${style.fontWeight??700} ${style.fontSize??48}px ${style.fontFamily??'sans-serif'}`;const measured=layoutDecoratedText(layer.text??'',glyph=>ctx.measureText(glyph).width,{...style,documentSeed:document.seed,layerId:layer.id});if(measured.background)this.drawStretchBackground(measured.background,style.background);ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillStyle=style.fill??'#111';ctx.strokeStyle=style.stroke??'transparent';ctx.lineWidth=style.strokeWidth??0;for(const glyph of measured.glyphs){ctx.save();ctx.translate(glyph.x+(measured.background?.paddingX??0),(style.lineHeight??style.fontSize??48)/2+(measured.background?.paddingY??0));ctx.rotate(glyph.rotation*Math.PI/180);if(style.strokeWidth)ctx.strokeText(glyph.glyph,0,0);ctx.fillText(glyph.glyph,0,0);ctx.restore();}}
|
||||
drawStretchBackground(box:{width:number;height:number},style:TextBackgroundStyle={}):void {const ctx=this.ctx;ctx.save();ctx.fillStyle=style.fill??'#ffe36e';const image=style.assetId?this.assets[style.assetId]:undefined;if(image&&style.capInsets)this.drawNineSlice(image,box.width,box.height,style.capInsets);else if(style.shape==='pill'){ctx.beginPath();ctx.roundRect(0,0,box.width,box.height,box.height/2);ctx.fill();}else ctx.fillRect(0,0,box.width,box.height);ctx.restore();}
|
||||
drawNineSlice(image:AssetMap[string],width:number,height:number,cap:{left:number;right:number;top:number;bottom:number}):void {const{ctx}=this,left=cap.left,right=cap.right,top=cap.top,bottom=cap.bottom,sx=[0,left,image.width-right],sy=[0,top,image.height-bottom],sw=[left,image.width-left-right,right],sh=[top,image.height-top-bottom,bottom],dx=[0,left,width-right],dy=[0,top,height-bottom],dw=[left,Math.max(0,width-left-right),right],dh=[top,Math.max(0,height-top-bottom),bottom];for(let y=0;y<3;y+=1)for(let x=0;x<3;x+=1)ctx.drawImage(image,sx[x]!,sy[y]!,sw[x]!,sh[y]!,dx[x]!,dy[y]!,dw[x]!,dh[y]!);}
|
||||
drawImage(layer:PosterLayer&AnyRecord):void {const image=this.assets[layer.assetId];if(image)this.ctx.drawImage(image,0,0,layer.width??image.width,layer.height??image.height);}
|
||||
drawBrush(layer:PosterLayer&AnyRecord):void {const ctx=this.ctx;for(const stroke of layer.strokes??[]){const points=stroke.points??[];if(!points.length)continue;if(stroke.engine==='stamp'){for(let index=0;index<points.length;index+=Math.max(1,stroke.step??3)){const point=points[index],stamp=stroke.assetId?this.assets[stroke.assetId]:undefined;ctx.save();ctx.translate(point.x,point.y);ctx.rotate((point.angle??0)*Math.PI/180);if(stamp){const size=point.size??stroke.size??28;ctx.drawImage(stamp,-size/2,-size/2,size,size);}else{ctx.font=`${stroke.size??28}px sans-serif`;ctx.fillText(stroke.stampGlyph??'✿',0,0);}ctx.restore();}}else{ctx.beginPath();ctx.strokeStyle=stroke.color??'#111';ctx.globalAlpha*=stroke.opacity??1;ctx.lineWidth=stroke.size??8;ctx.lineCap='round';ctx.moveTo(points[0].x,points[0].y);for(const point of points.slice(1))ctx.lineTo(point.x,point.y);ctx.stroke();}}}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export type LayerType = 'background' | 'group' | 'image' | 'vector' | 'text' | 'sticker' | 'brush';
|
||||
export type AdjustmentName = 'brightness' | 'contrast' | 'shadows' | 'highlights' | 'temperature' | 'hue' | 'saturation' | 'vignette' | 'sharpen' | 'grain';
|
||||
|
||||
export interface Transform { x: number; y: number; scaleX: number; scaleY: number; rotation: number }
|
||||
export interface CanvasSpec { width: number; height: number; background: string }
|
||||
export interface ToolBinding { layerIds?: string[]; tags?: string[] }
|
||||
export interface DocumentBindings { tools?: Record<string, ToolBinding>; [key: string]: unknown }
|
||||
export interface PosterLayer {
|
||||
id: string; type: LayerType; name: string; visible: boolean; locked: boolean; opacity: number;
|
||||
blendMode: GlobalCompositeOperation; transform: Transform; effects: unknown[]; tags: string[];
|
||||
children?: PosterLayer[]; [key: string]: unknown;
|
||||
}
|
||||
export interface PosterDocument {
|
||||
schema: 'entroia.poster-document'; version: 1; id: string; seed: string; canvas: CanvasSpec;
|
||||
layers: PosterLayer[]; bindings: DocumentBindings; meta: Record<string, unknown> & { title: string; createdAt: string };
|
||||
}
|
||||
export interface CreateDocumentOptions {
|
||||
id?: string; seed?: string; width?: number; height?: number; background?: string;
|
||||
layers?: PosterLayer[]; bindings?: DocumentBindings; title?: string; meta?: Record<string, unknown>;
|
||||
}
|
||||
export interface LayerProps {
|
||||
id?: string; name?: string; visible?: boolean; locked?: boolean; opacity?: number;
|
||||
blendMode?: GlobalCompositeOperation; transform?: Transform; effects?: unknown[]; tags?: string[];
|
||||
children?: PosterLayer[]; [key: string]: unknown;
|
||||
}
|
||||
export interface CatalogItem { id: string | number; tags?: string[]; [key: string]: unknown }
|
||||
export interface CatalogQuery { all?: string[]; any?: string[]; none?: string[] }
|
||||
export type LayoutSlot = readonly [number, number, number, number];
|
||||
export interface LayoutPreset { id: string; count: number; tags: string[]; slots: readonly LayoutSlot[] }
|
||||
export interface BrushPreset { id: string; engine: 'stroke' | 'texture' | 'particles' | 'stamp'; [key: string]: string | number | undefined }
|
||||
export interface TextBackgroundStyle {
|
||||
paddingX?: number; paddingY?: number; resizeMode?: string; fill?: string; shape?: string;
|
||||
assetId?: string; capInsets?: { left: number; right: number; top: number; bottom: number };
|
||||
}
|
||||
export interface DecoratedTextStyle {
|
||||
documentSeed?: string; layerId?: string; maxAngle?: number; rotationMode?: 'position' | 'glyph';
|
||||
letterSpacing?: number; lineHeight?: number; fontSize?: number; background?: TextBackgroundStyle;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface GlyphRun { glyph: string; index: number; x: number; width: number; rotation: number }
|
||||
export interface DecoratedTextLayout {
|
||||
glyphs: GlyphRun[]; width: number;
|
||||
background: (TextBackgroundStyle & { width: number; height: number; resizeMode: string; paddingX: number; paddingY: number }) | null;
|
||||
}
|
||||
export interface LayoutBox { index: number; x: number; y: number; width: number; height: number; mask: unknown }
|
||||
export interface ValidationResult { valid: boolean; errors: string[] }
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createDocument, deterministicGlyphAngle, stableHash } from './index.js';
|
||||
export type { CreateDocumentOptions, PosterDocument } from './types.js';
|
||||
@@ -1,26 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyLayout, createDocument, createLayer, deterministicGlyphAngle, layoutDecoratedText, resolveToolTargets, selectCatalogItems, validateDocument } from '../src/index.js';
|
||||
|
||||
test('document and layers validate', () => {
|
||||
const doc=createDocument({layers:[createLayer('text',{text:'hello'})]});
|
||||
assert.equal(validateDocument(doc).valid,true);
|
||||
});
|
||||
test('glyph rotations are deterministic and bounded', () => {
|
||||
const input={documentSeed:'a',layerId:'b',index:3,glyph:'花',maxAngle:8};
|
||||
assert.equal(deterministicGlyphAngle(input),deterministicGlyphAngle(input));
|
||||
assert.ok(Math.abs(deterministicGlyphAngle(input))<=8);
|
||||
});
|
||||
test('decorated background grows with text', () => {
|
||||
const short=layoutDecoratedText('花',()=>20,{fontSize:40,background:{paddingX:10}});
|
||||
const long=layoutDecoratedText('花花花',()=>20,{fontSize:40,background:{paddingX:10}});
|
||||
assert.ok(long.background.width>short.background.width);
|
||||
});
|
||||
test('tag rules and layouts are deterministic', () => {
|
||||
assert.deepEqual(selectCatalogItems([{id:1,tags:['a','b']},{id:2,tags:['a','c']}],{all:['a'],none:['c']}).map(x=>x.id),[1]);
|
||||
assert.equal(applyLayout('two-top-large').length,2);
|
||||
});
|
||||
test('tool bindings target layers by id and dynamic tag', () => {
|
||||
const doc=createDocument({layers:[createLayer('background',{id:'base',tags:['base-user-42']}),createLayer('image',{id:'photo',tags:['photo']})],bindings:{tools:{adjust:{layerIds:['base'],tags:['base-{user.id}']}}}});
|
||||
assert.deepEqual(resolveToolTargets(doc,'adjust',{user:{id:42}}).map(item=>item.id),['base']);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyLayout, createDocument, createLayer, deterministicGlyphAngle, layoutDecoratedText, resolveToolTargets, selectCatalogItems, validateDocument } from '../dist/index.js';
|
||||
|
||||
test('document and layers validate',()=>{const doc=createDocument({layers:[createLayer('text',{text:'hello'})]});assert.equal(validateDocument(doc).valid,true);});
|
||||
test('glyph rotations are deterministic and bounded',()=>{const input={documentSeed:'a',layerId:'b',index:3,glyph:'花',maxAngle:8};assert.equal(deterministicGlyphAngle(input),deterministicGlyphAngle(input));assert.ok(Math.abs(deterministicGlyphAngle(input))<=8);});
|
||||
test('decorated background grows with text',()=>{const short=layoutDecoratedText('花',()=>20,{fontSize:40,background:{paddingX:10}}),long=layoutDecoratedText('花花花',()=>20,{fontSize:40,background:{paddingX:10}});assert.ok(long.background&&short.background&&long.background.width>short.background.width);});
|
||||
test('tag rules and layouts are deterministic',()=>{assert.deepEqual(selectCatalogItems([{id:1,tags:['a','b']},{id:2,tags:['a','c']}],{all:['a'],none:['c']}).map(item=>item.id),[1]);assert.equal(applyLayout('two-top-large').length,2);});
|
||||
test('tool bindings target layers by id and dynamic tag',()=>{const doc=createDocument({layers:[createLayer('background',{id:'base',tags:['base-user-42']}),createLayer('image',{id:'photo',tags:['photo']})],bindings:{tools:{adjust:{layerIds:['base'],tags:['base-{user.id}']}}}});assert.deepEqual(resolveToolTargets(doc,'adjust',{user:{id:42}}).map(item=>item.id),['base']);});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user