/** 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){ const capabilities=await Promise.all(['/api/upload?agent_mode=True','/api/posts?agent_mode=True'].map(async url=>{const r=await fetch(base+url,{signal:AbortSignal.timeout(30000)});if(!r.ok)throw Error('无法读取 API 能力说明');return r.text()})); if(!capabilities[0].includes('assetOnly')||!capabilities[1].includes('publicId'))throw Error('目标站点尚未部署所需通用 API,停止发布'); for(let offset=0;offset'/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 ;\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
;\n}\n// 静态排版可作为 Server Component;交互外壳才需要 use client。`; const htmlExample=l=>`\n${l.id}\n\n
`; 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){p.markdown=p.markdown.trim();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.`);