fix(client): normalize enterprise template layer mappings
This commit is contained in:
parent
525ec62a78
commit
ade0807d8a
@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {atomic,digest} from './store.mjs';
|
||||
import {normalizeTemplateMapping} from './template-mapping.mjs';
|
||||
|
||||
const ref = ({fileId,revision}) => ({fileId,revision});
|
||||
const filterName = (name,prefix) => name && `${prefix}${name}`;
|
||||
@ -31,7 +32,9 @@ async function list(service,task,wait,filters) {
|
||||
|
||||
function kindOf(set,part) {
|
||||
if(['detail','main_images','sku_images'].includes(part.id))return part.id;
|
||||
if(set.category?.toUpperCase()==='SKU'&&part.assets.some(a=>['original','mapping','preview'].includes(a.role)))return 'sku_images';
|
||||
if(!part.assets.some(a=>['original','mapping','preview'].includes(a.role)))return;
|
||||
if(set.category?.toUpperCase()==='SKU')return 'sku_images';
|
||||
if(set.category==='主图')return 'main_images';
|
||||
}
|
||||
|
||||
const labels={original:'原稿',mapping:'字段映射',preview:'预览图',font:'字体',
|
||||
@ -62,57 +65,6 @@ function componentInfo(set,part,declared) {
|
||||
originals:part.assets.filter(a=>a.role==='original').map(a=>({name:a.file.name,size:a.file.size}))};
|
||||
}
|
||||
|
||||
function mapSku(raw, original, key) {
|
||||
const {template,main_image,text_layers,smart_objects}=raw;
|
||||
const layeredImage=raw.product??raw.main;
|
||||
if(raw.schema_version!==1||!template?.canvas||!text_layers||(!main_image&&!smart_objects?.main&&!layeredImage?.layer_path))throw Error('SKU 字段映射格式不受支持');
|
||||
if(template.sha256?.toLowerCase()!==original.file.sha256.toLowerCase())throw conflict('字段映射与当前原稿版本不符');
|
||||
const canvas=[template.canvas.width,template.canvas.height];
|
||||
const page=[0,0,...canvas];
|
||||
const bounds = ({left,top,right,bottom}) => [left,top,right,bottom];
|
||||
const field=(id,layerPath,rect,type,extra={})=>({
|
||||
field_id:id,asset_id:original.id,screen_id:'K01',type,name:layerPath.split('/').at(-1),
|
||||
ps_location:{name:layerPath.split('/').at(-1),path:layerPath},ps_style:{coverage_rect:rect},
|
||||
role:id,...(type==='文案'?{maxChars:60}:{}),...extra
|
||||
});
|
||||
const fields=main_image ? [
|
||||
field('K01-product',main_image.layer,bounds(main_image.original_bounds),'图片'),
|
||||
...Object.entries(text_layers).map(([id,value])=>field('K01-'+id,value.layer,bounds(value.safe_bounds),'文案'))
|
||||
] : smart_objects?.main ? [
|
||||
...[smart_objects.main,...(smart_objects.details??[])].map((slot,i)=>
|
||||
field('K01-image-'+(i+1),slot.path,slot.target_bounds,'图片')),
|
||||
...Object.values(text_layers).flat().map((slot,i)=>field('K01-text-'+(i+1),slot.path,page,'文案',{nativeTextBounds:true}))
|
||||
] : [
|
||||
...[layeredImage,...(raw.details??[])].map((slot,i)=>
|
||||
field('K01-image-'+(i+1),slot.layer_path,slot.target_bounds??slot.bounds??slot.original_bounds,'图片')),
|
||||
...Object.entries(text_layers).flatMap(([role,value])=>
|
||||
(Array.isArray(value)?value:[value]).map((slot,i)=>field(
|
||||
'K01-'+role+(Array.isArray(value)?'-'+(i+1):''),
|
||||
typeof slot==='string'?slot:slot.path,
|
||||
slot.safe_bounds??page,'文案',slot.safe_bounds?{}:{nativeTextBounds:true}
|
||||
)))
|
||||
];
|
||||
return {template_id:key,mapping_version:'1',source:{assets:[{asset_id:original.id,ps_sha256:template.sha256,canvas_px:canvas}]},
|
||||
screens:[{asset_id:original.id,screen_id:'K01',title:'SKU 规格图',bounds:page}],fields};
|
||||
}
|
||||
|
||||
function normalizeMapping(raw, originals, key, kind) {
|
||||
if(raw.template && kind==='sku_images' && originals.length===1)return mapSku(raw,originals[0],key);
|
||||
if(raw.template_id!==key||(raw.component??'detail')!==kind||!Array.isArray(raw.fields)||!Array.isArray(raw.screens))throw Error('字段映射与所选资源组或组件不一致');
|
||||
const expected=kind==='detail'&&originals.length===1
|
||||
? [{asset_id:originals[0].id,ps_sha256:raw.source?.sha256}]
|
||||
: raw.source?.assets;
|
||||
if(!Array.isArray(expected)||expected.length!==originals.length||
|
||||
originals.some(a=>!expected.some(source=>source.asset_id===a.id&&source.ps_sha256?.toLowerCase()===a.file.sha256.toLowerCase()))) {
|
||||
throw conflict('字段映射与当前原稿版本不符');
|
||||
}
|
||||
return {...raw,...(kind==='detail'?{source:{...raw.source,sha256:raw.source.sha256.toLowerCase()}}:{}),fields:raw.fields.map(field=>{
|
||||
const rect=field.type==='图片'?field.ps_style.coverage_rect:field.ps_style.bounds;
|
||||
const coverage_rect=Array.isArray(rect)?rect:[rect.left,rect.top,rect.right,rect.bottom];
|
||||
return {...field,asset_id:field.asset_id||field.ps_location?.asset_id,ps_style:{...field.ps_style,coverage_rect}};
|
||||
})};
|
||||
}
|
||||
|
||||
function templateAssets(part) {
|
||||
return part.assets.filter(a=>a.status==='available'&&(
|
||||
['original','mapping','font','layer_inventory','font_manifest'].includes(a.role)||a.role==='attachment'&&a.required));
|
||||
@ -271,7 +223,7 @@ export async function resourceRequest(service,input,library,task,wait,buildPack)
|
||||
if(sourceId!==mapping.file.fileId)throw conflict('字段映射文件身份不符');
|
||||
let raw;
|
||||
try {raw=JSON.parse(document.result.text);} catch {throw Error('字段映射不是有效的 JSON,仍可查看模板预览');}
|
||||
const parsed=normalizeMapping(raw,part.assets.filter(a=>a.role==='original'),set.resourceSet.key,info.kind);
|
||||
const parsed=normalizeTemplateMapping(raw,part.assets.filter(a=>a.role==='original'),set.resourceSet.key,info.kind);
|
||||
const pack=await snapshot(service,set,part,input,assets,version,parsed,buildPack);
|
||||
return {items:[{...view,pack}]};
|
||||
} catch(error) {
|
||||
|
||||
@ -114,7 +114,7 @@ export function componentPack(manifest, revision, mappings, binding) {
|
||||
const run=field.ps_style.style_runs?.[0],text=field.raw_text||field.reference||'';
|
||||
return {fieldId:field.field_id,name:field.name,role:field.role,kind,
|
||||
frame:{x:x-left,y:y-nativeTop,width:x2-x,height:y2-y},nativeFrame:[l,t,r,b],
|
||||
psLayer:field.ps_location,crop:'cover',...(field.nativeTextBounds?{nativeTextBounds:true}:{}),
|
||||
psLayer:field.ps_location,crop:'cover',...(field.nativeTextBounds?{nativeTextBounds:true}:{}),...(field.nativeBounds?{nativeBounds:field.nativeBounds}:{}),
|
||||
requirements:[field.purpose,field.fill_requirements,field.adaptation_requirements,field.output_spec,field.rules].filter(Boolean).join('\n'),
|
||||
relatedFields:field.related_fields,example:field.reference,
|
||||
...(kind==='text'?{font:{family:run?.font_name||'',size:run?.font_size||24,weight:400,color:'#000000'},
|
||||
|
||||
142
apps/detail-agent-pi/src/template-mapping.mjs
Normal file
142
apps/detail-agent-pi/src/template-mapping.mjs
Normal file
@ -0,0 +1,142 @@
|
||||
const object=value=>value&&typeof value==='object'&&!Array.isArray(value);
|
||||
const own=(value,key)=>Object.prototype.hasOwnProperty.call(value,key);
|
||||
const fail=message=>{throw Error(message);};
|
||||
const conflict=message=>Object.assign(Error(message),{status:409});
|
||||
const text=value=>typeof value==='string'&&value.trim().length>0;
|
||||
const itemList=value=>Array.isArray(value)?value:[value];
|
||||
const matchesOriginal=(declared,original)=>typeof declared==='string'&&declared.toLowerCase()===original.file.sha256.toLowerCase();
|
||||
|
||||
function rectangle(value,label) {
|
||||
const values=Array.isArray(value)?value:object(value)?[value.left,value.top,value.right,value.bottom]:[];
|
||||
if(values.length!==4||!values.every(Number.isFinite)||values[2]<=values[0]||values[3]<=values[1])fail(`${label}的范围无效`);
|
||||
return values;
|
||||
}
|
||||
|
||||
function layerRef(value,{byName=false,alternatives}={}) {
|
||||
if(!text(value))fail('字段缺少原稿图层路径或名称');
|
||||
return {
|
||||
path:value,
|
||||
name:value.split('/').at(-1),
|
||||
...(byName&&!value.includes('/')?{byName:true}:{}),
|
||||
...(alternatives?.length?{alternatives}:{})
|
||||
};
|
||||
}
|
||||
|
||||
function imageDeclarations(raw) {
|
||||
const smart=raw.smart_objects;
|
||||
if(Array.isArray(smart))return smart;
|
||||
if(smart!==undefined&&!object(smart))fail('图片图层声明须为清单或对象');
|
||||
const main=raw.main_image??smart?.main??raw.product??raw.main??raw.product_image;
|
||||
const details=smart?.details??raw.details;
|
||||
if(details!==undefined&&!Array.isArray(details)&&!object(details))fail('卖点图片声明须为图层清单');
|
||||
let declared=details??[];
|
||||
if(object(details)){
|
||||
const {layers,targets,...style}=details;
|
||||
if(!Array.isArray(layers)||!layers.length||targets!==undefined&&(!Array.isArray(targets)||targets.length!==layers.length)) {
|
||||
fail('卖点图层和目标图层须按相同顺序一一对应');
|
||||
}
|
||||
declared=layers.map((layer,index)=>({...style,layer,target:targets?.[index]}));
|
||||
}
|
||||
const other=Object.entries(smart??{}).filter(([role])=>role!=='main'&&role!=='details').map(([,slot])=>slot);
|
||||
return [...(main?[main]:[]),...declared,...other];
|
||||
}
|
||||
|
||||
function fieldBounds(slot,reference,kind,label) {
|
||||
const area=kind==='image'
|
||||
? slot.target_bounds??slot.bounds??slot.original_bounds
|
||||
: slot.safe_bounds??slot.safe_area??slot.target_bounds??slot.bounds;
|
||||
if(area!==undefined)return {area:rectangle(area,label)};
|
||||
if(kind==='text')return {nativeBounds:{kind:'field'}};
|
||||
const target=slot.target_path??slot.target;
|
||||
if(target==='canvas')return {nativeBounds:{kind:'canvas'}};
|
||||
if(target===undefined||target==='original-layer-bounds')return {nativeBounds:{kind:'field'}};
|
||||
if(text(target))return {nativeBounds:{kind:'layer',layer:layerRef(target,{byName:slot.target_path===undefined})}};
|
||||
if(object(target)&&target.type==='virtual-rect'&&target.bottom_edge==='top'&&Number.isFinite(target.top)&&text(target.bottom_layer)) {
|
||||
return {nativeBounds:{kind:'above-layer',top:target.top,layer:layerRef(target.bottom_layer,{byName:true})}};
|
||||
}
|
||||
fail(`「${reference.name}」的目标范围未声明为矩形、画布或可核对的原稿图层`);
|
||||
}
|
||||
|
||||
/** Read old compact layout declarations by structure, without consulting template or brand names. */
|
||||
function compact(raw,original,key,component) {
|
||||
const {template}=raw;
|
||||
if(![1,3].includes(raw.schema_version)||!object(template?.canvas))fail('字段映射缺少受支持的版本或原稿尺寸');
|
||||
if(!matchesOriginal(template.sha256,original))throw conflict('字段映射与当前原稿版本不符');
|
||||
const canvas=[template.canvas.width,template.canvas.height];
|
||||
if(!canvas.every(n=>Number.isInteger(n)&&n>0))fail('字段映射的原稿尺寸无效');
|
||||
const page=[0,0,...canvas];
|
||||
const screen=component==='sku_images'?'K01':'M01';
|
||||
const fields=[];
|
||||
const append=(id,reference,slot,kind)=>{
|
||||
const {area,nativeBounds}=fieldBounds(slot,reference,kind,`「${reference.name}」`);
|
||||
fields.push({
|
||||
field_id:screen+'-'+id,asset_id:original.id,screen_id:screen,type:kind==='image'?'图片':'文案',
|
||||
name:reference.name,ps_location:reference,ps_style:{coverage_rect:area??page},role:screen+'-'+id,
|
||||
...(kind==='text'?{maxChars:60}:{}),
|
||||
...(nativeBounds?.kind==='field'&&kind==='text'?{nativeTextBounds:true}:nativeBounds?{nativeBounds}:{})
|
||||
});
|
||||
};
|
||||
const images=imageDeclarations(raw);
|
||||
if(!images.length)fail('字段映射缺少可编辑的图片图层');
|
||||
images.forEach((slot,i)=>{
|
||||
if(!object(slot))fail('图片图层声明无效');
|
||||
const ref=layerRef(slot.layer_path??slot.path??slot.layer,{byName:!slot.layer_path&&!slot.path});
|
||||
append(raw.main_image&&i===0?'product':'image-'+(i+1),ref,slot,'image');
|
||||
});
|
||||
|
||||
if(raw.text_layers!==undefined&&!object(raw.text_layers))fail('文字图层声明须按字段分组');
|
||||
const numbered=raw.smart_objects!==undefined;
|
||||
let number=0;
|
||||
for(const [role,value] of Object.entries(raw.text_layers??{})){
|
||||
if(object(value)&&value.rendered===false)continue;
|
||||
const grouped=object(value)&&own(value,'layers');
|
||||
if(grouped&&(!Array.isArray(value.layers)||!value.layers.length))fail(`文字字段「${role}」的图层清单无效`);
|
||||
const items=grouped?value.layers:itemList(value);
|
||||
for(const [index,item] of items.entries()){
|
||||
const slot=typeof item==='string'?{layer:item}:item;
|
||||
if(!object(slot))fail(`文字字段「${role}」的图层声明无效`);
|
||||
const declaredPath=slot.layer_path??slot.path;
|
||||
const named=declaredPath??slot.layer??(slot.safe_area!==undefined?role:null);
|
||||
const alternatives=grouped?value.fallback_layers?.[named]:undefined;
|
||||
if(alternatives!==undefined&&(!Array.isArray(alternatives)||!alternatives.every(text)))fail(`文字字段「${role}」的备用图层无效`);
|
||||
const ref=layerRef(named,{byName:!declaredPath,alternatives});
|
||||
const suffix=grouped||Array.isArray(value)?'-'+(index+1):'';
|
||||
append(numbered?'text-'+(++number):role+suffix,ref,slot,'text');
|
||||
}
|
||||
}
|
||||
return {
|
||||
template_id:key,mapping_version:'1',
|
||||
source:{assets:[{asset_id:original.id,ps_sha256:template.sha256,canvas_px:canvas}]},
|
||||
screens:[{asset_id:original.id,screen_id:screen,title:component==='sku_images'?'SKU 规格图':'主图',bounds:page}],
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
/** The canonical layout identifies each original by declared asset ID and digest. */
|
||||
export function normalizeTemplateMapping(raw,originals,key,component) {
|
||||
if(!object(raw))fail('字段映射须为 JSON 对象');
|
||||
if(raw.template&&component!=='detail'&&originals.length===1)return compact(raw,originals[0],key,component);
|
||||
if(raw.template_id!==key||(raw.component??'detail')!==component||!Array.isArray(raw.fields)||!Array.isArray(raw.screens)){
|
||||
fail('字段映射与所选资源组或组件不一致');
|
||||
}
|
||||
const expected=component==='detail'&&originals.length===1
|
||||
? [{asset_id:originals[0].id,ps_sha256:raw.source?.sha256}]
|
||||
: raw.source?.assets;
|
||||
if(!Array.isArray(expected)||expected.length!==originals.length||
|
||||
originals.some(a=>!expected.some(source=>source?.asset_id===a.id&&matchesOriginal(source.ps_sha256,a)))){
|
||||
throw conflict('字段映射与当前原稿版本不符');
|
||||
}
|
||||
const screens=raw.screens.map(screen=>{
|
||||
if(!object(screen)||!text(screen.screen_id))fail('字段映射缺少有效的页面编号');
|
||||
return {...screen,bounds:rectangle(screen.bounds,`页面「${screen.screen_id}」`)};
|
||||
});
|
||||
const screenIds=new Set(screens.map(screen=>screen.screen_id));
|
||||
if(!screens.length||screenIds.size!==screens.length)fail('字段映射缺少页面或页面编号重复');
|
||||
return {...raw,screens,...(component==='detail'?{source:{...raw.source,sha256:raw.source.sha256.toLowerCase()}}:{}),fields:raw.fields.map(field=>{
|
||||
if(!object(field)||!object(field.ps_location)||!text(field.ps_location.path)||!text(field.ps_location.name))fail('字段缺少原稿图层路径或名称');
|
||||
if(!screenIds.has(field.screen_id))fail(`字段「${field.name??field.field_id}」缺少对应页面`);
|
||||
const rect=field.type==='图片'?field.ps_style?.coverage_rect:field.ps_style?.bounds;
|
||||
const coverage_rect=rectangle(rect,`「${field.name??field.field_id}」`);
|
||||
return {...field,asset_id:field.asset_id||field.ps_location.asset_id,ps_style:{...field.ps_style,coverage_rect}};
|
||||
})};
|
||||
}
|
||||
@ -74,6 +74,53 @@ test('a selected SKU pins the mapping and original; preparing uses only those pi
|
||||
assert.equal(calls.some(([,args])=>args.fileId?.startsWith('ekb:')),false);
|
||||
});
|
||||
|
||||
const groupedMapping={schema_version:1,template:mapping.template,smart_objects:{
|
||||
main:{layer:'产品图片',target:'canvas'},
|
||||
details:{layers:['卖点图1','卖点图2'],targets:['卖点底纹1','卖点底纹2']}
|
||||
},text_layers:{
|
||||
product_name:{layer:'产品名称'},
|
||||
selling_copy:{layers:['卖点文案1','卖点文案2'],fallback_layers:{卖点文案2:['买点文案2']}},
|
||||
hidden:{rendered:false}
|
||||
}};
|
||||
|
||||
test('grouped image and text declarations use the same layer rules for any template',async t=>{
|
||||
const {request}=await fixture(t,{readDocument:()=>({result:{sourceId:'mapping',text:JSON.stringify(groupedMapping)}})});
|
||||
const {items:[result]}=await request({action:'resource-component',resourceSetId:setId,componentId:'default',previewId:'preview'});
|
||||
assert.deepEqual(result.issues,[]);
|
||||
const slots=result.pack.outputs[0].slots;
|
||||
assert.deepEqual(slots.map(f=>f.psLayer.path),['产品图片','卖点图1','卖点图2','产品名称','卖点文案1','卖点文案2']);
|
||||
assert.deepEqual(slots[0].nativeBounds,{kind:'canvas'});
|
||||
assert.deepEqual(slots[2].nativeBounds,{kind:'layer',layer:{path:'卖点底纹2',name:'卖点底纹2',byName:true}});
|
||||
assert.deepEqual(slots[5].psLayer.alternatives,['买点文案2']);
|
||||
assert.equal(slots[3].nativeTextBounds,true);
|
||||
});
|
||||
|
||||
test('unpaired image groups explain the mapping issue and keep the preview',async t=>{
|
||||
const unpaired=structuredClone(groupedMapping);
|
||||
unpaired.smart_objects.details.targets.pop();
|
||||
const {request}=await fixture(t,{readDocument:()=>({result:{sourceId:'mapping',text:JSON.stringify(unpaired)}})});
|
||||
const {items:[result]}=await request({action:'resource-component',resourceSetId:setId,componentId:'default',previewId:'preview'});
|
||||
assert.deepEqual(result.issues,['卖点图层和目标图层须按相同顺序一一对应']);
|
||||
assert.ok(result.imagePath);
|
||||
assert.equal(result.pack,undefined);
|
||||
});
|
||||
|
||||
test('named image slots and standalone main image packages share the same importer',async t=>{
|
||||
const {request}=await fixture(t,{
|
||||
call(name){if(name==='get_resource_set')return {result:{resourceSet:{...resourceSet,category:'主图'},components:[sku('800','available')]}};},
|
||||
readDocument:()=>({result:{sourceId:'mapping',text:JSON.stringify({
|
||||
schema_version:3,template:mapping.template,
|
||||
smart_objects:{main:{layer:'产品图',target:'original-layer-bounds'},detail1:{layer:'细节图',target:'装饰/底图'}},
|
||||
text_layers:{产品名:{safe_area:bounds}}
|
||||
})}})
|
||||
});
|
||||
const {items:[result]}=await request({action:'resource-component',resourceSetId:setId,componentId:'800',categoryName:'主图'});
|
||||
assert.deepEqual(result.issues,[]);
|
||||
assert.equal(result.pack.enterprise.component,'main_images');
|
||||
assert.deepEqual(result.pack.outputs[0].slots.map(f=>f.psLayer.path),['产品图','细节图','产品名']);
|
||||
assert.equal(result.pack.outputs[0].slots[1].nativeBounds.layer.byName,undefined);
|
||||
});
|
||||
|
||||
test('a mismatched 750 mapping can display its preview without blocking the available 800 component',async t=>{
|
||||
const {calls,request}=await fixture(t,{parts:[sku('750','needs_review','needs_review'),sku('800','available')]});
|
||||
const choose=componentId=>request({action:'resource-component',resourceSetId:setId,componentId,previewId:'preview'});
|
||||
|
||||
@ -111,6 +111,26 @@ fn definition<'a>(state: &'a Value, id: &str, variant_id: &str) -> R<(&'a Value,
|
||||
}
|
||||
Err("图片目标不属于当前模板和花色".into())
|
||||
}
|
||||
|
||||
fn mapped_slot<'a>(state: &'a Value, id: &str, variant_id: &str) -> Option<&'a Value> {
|
||||
let mapping = &state["mappings"][state["activeMapping"].as_str()?];
|
||||
if mapping["variantId"] != variant_id {
|
||||
return None;
|
||||
}
|
||||
mapping["outputs"].as_array().into_iter().flatten()
|
||||
.flat_map(|o| o["mapping"]["fields"].as_array().into_iter().flatten().chain(o["fields"].as_array().into_iter().flatten()))
|
||||
.chain(mapping["fields"].as_array().into_iter().flatten())
|
||||
.find(|row| row["slotId"] == id)
|
||||
}
|
||||
|
||||
fn slot_frame<'a>(field: &'a Value, mapped: Option<&'a Value>) -> Option<&'a Value> {
|
||||
if field["nativeBounds"].is_object() {
|
||||
mapped.map(|row| &row["frame"]).filter(|frame| frame.is_object())
|
||||
} else {
|
||||
Some(&field["frame"])
|
||||
}
|
||||
}
|
||||
|
||||
fn references(dir: &Path, images: &Value) -> R<Vec<Value>> {
|
||||
let images = images.as_array().filter(|v| v.len() <= 8).ok_or("每花色最多8张产品/场景参考图")?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@ -589,15 +609,10 @@ pub(super) fn snapshot(dir: &Path) -> R<Value> {
|
||||
}
|
||||
for field in output["slots"].as_array().into_iter().flatten().filter(|f| f["kind"] == "image") {
|
||||
let id = slot_id(&state["template"]["contentHash"], active, field["fieldId"].as_str().ok_or("图片目标编号缺失")?);
|
||||
let mapped = active_mapping["outputs"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|o| o["mapping"]["fields"].as_array().into_iter().flatten().chain(o["fields"].as_array().into_iter().flatten()))
|
||||
.chain(active_mapping["fields"].as_array().into_iter().flatten())
|
||||
.find(|row| row["slotId"] == id);
|
||||
let mapped = mapped_slot(&state, &id, active);
|
||||
let Some(frame) = slot_frame(field, mapped) else { continue; };
|
||||
let candidates: Vec<_> = history.iter().filter(|record| record["binding"]["slotId"] == id).map(|record| json!({"id":record["id"],"taskId":record["id"],"variant":record["id"],"sequence":record["sequence"],"status":record["phase"],"requestId":record["requestId"],"instructions":record["instructions"],"parentCandidateId":record["parentCandidateId"],"previewUri":format!("bytes://template-candidate-{}",record["id"].as_str().unwrap_or("")),"error":record["message"]})).collect();
|
||||
slots.push(json!({"id":id,"fieldId":field["fieldId"],"documentId":output["id"],"outputId":output["component"],"variantId":active,"kind":"image","label":field.get("name").or(field.get("label")).unwrap_or(&field["fieldId"]),"width":field["frame"]["width"],"height":field["frame"]["height"],"frame":field["frame"],"box":field["frame"],"crop":field["crop"],"nativeLayerId":mapped.map(|m|m["nativeLayerId"].clone()),"candidates":candidates,"selectedCandidateId":null,"appliedCandidateId":null,"fields":[],"required":false}));
|
||||
slots.push(json!({"id":id,"fieldId":field["fieldId"],"documentId":output["id"],"outputId":output["component"],"variantId":active,"kind":"image","label":field.get("name").or(field.get("label")).unwrap_or(&field["fieldId"]),"width":frame["width"],"height":frame["height"],"frame":frame,"box":frame,"crop":field["crop"],"nativeLayerId":mapped.map(|m|m["nativeLayerId"].clone()),"candidates":candidates,"selectedCandidateId":null,"appliedCandidateId":null,"fields":[],"required":false}));
|
||||
}
|
||||
}
|
||||
let original_canvas = &state["template"]["enterprise"]["canvas"];
|
||||
@ -745,7 +760,10 @@ impl Backend {
|
||||
if !output_uses_variant(&state["brief"], output["component"].as_str().unwrap_or("detail"), &variant_id) {
|
||||
return Err("此颜色未选入当前图类,请在产品资料调整出图颜色".into());
|
||||
}
|
||||
let (output, field) = (output.clone(), field.clone());
|
||||
let mut field = field.clone();
|
||||
field["frame"] = slot_frame(&field, mapped_slot(&state, &id, &variant_id))
|
||||
.ok_or("请先打开对应模板画布,核对图片图层范围")?.clone();
|
||||
let output = output.clone();
|
||||
let instructions = text(&input["instructions"], 2000, "图片要求")?;
|
||||
let parent = input.get("parentCandidateId").filter(|v| !v.is_null()).map(|value| uuid(value, "原候选")).transpose()?;
|
||||
let mut attachments = creation::composer_attachments::resolve(&dir, &input.get("attachments").cloned().unwrap_or_else(|| json!([])))?;
|
||||
@ -845,23 +863,9 @@ impl Backend {
|
||||
return Err("原候选像素记录不一致,未采纳".into());
|
||||
}
|
||||
let layer = input["nativeLayerId"].as_u64().filter(|v| *v > 0).ok_or("原生图片图层身份缺失")?;
|
||||
let active = &state["mappings"][state["activeMapping"].as_str().ok_or("当前原生映射缺失")?];
|
||||
let mapped = active["variantId"] == variant_id
|
||||
&& active["fields"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(
|
||||
active["outputs"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|o| o["mapping"]["fields"].as_array().into_iter().flatten().chain(o["fields"].as_array().into_iter().flatten())),
|
||||
)
|
||||
.any(|m| m["slotId"] == id && m["nativeLayerId"] == layer);
|
||||
if !mapped {
|
||||
return Err("原生图层不属于此花色和图片目标,未采纳".into());
|
||||
}
|
||||
let mapped = mapped_slot(&state, id, &variant_id).filter(|row| row["nativeLayerId"] == layer)
|
||||
.ok_or("原生图层不属于此花色和图片目标,未采纳")?;
|
||||
let frame = slot_frame(field, Some(mapped)).ok_or("当前图片图层缺少已核对的范围,未采纳")?;
|
||||
let path = dir.join(artifact["path"].as_str().ok_or("原图片路径无效")?);
|
||||
let mut bytes = Vec::new();
|
||||
std::io::Read::read_to_end(&mut std::io::Read::take(fs::File::open(path).map_err(|_| "原候选图片不可读取")?, 20 * 1024 * 1024 + 1), &mut bytes)
|
||||
@ -871,7 +875,7 @@ impl Backend {
|
||||
return Err("候选像素已变化,未采纳".into());
|
||||
}
|
||||
let adoption = (!preview).then(|| Uuid::new_v4().to_string());
|
||||
let meta = json!({"canvasFirst":true,"doc":doc,"accountId":self.auth["account"]["id"],"origin":self.origin,"baseRevision":input["baseRevision"],"workspaceRevision":state["revision"],"workspaceId":state["id"],"templateHash":state["template"]["contentHash"],"structureHash":state["structureHash"],"outputId":output["component"],"documentId":output["id"],"slotId":id,"variantId":variant_id,"candidateId":candidate,"taskId":candidate,"nativeLayerId":layer,"sha256":artifact["sha256"],"fit":field["crop"],"frame":field["frame"]});
|
||||
let meta = json!({"canvasFirst":true,"doc":doc,"accountId":self.auth["account"]["id"],"origin":self.origin,"baseRevision":input["baseRevision"],"workspaceRevision":state["revision"],"workspaceId":state["id"],"templateHash":state["template"]["contentHash"],"structureHash":state["structureHash"],"outputId":output["component"],"documentId":output["id"],"slotId":id,"variantId":variant_id,"candidateId":candidate,"taskId":candidate,"nativeLayerId":layer,"sha256":artifact["sha256"],"fit":field["crop"],"frame":frame});
|
||||
let mut meta = meta;
|
||||
if preview {
|
||||
meta["project"] = json!(project);
|
||||
|
||||
@ -3,8 +3,9 @@
|
||||
//! This module does no network I/O and never replaces an open document. The caller admits the
|
||||
//! returned document through the normal engine/UI path. Candidate creation and selection belong
|
||||
//! to the durable workflow journal, not to canvas construction.
|
||||
use photocraft_doc::{Affine, Document, Layer, LayerContent, SmartObject, SmartSource, Subpath, VectorMask};
|
||||
use photocraft_doc::{Affine, Document, Layer, LayerContent, LayerId, SmartObject, SmartSource, Subpath, VectorMask};
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
@ -433,6 +434,90 @@ pub fn enterprise(pack: &Value, prepared: &Value, component: &str, document: Opt
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OriginalLayers {
|
||||
ids: HashMap<u32, (LayerId, String)>,
|
||||
paths: HashMap<String, Vec<LayerId>>,
|
||||
names: HashMap<String, Vec<LayerId>>,
|
||||
}
|
||||
|
||||
impl OriginalLayers {
|
||||
fn index(&mut self, layers: &[Layer], parent: &str) -> R<()> {
|
||||
for layer in layers {
|
||||
let path = if parent.is_empty() { layer.name.clone() } else { format!("{parent}/{}", layer.name) };
|
||||
if let Some(id) = layer.psd_id && self.ids.insert(id, (layer.id, path.clone())).is_some() {
|
||||
return Err("原稿图层编号重复".into());
|
||||
}
|
||||
self.paths.entry(path.clone()).or_default().push(layer.id);
|
||||
self.names.entry(layer.name.clone()).or_default().push(layer.id);
|
||||
if let LayerContent::Group(group) = &layer.content {
|
||||
self.index(&group.children, &path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve(&self, reference: &Value) -> R<LayerId> {
|
||||
let path = reference["path"].as_str().ok_or("原稿图层路径缺失")?;
|
||||
let matches = |name: &str, by_name: bool| {
|
||||
if by_name { self.names.get(name) } else { self.paths.get(name) }
|
||||
};
|
||||
let ambiguous = || format!("「{path}」对应多个原稿图层,请在字段映射填写唯一的完整路径");
|
||||
match matches(path, reference["byName"] == true).map(Vec::as_slice) {
|
||||
Some([id]) => return Ok(*id),
|
||||
Some(_) => return Err(ambiguous()),
|
||||
None => {}
|
||||
}
|
||||
let mut alternatives = HashSet::new();
|
||||
for name in reference["alternatives"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
alternatives.extend(matches(name, !name.contains('/')).into_iter().flatten().copied());
|
||||
}
|
||||
match alternatives.len() {
|
||||
0 => Err(format!("字段映射指定的「{path}」在原稿中不存在")),
|
||||
1 => Ok(*alternatives.iter().next().ok_or("备用图层不存在")?),
|
||||
_ => Err(ambiguous()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn original_frame(doc: &Document, layer: &Layer) -> Rect {
|
||||
if let Some(mask) = &layer.vector_mask
|
||||
&& mask.enabled
|
||||
&& let Some((l, t, r, b)) = mask.path.control_bounds()
|
||||
{
|
||||
return Rect::new(l.floor() as i32, t.floor() as i32, r.ceil() as i32, b.ceil() as i32);
|
||||
}
|
||||
if let Some(mask) = &layer.mask && mask.enabled {
|
||||
let bounds = mask.surface.content_bounds();
|
||||
if !bounds.is_empty() { return bounds; }
|
||||
}
|
||||
photocraft_compose::layer_bounds(layer, doc.bounds())
|
||||
}
|
||||
|
||||
fn original_image_frame(doc: &Document, layers: &OriginalLayers, layer: &Layer, rule: &Value) -> R<Rect> {
|
||||
let canvas = doc.bounds();
|
||||
let bounds = match rule["kind"].as_str() {
|
||||
Some("canvas") => canvas,
|
||||
Some("field") => original_frame(doc, layer),
|
||||
Some(kind @ ("layer" | "above-layer")) => {
|
||||
let target = doc.layer(layers.resolve(&rule["layer"])?).ok_or("原稿目标图层不存在")?;
|
||||
let target_bounds = original_frame(doc, target);
|
||||
if target_bounds.is_empty() {
|
||||
return Err(format!("目标图层「{}」缺少可核对的范围", target.name));
|
||||
}
|
||||
if kind == "layer" { target_bounds } else {
|
||||
let top = rule["top"].as_f64().ok_or("图片目标顶部坐标缺失")?;
|
||||
Rect::new(canvas.x0, top.floor() as i32, canvas.x1, target_bounds.y0)
|
||||
}
|
||||
}
|
||||
_ => return Err("图片字段的原稿范围规则不受支持".into()),
|
||||
}.intersect(&canvas);
|
||||
if bounds.is_empty() {
|
||||
return Err(format!("图片字段「{}」在画布内缺少可核对的范围", layer.name));
|
||||
}
|
||||
Ok(bounds)
|
||||
}
|
||||
|
||||
fn enterprise_source(pack: &Value, source: &Value, outputs: &[&Value], variant: &str) -> R<(Document, Vec<Value>, Vec<String>)> {
|
||||
let original = Path::new(source["originalPath"].as_str().ok_or("模板原稿未下载")?);
|
||||
let options = photocraft_io::ImportOptions { psd_max_pixel_bytes:4 << 30, ..Default::default() };
|
||||
@ -441,40 +526,35 @@ fn enterprise_source(pack: &Value, source: &Value, outputs: &[&Value], variant:
|
||||
let mut doc = imported.document;
|
||||
doc.name = pack["name"].as_str().ok_or("模板名称缺失")?.to_owned();
|
||||
if json!([doc.size.width,doc.size.height]) != source["canvas"] { return Err("原稿尺寸与字段映射不一致".into()); }
|
||||
let mut locations = HashMap::new();
|
||||
let mut paths = HashMap::<String, Vec<photocraft_doc::LayerId>>::new();
|
||||
fn locate(layers: &[Layer], parent: &str, locations: &mut HashMap<u32, (photocraft_doc::LayerId, String)>,
|
||||
paths: &mut HashMap<String, Vec<photocraft_doc::LayerId>>) -> R<()> {
|
||||
for layer in layers {
|
||||
let path = if parent.is_empty() { layer.name.clone() } else { format!("{parent}/{}", layer.name) };
|
||||
if let Some(id) = layer.psd_id && locations.insert(id, (layer.id, path.clone())).is_some() { return Err("原稿图层编号重复".into()); }
|
||||
paths.entry(path.clone()).or_default().push(layer.id);
|
||||
if let LayerContent::Group(group) = &layer.content { locate(&group.children, &path, locations, paths)?; }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
locate(&doc.layers, "", &mut locations, &mut paths)?;
|
||||
let mut layers = OriginalLayers::default();
|
||||
layers.index(&doc.layers, "")?;
|
||||
let mut mapped_outputs = Vec::new();
|
||||
for output in outputs {
|
||||
let mut fields = Vec::new();
|
||||
for field in output["slots"].as_array().ok_or("模板字段缺失")? {
|
||||
let psd_id = field["psLayer"]["layer_id"].as_u64().and_then(|id| u32::try_from(id).ok());
|
||||
let expected_path = field["psLayer"]["path"].as_str().ok_or("原稿图层路径缺失")?;
|
||||
let (id, path) = if let Some(psd_id) = psd_id {
|
||||
let (id, path) = locations.get(&psd_id).ok_or_else(|| format!("字段 {} 的原稿图层不存在", field["fieldId"]))?;
|
||||
(*id, path.as_str())
|
||||
let id = if let Some(psd_id) = psd_id {
|
||||
let (id, path) = layers.ids.get(&psd_id).ok_or_else(|| format!("字段 {} 的原稿图层不存在", field["fieldId"]))?;
|
||||
if path != expected_path {
|
||||
return Err(format!("字段 {} 的图层名称或层级与发布映射不一致", field["fieldId"]));
|
||||
}
|
||||
*id
|
||||
} else if pack["enterprise"]["adapterVersion"] == 4 {
|
||||
let ids = paths.get(expected_path).filter(|ids| ids.len() == 1)
|
||||
.ok_or_else(|| format!("原稿没有唯一图层「{expected_path}」"))?;
|
||||
(*ids.first().ok_or("原稿图层不存在")?, expected_path)
|
||||
layers.resolve(&field["psLayer"])?
|
||||
} else {
|
||||
return Err("字段原稿图层编号缺失".into());
|
||||
};
|
||||
let original_layer = doc.layer(id).ok_or("原稿图层不存在")?;
|
||||
let original_psd_id = original_layer.psd_id;
|
||||
let original_bounds = (field["nativeTextBounds"] == true).then(|| photocraft_doc::comps::position_bounds(original_layer));
|
||||
let original_name = original_layer.name.clone();
|
||||
let original_bounds = if field["nativeTextBounds"] == true {
|
||||
Some(photocraft_doc::comps::position_bounds(original_layer))
|
||||
} else if field["nativeBounds"].is_object() {
|
||||
Some(original_image_frame(&doc, &layers, original_layer, &field["nativeBounds"])?)
|
||||
} else { None };
|
||||
let original_text = if let LayerContent::Text(text) = &original_layer.content { Some(text.text.clone()) } else { None };
|
||||
if original_layer.name != field["psLayer"]["name"].as_str().ok_or("原图层名称缺失")? || path != expected_path {
|
||||
if psd_id.is_some() && original_name != field["psLayer"]["name"].as_str().ok_or("原图层名称缺失")? {
|
||||
return Err(format!("字段 {} 的图层名称或层级与发布映射不一致", field["fieldId"]));
|
||||
}
|
||||
if field["kind"] == "image" {
|
||||
@ -492,7 +572,7 @@ fn enterprise_source(pack: &Value, source: &Value, outputs: &[&Value], variant:
|
||||
let layer = doc.layer_mut(id).ok_or("图片字段不存在")?;
|
||||
if !matches!(layer.content, LayerContent::Smart(_)) { return Err("图片字段不是可替换图层".into()); }
|
||||
// Retain the original clipping stack; its frame also guides fitted replacement.
|
||||
if layer.vector_mask.is_none() && layer.mask.is_none() {
|
||||
if layer.vector_mask.is_none() && layer.mask.is_none() && original_bounds.is_none() {
|
||||
let frame = field["nativeFrame"].as_array().filter(|frame| frame.len()==4).ok_or("原图框无效")?;
|
||||
let values = frame.iter().map(|value| value.as_f64().ok_or("原图框坐标无效")).collect::<Result<Vec<_>,_>>()?;
|
||||
let (l,t,r,b) = (values[0],values[1],values[2],values[3]);
|
||||
@ -511,7 +591,7 @@ fn enterprise_source(pack: &Value, source: &Value, outputs: &[&Value], variant:
|
||||
mapped["slotId"] = json!(sha(json!([pack["contentHash"],variant,field["fieldId"]]).to_string().as_bytes()));
|
||||
mapped["nativeLayerId"] = json!(id.0);
|
||||
mapped["psdLayerId"] = json!(psd_id.or(original_psd_id));
|
||||
mapped["layerName"] = field["psLayer"]["name"].clone();
|
||||
mapped["layerName"] = json!(original_name);
|
||||
mapped["label"] = field["name"].clone();
|
||||
mapped["frame"]["y"] = json!(mapped["frame"]["y"].as_f64().ok_or("字段纵坐标无效")? + output["top"].as_f64().ok_or("页面纵坐标无效")?);
|
||||
fields.push(mapped);
|
||||
@ -647,6 +727,28 @@ pub fn from_component(applications: &[Value], dir: &Path) -> R<Document> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enterprise_mapping_resolves_named_target_and_rejects_ambiguous_layers() {
|
||||
let image = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgba, [140, 90, 40, 255].repeat(32)).unwrap();
|
||||
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let mut doc = photocraft_io::import("source.png", &png).unwrap().document;
|
||||
let mut left = doc.layers.pop().unwrap();
|
||||
left.name = "卖点图".into();
|
||||
let left_id = left.id;
|
||||
let mut right = Layer::new("卖点图", left.content.clone());
|
||||
right.vector_mask = Some(VectorMask::new(photocraft_doc::Path::new(vec![
|
||||
Subpath::polygon(&[(2., 1.), (6., 1.), (6., 3.), (2., 3.)])
|
||||
])));
|
||||
doc.layers = vec![Layer::group("左", vec![left]), Layer::group("右", vec![right])];
|
||||
let mut layers = OriginalLayers::default();
|
||||
layers.index(&doc.layers, "").unwrap();
|
||||
assert_eq!(layers.resolve(&json!({"path":"左/卖点图"})).unwrap(), left_id);
|
||||
assert!(layers.resolve(&json!({"path":"卖点图","byName":true})).unwrap_err().contains("完整路径"));
|
||||
let field = doc.layer(left_id).unwrap();
|
||||
let rule = json!({"kind":"layer","layer":{"path":"右/卖点图"}});
|
||||
assert_eq!(original_image_frame(&doc, &layers, field, &rule).unwrap(), Rect::new(2, 1, 6, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_components_bind_repeated_psd_ids_to_their_original_files() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-components-{}",uuid::Uuid::new_v4()));
|
||||
|
||||
9
docs/photocraft-template-mapping.md
Normal file
9
docs/photocraft-template-mapping.md
Normal file
@ -0,0 +1,9 @@
|
||||
# 小壹模板接入规则
|
||||
|
||||
知识库按资源组提供组件和文件用途。小壹使用品牌、品类和模板编号查找资源组,用组件内标注的原稿、字段映射、预览图、字体和附件组装模板。各组件独立核对,原稿和映射以文件身份、版本和映射中声明的原稿校验值配对。
|
||||
|
||||
新模板推荐使用 `screens` / `fields` 结构。模板编号填资源组的 `key`,组件标明详情页、主图或 SKU。每个页面声明原稿和画布范围,每个字段声明所属页面、用途、原稿图层编号、完整路径及目标矩形。图片矩形使用 `ps_style.coverage_rect`,文字矩形使用 `ps_style.bounds`,坐标顺序是左、上、右、下。多份原稿用不同的 `asset_id` 分开绑定。
|
||||
|
||||
小壹也读取已发布的简版映射。图片字段可以逐项列出,或者用 `layers` 和 `targets` 按顺序配对。图片范围可用明确矩形、整张画布、原图层范围或指定目标图层。文字字段逐项绑定原稿文字层。简版只写图层名时,名称须在原稿内唯一;同名时写完整路径。只有显式声明的备用图层才能在主图层缺失时使用。
|
||||
|
||||
预览通过表示映射文件和已发布的原稿版本能配对。进入画布时才核对实际图层、类型和范围。映射结构不完整、原稿版本变化或图层无法定位时,按组件提示具体缺项,可用预览和其他齐备组件仍可查看。已保存项目继续读取自己的固定版本。
|
||||
Loading…
x
Reference in New Issue
Block a user