mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
feat(desktop): export artifacts directly to PDF (#532)
* feat(desktop): export artifacts directly to PDF * fix(desktop): PDF 내보내기 기본 여백 제거
This commit is contained in:
parent
0b757c2452
commit
109722de3a
12 changed files with 575 additions and 4 deletions
59
apps/daemon/src/pdf-export.ts
Normal file
59
apps/daemon/src/pdf-export.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import path from 'node:path';
|
||||
|
||||
import type { DesktopExportPdfInput } from '@open-design/sidecar-proto';
|
||||
|
||||
import { readProjectFile } from './projects.js';
|
||||
|
||||
export interface BuildDesktopPdfExportInputOptions {
|
||||
daemonUrl: string;
|
||||
deck?: boolean;
|
||||
fileName: string;
|
||||
projectId: string;
|
||||
projectsRoot: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export async function buildDesktopPdfExportInput(
|
||||
options: BuildDesktopPdfExportInputOptions,
|
||||
): Promise<DesktopExportPdfInput> {
|
||||
const file = await readProjectFile(options.projectsRoot, options.projectId, options.fileName);
|
||||
const title = displayTitle(options.title, options.fileName);
|
||||
return {
|
||||
baseHref: rawBaseHref(options.daemonUrl, options.projectId, options.fileName),
|
||||
deck: options.deck === true,
|
||||
defaultFilename: `${safeFilename(title, 'artifact')}.pdf`,
|
||||
html: file.buffer.toString('utf8'),
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function displayTitle(title: string | undefined, fileName: string): string {
|
||||
if (typeof title === 'string' && title.trim().length > 0) return title.trim();
|
||||
const base = path.posix.basename(fileName);
|
||||
const dot = base.lastIndexOf('.');
|
||||
return dot > 0 ? base.slice(0, dot) : base || 'artifact';
|
||||
}
|
||||
|
||||
function rawBaseHref(daemonUrl: string, projectId: string, fileName: string): string {
|
||||
const dir = path.posix.dirname(fileName.replace(/^\/+/, ''));
|
||||
const safeProjectId = encodeURIComponent(projectId);
|
||||
const rawBase = `${daemonUrl.replace(/\/+$/, '')}/api/projects/${safeProjectId}/raw/`;
|
||||
if (!dir || dir === '.') return rawBase;
|
||||
return `${rawBase}${encodePathSegments(dir)}/`;
|
||||
}
|
||||
|
||||
function encodePathSegments(value: string): string {
|
||||
return value
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function safeFilename(name: string, fallback: string): string {
|
||||
const slug = (name || fallback)
|
||||
.replace(/[^\w.\-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
return slug || fallback;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
// @ts-nocheck
|
||||
import type { DesktopExportPdfInput, DesktopExportPdfResult } from '@open-design/sidecar-proto';
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
|
|
@ -68,6 +69,7 @@ import { buildDocumentPreview } from './document-preview.js';
|
|||
import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js';
|
||||
import { loadCraftSections } from './craft.js';
|
||||
import { stageActiveSkill } from './cwd-aliases.js';
|
||||
import { buildDesktopPdfExportInput } from './pdf-export.js';
|
||||
import { generateMedia } from './media.js';
|
||||
import { searchResearch, ResearchError } from './research/index.js';
|
||||
import { renderResearchCommandContract } from './prompts/research-contract.js';
|
||||
|
|
@ -1724,6 +1726,15 @@ export function createSseResponse(
|
|||
};
|
||||
}
|
||||
|
||||
export type DesktopPdfExporter = (input: DesktopExportPdfInput) => Promise<DesktopExportPdfResult>;
|
||||
|
||||
export interface StartServerOptions {
|
||||
desktopPdfExporter?: DesktopPdfExporter | null;
|
||||
host?: string;
|
||||
port?: number;
|
||||
returnServer?: boolean;
|
||||
}
|
||||
|
||||
function resolveChatRunInactivityTimeoutMs() {
|
||||
const raw = Number(process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS);
|
||||
if (!Number.isFinite(raw)) return 2 * 60 * 1000;
|
||||
|
|
@ -1736,7 +1747,12 @@ function resolveChatRunShutdownGraceMs() {
|
|||
return Math.max(0, Math.floor(raw));
|
||||
}
|
||||
|
||||
export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST || '127.0.0.1', returnServer = false } = {}) {
|
||||
export async function startServer({
|
||||
port = 7456,
|
||||
host = process.env.OD_BIND_HOST || '127.0.0.1',
|
||||
returnServer = false,
|
||||
desktopPdfExporter = null,
|
||||
}: StartServerOptions = {}) {
|
||||
let resolvedPort = port;
|
||||
let daemonShuttingDown = false;
|
||||
const extraAllowedOrigins = configuredAllowedOrigins();
|
||||
|
|
@ -4098,6 +4114,41 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/export/pdf', async (req, res) => {
|
||||
if (typeof desktopPdfExporter !== 'function') {
|
||||
return sendApiError(
|
||||
res,
|
||||
501,
|
||||
'UPSTREAM_UNAVAILABLE',
|
||||
'desktop PDF export is only available in the desktop runtime',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const { fileName, title, deck } = req.body || {};
|
||||
if (typeof fileName !== 'string' || fileName.length === 0) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'fileName required');
|
||||
}
|
||||
const input = await buildDesktopPdfExportInput({
|
||||
daemonUrl,
|
||||
deck: deck === true,
|
||||
fileName,
|
||||
projectId: req.params.id,
|
||||
projectsRoot: PROJECTS_DIR,
|
||||
title: typeof title === 'string' ? title : undefined,
|
||||
});
|
||||
const result = await desktopPdfExporter(input);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||
sendApiError(
|
||||
res,
|
||||
status,
|
||||
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
||||
String(err?.message || err),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:id/raw/*', async (req, res) => {
|
||||
try {
|
||||
const project = getProject(db, req.params.id);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
import type { Server } from "node:http";
|
||||
|
||||
import {
|
||||
APP_KEYS,
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
SIDECAR_ENV,
|
||||
SIDECAR_MESSAGES,
|
||||
normalizeDaemonSidecarMessage,
|
||||
type DaemonStatusSnapshot,
|
||||
type DesktopExportPdfInput,
|
||||
type DesktopExportPdfResult,
|
||||
type SidecarStamp,
|
||||
} from "@open-design/sidecar-proto";
|
||||
import {
|
||||
createJsonIpcServer,
|
||||
requestJsonIpc,
|
||||
resolveAppIpcPath,
|
||||
type JsonIpcServerHandle,
|
||||
type SidecarRuntimeContext,
|
||||
} from "@open-design/sidecar";
|
||||
|
|
@ -97,7 +103,22 @@ function attachParentMonitor(stop: () => Promise<void>): void {
|
|||
}
|
||||
|
||||
export async function startDaemonSidecar(runtime: SidecarRuntimeContext<SidecarStamp>): Promise<DaemonSidecarHandle> {
|
||||
const started = await startServer({ port: parsePort(process.env[DAEMON_PORT_ENV]), returnServer: true }) as
|
||||
const started = await startServer({
|
||||
desktopPdfExporter: async (input: DesktopExportPdfInput): Promise<DesktopExportPdfResult> => {
|
||||
const desktopIpc = resolveAppIpcPath({
|
||||
app: APP_KEYS.DESKTOP,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace: runtime.namespace,
|
||||
});
|
||||
return await requestJsonIpc<DesktopExportPdfResult>(
|
||||
desktopIpc,
|
||||
{ input, type: SIDECAR_MESSAGES.EXPORT_PDF },
|
||||
{ timeoutMs: 600_000 },
|
||||
);
|
||||
},
|
||||
port: parsePort(process.env[DAEMON_PORT_ENV]),
|
||||
returnServer: true,
|
||||
}) as
|
||||
| string
|
||||
| StartedDaemonServer;
|
||||
if (typeof started === "string") {
|
||||
|
|
|
|||
104
apps/daemon/tests/pdf-export.test.ts
Normal file
104
apps/daemon/tests/pdf-export.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildDesktopPdfExportInput } from '../src/pdf-export.js';
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
describe('buildDesktopPdfExportInput', () => {
|
||||
let projectsRoot = '';
|
||||
const projectId = 'proj-pdf-test';
|
||||
|
||||
beforeEach(async () => {
|
||||
projectsRoot = mkdtempSync(path.join(tmpdir(), 'od-pdf-export-'));
|
||||
await mkdir(path.join(projectsRoot, projectId, 'deck', 'assets'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(projectsRoot, projectId, 'deck', 'index.html'),
|
||||
'<!doctype html><section class="slide">One</section>',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (projectsRoot) rmSync(projectsRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads the project file and derives a raw-route baseHref from the file directory', async () => {
|
||||
const input = await buildDesktopPdfExportInput({
|
||||
daemonUrl: 'http://127.0.0.1:7456',
|
||||
deck: true,
|
||||
fileName: 'deck/index.html',
|
||||
projectId,
|
||||
projectsRoot,
|
||||
title: 'Seed Deck',
|
||||
});
|
||||
|
||||
expect(input).toEqual({
|
||||
baseHref: 'http://127.0.0.1:7456/api/projects/proj-pdf-test/raw/deck/',
|
||||
deck: true,
|
||||
defaultFilename: 'Seed-Deck.pdf',
|
||||
html: '<!doctype html><section class="slide">One</section>',
|
||||
title: 'Seed Deck',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the file basename when the caller omits a title', async () => {
|
||||
const input = await buildDesktopPdfExportInput({
|
||||
daemonUrl: 'http://127.0.0.1:7456',
|
||||
deck: false,
|
||||
fileName: 'deck/index.html',
|
||||
projectId,
|
||||
projectsRoot,
|
||||
});
|
||||
|
||||
expect(input.title).toBe('index');
|
||||
expect(input.defaultFilename).toBe('index.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/projects/:id/export/pdf', () => {
|
||||
it('forwards the project HTML file to the configured desktop PDF exporter', async () => {
|
||||
const projectId = `proj-pdf-route-${Date.now()}`;
|
||||
const calls: unknown[] = [];
|
||||
const started = await startServer({
|
||||
port: 0,
|
||||
returnServer: true,
|
||||
desktopPdfExporter: async (input: unknown) => {
|
||||
calls.push(input);
|
||||
return { ok: true, path: '/tmp/seed.pdf' };
|
||||
},
|
||||
}) as { server: { close(cb: () => void): void }; url: string };
|
||||
|
||||
try {
|
||||
await fetch(`${started.url}/api/projects/${encodeURIComponent(projectId)}/files`, {
|
||||
body: JSON.stringify({
|
||||
content: '<!doctype html><section class="slide">One</section>',
|
||||
name: 'deck/index.html',
|
||||
}),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const response = await fetch(`${started.url}/api/projects/${encodeURIComponent(projectId)}/export/pdf`, {
|
||||
body: JSON.stringify({ deck: true, fileName: 'deck/index.html', title: 'Seed Deck' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true, path: '/tmp/seed.pdf' });
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
baseHref: `${started.url}/api/projects/${encodeURIComponent(projectId)}/raw/deck/`,
|
||||
deck: true,
|
||||
defaultFilename: 'Seed-Deck.pdf',
|
||||
html: '<!doctype html><section class="slide">One</section>',
|
||||
title: 'Seed Deck',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => started.server.close(resolve));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
normalizeDesktopSidecarMessage,
|
||||
type DesktopClickInput,
|
||||
type DesktopEvalInput,
|
||||
type DesktopExportPdfInput,
|
||||
type DesktopScreenshotInput,
|
||||
type SidecarStamp,
|
||||
type WebStatusSnapshot,
|
||||
|
|
@ -135,6 +136,8 @@ export async function runDesktopMain(
|
|||
return desktop.console();
|
||||
case SIDECAR_MESSAGES.CLICK:
|
||||
return await desktop.click(request.input as DesktopClickInput);
|
||||
case SIDECAR_MESSAGES.EXPORT_PDF:
|
||||
return await desktop.exportPdf(request.input as DesktopExportPdfInput);
|
||||
case SIDECAR_MESSAGES.SHUTDOWN:
|
||||
setImmediate(() => {
|
||||
shutdownAndExit();
|
||||
|
|
|
|||
169
apps/desktop/src/main/pdf-export.ts
Normal file
169
apps/desktop/src/main/pdf-export.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
import { BrowserWindow, dialog } from "electron";
|
||||
import type { DesktopExportPdfInput, DesktopExportPdfResult } from "@open-design/sidecar-proto";
|
||||
|
||||
type PageSize = { height: number; width: number };
|
||||
|
||||
const DECK_PAGE_SIZE: PageSize = { width: 13.333333, height: 7.5 };
|
||||
const MAX_PAGE_INCHES = 200;
|
||||
|
||||
const DECK_PRINT_CSS = `
|
||||
@media print {
|
||||
@page { size: 1920px 1080px; margin: 0; }
|
||||
html, body {
|
||||
width: 1920px !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
body {
|
||||
display: block !important;
|
||||
scroll-snap-type: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.slide, [data-screen-label], section.slide, .deck-slide, .ppt-slide {
|
||||
flex: none !important;
|
||||
width: 1920px !important;
|
||||
height: 1080px !important;
|
||||
min-height: 1080px !important;
|
||||
max-height: 1080px !important;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
scroll-snap-align: none !important;
|
||||
transform: none !important;
|
||||
position: relative !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.slide:last-child, [data-screen-label]:last-child { page-break-after: auto; break-after: auto; }
|
||||
.deck-counter, .deck-hint, .deck-nav,
|
||||
[aria-label="Previous slide"], [aria-label="Next slide"] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function exportPdfFromHtml(input: DesktopExportPdfInput): Promise<DesktopExportPdfResult> {
|
||||
const save = await dialog.showSaveDialog({
|
||||
defaultPath: input.defaultFilename,
|
||||
filters: [
|
||||
{ name: "PDF", extensions: ["pdf"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
title: "Save PDF",
|
||||
});
|
||||
if (save.canceled || !save.filePath) return { canceled: true, ok: true };
|
||||
|
||||
const window = new BrowserWindow({
|
||||
height: input.deck ? 1080 : 900,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
width: input.deck ? 1920 : 1440,
|
||||
});
|
||||
|
||||
try {
|
||||
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildPrintableDocument(input))}`);
|
||||
await waitForPrintableContent(window);
|
||||
const pageSize = input.deck ? DECK_PAGE_SIZE : await inferPageSize(window);
|
||||
const pdf = await window.webContents.printToPDF({
|
||||
margins: { bottom: 0, left: 0, right: 0, top: 0 },
|
||||
pageSize,
|
||||
preferCSSPageSize: true,
|
||||
printBackground: true,
|
||||
});
|
||||
await writeFile(save.filePath, pdf);
|
||||
return { ok: true, path: save.filePath };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error), ok: false };
|
||||
} finally {
|
||||
if (!window.isDestroyed()) window.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrintableDocument(input: DesktopExportPdfInput): string {
|
||||
const source = injectBaseHref(input.html, input.baseHref);
|
||||
const withTitle = injectTitle(source, input.title);
|
||||
return input.deck ? injectPrintStylesheet(withTitle, DECK_PRINT_CSS) : withTitle;
|
||||
}
|
||||
|
||||
function injectBaseHref(doc: string, baseHref: string | undefined): string {
|
||||
if (!baseHref) return doc;
|
||||
const tag = `<base href="${escapeHtmlAttribute(baseHref)}">`;
|
||||
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (match) => `${match}${tag}`);
|
||||
if (/<html[^>]*>/i.test(doc)) return doc.replace(/<html[^>]*>/i, (match) => `${match}<head>${tag}</head>`);
|
||||
return `<!doctype html><html><head>${tag}</head><body>${doc}</body></html>`;
|
||||
}
|
||||
|
||||
function injectTitle(doc: string, title: string): string {
|
||||
const tag = `<title>${escapeHtmlText(title)}</title>`;
|
||||
if (/<title[^>]*>.*?<\/title>/is.test(doc)) return doc.replace(/<title[^>]*>.*?<\/title>/is, tag);
|
||||
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (match) => `${match}${tag}`);
|
||||
if (/<html[^>]*>/i.test(doc)) return doc.replace(/<html[^>]*>/i, (match) => `${match}<head>${tag}</head>`);
|
||||
return `<!doctype html><html><head>${tag}</head><body>${doc}</body></html>`;
|
||||
}
|
||||
|
||||
function injectPrintStylesheet(doc: string, css: string): string {
|
||||
const tag = `<style data-od-desktop-pdf>${css}</style>`;
|
||||
if (/<\/head>/i.test(doc)) return doc.replace(/<\/head>/i, `${tag}</head>`);
|
||||
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (match) => `${match}${tag}`);
|
||||
return `${tag}${doc}`;
|
||||
}
|
||||
|
||||
async function waitForPrintableContent(window: BrowserWindow): Promise<void> {
|
||||
await window.webContents.executeJavaScript(
|
||||
`Promise.all([
|
||||
document.fonts && document.fonts.ready ? document.fonts.ready.catch(function(){}) : Promise.resolve(),
|
||||
Promise.all(Array.from(document.images || []).map(function(img) {
|
||||
if (img.complete) return Promise.resolve();
|
||||
return new Promise(function(resolve) {
|
||||
img.addEventListener('load', resolve, { once: true });
|
||||
img.addEventListener('error', resolve, { once: true });
|
||||
});
|
||||
}))
|
||||
]).then(function(){ return true; })`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function inferPageSize(window: BrowserWindow): Promise<PageSize> {
|
||||
const size = await window.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const de = document.documentElement;
|
||||
const body = document.body || de;
|
||||
return {
|
||||
width: Math.max(de.scrollWidth, body.scrollWidth, de.clientWidth, 1440),
|
||||
height: Math.max(de.scrollHeight, body.scrollHeight, de.clientHeight, 900)
|
||||
};
|
||||
})()`,
|
||||
true,
|
||||
) as { height?: unknown; width?: unknown };
|
||||
const widthPx = typeof size.width === "number" && Number.isFinite(size.width) ? size.width : 1440;
|
||||
const heightPx = typeof size.height === "number" && Number.isFinite(size.height) ? size.height : 900;
|
||||
return {
|
||||
width: clamp(widthPx / 96, 1, MAX_PAGE_INCHES),
|
||||
height: clamp(heightPx / 96, 1, MAX_PAGE_INCHES),
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeHtmlText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
import type { DesktopExportPdfInput, DesktopExportPdfResult } from "@open-design/sidecar-proto";
|
||||
|
||||
import { exportPdfFromHtml } from "./pdf-export.js";
|
||||
|
||||
const PENDING_POLL_MS = 120;
|
||||
const RUNNING_POLL_MS = 2000;
|
||||
|
|
@ -59,6 +62,7 @@ export type DesktopRuntime = {
|
|||
click(input: DesktopClickInput): Promise<DesktopClickResult>;
|
||||
console(): DesktopConsoleResult;
|
||||
eval(input: DesktopEvalInput): Promise<DesktopEvalResult>;
|
||||
exportPdf(input: DesktopExportPdfInput): Promise<DesktopExportPdfResult>;
|
||||
screenshot(input: DesktopScreenshotInput): Promise<DesktopScreenshotResult>;
|
||||
show(): void;
|
||||
status(): DesktopStatusSnapshot;
|
||||
|
|
@ -415,6 +419,9 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
|
|||
return { error: error instanceof Error ? error.message : String(error), ok: false };
|
||||
}
|
||||
},
|
||||
exportPdf(input) {
|
||||
return exportPdfFromHtml(input);
|
||||
},
|
||||
async screenshot(input) {
|
||||
if (window.isDestroyed()) throw new Error("desktop window is destroyed");
|
||||
const outputPath = normalizeScreenshotPath(input.path);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
exportAsJsx,
|
||||
exportAsMd,
|
||||
exportAsPdf,
|
||||
exportProjectAsPdf,
|
||||
exportProjectAsZip,
|
||||
exportReactComponentAsHtml,
|
||||
exportReactComponentAsZip,
|
||||
|
|
@ -4340,7 +4341,13 @@ function HtmlViewer({
|
|||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShareMenuOpen(false);
|
||||
exportAsPdf(source ?? '', exportTitle, { deck: effectiveDeck });
|
||||
void exportProjectAsPdf({
|
||||
deck: effectiveDeck,
|
||||
fallbackPdf: () => exportAsPdf(source ?? '', exportTitle, { deck: effectiveDeck }),
|
||||
filePath: file.name,
|
||||
projectId,
|
||||
title: exportTitle,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
|
||||
|
|
|
|||
|
|
@ -66,6 +66,36 @@ export function exportAsMd(source: string, title: string): void {
|
|||
triggerDownload(blob, `${safeFilename(title, 'artifact')}.md`);
|
||||
}
|
||||
|
||||
export type ProjectPdfExportResult = 'desktop' | 'fallback';
|
||||
|
||||
export async function exportProjectAsPdf(opts: {
|
||||
deck: boolean;
|
||||
fallbackPdf: () => void;
|
||||
filePath: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
}): Promise<ProjectPdfExportResult> {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(opts.projectId)}/export/pdf`, {
|
||||
body: JSON.stringify({
|
||||
deck: opts.deck,
|
||||
fileName: opts.filePath,
|
||||
title: opts.title,
|
||||
}),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) throw new Error(`desktop PDF export unavailable (${resp.status})`);
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (body && body.ok === false) throw new Error(body.error || 'desktop PDF export failed');
|
||||
return 'desktop';
|
||||
} catch (err) {
|
||||
console.warn('[exportProjectAsPdf] falling back to browser print:', err);
|
||||
opts.fallbackPdf();
|
||||
return 'fallback';
|
||||
}
|
||||
}
|
||||
|
||||
type ReactSourceExtension = '.jsx' | '.tsx';
|
||||
|
||||
export function exportAsJsx(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
buildSandboxedPreviewDocument,
|
||||
exportAsMd,
|
||||
exportAsPdf,
|
||||
exportProjectAsPdf,
|
||||
openSandboxedPreviewInNewTab,
|
||||
} from '../../src/runtime/exports';
|
||||
|
||||
|
|
@ -72,6 +73,51 @@ describe('archiveFilenameFrom', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('exportProjectAsPdf', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses the daemon desktop PDF export API before falling back to browser print', async () => {
|
||||
const fallback = vi.fn();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })));
|
||||
|
||||
const result = await exportProjectAsPdf({
|
||||
deck: true,
|
||||
fallbackPdf: fallback,
|
||||
filePath: 'deck/index.html',
|
||||
projectId: 'proj-1',
|
||||
title: 'Seed Deck',
|
||||
});
|
||||
|
||||
expect(result).toBe('desktop');
|
||||
expect(fallback).not.toHaveBeenCalled();
|
||||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj-1/export/pdf', {
|
||||
body: JSON.stringify({ deck: true, fileName: 'deck/index.html', title: 'Seed Deck' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to browser print when the desktop PDF export API is unavailable', async () => {
|
||||
const fallback = vi.fn();
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: { message: 'unavailable' } }), { status: 501 })));
|
||||
|
||||
const result = await exportProjectAsPdf({
|
||||
deck: false,
|
||||
fallbackPdf: fallback,
|
||||
filePath: 'index.html',
|
||||
projectId: 'proj-1',
|
||||
title: 'Landing',
|
||||
});
|
||||
|
||||
expect(result).toBe('fallback');
|
||||
expect(fallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// `exportAsMd` is a pass-through (the file body is the artifact source
|
||||
// verbatim, only the extension and Content-Type flip). Tests exercise it
|
||||
// end-to-end by stubbing the few DOM globals `triggerDownload` touches —
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export const SIDECAR_MESSAGES = Object.freeze({
|
|||
CLICK: "click",
|
||||
CONSOLE: "console",
|
||||
EVAL: "eval",
|
||||
EXPORT_PDF: "export-pdf",
|
||||
SCREENSHOT: "screenshot",
|
||||
SHUTDOWN: "shutdown",
|
||||
STATUS: "status",
|
||||
|
|
@ -157,12 +158,28 @@ export type DesktopClickResult = {
|
|||
found: boolean;
|
||||
};
|
||||
|
||||
export type DesktopExportPdfInput = {
|
||||
baseHref?: string;
|
||||
deck: boolean;
|
||||
defaultFilename: string;
|
||||
html: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type DesktopExportPdfResult = {
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
ok: boolean;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type SidecarStatusMessage = { type: typeof SIDECAR_MESSAGES.STATUS };
|
||||
export type SidecarShutdownMessage = { type: typeof SIDECAR_MESSAGES.SHUTDOWN };
|
||||
export type DesktopEvalMessage = { input: DesktopEvalInput; type: typeof SIDECAR_MESSAGES.EVAL };
|
||||
export type DesktopScreenshotMessage = { input: DesktopScreenshotInput; type: typeof SIDECAR_MESSAGES.SCREENSHOT };
|
||||
export type DesktopConsoleMessage = { type: typeof SIDECAR_MESSAGES.CONSOLE };
|
||||
export type DesktopClickMessage = { input: DesktopClickInput; type: typeof SIDECAR_MESSAGES.CLICK };
|
||||
export type DesktopExportPdfMessage = { input: DesktopExportPdfInput; type: typeof SIDECAR_MESSAGES.EXPORT_PDF };
|
||||
|
||||
export type DaemonSidecarMessage = SidecarStatusMessage | SidecarShutdownMessage;
|
||||
export type WebSidecarMessage = SidecarStatusMessage | SidecarShutdownMessage;
|
||||
|
|
@ -172,7 +189,8 @@ export type DesktopSidecarMessage =
|
|||
| DesktopEvalMessage
|
||||
| DesktopScreenshotMessage
|
||||
| DesktopConsoleMessage
|
||||
| DesktopClickMessage;
|
||||
| DesktopClickMessage
|
||||
| DesktopExportPdfMessage;
|
||||
|
||||
export type ShutdownResult = {
|
||||
accepted: true;
|
||||
|
|
@ -336,6 +354,23 @@ function normalizeDesktopClickInput(input: unknown): DesktopClickInput {
|
|||
return { selector: normalizeNonEmptyString(value.selector, "desktop click selector") };
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeDesktopExportPdfInput(input: unknown): DesktopExportPdfInput {
|
||||
const value = assertObject(input, "desktop PDF export input");
|
||||
assertKnownKeys(value, ["baseHref", "deck", "defaultFilename", "html", "title"], "desktop PDF export input");
|
||||
return {
|
||||
...(value.baseHref == null ? {} : { baseHref: normalizeNonEmptyString(value.baseHref, "desktop PDF export baseHref") }),
|
||||
deck: normalizeBoolean(value.deck, "desktop PDF export deck"),
|
||||
defaultFilename: normalizeNonEmptyString(value.defaultFilename, "desktop PDF export defaultFilename"),
|
||||
html: normalizeNonEmptyString(value.html, "desktop PDF export html"),
|
||||
title: normalizeNonEmptyString(value.title, "desktop PDF export title"),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessageType(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new SidecarContractError(SIDECAR_ERROR_CODES.INVALID_MESSAGE, `${label} type must be a non-empty string`);
|
||||
|
|
@ -381,6 +416,9 @@ export function normalizeDesktopSidecarMessage(input: unknown): DesktopSidecarMe
|
|||
case SIDECAR_MESSAGES.CLICK:
|
||||
assertKnownKeys(value, ["input", "type"], "desktop sidecar message");
|
||||
return { input: normalizeDesktopClickInput(value.input), type };
|
||||
case SIDECAR_MESSAGES.EXPORT_PDF:
|
||||
assertKnownKeys(value, ["input", "type"], "desktop sidecar message");
|
||||
return { input: normalizeDesktopExportPdfInput(value.input), type };
|
||||
default:
|
||||
throw new SidecarContractError(SIDECAR_ERROR_CODES.UNKNOWN_MESSAGE, `unknown desktop sidecar message: ${type}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,4 +74,40 @@ describe("open-design sidecar contract", () => {
|
|||
expect(() => normalizeDesktopSidecarMessage({ input: { expression: 42 }, type: SIDECAR_MESSAGES.EVAL })).toThrow();
|
||||
expect(() => normalizeDesktopSidecarMessage({ input: { selector: "" }, type: SIDECAR_MESSAGES.CLICK })).toThrow();
|
||||
});
|
||||
|
||||
it("validates desktop PDF export IPC message inputs", () => {
|
||||
expect(
|
||||
normalizeDesktopSidecarMessage({
|
||||
input: {
|
||||
baseHref: "http://127.0.0.1:7456/api/projects/proj/raw/deck/",
|
||||
deck: true,
|
||||
defaultFilename: "Seed Deck.pdf",
|
||||
html: "<!doctype html><section class=\"slide\">One</section>",
|
||||
title: "Seed Deck",
|
||||
},
|
||||
type: SIDECAR_MESSAGES.EXPORT_PDF,
|
||||
}),
|
||||
).toEqual({
|
||||
input: {
|
||||
baseHref: "http://127.0.0.1:7456/api/projects/proj/raw/deck/",
|
||||
deck: true,
|
||||
defaultFilename: "Seed Deck.pdf",
|
||||
html: "<!doctype html><section class=\"slide\">One</section>",
|
||||
title: "Seed Deck",
|
||||
},
|
||||
type: "export-pdf",
|
||||
});
|
||||
expect(() =>
|
||||
normalizeDesktopSidecarMessage({
|
||||
input: { deck: true, defaultFilename: "x.pdf", html: "", title: "x" },
|
||||
type: SIDECAR_MESSAGES.EXPORT_PDF,
|
||||
}),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
normalizeDesktopSidecarMessage({
|
||||
input: { deck: "yes", defaultFilename: "x.pdf", html: "<p>x</p>", title: "x" },
|
||||
type: SIDECAR_MESSAGES.EXPORT_PDF,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue