This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.tgz
|
||||
coverage/
|
||||
@@ -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 的小程序兼容入口。
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -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)]); }
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
function hash(value){var h=2166136261,s=String(value);for(var i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return h>>>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};
|
||||
Reference in New Issue
Block a user