feat(plan-canvas): download artifacts as PDF

This commit is contained in:
haelyra
2026-08-28 16:37:13 -04:00
parent 61652b74ff
commit 93cec5aa6c
11 changed files with 608 additions and 8 deletions
+261
View File
@@ -0,0 +1,261 @@
'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 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 {
const stat = fsImpl.statSync(file);
if (!stat.isFile() || stat.size < 12) return false;
fd = fsImpl.openSync(file, 'r');
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));
}
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
} = {}) {
const safeUrl = assertLoopbackUrl(url);
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;
try {
const args = [
'--headless=new',
'--disable-gpu',
'--disable-component-update',
'--disable-default-apps',
'--disable-extensions',
'--disable-sync',
'--metrics-recording-only',
'--mute-audio',
'--no-first-run',
'--no-default-browser-check',
'--no-pdf-header-footer',
'--print-to-pdf-no-header',
'--hide-scrollbars',
`--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);
fsImpl.rmSync(tempDir, { recursive: true, force: true });
}
}
module.exports = {
DEFAULT_EXPORT_TIMEOUT_MS,
assertLoopbackUrl,
exportPdf,
isCompletePdf,
pdfFileName,
resolveChromiumExecutable
};
+36 -1
View File
@@ -17,6 +17,7 @@ const path = require('path');
const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard');
const { renderMarkdown } = require('./markdown');
const { exportPdf } = require('./pdf');
const { artifactSdkJs } = require('./sdk');
const {
canvasCss,
@@ -35,7 +36,7 @@ const MAX_BODY_BYTES = 1024 * 1024;
const DEFAULT_THINKING_STALE_MS = 90 * 1000;
// An explicit typing signal expires faster: it means "a reply is seconds away".
const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000;
const PLAN_CANVAS_PROTOCOL_VERSION = 3;
const PLAN_CANVAS_PROTOCOL_VERSION = 4;
const TYPING_STATES = new Set(['thinking', 'typing', 'idle']);
// Package versions do not distinguish two worktrees on the same release.
@@ -45,6 +46,7 @@ function computeRuntimeId() {
const sources = [
['loopback-guard.js', path.join(__dirname, '..', 'loopback-guard.js')],
['markdown.js', path.join(__dirname, 'markdown.js')],
['pdf.js', path.join(__dirname, 'pdf.js')],
['sdk.js', path.join(__dirname, 'sdk.js')],
['server.js', __filename],
['sessions.js', path.join(__dirname, 'sessions.js')],
@@ -135,6 +137,22 @@ function sendHtml(res, statusCode, html, { csp = true } = {}) {
res.end(html);
}
function sendPdf(res, { buffer, filename }) {
const asciiName = filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '-');
const encodedName = encodeURIComponent(filename).replace(/['()]/g, character =>
`%${character.charCodeAt(0).toString(16).toUpperCase()}`
);
res.writeHead(200, {
'content-type': 'application/pdf',
'content-length': buffer.length,
'content-disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`,
'x-content-type-options': 'nosniff',
'x-plan-canvas-filename': encodeURIComponent(filename),
'cache-control': 'no-store'
});
res.end(buffer);
}
function createPlanCanvasServer({
store,
host = DEFAULT_HOST,
@@ -143,6 +161,7 @@ function createPlanCanvasServer({
heartbeatMs = 15000,
thinkingStaleMs = DEFAULT_THINKING_STALE_MS,
typingExpiryMs = DEFAULT_TYPING_EXPIRY_MS,
pdfExporter = exportPdf,
onIdleShutdown = null,
log = () => {}
} = {}) {
@@ -371,6 +390,22 @@ function createPlanCanvasServer({
});
}
const pdfMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/pdf$/);
if (pdfMatch && req.method === 'GET') {
const session = store.get(pdfMatch[1]);
if (!session) return sendJson(res, 404, { error: 'unknown session' });
try {
const pdf = await pdfExporter({
url: `http://${req.headers.host}/artifact/${session.key}/?pdf=1`,
artifactFile: session.file
});
return sendPdf(res, pdf);
} catch (error) {
const statusCode = error.code === 'PDF_BROWSER_NOT_FOUND' ? 503 : 500;
return sendJson(res, statusCode, { error: error.message, code: error.code || 'PDF_EXPORT_FAILED' });
}
}
const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply|typing)$/);
if (sessionMatch && req.method === 'POST') {
const [, key, action] = sessionMatch;
+67 -1
View File
@@ -117,9 +117,19 @@ function canvasCss() {
.toggle[aria-pressed="true"] .track{background:var(--accent);border-color:var(--accent-dim)}
.toggle[aria-pressed="true"] .knob{transform:translateX(13px);background:#fff}
.icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;transition:all .12s}
.icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;white-space:nowrap;transition:all .12s}
.icon-btn:hover{border-color:var(--border-light);color:var(--text);background:var(--bg4)}
.icon-btn:disabled{cursor:wait;opacity:.65}
.icon-btn.danger:hover{border-color:var(--red);color:var(--red);background:var(--red-glow)}
.export-status{max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10.5px;color:var(--text2)}
.export-status.error{color:var(--red)}
@media(max-width:900px){
.bar{gap:8px;padding:0 10px}
.brand .name,.presence,.toggle>span:first-child,.export-status{display:none}
.brand .file{max-width:90px}
#downloadPdfBtn{font-size:0}
#downloadPdfBtn:after{content:'PDF';font-size:11.5px}
}
.layout{display:flex;height:calc(100% - 52px)}
.frame{flex:1;min-width:0;position:relative;background:var(--bg2)}
@@ -204,6 +214,8 @@ function canvasClientJs() {
const sendBtn = $('send');
const statusEl = $('sendStatus');
const presence = $('presence');
const downloadPdfBtn = $('downloadPdfBtn');
const exportStatus = $('exportStatus');
const QKEY = 'ecc-plan-canvas:queue:' + key;
let queue = [];
let lastScroll = { x: 0, y: 0 };
@@ -415,6 +427,48 @@ function canvasClientJs() {
$('changes').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'request-changes' }]));
// --- session controls ------------------------------------------------
let exportStatusTimer = null;
function reportExportStatus(message, isError) {
clearTimeout(exportStatusTimer);
exportStatus.textContent = message;
exportStatus.classList.toggle('error', Boolean(isError));
if (message && !isError) {
exportStatusTimer = setTimeout(() => { exportStatus.textContent = ''; }, 5000);
}
}
downloadPdfBtn.addEventListener('click', async () => {
if (downloadPdfBtn.disabled) return;
downloadPdfBtn.disabled = true;
downloadPdfBtn.textContent = 'Preparing PDF\u2026';
reportExportStatus('Rendering locally\u2026');
try {
const res = await fetch('/api/session/' + key + '/pdf', { cache: 'no-store' });
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.error || ('HTTP ' + res.status));
}
const blob = await res.blob();
if (!blob.size || blob.type !== 'application/pdf') throw new Error('server returned an invalid PDF');
const encodedName = res.headers.get('x-plan-canvas-filename') || 'plan.pdf';
let filename = 'plan.pdf';
try { filename = decodeURIComponent(encodedName); } catch { /* safe fallback */ }
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
link.hidden = true;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 30000);
reportExportStatus('Downloaded ' + filename);
} catch (error) {
reportExportStatus('PDF failed: ' + error.message, true);
} finally {
downloadPdfBtn.disabled = false;
downloadPdfBtn.textContent = 'Download PDF';
}
});
$('reloadBtn').addEventListener('click', reloadArtifact);
$('endBtn').addEventListener('click', async () => {
if (!window.confirm('End this review session?')) return;
@@ -527,6 +581,8 @@ function renderCanvasHtml(session, { clientPath = '/client.js', cssPath = '/canv
<span>Annotate</span><span class="track"><span class="knob"></span></span>
</div>
<button id="themeBtn" class="icon-btn" type="button">light</button>
<span id="exportStatus" class="export-status" role="status" aria-live="polite"></span>
<button id="downloadPdfBtn" class="icon-btn" type="button" aria-label="Download PDF" title="Download the current artifact as a PDF">Download PDF</button>
<button id="reloadBtn" class="icon-btn" type="button" title="Reload artifact">Reload</button>
<button id="endBtn" class="icon-btn danger" type="button">End session</button>
</header>
@@ -600,6 +656,16 @@ ${TOKENS_CSS}
pre.mermaid[data-processed]{background:transparent;border:none;padding:4px 0;text-align:center;overflow-x:auto}
pre.mermaid[data-processed] svg{max-width:100%;height:auto}
pre.mermaid.mermaid-unrendered:before{content:'diagram source (renderer unavailable)';display:block;font-family:var(--font);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);margin-bottom:6px}
@page{margin:14mm}
@media print{
:root{--bg:#fff;--bg2:#fff;--bg3:#f4f5f7;--bg4:#eaecef;--surface:#fff;--surface-hover:#fff;--border:#d8dce5;--border-light:#c6cbd6;--text:#161922;--text2:#505667;--text3:#73798a;--accent:#3d5ab8;--accent-glow:rgba(61,90,184,.1);--pink:#b94076}
*{-webkit-print-color-adjust:exact;print-color-adjust:exact}
body{background:#fff;color:var(--text);font-size:11pt}
.doc{max-width:none;padding:0}
h1,h2,h3,h4,h5,h6{break-after:avoid-page}
blockquote,pre,table,img,svg{break-inside:avoid-page}
[data-ecc-plan-canvas="ui"]{display:none!important}
}
</style>
</head>
<body>
+2 -1
View File
@@ -71,7 +71,8 @@ function usage() {
' typing: --state <thinking|typing|idle> Defaults to typing',
' server: --port <n> --host <h>',
'',
'Environment: ECC_PLAN_CANVAS_PORT, ECC_PLAN_CANVAS_STATE_DIR, ECC_PLAN_CANVAS_IDLE_MS'
'Environment: ECC_PLAN_CANVAS_PORT, ECC_PLAN_CANVAS_STATE_DIR, ECC_PLAN_CANVAS_IDLE_MS,',
' ECC_PLAN_CANVAS_CHROME_PATH'
].join('\n');
}