69 lines
14 KiB
JavaScript
69 lines
14 KiB
JavaScript
import {readRepositoryMedia} from './managed-media.mjs';
|
||
/** 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 readRepositoryMedia('content/layout-reference/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<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){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.`);
|