From ade0807d8a9e02c189d1df6df7be22bd5e9c5dcc Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Mon, 9 Nov 2026 15:03:03 +0800 Subject: [PATCH] fix(client): normalize enterprise template layer mappings --- .../src/enterprise-resource-map.mjs | 58 +------ .../src/enterprise-templates.mjs | 2 +- apps/detail-agent-pi/src/template-mapping.mjs | 142 +++++++++++++++++ .../test/enterprise-resource-map.test.mjs | 47 ++++++ .../src/jiansun_canvas_workspace.rs | 58 +++---- .../photocraft/src/jiansun_slot_canvas.rs | 150 +++++++++++++++--- docs/photocraft-template-mapping.md | 9 ++ 7 files changed, 361 insertions(+), 105 deletions(-) create mode 100644 apps/detail-agent-pi/src/template-mapping.mjs create mode 100644 docs/photocraft-template-mapping.md diff --git a/apps/detail-agent-pi/src/enterprise-resource-map.mjs b/apps/detail-agent-pi/src/enterprise-resource-map.mjs index af91956d..f7507d1e 100644 --- a/apps/detail-agent-pi/src/enterprise-resource-map.mjs +++ b/apps/detail-agent-pi/src/enterprise-resource-map.mjs @@ -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) { diff --git a/apps/detail-agent-pi/src/enterprise-templates.mjs b/apps/detail-agent-pi/src/enterprise-templates.mjs index 4a980488..7981cf8d 100644 --- a/apps/detail-agent-pi/src/enterprise-templates.mjs +++ b/apps/detail-agent-pi/src/enterprise-templates.mjs @@ -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'}, diff --git a/apps/detail-agent-pi/src/template-mapping.mjs b/apps/detail-agent-pi/src/template-mapping.mjs new file mode 100644 index 00000000..ab689265 --- /dev/null +++ b/apps/detail-agent-pi/src/template-mapping.mjs @@ -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}}; + })}; +} diff --git a/apps/detail-agent-pi/test/enterprise-resource-map.test.mjs b/apps/detail-agent-pi/test/enterprise-resource-map.test.mjs index 53d64d17..d44dc5e0 100644 --- a/apps/detail-agent-pi/test/enterprise-resource-map.test.mjs +++ b/apps/detail-agent-pi/test/enterprise-resource-map.test.mjs @@ -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'}); diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_canvas_workspace.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_canvas_workspace.rs index fc2ca3fc..2f1e15d6 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_canvas_workspace.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_canvas_workspace.rs @@ -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> { 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 { } 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); diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_slot_canvas.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_slot_canvas.rs index e687fb25..a54823f8 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_slot_canvas.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_slot_canvas.rs @@ -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, + paths: HashMap>, + names: HashMap>, +} + +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 { + 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 { + 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, Vec)> { 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::>::new(); - fn locate(layers: &[Layer], parent: &str, locations: &mut HashMap, - paths: &mut HashMap>) -> 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::,_>>()?; 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 { 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())); diff --git a/docs/photocraft-template-mapping.md b/docs/photocraft-template-mapping.md new file mode 100644 index 00000000..a1d964fc --- /dev/null +++ b/docs/photocraft-template-mapping.md @@ -0,0 +1,9 @@ +# 小壹模板接入规则 + +知识库按资源组提供组件和文件用途。小壹使用品牌、品类和模板编号查找资源组,用组件内标注的原稿、字段映射、预览图、字体和附件组装模板。各组件独立核对,原稿和映射以文件身份、版本和映射中声明的原稿校验值配对。 + +新模板推荐使用 `screens` / `fields` 结构。模板编号填资源组的 `key`,组件标明详情页、主图或 SKU。每个页面声明原稿和画布范围,每个字段声明所属页面、用途、原稿图层编号、完整路径及目标矩形。图片矩形使用 `ps_style.coverage_rect`,文字矩形使用 `ps_style.bounds`,坐标顺序是左、上、右、下。多份原稿用不同的 `asset_id` 分开绑定。 + +小壹也读取已发布的简版映射。图片字段可以逐项列出,或者用 `layers` 和 `targets` 按顺序配对。图片范围可用明确矩形、整张画布、原图层范围或指定目标图层。文字字段逐项绑定原稿文字层。简版只写图层名时,名称须在原稿内唯一;同名时写完整路径。只有显式声明的备用图层才能在主图层缺失时使用。 + +预览通过表示映射文件和已发布的原稿版本能配对。进入画布时才核对实际图层、类型和范围。映射结构不完整、原稿版本变化或图层无法定位时,按组件提示具体缺项,可用预览和其他齐备组件仍可查看。已保存项目继续读取自己的固定版本。