Files
probably-rendering-one-more…/scripts/publish-layout-reference.mjs

69 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.`);