wechat-robot-skills/skills/pdf/scripts/_render_html.cjs
2026-09-09 12:30:35 +08:00

140 lines
9.7 KiB
JavaScript
Raw 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.

'use strict';
// Internal renderer. The agent invokes create_design_pdf.py, never Node or this file.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function packageRoot(name) {
for (const root of require.resolve.paths(name) || []) {
const candidate = path.join(root, name, 'package.json');
if (fs.existsSync(candidate)) return fs.realpathSync(path.dirname(candidate));
}
throw new Error(`基础镜像缺少 ${name};需要更新镜像,任务中不能安装`);
}
function within(root, filename) {
const relative = path.relative(root, filename);
return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
}
const MIME = {'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript',
'.mjs':'text/javascript','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp',
'.svg':'image/svg+xml','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf','.otf':'font/otf'};
async function render(request) {
const {chromium} = require('playwright-core');
const libraries = Object.fromEntries(['pagedjs','katex','mermaid'].map(name => [name, packageRoot(name)]));
const candidates = [process.env.CHROME_BIN, process.env.CHROME_PATH, '/usr/bin/chromium', '/usr/bin/chromium-browser'];
const executablePath = candidates.find(value => value && fs.existsSync(value));
if (!executablePath) throw new Error('基础镜像缺少可用 Chromium;检查 CHROME_BIN,任务中不能下载浏览器');
const sourceRoot = fs.realpathSync(path.dirname(request.input));
const assetRoot = fs.realpathSync(request.assets);
const errors = [], blocked = [];
const nonce = crypto.randomBytes(18).toString('base64');
const policy = `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline' https://pdf.local; img-src https://pdf.local data: blob:; font-src https://pdf.local data:; connect-src https://pdf.local; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'`;
async function inject(page, filename) {
await page.evaluate(({content, nonce}) => {const script=document.createElement('script'); script.nonce=nonce; script.textContent=content; document.head.append(script);}, {content:fs.readFileSync(filename,'utf8'), nonce});
}
const browser = await chromium.launch({headless:true, executablePath,
args:['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage'], timeout:request.timeout_ms});
try {
const context = await browser.newContext({serviceWorkers:'block', acceptDownloads:false});
const page = await context.newPage();
page.setDefaultTimeout(request.timeout_ms);
page.on('pageerror', error => errors.push(error.message.slice(0, 500)));
page.on('console', message => {
if (message.type() === 'error') errors.push(message.text().slice(0, 500));
});
await context.route('**/*', async route => {
const url = new URL(route.request().url());
if (url.protocol === 'data:' || url.protocol === 'blob:') return route.continue();
let root = sourceRoot, relative = decodeURIComponent(url.pathname).replace(/^\/+/, '');
if (url.origin !== 'https://pdf.local') {
blocked.push('外部资源:' + url.hostname); return route.abort();
}
if (relative.startsWith('__skill__/')) { root = assetRoot; relative = relative.slice(10); }
else if (relative.startsWith('__lib__/')) {
const parts = relative.split('/'); root = libraries[parts[1]]; relative = parts.slice(2).join('/');
}
try {
if (!root) throw new Error('unknown root');
const target = fs.realpathSync(path.resolve(root, relative));
if (!within(root, target) || !MIME[path.extname(target).toLowerCase()]) throw new Error('forbidden asset');
if (fs.statSync(target).size > 25 * 1024 * 1024) throw new Error('asset too large');
return route.fulfill({body:fs.readFileSync(target), contentType:MIME[path.extname(target).toLowerCase()], headers:{'Content-Security-Policy':policy}});
} catch (_) { blocked.push('缺失或不允许的本地资源:' + relative.slice(0, 200)); return route.abort(); }
});
await page.goto('https://pdf.local/' + encodeURIComponent(path.basename(request.input)), {waitUntil:'load'});
if (await page.evaluate(() => document.compatMode !== 'CSS1Compat'))
throw new Error('HTML 需以 <!doctype html> 声明标准模式,否则分页和数学公式无法正确排版');
// Reject active content in the actual DOM too, after the Python preflight.
const active = await page.evaluate(() => [...document.querySelectorAll('*')].some(el =>
['SCRIPT','IFRAME','OBJECT','EMBED','BASE','FRAME'].includes(el.tagName) ||
[...el.attributes].some(a => /^on/i.test(a.name) || a.name === 'srcdoc')));
if (active) throw new Error('HTML 包含主动脚本内容;只允许静态 HTML/CSS/SVG');
await page.emulateMedia({media:'print'});
let css = fs.readFileSync(path.join(assetRoot, 'design.css'), 'utf8');
if (request.page_size === 'LETTER') css = css.replaceAll('210mm','215.9mm').replaceAll('297mm','279.4mm').replaceAll('size: A4','size: Letter');
// Defaults precede author styles so explicit user typography takes precedence.
await page.evaluate(css => {const style=document.createElement('style'); style.textContent=css; document.head.prepend(style);}, css);
if (request.css) await page.addStyleTag({content:fs.readFileSync(request.css,'utf8')});
const stats = await page.evaluate(() => ({figures:document.querySelectorAll('figure').length,
tables:document.querySelectorAll('table').length, mermaid:document.querySelectorAll('.mermaid').length,
math:document.querySelectorAll('.math-inline,.math-display').length}));
if (stats.mermaid) {
const unsafe = await page.locator('.mermaid').evaluateAll(nodes => nodes.some(n => /%%\s*\{|^\s*---/m.test(n.textContent)));
if (unsafe) throw new Error('Mermaid 不允许内嵌配置;主题和安全选项由固定渲染器设置');
await inject(page, path.join(libraries.mermaid,'dist/mermaid.min.js'));
await page.evaluate(async () => {
mermaid.initialize({startOnLoad:false, securityLevel:'strict', theme:'neutral', maxTextSize:50000,
flowchart:{htmlLabels:false}, suppressErrorRendering:true});
await mermaid.run({querySelector:'.mermaid'});
});
}
if (stats.math) {
await page.addStyleTag({url:'https://pdf.local/__lib__/katex/dist/katex.min.css'});
await inject(page, path.join(libraries.katex,'dist/katex.min.js'));
await page.evaluate(() => {
for (const el of document.querySelectorAll('.math-inline,.math-display')) {
katex.render(el.textContent, el, {displayMode:el.classList.contains('math-display'), throwOnError:true,
trust:false, maxExpand:1000, maxSize:30, strict:'warn'});
}
});
}
await page.evaluate(async () => {
await document.fonts.ready;
await Promise.all([...document.images].map(img => img.complete ? Promise.resolve() : new Promise(resolve => {img.onload=img.onerror=resolve;})));
});
const broken = await page.evaluate(() => [...document.images].filter(img => !img.naturalWidth).length);
if (broken || blocked.length || errors.length) throw new Error('资源加载失败:' + [...new Set([...blocked,...errors]), ...(broken ? [`${broken} 张图片不可读`] : [])].join(';'));
await page.evaluate(() => {window.PagedConfig={auto:false};});
await inject(page, path.join(libraries.pagedjs,'dist/paged.polyfill.js'));
const total = await page.evaluate(async timeout => {
const flow = await Promise.race([window.PagedPolyfill.preview(), new Promise((_,reject) =>
setTimeout(() => reject(new Error('分页超时,未输出 PDF')), timeout))]);
await document.fonts.ready;
return flow.total;
}, request.timeout_ms);
if (!Number.isInteger(total) || total < 1 || total > 200) throw new Error('PDF 页数需在 1–200 之间');
if (errors.length || blocked.length) throw new Error([...errors,...blocked].slice(0,8).join(';'));
const pages = await page.evaluate(() => [...document.querySelectorAll('.pagedjs_page')].map((page, index) => {
const area = page.querySelector('.pagedjs_page_content');
const rect = area.getBoundingClientRect(), issues=[];
for (const el of area.querySelectorAll('table,figure,img,svg,pre,.math-display,h1,h2,h3,p')) {
const box=el.getBoundingClientRect();
if (box.width && (box.left < rect.left-2 || box.right > rect.right+2 || el.scrollWidth > el.clientWidth+3))
issues.push({tag:el.tagName.toLowerCase(),text:el.textContent.trim().slice(0,60)});
}
return {page:index+1,characters:area.innerText.trim().length,visuals:area.querySelectorAll('img,svg').length,overflows:issues.slice(0,10)};
}));
if (pages.length !== total) throw new Error('分页尚未完成:DOM 页数不一致');
if (pages.some(p => p.overflows.length)) throw new Error('检测到内容横向溢出:' + JSON.stringify(pages.filter(p=>p.overflows.length)));
await page.pdf({path:request.output, printBackground:true, preferCSSPageSize:true, tagged:true, scale:1,
timeout:request.timeout_ms});
return {ok:true,engine:'chromium+pagedjs',offline:true,page_count:total,content:stats,pages,
warnings:pages.filter(p => !p.characters && !p.visuals).map(p=>`第 ${p.page} 页可能为空白,需要视觉检查`)};
} finally {await browser.close();}
}
(async () => {
try {const result=await render(JSON.parse(fs.readFileSync(process.argv[2],'utf8'))); process.stdout.write(JSON.stringify(result));}
catch(error) {process.stdout.write(JSON.stringify({ok:false,error:String(error.message||error)})); process.exitCode=1;}
})();