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:
@@ -0,0 +1,17 @@
|
||||
import {createRequire} from 'node:module';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import path from 'node:path';
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
||||
const require=createRequire(path.join(root,'apps/wiki/package.json')),sharp=require('sharp');
|
||||
const layouts=JSON.parse(await readFile(path.join(root,'content/layout-reference/manifest.json'),'utf8'));
|
||||
const layers=[];let shapes='';
|
||||
for(const[i,l]of layouts.entries()){
|
||||
const x=52+i%6*166,y=200+Math.floor(i/6)*232;
|
||||
shapes+=`<rect x="${x}" y="${y}" width="146" height="222" fill="#faf9f3" stroke="#cbd1ca"/><text x="${x+73}" y="${y+214}" text-anchor="middle" font-size="16" letter-spacing="2">${l.id.toUpperCase()}</text>`;
|
||||
const bytes=await sharp(path.join(root,'content/layout-reference/assets',l.id,'source.png')).resize({width:130,height:190,fit:'inside'}).png().toBuffer();
|
||||
const meta=await sharp(bytes).metadata();layers.push({input:bytes,left:Math.round(x+(146-meta.width)/2),top:y+7});
|
||||
}
|
||||
const svg=`<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1440"><rect width="1080" height="1440" fill="#eeeee8"/><g fill="#263734" font-family="Arial,PingFang SC,sans-serif"><text x="52" y="150" font-family="Georgia,serif" font-size="136">27</text><text x="230" y="100" font-size="49">原图对应排版</text><text x="232" y="145" font-size="23" letter-spacing="3">一张设计 · 一份构件清单</text><path d="M52 175H1028" stroke="#9ca9a2"/>${shapes}<text x="570" y="1190" font-size="28">构件分层</text><text x="572" y="1237" font-size="23" fill="#2c64d8">T 文字 113</text><text x="780" y="1237" font-size="23" fill="#c8672f">I 图位 51</text><text x="572" y="1276" font-size="23" fill="#08875d">D 装饰 53</text><text x="780" y="1276" font-size="23">共 217 组</text><text x="570" y="1320" font-size="18">原裁片 / 坐标 / 代码 / 验收</text><text x="52" y="1404" font-size="20" letter-spacing="2">WIKI · TEXT + IMAGE</text><text x="1028" y="1404" text-anchor="end" font-size="20">REF-01 — REF-27</text></g></svg>`;
|
||||
await sharp(Buffer.from(svg)).composite(layers).png().toFile(path.join(root,'content/layout-reference/assets/router-cover.png'));
|
||||
console.log('Created Router cover from all 27 actual source crops.');
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Local authoring client. Publishes text/images exclusively through existing generic Wiki APIs. */
|
||||
import {readFile,writeFile,mkdir} from 'node:fs/promises';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import path from 'node:path';
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),dataRoot=path.join(root,'content/layout-reference');
|
||||
const raw=JSON.parse(await readFile(path.join(dataRoot,'manifest.json'),'utf8'));
|
||||
const ids=JSON.parse(await readFile(path.join(dataRoot,'post-ids.json'),'utf8'));
|
||||
const renderer=await readFile(path.join(dataRoot,'renderer.tsx'),'utf8');
|
||||
const base=(process.env.WIKI_BASE||'http://8.211.154.134:18131').replace(/\/$/,'');
|
||||
const statePath=process.env.WIKI_PUBLISH_STATE||path.join(root,'output/layout-reference-publish-state.json');
|
||||
const dryRun=process.argv.includes('--dry-run');
|
||||
let state;try{state=JSON.parse(await readFile(statePath,'utf8'))}catch{state={base,assets:{},posts:{}}}
|
||||
if(state.base!==base)throw Error('发布状态属于另一站点,请使用独立状态文件');
|
||||
const save=async()=>{await mkdir(path.dirname(statePath),{recursive:true});await writeFile(statePath,JSON.stringify(state,null,2)+'\n')};
|
||||
async function api(url,options={}){
|
||||
const r=await fetch(base+url,{...options,signal:AbortSignal.timeout(120000)});if(r.status===404)return null;
|
||||
if(!r.ok)throw Error(`${url}: ${r.status} ${(await r.text()).slice(0,400)}`);return r.json();
|
||||
}
|
||||
const assetNames=new Set(['router-cover.png','source.png','numbered-source.png']);
|
||||
for(const l of raw){for(const field of ['reference','annotated','background'])assetNames.add(l[field].slice('asset:'.length));for(const kind of ['text','image','decoration'])assetNames.add(`${l.id}/${kind}-map.png`);for(const e of l.elements)for(const field of ['asset','mask','underlay'])if(e[field])assetNames.add(e[field].slice('asset:'.length))}
|
||||
const byFile={},pending=[];
|
||||
for(const name of [...assetNames].sort()){
|
||||
const bytes=await readFile(path.join(dataRoot,'assets',name)),hash=createHash('sha256').update(bytes).digest('hex');byFile[name]={bytes,hash};
|
||||
if(!state.assets[hash]&&!pending.some(a=>a.hash===hash))pending.push({name,bytes,hash});
|
||||
}
|
||||
if(!dryRun){
|
||||
for(let offset=0;offset<pending.length;offset+=16){
|
||||
const batch=pending.slice(offset,offset+16),form=new FormData();form.set('kind','image');form.set('assetOnly','true');
|
||||
for(const item of batch)form.append('files',new Blob([item.bytes],{type:'image/png'}),item.name.replaceAll('/','--'));
|
||||
const uploaded=await api('/api/upload',{method:'POST',body:form});if(!uploaded?.files||uploaded.files.length!==batch.length)throw Error('素材上传响应不完整');
|
||||
for(const file of uploaded.files)state.assets[file.sha256]=file.id;await save();console.log(`Uploaded image assets: ${Math.min(offset+16,pending.length)}/${pending.length}`);
|
||||
}
|
||||
}
|
||||
const media=name=>'/api/media/'+(state.assets[byFile[name]?.hash]||(dryRun?'00000000-0000-0000-0000-000000000000':(()=>{throw Error('素材缺失:'+name)})()));
|
||||
const map=value=>typeof value==='string'&&value.startsWith('asset:')?media(value.slice('asset:'.length)):Array.isArray(value)?value.map(map):value&&typeof value==='object'?Object.fromEntries(Object.entries(value).map(([k,v])=>[k,map(v)])):value;
|
||||
const layouts=raw.map(({ocr,...l})=>map(l));
|
||||
const link=(id,label=id)=>`[${label}](/explore/${ids[id]}?agent_mode=True)`;
|
||||
const fence=(lang,text)=>'```'+lang+'\n'+text+'\n```';
|
||||
const kindName=k=>({text:'文字',image:'图片占位/色块',decoration:'装饰'})[k];
|
||||
const reactExample=l=>`// 将本文 JSON 保存为 ${l.id}.json;将下方组件保存为 LayoutPoster.tsx。\nimport {LayoutPoster} from './LayoutPoster';\nimport layout from './${l.id}.json';\nexport default function Example(){\n return <LayoutPoster id="${l.id}" layout={layout} assetBase="${base}" />;\n}`;
|
||||
const nextExample=l=>`// 你的 Next.js 项目 app/page.tsx,沿用现有根布局。\nimport {LayoutPoster} from '@/components/LayoutPoster';\nimport layout from '@/content/${l.id}.json';\nexport default function Page(){\n return <main><LayoutPoster id="${l.id}" layout={layout} assetBase="${base}" /></main>;\n}\n// 静态排版可作为 Server Component;交互外壳才需要 use client。`;
|
||||
const htmlExample=l=>`<!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}</title>\n<style>body{margin:0}.canvas{margin:0;position:relative;width:min(100%,672px);background:#f4f3ee}.canvas img{position:absolute;display:block}</style></head>\n<body><figure class="canvas" id="canvas"></figure><script>\nconst m=${JSON.stringify(l).replaceAll('<','\\u003c')};\nconst assetBase=${JSON.stringify(base)};\nconst canvas=document.getElementById('canvas');canvas.style.aspectRatio=m.width+'/'+m.height;\nconst bg=document.createElement('img');bg.src=assetBase+m.background;bg.alt='';bg.style.cssText='inset:0;width:100%;height:100%';canvas.append(bg);\nfor(const e of m.elements){const[x,y,w,h]=e.box,img=document.createElement('img');img.src=assetBase+e.asset;img.alt=e.kind==='text'?(e.text||e.role+'(未辨识原字形)'):'';Object.assign(img.style,{left:x/m.width*100+'%',top:y/m.height*100+'%',width:w/m.width*100+'%',height:h/m.height*100+'%',zIndex:e.kind==='text'?'3':e.kind==='decoration'?'2':'1'});canvas.append(img)}\n</script></body></html>`;
|
||||
const common='默认模式使用已分类的原字形与图形 PNG,重组后保留原像素;它不是字体或矢量轮廓的自动恢复。替换文字时才渲染 DOM 文本,字体变化须重新验收。微字内容未辨识时保持 null,不猜写。参考图中的灰绿区域是图片占位/色块,没有可辨认的真实照片。';
|
||||
const sourceNote='原始拼图仅 736×980,每张约 112×173;放大不恢复缺失细节。图片与构件均通过本文的 /api/media 链接读取,JSON 和源码直接作为文本提供。';
|
||||
const routerMarkdown=`这是一份以文本和图像为载体的 27 张原图排版路由文档。左侧说明栏不计入;从上到下、每行从左到右编号,前三行各 5 张、后两行各 6 张。每张设计有独立 post、源坐标、构件清单与代码示例。\n\n## 27 张固定对应索引\n\n| ID | 原图位置(含左侧说明栏所在列) | 设计 |\n| --- | --- | --- |\n${layouts.map(l=>`| ${link(l.id)} | 第 ${l.source.row} 行、第 ${l.source.column} 列 | ${l.name} |`).join('\n')}\n\n## 路由规则\n\n输入必须给出 ref-01 至 ref-27 中的一个 ID;不能用近似风格名随意替换构图。读取目标 post 的原图、分类图与 JSON,再按 source.box 和每个构件 box 定位。对外使用完整构件键,例如 ref-01/T01;不得混入 ref-02 的素材。\n\n## 三类构件\n\nT 是文字:标题、品牌、日期、正文、图注。I 是图片占位或色块。D 是线框、圆环、节点和引线。纸底单独保存。相同单词的残字应合并,ref-01 的品牌字标整体为 T01、左下说明为 T02,没有重复的 T03。ref-21 的 03 日期与旋转英文保持独立。\n\n## 识别与复核\n\n先按背景差分和四邻域连通域检测 27 张外框;按 y 聚行、按 x 排序。使用本机 Vision 提取 OCR 候选,再人工核对文字区与图形区;图位按局部颜色种子和连通形状提取。残字按基线、间距和字高合并。默认图层实际合成覆盖 527,155 个像素,与原裁片 RGB 一致;这只证明重组保真,不代替语义复核。\n\n${common}\n\n${sourceNote}\n\n## 调用与交付\n\n1. 明确 layoutId。\n2. 阅读对应 post 的文字、图像与 JSON。\n3. 检查各构件 id、类型、坐标与方向。\n4. 根据 HTML、React 或 Next.js 示例创建你自己的作品。\n5. 替换内容后检查溢出、字体、竖排、裁切和透明边缘。\n\n所有本系列资料均是普通 Wiki post 与媒体资产;不需要独立演示页、检查器、JSON 文件端点或下载页面。获取纯文本用同一个 post 地址加 ?agent_mode=True。\n\n## 共用 React / TSX 渲染器\n\n将下面代码保存到你自己的项目中,配合子文档的 JSON;这些是创作示例,不是 Wiki 站的页面。跨站使用前可先通过 API 下载素材到自己的项目,并按需替换资源路径。\n\n${fence('tsx',renderer)}\n`;
|
||||
const posts=[{key:'router',publicId:ids.router,slug:'layout-reference-router',title:'高级感排版 · 27 张原图 Router',description:'27 张实际原图的编号索引,按文字、图位和装饰分层调用;文档与图片均可直接读取。',markdown:routerMarkdown,main:['router-cover.png','numbered-source.png'],extra:['source.png']}];
|
||||
for(const l of layouts){
|
||||
const text=l.elements.filter(e=>e.kind==='text'),images=l.elements.filter(e=>e.kind==='image'),decor=l.elements.filter(e=>e.kind==='decoration');
|
||||
const markdown=`${l.id} 只对应原图第 ${l.source.row} 行、第 ${l.source.column} 列的“${l.name}”。原裁片 source.box = [${l.source.box.join(', ')}]。\n\n## 图像阅读顺序\n\n第 1 张是原裁片;第 2 张是构件标注;第 3 张为蓝色文字掩膜;第 4 张为橙色图位掩膜;第 5 张为绿色装饰掩膜。文字 ${text.length} 组、图片占位/色块 ${images.length} 组、装饰 ${decor.length} 组。T 和 I 可以叠放,但不能混成同一构件。\n\n## 构件逐项对应\n\n| 完整键 | 类型 | 用途 | 坐标 x,y,w,h | 内容或状态 |\n| --- | --- | --- | --- | --- |\n${l.elements.map(e=>`| ${l.id}/${e.id} | ${kindName(e.kind)} | ${e.role} | ${e.box.join(', ')} | ${e.kind==='text'?(e.text??'待核对,保留原字形'):'非文字'} |`).join('\n')}\n\n## 制作步骤\n\n1. 使用 ${l.width}×${l.height} 的源坐标建立画板,保持比例。\n2. 按 I 构件放置 ${images.map(e=>e.id+' '+e.role).join('、')},采用本构件 mask。\n3. 分别放置 ${text.map(e=>e.id+' '+e.role).join('、')}。\n4. 还原 ${decor.map(e=>e.id+' '+e.role).join('、')||'原有细节'}。\n5. 与原裁片叠加比较,检查同词拆框、文字遮挡与边缘裁切。\n\n## 文字方向\n\n${text.map(e=>'- '+e.id+':'+({vertical:'竖排',rotated:'整体旋转',angled:'倾斜排字',curved:'沿曲线排字',horizontal:'横排'}[e.direction]||e.direction)+';'+(e.text?'已核对“'+e.text+'”':'无法可靠辨认,保留独立原字形')+'。').join('\n')}\n\n${common}\n\n${sourceNote}\n\n## 机器可读构件清单\n\n以下 JSON 即本设计清单,所有资源都指向标准媒体 API。将它另存到创作项目中即可使用,无需访问其他页面。\n\n${fence('json',JSON.stringify(l,null,2))}\n\n## HTML / CSS 示例\n\n${fence('html',htmlExample(l))}\n\n## React / TSX 示例\n\n${fence('tsx',reactExample(l))}\n\n## 共用渲染器源码\n\n${fence('tsx',renderer)}\n\n## Next.js App Router 示例\n\n${fence('tsx',nextExample(l))}\n\n## 替换与验收\n\n只允许本设计完整键,如 ${l.id}/${text[0].id};图位使用 I 键,不能作为文字键。原样模式保留原图;替换模式会改变内容和字体,需重新验收。弧形文字须沿该设计专用 SVG 路径排字,通用渲染器拒绝直接改成横排。网页长文另提供正常字号正文,不把低分辨率海报小字当作阅读版。\n\n${link('router','返回 27 张 Router')}\n`;
|
||||
const extra=new Set(['source.png',`${l.id}/background.png`]);for(const e of raw.find(x=>x.id===l.id).elements)for(const key of ['asset','mask','underlay'])if(e[key])extra.add(e[key].slice('asset:'.length));
|
||||
posts.push({key:l.id,publicId:ids[l.id],slug:'layout-reference-'+l.id,title:l.id+' · '+l.name,description:`${l.id} 原图一一对应:${text.length} 组文字、${images.length} 组图位、${decor.length} 组装饰;含图像、坐标及 HTML/React/Next.js 示例。`,markdown,main:[`${l.id}/source.png`,`${l.id}/annotated.png`,`${l.id}/text-map.png`,`${l.id}/image-map.png`,`${l.id}/decoration-map.png`],extra:[...extra]});
|
||||
}
|
||||
for(const p of posts){if(p.markdown.length>100000)throw Error('正文超长 '+p.key);if(p.markdown.includes('/layout-reference/'))throw Error('正文仍有专用静态路径 '+p.key)}
|
||||
if(dryRun){console.log(JSON.stringify({posts:posts.length,uniqueFiles:assetNames.size,uniqueHashes:new Set(Object.values(byFile).map(a=>a.hash)).size,maxText:Math.max(...posts.map(p=>p.markdown.length)),cover:'router-cover.png'}));process.exit(0)}
|
||||
const tag=await api('/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'高级感排版',slug:'premium-layout'})});
|
||||
for(const p of [...posts.slice(1),posts[0]]){
|
||||
const seen=new Set(),assets=[];for(const[files,role]of [[p.main,'content'],[p.extra,'caption']])for(const name of files){const id=state.assets[byFile[name].hash];if(!seen.has(id)){assets.push({id,role});seen.add(id)}}
|
||||
const coverAssetId=state.assets[byFile[p.main[0]].hash];
|
||||
let existing=await api('/api/notes/'+p.publicId);
|
||||
if(existing){if(existing.entry.postSlug!==p.slug)throw Error('ID 已被其他内容占用:'+p.publicId);const e=existing.entry;const same=e.title===p.title&&e.description===p.description&&e.editableMarkdown===p.markdown&&e.coverAssetId===coverAssetId&&JSON.stringify(e.assets.map(a=>({id:a.id,role:a.role})))===JSON.stringify(assets)&&e.categories.includes(tag.name);if(!same)existing=await api('/api/notes/'+p.publicId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:p.title,description:p.description,markdown:p.markdown,tags:[tag.id],revision:e.revision,assets,coverAssetId})});}
|
||||
else await api('/api/posts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'article',title:p.title,publicId:p.publicId,slug:p.slug,description:p.description,text:p.markdown,tags:[tag.id],coverAssetId,assets})});
|
||||
state.posts[p.key]={publicId:p.publicId,slug:p.slug,coverAssetId,assets:assets.length};await save();console.log('Published '+p.key+' /explore/'+p.publicId);
|
||||
}
|
||||
console.log(`Published ${posts.length} ordinary Wiki posts using text and image APIs only.`);
|
||||
@@ -0,0 +1,22 @@
|
||||
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")
|
||||
@@ -0,0 +1,128 @@
|
||||
/** 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 '../contentasset:annotations.json';
|
||||
import {textMergeTarget,unionBoxes} from '../contentasset:group-text';
|
||||
import ocr from '../contentasset:ocr-evidence.json';
|
||||
const root=path.resolve(import.meta.dirname,'..'),target=path.join(root,'contentasset:assets');
|
||||
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=`asset:${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=`asset:${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=`asset:${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:'asset: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:`asset:${id}/source.png`,annotated:`asset:${id}/annotated.png`,background:`asset:${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,'contentasset: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:`asset:${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