Files
ECC/scripts/lib/plan-canvas/pdf.js

300 lines
9.7 KiB
JavaScript

'use strict';
/**
* Local PDF export for Plan Canvas.
*
* Chromium's print-to-PDF implementation renders the same loopback artifact
* the reviewer sees, so Markdown, HTML, images, tables, and Mermaid diagrams
* keep their browser layout. No artifact content is sent to a converter or
* added as an npm runtime dependency.
*/
const { spawn } = require('child_process');
const fs = require('fs');
const net = require('net');
const os = require('os');
const path = require('path');
const DEFAULT_EXPORT_TIMEOUT_MS = 45 * 1000;
const PDF_POLL_MS = 100;
function errorWithCode(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
function isExecutable(file, { platform = process.platform, fsImpl = fs } = {}) {
if (!file) return false;
try {
fsImpl.accessSync(file, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
return fsImpl.statSync(file).isFile();
} catch {
return false;
}
}
function pathExecutableNames(platform) {
if (platform === 'win32') {
return ['chrome.exe', 'msedge.exe', 'chromium.exe'];
}
return ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge-stable', 'microsoft-edge'];
}
function executableCandidates({ env, platform }) {
const candidates = [];
if (platform === 'darwin') {
candidates.push(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
);
if (env.HOME) {
candidates.push(
path.join(env.HOME, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
path.join(env.HOME, 'Applications/Chromium.app/Contents/MacOS/Chromium'),
path.join(env.HOME, 'Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge')
);
}
}
if (platform === 'win32') {
for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) {
candidates.push(
path.join(root, 'Google/Chrome/Application/chrome.exe'),
path.join(root, 'Microsoft/Edge/Application/msedge.exe'),
path.join(root, 'Chromium/Application/chrome.exe')
);
}
}
const pathEntries = String(env.PATH || '')
.split(path.delimiter)
.map(entry => entry.replace(/^"|"$/g, ''))
.filter(Boolean);
for (const directory of pathEntries) {
for (const name of pathExecutableNames(platform)) candidates.push(path.join(directory, name));
}
return candidates;
}
function resolveChromiumExecutable({
env = process.env,
platform = process.platform,
fsImpl = fs
} = {}) {
const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim();
if (override) return isExecutable(override, { platform, fsImpl }) ? override : null;
return executableCandidates({ env, platform }).find(candidate => isExecutable(candidate, { platform, fsImpl })) || null;
}
function pdfFileName(artifactFile) {
const original = path.basename(String(artifactFile || 'plan'));
const stem = original
.replace(/\.(?:plan\.)?(?:md|markdown|html?|htm)$/i, '')
.split('')
.map(character => character.charCodeAt(0) < 32 ? '-' : character)
.join('')
.replace(/[<>:"/\\|?*]/g, '-')
.replace(/[.\s]+$/g, '')
.trim() || 'plan';
return `${stem}.pdf`;
}
function assertLoopbackUrl(value) {
let url;
try {
url = new URL(value);
} catch {
throw errorWithCode('PDF export URL is invalid', 'PDF_EXPORT_INVALID_URL');
}
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) {
throw errorWithCode('PDF export is restricted to the Plan Canvas loopback server', 'PDF_EXPORT_INVALID_URL');
}
return url.toString();
}
function isCompletePdf(file, fsImpl = fs) {
let fd;
try {
fd = fsImpl.openSync(file, 'r');
const stat = fsImpl.fstatSync(fd);
if (!stat.isFile() || stat.size < 12) return false;
const head = Buffer.alloc(5);
fsImpl.readSync(fd, head, 0, head.length, 0);
const tailLength = Math.min(2048, stat.size);
const tail = Buffer.alloc(tailLength);
fsImpl.readSync(fd, tail, 0, tailLength, stat.size - tailLength);
return head.toString('ascii') === '%PDF-' && tail.toString('latin1').includes('%%EOF');
} catch {
return false;
} finally {
if (fd !== undefined) {
try { fsImpl.closeSync(fd); } catch { /* best-effort probe */ }
}
}
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function startDenyProxy(netImpl = net) {
return new Promise((resolve, reject) => {
const server = netImpl.createServer(socket => {
socket.on('error', () => {});
socket.destroy();
});
const onError = error => reject(errorWithCode(
`Could not isolate PDF renderer network access (${error.message})`,
'PDF_EXPORT_FAILED'
));
server.once('error', onError);
server.listen(0, '127.0.0.1', () => {
server.removeListener('error', onError);
server.on('error', () => {});
server.unref();
resolve({ server, url: `http://127.0.0.1:${server.address().port}` });
});
});
}
function stopDenyProxy(server) {
if (!server) return Promise.resolve();
return new Promise(resolve => {
try { server.close(resolve); } catch { resolve(); }
});
}
async function stopChild(child) {
if (!child || child.exitCode !== null || child.signalCode) return;
const closed = new Promise(resolve => child.once('close', resolve));
try { child.kill('SIGTERM'); } catch { return; }
await Promise.race([closed, delay(1000)]);
if (child.exitCode === null && !child.signalCode) {
try { child.kill('SIGKILL'); } catch { /* already gone */ }
await Promise.race([closed, delay(500)]);
}
}
function waitForPdf(child, outputFile, { timeoutMs, fsImpl }) {
return new Promise((resolve, reject) => {
let stderr = '';
let settled = false;
const finish = error => {
if (settled) return;
settled = true;
clearInterval(poll);
clearTimeout(timeout);
child.removeListener('error', onError);
child.removeListener('close', onClose);
if (error) reject(error);
else resolve();
};
const failure = message => {
const detail = stderr.trim().slice(-1200);
return errorWithCode(`${message}${detail ? `: ${detail}` : ''}`, 'PDF_EXPORT_FAILED');
};
const onError = error => finish(failure(`Could not start the PDF renderer (${error.message})`));
const onClose = code => {
if (isCompletePdf(outputFile, fsImpl)) finish();
else finish(failure(`PDF renderer exited with status ${code}`));
};
if (child.stderr) {
child.stderr.on('data', chunk => {
stderr += chunk.toString();
if (stderr.length > 8000) stderr = stderr.slice(-4000);
});
}
child.once('error', onError);
child.once('close', onClose);
const poll = setInterval(() => {
if (isCompletePdf(outputFile, fsImpl)) finish();
}, PDF_POLL_MS);
const timeout = setTimeout(() => finish(failure(`PDF export timed out after ${timeoutMs}ms`)), timeoutMs);
});
}
async function exportPdf({
url,
artifactFile,
env = process.env,
platform = process.platform,
executable = null,
timeoutMs = DEFAULT_EXPORT_TIMEOUT_MS,
spawnImpl = spawn,
fsImpl = fs,
osImpl = os,
netImpl = net
} = {}) {
const safeUrl = assertLoopbackUrl(url);
const exportUrl = new URL(safeUrl);
const browser = executable || resolveChromiumExecutable({ env, platform, fsImpl });
if (!browser) {
const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim();
throw errorWithCode(
override
? `PDF renderer not found at ECC_PLAN_CANVAS_CHROME_PATH=${override}`
: 'PDF export requires Google Chrome, Chromium, or Microsoft Edge; set ECC_PLAN_CANVAS_CHROME_PATH to its executable',
'PDF_BROWSER_NOT_FOUND'
);
}
const tempDir = fsImpl.mkdtempSync(path.join(osImpl.tmpdir(), 'ecc-plan-canvas-pdf-'));
const outputFile = path.join(tempDir, 'artifact.pdf');
const profileDir = path.join(tempDir, 'profile');
fsImpl.mkdirSync(profileDir, { recursive: true });
let child = null;
let denyProxy = null;
try {
denyProxy = await startDenyProxy(netImpl);
const args = [
'--headless=new',
'--disable-background-networking',
'--disable-gpu',
'--disable-component-update',
'--disable-default-apps',
'--disable-extensions',
'--disable-sync',
'--disable-quic',
'--metrics-recording-only',
'--mute-audio',
'--no-first-run',
'--no-default-browser-check',
'--no-pdf-header-footer',
'--print-to-pdf-no-header',
'--hide-scrollbars',
`--proxy-server=${denyProxy.url}`,
`--proxy-bypass-list=<-loopback>;${exportUrl.origin}`,
`--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE ${exportUrl.hostname}`,
`--user-data-dir=${profileDir}`,
`--print-to-pdf=${outputFile}`,
'--virtual-time-budget=5000',
safeUrl
];
child = spawnImpl(browser, args, {
stdio: ['ignore', 'ignore', 'pipe'],
shell: false,
env: { ...env, LANG: 'en_US.UTF-8' }
});
await waitForPdf(child, outputFile, { timeoutMs, fsImpl });
const buffer = fsImpl.readFileSync(outputFile);
if (!isCompletePdf(outputFile, fsImpl)) {
throw errorWithCode('PDF renderer produced an incomplete document', 'PDF_EXPORT_FAILED');
}
return { buffer, filename: pdfFileName(artifactFile) };
} finally {
await stopChild(child);
await stopDenyProxy(denyProxy && denyProxy.server);
fsImpl.rmSync(tempDir, { recursive: true, force: true });
}
}
module.exports = {
DEFAULT_EXPORT_TIMEOUT_MS,
assertLoopbackUrl,
exportPdf,
isCompletePdf,
pdfFileName,
resolveChromiumExecutable
};