export type Slot={id:string;kind:string;role:string;box:number[];direction:string;text:string|null;reading:string}; export type Layout={id:string;name:string;width:number;height:number;elements:Slot[]}; export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end'}; export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain'}; export type DecorationValue={color?:string;hidden?:boolean}; export type Parameters={texts:Record;images:Record;decorations:Record;paper?:string}; export class LayoutParameterError extends Error{} export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v); function fail(message:string):never{throw new LayoutParameterError(message)} function object(v:unknown):Record{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record} function keys(v:Record,allowed:string[]){if(Object.keys(v).some(k=>!allowed.includes(k)))fail('未知参数或构件 ID')} function color(v:unknown){if(typeof v!=='string'||!/^#[a-f0-9]{6}$/i.test(v))fail('颜色使用 #RRGGBB');return v} export function parameters(layout:Layout,input:unknown):Parameters{ const raw=object(input);keys(raw,['texts','images','decorations','paper']);const result:Parameters={texts:{},images:{},decorations:{}}; if(raw.paper!==undefined)result.paper=color(raw.paper); for(const [group,kind] of [['texts','text'],['images','image'],['decorations','decoration']] as const){ const entries=object(raw[group]??{});keys(entries,layout.elements.filter(e=>e.kind===kind).map(e=>e.id)); for(const [id,value] of Object.entries(entries)){const v=object(value); if(group==='texts'){keys(v,['value','color','scale','align']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}result.texts[id]=t; }else if(group==='images'){keys(v,['assetId','color','fit']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}result.images[id]=image; }else{keys(v,['color','hidden']);const d:DecorationValue={};if(v.color!==undefined)d.color=color(v.color);if(v.hidden!==undefined){if(typeof v.hidden!=='boolean')fail('hidden 必须为布尔值');d.hidden=v.hidden}result.decorations[id]=d} } } return result; }