'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 需以 声明标准模式,否则分页和数学公式无法正确排版'); // 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 () => { window.mermaid.initialize({startOnLoad:false, securityLevel:'strict', theme:'neutral', maxTextSize:50000, flowchart:{htmlLabels:false}, suppressErrorRendering:true}); await window.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')) { window.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;} })();