Publish reference layouts as API-authored text and image notes
wiki CI and deployment / verify-and-deploy (push) Successful in 16m28s
wiki CI and deployment / verify-and-deploy (push) Successful in 16m28s
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
import {readFile,writeFile,mkdir} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import layouts from '../src/content/layouts/reference/manifest.json';
|
||||
const root=path.resolve(import.meta.dirname,'..'),target=path.join(root,'public/layout-reference');
|
||||
const esc=(s:string)=>s.replaceAll('&','&').replaceAll('<','<').replaceAll('"','"');
|
||||
async function main(){
|
||||
await writeFile(path.join(target,'manifest.json'),JSON.stringify(layouts,null,2)+'\n');
|
||||
const runtime=await readFile(path.join(root,'src/content/layouts/reference/ReferenceLayout.tsx'),'utf8');await writeFile(path.join(target,'ReferenceLayout.tsx'),runtime);
|
||||
for(const l of layouts){
|
||||
const layers=l.elements.map(e=>{const[x,y,w,h]=e.box;return `<img data-component="${l.id}/${e.id}" data-kind="${e.kind}" src="${e.asset}" alt="${esc(e.kind==='text'?e.text||e.role+'(未辨识原字形)':'')}" style="left:${x/l.width*100}%;top:${y/l.height*100}%;width:${w/l.width*100}%;height:${h/l.height*100}%">`}).join('\n');
|
||||
const html=`<!doctype html>\n<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${l.id} · ${l.name}</title><style>body{margin:0;padding:20px;background:#e8e8e3;color:#222;font:15px/1.7 system-ui}main{display:grid;grid-template-columns:minmax(280px,560px) minmax(280px,1fr);gap:24px;max-width:1250px;margin:auto}.canvas{position:relative;isolation:isolate;aspect-ratio:${l.width}/${l.height};background:#f4f3ee}.canvas img{position:absolute;display:block}.canvas .base{inset:0;width:100%;height:100%}.overlay{position:absolute;z-index:10;pointer-events:none;inset:0;width:100%;height:100%;display:none}label{display:inline-flex;gap:5px;margin-right:16px}button,a{color:#244f9d}table{width:100%;border-collapse:collapse}td,th{padding:7px;border-bottom:1px solid #bbb;text-align:left;font-size:13px}.muted{color:#666}#detail{padding:12px;background:#fff}tr{cursor:pointer}tr:focus,tr:hover{background:#fff}fieldset{border:1px solid #aaa;margin:0 0 16px}.canvas img[data-kind="text"]{z-index:3}.canvas img[data-kind="decoration"]{z-index:2}.canvas img[data-kind="image"]{z-index:1}@media(max-width:800px){main{display:block}.canvas{max-width:560px;margin:auto}aside{margin-top:24px}}</style></head><body><main><section><h1>${l.id} / ${l.name}</h1><div class="canvas" aria-label="按独立图层重建的原版面"><img class="base" src="${l.background}" alt="原图纸底与画框">\n${layers}\n<img class="overlay" src="${l.annotated}" alt="构件标注"></div><p class="muted">原图第 ${l.source.row} 行、第 ${l.source.column} 列;${l.width}×${l.height} 像素。显示为原始构件重组,无新生成图形。放大不增加细节。</p></section><aside><fieldset><legend>按类型检查</legend><label><input type="checkbox" data-toggle="text" checked>文字 T</label><label><input type="checkbox" data-toggle="image" checked>图片/色块 I</label><label><input type="checkbox" data-toggle="decoration" checked>装饰 D</label><label><input id="labels" type="checkbox">显示边界</label></fieldset><p id="detail">点击表格查看对应构件。蓝色 T 为文字、橙色 I 为图片占位/色块、绿色 D 为装饰;原图没有真实照片。</p><table><thead><tr><th>ID</th><th>分类与用途</th><th>文字内容</th></tr></thead><tbody>${l.elements.map(e=>`<tr tabindex="0" data-key="${e.id}"><td>${e.id}</td><td>${e.kind==='text'?'文字':e.kind==='image'?'图片占位/色块':'装饰'} / ${esc(e.role)}</td><td>${esc(e.kind==='text'?e.text??'待核对,保留原字形':'—')}</td></tr>`).join('')}</tbody></table><p><a href="/layout-reference/${l.id}/manifest.json">构件 JSON</a> · <a href="${l.reference}">原裁片</a> · <a href="${l.annotated}">标注图</a> · <a href="/layout-reference/index.html">27 张索引</a></p><p class="muted">修改文字会产生字体差异;替换图片会改变视觉内容。严格原图对应模式保持原字形与原颜色。构件坐标、方向与所属版面固定。</p></aside></main><script>
|
||||
const manifest=${JSON.stringify(l).replaceAll('<','\\u003c')};
|
||||
for(const input of document.querySelectorAll('[data-toggle]'))input.addEventListener('change',()=>{for(const el of document.querySelectorAll('[data-kind="'+input.dataset.toggle+'"]'))el.style.visibility=input.checked?'visible':'hidden'});
|
||||
document.getElementById('labels').addEventListener('change',event=>{document.querySelector('.overlay').style.display=event.target.checked?'block':'none'});
|
||||
for(const row of document.querySelectorAll('[data-key]')){const select=()=>{const e=manifest.elements.find(e=>e.id===row.dataset.key);for(const el of document.querySelectorAll('[data-component]'))el.style.outline=el.dataset.component.endsWith('/'+e.id)?'2px solid #ed4433':'';document.getElementById('detail').textContent=manifest.id+'/'+e.id+' · '+e.kind+' · '+e.role+' · bbox ['+e.box.join(', ')+']'+(e.kind==='text'?' · '+(e.text||'内容未辨识,保留原字形'):'')};row.addEventListener('click',select);row.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();select()}})}
|
||||
</script></body></html>\n`;
|
||||
await writeFile(path.join(target,l.id,'index.html'),html);
|
||||
}
|
||||
await writeFile(path.join(target,'index.html'),`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>27 张原图版面索引</title><style>body{font:16px/1.6 system-ui;background:#eee;padding:24px}nav{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:18px}a{color:#222;text-decoration:none;background:#fff;padding:12px}img{width:100%;aspect-ratio:113/173;object-fit:contain}small{display:block}</style><h1>27 张原图版面 · 一一对应</h1><p>按原图从上到下、每行从左到右编号,排除左侧说明栏。点击每张可分开检查文字、图片占位/色块与装饰。</p><p><a href="/layout-reference/numbered-source.png">查看编号原图</a> · <a href="/layout-reference/index.json">机器索引</a></p><nav>${layouts.map(l=>`<a href="/layout-reference/${l.id}/index.html"><img src="${l.reference}" alt="${l.id} 原裁片"><b>${l.id}</b><small>${esc(l.name)}</small><small>原图 ${l.source.row} 行 ${l.source.column} 列</small></a>`).join('')}</nav></html>\n`);
|
||||
console.log('Built 27 layer inspectors and the source ID index.');
|
||||
}
|
||||
main().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -1,19 +0,0 @@
|
||||
import {readFile,writeFile,mkdir,cp,rm} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {guidanceMarkdown} from '../src/content/skills/guidance';
|
||||
import {layouts,nextExample} from '../src/content/layouts/documents';
|
||||
async function main(){
|
||||
const root=path.resolve(import.meta.dirname,'..'),stage=path.join(tmpdir(),'prompt-layout-reference-toolkit');await rm(stage,{recursive:true,force:true});await mkdir(stage,{recursive:true});
|
||||
const put=async(name:string,text:string)=>{const file=path.join(stage,name);await mkdir(path.dirname(file),{recursive:true});await writeFile(file,text+'\n')};
|
||||
await cp(path.join(root,'public/layout-reference'),path.join(stage,'public/layout-reference'),{recursive:true,filter:source=>!source.endsWith('.zip')});
|
||||
for(const file of ['ReferenceLayout.tsx','manifest.json'])await put('components/reference-layouts/'+file,await readFile(path.join(root,'src/content/layouts/reference',file),'utf8'));
|
||||
for(const l of layouts){await put('documents/'+l.id+'.md',guidanceMarkdown('premium-layout/'+l.id)!);await put('app/designs/'+l.id+'/page.tsx',nextExample(l.id))}
|
||||
await put('documents/router.md',guidanceMarkdown('premium-layout/router')!);
|
||||
await put('app/layout.tsx',`import type {ReactNode} from 'react';\nexport default function RootLayout({children}:{children:ReactNode}){return <html lang="zh-CN"><body>{children}</body></html>}`);
|
||||
await put('README.md',`# 27 张原图一一对应排版\n\n先读 documents/router.md,再选定 ref-01 至 ref-27 中的固定 ID。\n\n原始参考拼图是 736×980,每张约 112×173;素材保留原像素,不能恢复缺失的小字。默认 ReferenceLayout 逐层重组原图;文字仍是分类明确的原字形资产,不是已恢复成精确字体的矢量文字。text 属性可按完整构件键替换为 DOM 文本,未辨识细字不要猜写。\n\nHTML 检查器位于 public/layout-reference/index.html,需要通过静态服务器提供 public 目录。React 项目将 components 和 public 素材合入现有项目;Next.js 项目同时可使用 app/designs 下各个固定路由页。已有根布局应合并,不覆盖;@/ 指向源码根。\n\n包中没有 package.json,供合入现有 React / Next.js 项目。保留 manifest.json 与所有素材的相对路径。调用 ref-01 时只允许 ref-01/Txx 和 ref-01/Ixx;组件会验证类型与归属。弧形文字替换须专门建立该版面的 SVG 文字路径,通用组件会拒绝直接改成横排。\n\n原作者与素材许可未提供;本包用于用户提供参考图的识别、设计分析与原图对照,不声明模板所有权。\n`);
|
||||
const result=spawnSync('python3',['-c',`import pathlib,zipfile,sys\nroot=pathlib.Path(sys.argv[1]);out=pathlib.Path(sys.argv[2])\nwith zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED) as z:\n for p in sorted(root.rglob('*')):\n if p.is_file():z.write(p,p.relative_to(root))`,stage,path.join(root,'public/layout-reference/layout-reference-toolkit.zip')],{stdio:'inherit'});
|
||||
if(result.status!==0)throw Error('ZIP packaging failed');console.log('Packaged 27 layouts, 28 documents, React, Next.js and classified source assets.');
|
||||
}
|
||||
main().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -1,22 +0,0 @@
|
||||
import Foundation
|
||||
import Vision
|
||||
import AppKit
|
||||
let folder=URL(fileURLWithPath:CommandLine.arguments[1])
|
||||
var result:[[String:Any]]=[]
|
||||
for url in try FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:nil).filter({$0.pathExtension=="png"}).sorted(by:{$0.lastPathComponent<$1.lastPathComponent}) {
|
||||
let request=VNRecognizeTextRequest()
|
||||
request.recognitionLevel = .accurate
|
||||
request.recognitionLanguages = ["zh-Hans","en-US"]
|
||||
request.usesLanguageCorrection = false
|
||||
request.minimumTextHeight = 0.004
|
||||
try VNImageRequestHandler(url:url).perform([request])
|
||||
let lines=(request.results ?? []).compactMap { observation -> [String:Any]? in
|
||||
guard let candidate=observation.topCandidates(1).first else{return nil}
|
||||
let b=observation.boundingBox
|
||||
return ["text":candidate.string,"confidence":candidate.confidence,"box":[b.minX,1-b.maxY,b.width,b.height]]
|
||||
}
|
||||
result.append(["file":url.lastPathComponent,"lines":lines])
|
||||
}
|
||||
let data=try JSONSerialization.data(withJSONObject:result,options:[.prettyPrinted,.sortedKeys])
|
||||
try data.write(to:URL(fileURLWithPath:CommandLine.arguments[2]))
|
||||
print("OCR processed \(result.count) panels")
|
||||
@@ -1,128 +0,0 @@
|
||||
/** Deterministic segmentation + reviewed semantic regions; no generated imagery. */
|
||||
import sharp from 'sharp';
|
||||
import {readFile,writeFile,mkdir,rm} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {createHash} from 'node:crypto';
|
||||
import annotations from '../src/content/layouts/reference/annotations.json';
|
||||
import {textMergeTarget,unionBoxes} from '../src/content/layouts/reference/group-text';
|
||||
import ocr from '../src/content/layouts/reference/ocr-evidence.json';
|
||||
const root=path.resolve(import.meta.dirname,'..'),target=path.join(root,'public/layout-reference');
|
||||
type Box=[number,number,number,number];
|
||||
type Element={id:string;kind:'text'|'image'|'decoration';role:string;box:Box;text:string|null;direction:string;reading:'verified'|'unreadable'|'not-text';asset:string;mask?:string;underlay?:string;pixels:number};
|
||||
function components(mask:Uint8Array,w:number,h:number){
|
||||
const seen=new Uint8Array(mask.length),items:{box:Box;pixels:number[]}[]=[];
|
||||
for(let i=0;i<mask.length;i++)if(mask[i]&&!seen[i]){
|
||||
const q=[i];let c=0,x0=w,y0=h,x1=0,y1=0;seen[i]=1;
|
||||
while(c<q.length){const j=q[c++],x=j%w,y=Math.floor(j/w);x0=Math.min(x0,x);y0=Math.min(y0,y);x1=Math.max(x1,x);y1=Math.max(y1,y);for(const k of [j-1,j+1,j-w,j+w])if(k>=0&&k<mask.length&&!seen[k]&&mask[k]&&Math.abs(k%w-x)<=1){seen[k]=1;q.push(k)}}
|
||||
items.push({box:[x0,y0,x1-x0+1,y1-y0+1],pixels:q});
|
||||
}return items;
|
||||
}
|
||||
async function main(){
|
||||
const source=await readFile(path.join(target,'source.png')),{data,info}=await sharp(source).removeAlpha().raw().toBuffer({resolveWithObject:true}),W=info.width,H=info.height;
|
||||
const bg=[...data.subarray((3*W+W-6)*3,(3*W+W-6)*3+3)],mask=new Uint8Array(W*H);
|
||||
for(let i=0;i<mask.length;i++)mask[i]=Math.max(...bg.map((c,k)=>Math.abs(data[i*3+k]-c)))>19?1:0;
|
||||
const found=components(mask,W,H).filter(({box:[,,w,h]})=>w>85&&w<125&&h>145&&h<190).map(({box:[x,y,w,h]})=>({x,y,w,h}));
|
||||
if(found.length!==27)throw Error(`Expected 27 panels; found ${found.length}`);
|
||||
const rows:number[]=[],cols:number[]=[];
|
||||
for(const p of [...found].sort((a,b)=>a.y-b.y))if(!rows.some(y=>Math.abs(y-p.y)<8))rows.push(p.y);
|
||||
for(const p of [...found].sort((a,b)=>a.x-b.x))if(!cols.some(x=>Math.abs(x-p.x)<8))cols.push(p.x);
|
||||
const panels=found.map(p=>({...p,row:rows.findIndex(y=>Math.abs(y-p.y)<8)+1,column:cols.findIndex(x=>Math.abs(x-p.x)<8)+1})).sort((a,b)=>a.row-b.row||a.x-b.x);
|
||||
const manifests=[];let totalPixels=0;
|
||||
for(const [index,p]of panels.entries()){
|
||||
const id='ref-'+String(index+1).padStart(2,'0'),spec=annotations[index],dir=path.join(target,id);await rm(dir,{recursive:true,force:true});await mkdir(dir,{recursive:true});
|
||||
const crop=await sharp(source).extract({left:p.x,top:p.y,width:p.w,height:p.h}).removeAlpha().raw().toBuffer(),w=p.w,h=p.h;
|
||||
await sharp(crop,{raw:{width:w,height:h,channels:3}}).png().toFile(path.join(dir,'source.png'));
|
||||
const owner=new Int16Array(w*h).fill(-1),elements:Element[]=[];
|
||||
function entry(kind:Element['kind'],role:string,box:number[],text:string|null=null,direction='horizontal'){
|
||||
const prefix={text:'T',image:'I',decoration:'D'}[kind],n=elements.filter(e=>e.kind===kind).length+1;
|
||||
let[x,y,bw,bh]=box.map(Math.round);x=Math.max(0,Math.min(w-1,x));y=Math.max(0,Math.min(h-1,y));
|
||||
const e:Element={id:prefix+String(n).padStart(2,'0'),kind,role,box:[x,y,Math.max(1,Math.min(w-x,bw)),Math.max(1,Math.min(h-y,bh))],text,direction,reading:kind==='text'?(text===null?'unreadable':'verified'):'not-text',asset:'',pixels:0};elements.push(e);return e;
|
||||
}
|
||||
function region(box:Box,fn:(i:number)=>boolean){const[x,y,bw,bh]=box;for(let yy=y;yy<y+bh;yy++)for(let xx=x;xx<x+bw;xx++){const i=yy*w+xx;if(owner[i]===-1&&fn(i))owner[i]=elements.length-1}}
|
||||
function fillColor(seed:number[]){const[sx,sy]=seed.map(Math.round),hist=new Map<string,{rgb:number[];count:number}>();
|
||||
for(let y=Math.max(1,sy-7);y<Math.min(h-1,sy+8);y++)for(let x=Math.max(1,sx-7);x<Math.min(w-1,sx+8);x++){const i=y*w+x,rgb=[...crop.subarray(i*3,i*3+3)];if(Math.max(...rgb)>220||Math.min(...rgb)<65)continue;const key=rgb.map(v=>Math.round(v/8)).join(','),c=hist.get(key);if(c)c.count++;else hist.set(key,{rgb,count:1})}
|
||||
const color=[...hist.values()].sort((a,b)=>b.count-a.count)[0]?.rgb;if(!color)throw Error(id+': no fill color');return color;
|
||||
}
|
||||
const imageColors=spec.images.map(image=>fillColor(image.seed));
|
||||
for(const d of spec.decor.filter(d=>d.box[3]<=2&&d.box[2]>8)){const e=entry('decoration',d.role,d.box);region(e.box,i=>Math.max(...crop.subarray(i*3,i*3+3))<225)}
|
||||
for(const t of spec.texts){const e=entry('text',t.role,t.box,t.text,t.direction);
|
||||
const[x,y,bw,bh]=e.box,hist=new Map<number,number>();for(let yy=y;yy<y+bh;yy++)for(let xx=x;xx<x+bw;xx++){const i=(yy*w+xx)*3,v=Math.round((crop[i]+crop[i+1]+crop[i+2])/24)*8;hist.set(v,(hist.get(v)||0)+1)}
|
||||
const mode=[...hist].sort((a,b)=>b[1]-a[1])[0][0];
|
||||
region(e.box,i=>{
|
||||
const rgb=[...crop.subarray(i*3,i*3+3)],lum=(rgb[0]+rgb[1]+rgb[2])/3;
|
||||
if(t.tone==='light'||t.tone==='muted-light')return lum>mode+7;
|
||||
if(t.tone==='muted-dark')return lum<mode-12;
|
||||
return lum<(t.text===null?Math.min(240,mode-4):Math.min(225,mode-8))&&!imageColors.some((color,n)=>{const[a,b,c,d]=spec.images[n].box,x=i%w,y=Math.floor(i/w);return x>=a&&x<a+c&&y>=b&&y<b+d&&Math.hypot(...color.map((v,k)=>rgb[k]-v))<16});
|
||||
})
|
||||
}
|
||||
|
||||
for(const[n,image]of spec.images.entries()){
|
||||
const e=entry('image',image.role,image.box),color=imageColors[n],candidate=new Uint8Array(w*h),[a,b,c,d]=image.box;
|
||||
for(let y=Math.max(1,b-12);y<Math.min(h-1,b+d+12);y++)for(let x=Math.max(1,a-12);x<Math.min(w-1,a+c+12);x++){const i=y*w+x;if(Math.hypot(...color.map((v,k)=>crop[i*3+k]-v))<=36)candidate[i]=1}
|
||||
const[sx,sy]=image.seed,options=components(candidate,w,h).filter(c=>c.pixels.length>=12).sort((u,v)=>{
|
||||
const distance=(part:{box:Box})=>{const[x,y,bw,bh]=part.box;return Math.max(x-sx,sx-x-bw,0)**2+Math.max(y-sy,sy-y-bh,0)**2};return distance(u)-distance(v)||v.pixels.length-u.pixels.length;
|
||||
});
|
||||
const selected=options[0];if(!selected)throw Error(id+': no connected image region '+image.role);
|
||||
for(const i of selected.pixels)if(owner[i]===-1)owner[i]=elements.length-1;
|
||||
}
|
||||
|
||||
for(const d of spec.decor.filter(d=>!(d.box[3]<=2&&d.box[2]>8))){const e=entry('decoration',d.role,d.box);region(e.box,i=>id==='ref-04'?Math.min(...crop.subarray(i*3,i*3+3))>218:Math.max(...crop.subarray(i*3,i*3+3))<240)}
|
||||
const residual=new Uint8Array(w*h);for(let y=4;y<h-4;y++)for(let x=4;x<w-4;x++){const i=y*w+x;if(owner[i]===-1&&Math.max(...crop.subarray(i*3,i*3+3))<90)residual[i]=1}
|
||||
const extra=components(residual,w,h),tiny:number[]=[];
|
||||
for(const c of extra){if(c.pixels.length<2)continue;const[,,bw,bh]=c.box;if((bw>=7&&bh<=2)||(bh>=7&&bw<=2)){entry('decoration','自动识别细线',c.box);for(const i of c.pixels)owner[i]=elements.length-1}else tiny.push(...c.pixels)}
|
||||
if(tiny.length){
|
||||
const joined=new Uint8Array(w*h);for(const i of tiny){const x=i%w,y=Math.floor(i/w);for(let dy=-1;dy<=1;dy++)for(let dx=-2;dx<=2;dx++)if(x+dx>=4&&x+dx<w-4&&y+dy>=4&&y+dy<h-4)joined[(y+dy)*w+x+dx]=1}
|
||||
for(const c of components(joined,w,h)){const set=new Set(c.pixels),ink=tiny.filter(i=>set.has(i));if(!ink.length)continue;const candidates=elements.filter(e=>e.kind==='text'),match=textMergeTarget(c.box,candidates);if(match>=0){const existing=candidates[match],n=elements.indexOf(existing);existing.box=unionBoxes(existing.box,c.box);for(const i of ink)owner[i]=n}else{entry('text','未辨识细字(内容待核对)',c.box);for(const i of ink)owner[i]=elements.length-1}}
|
||||
}
|
||||
for(const[n,e]of elements.entries()){
|
||||
let x0=w,y0=h,x1=-1,y1=-1;for(let i=0;i<owner.length;i++)if(owner[i]===n){const x=i%w,y=Math.floor(i/w);x0=Math.min(x0,x);y0=Math.min(y0,y);x1=Math.max(x1,x);y1=Math.max(y1,y)}
|
||||
if(x1>=0)e.box=[x0,y0,x1-x0+1,y1-y0+1];
|
||||
}
|
||||
const result=Buffer.alloc(w*h*3),background=Buffer.alloc(w*h*4);let assigned=0;
|
||||
for(let i=0;i<w*h;i++)if(owner[i]===-1){crop.copy(background,i*4,i*3,i*3+3);background[i*4+3]=255;crop.copy(result,i*3,i*3,i*3+3);assigned++}
|
||||
for(const[n,e]of elements.entries()){
|
||||
const[x,y,bw,bh]=e.box,rgba=Buffer.alloc(bw*bh*4),binary=new Uint8Array(bw*bh);
|
||||
for(let yy=y;yy<y+bh;yy++)for(let xx=x;xx<x+bw;xx++){const i=yy*w+xx,j=(yy-y)*bw+xx-x;if(owner[i]!==n)continue;crop.copy(rgba,j*4,i*3,i*3+3);rgba[j*4+3]=255;binary[j]=1;crop.copy(result,i*3,i*3,i*3+3);e.pixels++;assigned++}
|
||||
e.asset=`/layout-reference/${id}/${e.id}.png`;await sharp(rgba,{raw:{width:bw,height:bh,channels:4}}).png().toFile(path.join(dir,e.id+'.png'));
|
||||
if(e.kind==='text'){
|
||||
const underlay=Buffer.alloc(bw*bh*4);
|
||||
for(let yy=y;yy<y+bh;yy++)for(let xx=x;xx<x+bw;xx++){
|
||||
const i=yy*w+xx;if(owner[i]!==n)continue;const j=((yy-y)*bw+xx-x)*4;
|
||||
const samples:number[][]=[];
|
||||
for(let r=1;r<=8&&samples.length<4;r++)for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){
|
||||
if(Math.max(Math.abs(dx),Math.abs(dy))!==r||xx+dx<0||xx+dx>=w||yy+dy<0||yy+dy>=h)continue;
|
||||
const k=(yy+dy)*w+xx+dx;if(elements[owner[k]]?.kind==='text')continue;samples.push([...crop.subarray(k*3,k*3+3)]);
|
||||
}
|
||||
for(let c=0;c<3;c++)underlay[j+c]=samples.length?Math.round(samples.reduce((sum,rgb)=>sum+rgb[c],0)/samples.length):245;underlay[j+3]=255;underlay.copy(background,i*4,j,j+4);
|
||||
}
|
||||
e.underlay=`/layout-reference/${id}/${e.id}-underlay.png`;
|
||||
await sharp(underlay,{raw:{width:bw,height:bh,channels:4}}).png().toFile(path.join(dir,e.id+'-underlay.png'));
|
||||
}
|
||||
if(e.kind==='image'){
|
||||
const outside=new Uint8Array(binary.length),q:number[]=[];for(let i=0;i<binary.length;i++)if(!binary[i]&&(i<bw||i>=bw*(bh-1)||i%bw===0||i%bw===bw-1)){q.push(i);outside[i]=1}
|
||||
for(let c=0;c<q.length;c++){const i=q[c];for(const j of[i-1,i+1,i-bw,i+bw])if(j>=0&&j<binary.length&&!binary[j]&&!outside[j]&&Math.abs(j%bw-i%bw)<=1){outside[j]=1;q.push(j)}}
|
||||
const alpha=Buffer.alloc(binary.length*4);for(let i=0;i<binary.length;i++)if(!outside[i])alpha.fill(255,i*4,i*4+4);
|
||||
e.mask=`/layout-reference/${id}/${e.id}-mask.png`;await sharp(alpha,{raw:{width:bw,height:bh,channels:4}}).png().toFile(path.join(dir,e.id+'-mask.png'));
|
||||
}
|
||||
}
|
||||
await sharp(background,{raw:{width:w,height:h,channels:4}}).png().toFile(path.join(dir,'background.png'));
|
||||
for(const kind of ['text','image','decoration'] as const){
|
||||
const map=Buffer.alloc(w*h*4,255),color={text:[44,100,216],image:[230,109,34],decoration:[8,135,93]}[kind];
|
||||
for(let i=0;i<w*h;i++){const e=elements[owner[i]];if(e?.kind===kind)for(let k=0;k<3;k++)map[i*4+k]=color[k]}
|
||||
await sharp(map,{raw:{width:w,height:h,channels:4}}).resize(w*6,h*6,{kernel:'nearest'}).png().toFile(path.join(dir,kind+'-map.png'));
|
||||
}
|
||||
if(assigned!==w*h||!result.equals(crop))throw Error(id+': pixel reconstruction failed');
|
||||
await sharp(result,{raw:{width:w,height:h,channels:3}}).png().toFile(path.join(dir,'reconstructed.png'));
|
||||
const kept=elements.filter(e=>e.pixels>0),colors={text:'#2c64d8',image:'#e66d22',decoration:'#08875d'};
|
||||
const overlay=`<svg xmlns="http://www.w3.org/2000/svg" width="${w*6}" height="${h*6}" viewBox="0 0 ${w} ${h}">${kept.map(e=>{const[x,y,bw,bh]=e.box;return `<rect x="${x}" y="${y}" width="${bw}" height="${bh}" fill="${colors[e.kind]}" fill-opacity=".08" stroke="${colors[e.kind]}" stroke-width=".45"/><rect x="${x}" y="${y}" width="12" height="5" fill="${colors[e.kind]}"/><text x="${x+.5}" y="${y+3.8}" font-family="Arial" font-size="3.6" fill="white">${e.id}</text>`}).join('')}</svg>`;
|
||||
await sharp(crop,{raw:{width:w,height:h,channels:3}}).resize(w*6,h*6,{kernel:'nearest'}).composite([{input:Buffer.from(overlay)}]).png().toFile(path.join(dir,'annotated.png'));
|
||||
const manifest={id,name:spec.name,source:{url:'/layout-reference/source.png',sha256:createHash('sha256').update(source).digest('hex'),box:[p.x,p.y,w,h],row:p.row,column:p.column},width:w,height:h,reference:`/layout-reference/${id}/source.png`,annotated:`/layout-reference/${id}/annotated.png`,background:`/layout-reference/${id}/background.png`,elements:kept,ocr:ocr.find(r=>r.file===id+'.png')?.lines||[],verification:{pixelIdentical:true,pixels:w*h,unreadableText:kept.filter(e=>e.reading==='unreadable').length}};
|
||||
await writeFile(path.join(dir,'manifest.json'),JSON.stringify(manifest,null,2)+'\n');manifests.push(manifest);totalPixels+=w*h;
|
||||
}
|
||||
const overlay=`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">${manifests.map(m=>{const[x,y,w,h]=m.source.box;return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="#ed4237" stroke-width="1.5"/><rect x="${x}" y="${y}" width="35" height="14" fill="#ed4237"/><text x="${x+2}" y="${y+10}" font-family="Arial" font-size="9" fill="white">${m.id}</text>`}).join('')}</svg>`;
|
||||
await sharp(source).composite([{input:Buffer.from(overlay)}]).png().toFile(path.join(target,'numbered-source.png'));
|
||||
await writeFile(path.join(root,'src/content/layouts/reference/manifest.json'),JSON.stringify(manifests,null,2)+'\n');
|
||||
await writeFile(path.join(target,'index.json'),JSON.stringify({version:2,sourceSha256:manifests[0].source.sha256,numbering:'row-major; exclude left title banner',layouts:manifests.map(m=>({id:m.id,name:m.name,source:m.source,manifest:`/layout-reference/${m.id}/manifest.json`}))},null,2)+'\n');
|
||||
console.log(`Detected ${panels.length} panels; ${manifests.reduce((n,m)=>n+m.elements.length,0)} typed components; ${totalPixels} pixels reconstructed exactly.`);
|
||||
}
|
||||
main().catch(e=>{console.error(e);process.exitCode=1});
|
||||
Reference in New Issue
Block a user