From 88e1dd9308d453f67caf344972fcdaf075cc2dbd Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 19 Sep 2026 01:14:43 +0800 Subject: [PATCH] feat: initialize schema-driven poster editor --- .gitea/workflows/ci.yml | 31 +++++++ .gitignore | 3 + README.md | 7 ++ package.json | 20 +++++ schema/poster-document.schema.json | 31 +++++++ src/index.js | 136 +++++++++++++++++++++++++++++ src/renderer.js | 65 ++++++++++++++ test/editor.test.js | 26 ++++++ xhs/index.cjs | 5 ++ 9 files changed, 324 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 schema/poster-document.schema.json create mode 100644 src/index.js create mode 100644 src/renderer.js create mode 100644 test/editor.test.js create mode 100644 xhs/index.cjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..2987258 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,31 @@ +name: Verify editor package +on: + push: + branches: [main] + pull_request: +jobs: + test: + runs-on: ubuntu-latest + container: { image: "node:22-alpine" } + steps: + - name: Fetch exact revision + env: + SOURCE_SERVER: ${{ gitea.server_url }} + SOURCE_REPOSITORY: ${{ gitea.repository }} + SOURCE_REVISION: ${{ gitea.sha }} + SOURCE_TOKEN: ${{ secrets.GITEA_TOKEN || gitea.token }} + run: | + set -eu + apk add --no-cache python3 + python3 - <<'PY' + import io,os,shutil,tarfile,tempfile,urllib.request + from pathlib import Path + url=f"{os.environ['SOURCE_SERVER'].rstrip('/')}/api/v1/repos/{os.environ['SOURCE_REPOSITORY']}/archive/{os.environ['SOURCE_REVISION']}.tar.gz" + request=urllib.request.Request(url,headers={'Authorization':'token '+os.environ['SOURCE_TOKEN']}) + with urllib.request.urlopen(request,timeout=60) as response:data=response.read() + with tempfile.TemporaryDirectory() as directory: + 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 run check + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31e1c0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.tgz +coverage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6f1ea7 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# @entroia/poster-editor + +跨 Web 与小程序的 Schema 驱动图片编辑器核心。仓库名是工程容器,实际包名为 `@entroia/poster-editor`。 + +核心采用非破坏性 `PosterDocument`:背景、分组、图片、矢量、文字、贴纸和笔刷均为可独立绑定工具与效果的图层。业务图谱先转换为通用 vector/group 节点,编辑器不包含 PK 规则。 + +首版包含稳定逐字旋转、可伸缩花字背景、2–5 图布局、Tag 布尔筛选、Adjust 参数约定与包括花朵 Stamp Brush 在内的笔刷描述。`xhs/index.cjs` 提供无需 DOM 的小程序兼容入口。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..13dc18f --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "@entroia/poster-editor", + "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", + "exports": { + ".": "./src/index.js", + "./renderer": "./src/renderer.js", + "./schema": "./schema/poster-document.schema.json", + "./xhs": "./xhs/index.cjs" + }, + "files": ["src", "schema", "xhs"], + "scripts": { + "test": "node --test", + "check": "node --check src/index.js && node --check src/renderer.js && node --check xhs/index.cjs" + }, + "engines": { "node": ">=20" }, + "license": "UNLICENSED" +} diff --git a/schema/poster-document.schema.json b/schema/poster-document.schema.json new file mode 100644 index 0000000..4eda4a2 --- /dev/null +++ b/schema/poster-document.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://entroia.local/schemas/poster-document-v1.json", + "title": "PosterDocument", + "type": "object", + "required": ["schema", "version", "id", "seed", "canvas", "layers"], + "properties": { + "schema": { "const": "entroia.poster-document" }, + "version": { "const": 1 }, + "id": { "type": "string", "minLength": 1 }, + "seed": { "type": "string", "minLength": 1 }, + "canvas": { + "type": "object", "required": ["width", "height"], + "properties": { "width": { "type": "number", "exclusiveMinimum": 0 }, "height": { "type": "number", "exclusiveMinimum": 0 }, "background": { "type": "string" } } + }, + "layers": { "type": "array", "items": { "$ref": "#/$defs/layer" } }, + "bindings": { "type": "object" }, "meta": { "type": "object" } + }, + "$defs": { + "layer": { + "type": "object", "required": ["id", "type"], + "properties": { + "id": { "type": "string" }, + "type": { "enum": ["background", "group", "image", "vector", "text", "sticker", "brush"] }, + "visible": { "type": "boolean" }, "locked": { "type": "boolean" }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "tags": { "type": "array", "items": { "type": "string" } }, + "children": { "type": "array", "items": { "$ref": "#/$defs/layer" } } + }, "additionalProperties": true + } + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..87f2a50 --- /dev/null +++ b/src/index.js @@ -0,0 +1,136 @@ +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)]); } diff --git a/src/renderer.js b/src/renderer.js new file mode 100644 index 0000000..b8269b3 --- /dev/null +++ b/src/renderer.js @@ -0,0 +1,65 @@ +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(); } + } + } +} diff --git a/test/editor.test.js b/test/editor.test.js new file mode 100644 index 0000000..b2f8e36 --- /dev/null +++ b/test/editor.test.js @@ -0,0 +1,26 @@ +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']); +}); diff --git a/xhs/index.cjs b/xhs/index.cjs new file mode 100644 index 0000000..9d41566 --- /dev/null +++ b/xhs/index.cjs @@ -0,0 +1,5 @@ +'use strict'; +function hash(value){var h=2166136261,s=String(value);for(var i=0;i>>0;} +function glyphAngle(seed,layer,index,max){return((hash(seed+':'+layer+':'+index)/4294967295)*2-1)*Math.abs(max||0);} +function createDocument(options){options=options||{};return{schema:'entroia.poster-document',version:1,id:options.id||('doc-'+Date.now()),seed:options.seed||('seed-'+Date.now()),canvas:{width:options.width||1080,height:options.height||1440,background:options.background||'#fff'},layers:options.layers||[],bindings:options.bindings||{},meta:options.meta||{}};} +module.exports={stableHash:hash,deterministicGlyphAngle:glyphAngle,createDocument:createDocument};