feat(editor): export active markdown to PDF (#882)

Adds File > Export as PDF... menu item (Cmd+Shift+E) and an overflow
menu entry that renders the active markdown preview through a sandboxed
Electron BrowserWindow and writes it to disk via printToPDF.

- New main-side IPC handler (src/main/ipc/export.ts) and html-to-pdf
  helper that loads a CSP-locked HTML document in a sandboxed, context-
  isolated window with javascript enabled only for image-ready polling.
- Renderer helpers clone the rendered markdown subtree, inline all
  computed styles through a curated allowlist, and ship the resulting
  HTML fragment over IPC.
- Ref-counted listener registration so split-pane layouts install
  exactly one IPC subscription and survive panel churn.
This commit is contained in:
Jinjing 2026-04-20 19:41:12 -07:00 committed by GitHub
parent 8ea1f2ee33
commit 3a58a829f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 635 additions and 3 deletions

59
src/main/ipc/export.ts Normal file
View file

@ -0,0 +1,59 @@
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { writeFile } from 'node:fs/promises'
import { ExportTimeoutError, htmlToPdf } from '../lib/html-to-pdf'
export type ExportHtmlToPdfArgs = {
html: string
title: string
}
export type ExportHtmlToPdfResult =
| { success: true; filePath: string }
| { success: false; cancelled?: boolean; error?: string }
export function registerExportHandlers(): void {
ipcMain.removeHandler('export:html-to-pdf')
ipcMain.handle(
'export:html-to-pdf',
async (event, args: ExportHtmlToPdfArgs): Promise<ExportHtmlToPdfResult> => {
const { html, title } = args
if (!html.trim()) {
return { success: false, error: 'No content to export' }
}
try {
const pdfBuffer = await htmlToPdf(html)
// Why: sanitize to keep the suggested filename legal on every platform.
// Windows forbids /\:*?"<>| in filenames; truncate to keep the OS save
// dialog stable when titles are pathologically long.
const sanitizedTitle = title.replace(/[/\\:*?"<>|]/g, '_').slice(0, 100) || 'export'
const defaultFilename = `${sanitizedTitle}.pdf`
const parent = BrowserWindow.fromWebContents(event.sender) ?? undefined
const dialogOptions = {
defaultPath: defaultFilename,
filters: [{ name: 'PDF', extensions: ['pdf'] }]
}
const { canceled, filePath } = parent
? await dialog.showSaveDialog(parent, dialogOptions)
: await dialog.showSaveDialog(dialogOptions)
if (canceled || !filePath) {
return { success: false, cancelled: true }
}
await writeFile(filePath, pdfBuffer)
return { success: true, filePath }
} catch (error) {
if (error instanceof ExportTimeoutError) {
return { success: false, error: 'Export timed out' }
}
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to export PDF'
}
}
}
)
}

View file

@ -22,7 +22,8 @@ const {
registerBrowserHandlersMock,
setTrustedBrowserRendererWebContentsIdMock,
registerFilesystemWatcherHandlersMock,
registerAppHandlersMock
registerAppHandlersMock,
registerExportHandlersMock
} = vi.hoisted(() => ({
registerCliHandlersMock: vi.fn(),
registerPreflightHandlersMock: vi.fn(),
@ -45,7 +46,8 @@ const {
registerBrowserHandlersMock: vi.fn(),
setTrustedBrowserRendererWebContentsIdMock: vi.fn(),
registerFilesystemWatcherHandlersMock: vi.fn(),
registerAppHandlersMock: vi.fn()
registerAppHandlersMock: vi.fn(),
registerExportHandlersMock: vi.fn()
}))
vi.mock('./cli', () => ({
@ -72,6 +74,10 @@ vi.mock('./feedback', () => ({
registerFeedbackHandlers: registerFeedbackHandlersMock
}))
vi.mock('./export', () => ({
registerExportHandlers: registerExportHandlersMock
}))
vi.mock('./stats', () => ({
registerStatsHandlers: registerStatsHandlersMock
}))
@ -156,6 +162,7 @@ describe('registerCoreHandlers', () => {
setTrustedBrowserRendererWebContentsIdMock.mockReset()
registerFilesystemWatcherHandlersMock.mockReset()
registerAppHandlersMock.mockReset()
registerExportHandlersMock.mockReset()
})
it('passes the store through to handler registrars that need it', () => {

View file

@ -10,6 +10,7 @@ import { registerClaudeUsageHandlers } from './claude-usage'
import { registerCodexUsageHandlers } from './codex-usage'
import { registerGitHubHandlers } from './github'
import { registerFeedbackHandlers } from './feedback'
import { registerExportHandlers } from './export'
import { registerStatsHandlers } from './stats'
import { registerRateLimitHandlers } from './rate-limits'
import { registerRuntimeHandlers } from './runtime'
@ -63,6 +64,7 @@ export function registerCoreHandlers(
registerRateLimitHandlers(rateLimits)
registerGitHubHandlers(store, stats)
registerFeedbackHandlers()
registerExportHandlers()
registerStatsHandlers(stats)
registerNotificationHandlers(store)
registerSettingsHandlers(store)

View file

@ -0,0 +1,98 @@
import { app, BrowserWindow } from 'electron'
import { writeFile, unlink } from 'node:fs/promises'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
export class ExportTimeoutError extends Error {
constructor(message = 'Export timed out') {
super(message)
this.name = 'ExportTimeoutError'
}
}
const EXPORT_TIMEOUT_MS = 60_000
// Why: injected into the hidden export window so printToPDF does not fire while
// <img> elements are still fetching. printToPDF renders whatever is painted at
// the moment it runs; without this gate, remote images and Mermaid SVGs loaded
// via <img> can be missing from the output.
const WAIT_FOR_IMAGES_SCRIPT = `
new Promise((resolve) => {
const imgs = Array.from(document.images || [])
if (imgs.length === 0) { resolve(); return }
let remaining = imgs.length
const done = () => { remaining -= 1; if (remaining <= 0) resolve() }
imgs.forEach((img) => {
if (img.complete) { done(); return }
img.addEventListener('load', done, { once: true })
img.addEventListener('error', done, { once: true })
})
})
`
export async function htmlToPdf(html: string): Promise<Buffer> {
const tempDir = app.getPath('temp')
const tempPath = path.join(tempDir, `orca-export-${randomUUID()}.html`)
await writeFile(tempPath, html, 'utf-8')
const win = new BrowserWindow({
show: false,
webPreferences: {
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
// Why: image-wait needs to run a short script inside the export page, and
// the exported renderer DOM may already embed scripts/SVGs (e.g. Mermaid)
// that need JS to paint correctly. The window stays sandboxed and
// isolated so this is safe.
javascript: true
}
})
let timer: NodeJS.Timeout | undefined
try {
const loadPromise = new Promise<void>((resolve, reject) => {
win.webContents.once('did-finish-load', () => resolve())
win.webContents.once('did-fail-load', (_event, errorCode, errorDescription) => {
reject(new Error(`Failed to load export document: ${errorDescription} (${errorCode})`))
})
})
await win.loadFile(tempPath)
await loadPromise
const renderAndPrint = (async (): Promise<Buffer> => {
await win.webContents.executeJavaScript(WAIT_FOR_IMAGES_SCRIPT, true)
return win.webContents.printToPDF({
printBackground: true,
pageSize: 'A4',
margins: {
top: 0.75,
bottom: 0.75,
left: 0.75,
right: 0.75
}
})
})()
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new ExportTimeoutError()), EXPORT_TIMEOUT_MS)
})
return await Promise.race([renderAndPrint, timeoutPromise])
} finally {
if (timer) {
clearTimeout(timer)
}
if (!win.isDestroyed()) {
win.destroy()
}
try {
await unlink(tempPath)
} catch {
// Why: best-effort cleanup — losing the temp file should not surface
// as a user-facing export failure.
}
}
}

View file

@ -55,6 +55,26 @@ export function registerAppMenu({
{ role: 'quit' }
]
},
{
label: 'File',
submenu: [
{
label: 'Export as PDF...',
accelerator: 'CmdOrCtrl+Shift+E',
click: () => {
// Why: fire a one-way event into the focused renderer. The renderer
// owns the knowledge of whether a markdown surface is active and
// what DOM to extract — when no markdown surface is active this is
// a silent no-op on that side (see design doc §4 "Renderer UI
// trigger"). Keeping this as a send (not an invoke) avoids main
// needing to reason about surface state. Using
// BrowserWindow.getFocusedWindow() rather than the menu's
// focusedWindow param avoids the BaseWindow typing gap.
BrowserWindow.getFocusedWindow()?.webContents.send('export:requestPdf')
}
}
]
},
{
label: 'Edit',
submenu: [

View file

@ -171,6 +171,15 @@ export type PreflightApi = {
refreshAgents: () => Promise<RefreshAgentsResult>
}
export type ExportApi = {
htmlToPdf: (args: {
html: string
title: string
}) => Promise<
{ success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string }
>
}
export type StatsApi = {
getSummary: () => Promise<StatsSummary>
}
@ -330,6 +339,7 @@ export type PreloadApi = {
githubEmail: string | null
}) => Promise<{ ok: true } | { ok: false; status: number | null; error: string }>
}
export: ExportApi
gh: {
viewer: () => Promise<GitHubViewer | null>
repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null>
@ -602,6 +612,7 @@ export type PreloadApi = {
onCloseActiveTab: (callback: () => void) => () => void
onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void
onToggleStatusBar: (callback: () => void) => () => void
onExportPdfRequested: (callback: () => void) => () => void
onActivateWorktree: (
callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void
) => () => void

View file

@ -336,6 +336,15 @@ const api = {
ipcRenderer.invoke('feedback:submit', args)
},
export: {
htmlToPdf: (args: {
html: string
title: string
}): Promise<
{ success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string }
> => ipcRenderer.invoke('export:html-to-pdf', args)
},
gh: {
viewer: (): Promise<unknown> => ipcRenderer.invoke('gh:viewer'),
@ -1090,6 +1099,11 @@ const api = {
ipcRenderer.on('ui:toggleStatusBar', listener)
return () => ipcRenderer.removeListener('ui:toggleStatusBar', listener)
},
onExportPdfRequested: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('export:requestPdf', listener)
return () => ipcRenderer.removeListener('export:requestPdf', listener)
},
onActivateWorktree: (
callback: (data: {
repoId: string

View file

@ -5,7 +5,7 @@ across multiple components. Autosave now lives in a smaller headless controller
so hidden editor UI no longer participates in shutdown. */
import React, { useCallback, useEffect, useRef, useState, Suspense } from 'react'
import * as monaco from 'monaco-editor'
import { Columns2, Copy, ExternalLink, FileText, Rows2 } from 'lucide-react'
import { Columns2, Copy, ExternalLink, FileText, MoreHorizontal, Rows2 } from 'lucide-react'
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { getConnectionId } from '@/lib/connection-context'
@ -36,6 +36,7 @@ import {
type EditorPathMutationTarget
} from './editor-autosave'
import { UntitledFileRenameDialog } from './UntitledFileRenameDialog'
import { exportActiveMarkdownToPdf } from './export-active-markdown'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
@ -68,6 +69,34 @@ type DiffContent = GitDiffResult
const inFlightFileReads = new Map<string, Promise<FileContent>>()
const inFlightDiffReads = new Map<string, Promise<DiffContent>>()
// Why: the "File → Export as PDF..." menu IPC fans out to every EditorPanel
// instance, and split-pane layouts mount N panels concurrently. Without a
// guard, a single menu click would spawn N concurrent exports — each racing
// its own save dialog, toast, and printToPDF — producing duplicate output
// files and confusing UX. This module-level ref-counted singleton installs
// exactly one IPC subscription the first time any panel mounts, and tears
// it down only when the last panel unmounts. A simple "first mounter wins"
// counter would go dead if the first-mounting panel unmounted while others
// were still mounted — survivors never re-subscribed and the menu silently
// stopped working. The singleton pattern avoids that handoff bug entirely.
let exportPdfListenerOwners = 0
let exportPdfListenerUnsubscribe: (() => void) | null = null
function acquireExportPdfListener(): () => void {
exportPdfListenerOwners += 1
if (exportPdfListenerOwners === 1) {
exportPdfListenerUnsubscribe = window.api.ui.onExportPdfRequested(() => {
void exportActiveMarkdownToPdf()
})
}
return () => {
exportPdfListenerOwners -= 1
if (exportPdfListenerOwners === 0 && exportPdfListenerUnsubscribe) {
exportPdfListenerUnsubscribe()
exportPdfListenerUnsubscribe = null
}
}
}
function inFlightReadKey(connectionId: string | undefined, filePath: string): string {
return `${connectionId ?? ''}::${filePath}`
}
@ -151,6 +180,18 @@ function EditorPanelInner({
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
// Why: the system "File → Export as PDF..." menu item sends a one-way IPC
// event that reaches whichever renderer has focus. The EditorPanel is the
// natural owner of the active markdown surface, so the listener lives here
// and delegates to the shared export helper. Both entry points (menu and
// overflow button) funnel through exportActiveMarkdownToPdf so toasts and
// no-op gating stay consistent.
// Why (guard): split-pane layouts mount multiple EditorPanelInner instances.
// We ref-count via `acquireExportPdfListener` so exactly one IPC subscription
// exists regardless of how many panels are mounted — and it survives panel
// churn as long as at least one panel is still mounted.
useEffect(() => acquireExportPdfListener(), [])
// Why: keepCurrentModel / keepCurrent*Model retain Monaco models after unmount
// so undo history survives tab switches. When a tab is *closed*, the user has
// signalled they're done with the file — dispose the models to reclaim memory
@ -804,6 +845,37 @@ function EditorPanelInner({
onChange={(mode) => setMarkdownViewMode(activeFile.id, mode)}
/>
)}
{hasViewModeToggle && isMarkdown && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
aria-label="More actions"
title="More actions"
>
<MoreHorizontal size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
<DropdownMenuItem
// Why: the item is disabled (not hidden) only in source/Monaco
// mode, which has no document DOM to export. We intentionally
// don't poll the DOM (canExportActiveMarkdown) at render time:
// the Radix content renders in a Portal and the lookup can
// race with the active surface's paint, producing a stuck
// disabled state. exportActiveMarkdownToPdf is a safe no-op
// when no subtree is found.
disabled={mdViewMode === 'source'}
onSelect={() => {
void exportActiveMarkdownToPdf()
}}
>
Export as PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
<Suspense fallback={loadingFallback}>

View file

@ -0,0 +1,39 @@
import { toast } from 'sonner'
import { getActiveMarkdownExportPayload } from './markdown-export-extract'
/**
* Export the currently-active markdown document to PDF via the main-process
* IPC bridge. Silent no-op when no markdown surface is active the menu
* item and overflow action can both share this entry point.
*/
export async function exportActiveMarkdownToPdf(): Promise<void> {
const payload = getActiveMarkdownExportPayload()
if (!payload) {
// Why: design doc §5 — menu-triggered export with no markdown surface is
// a silent no-op. The overflow-menu item is disabled in that case so we
// only reach this branch for stray menu shortcuts.
return
}
const toastId = toast.loading('Exporting PDF...')
try {
const result = await window.api.export.htmlToPdf({
html: payload.html,
title: payload.title
})
if (result.success) {
toast.success(`Exported to ${result.filePath}`, { id: toastId })
return
}
if (result.cancelled) {
// Why: user pressed Cancel in the save dialog — clear the loading toast
// without surfacing an error.
toast.dismiss(toastId)
return
}
toast.error(result.error ?? 'Failed to export PDF', { id: toastId })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to export PDF'
toast.error(message, { id: toastId })
}
}

View file

@ -0,0 +1,143 @@
// Why: this stylesheet targets the *exported* PDF document, not the live Orca
// pane. In-app CSS assumes sticky UI chrome, hover affordances, and app-shell
// spacing that would look wrong when flattened to paper. Keeping export CSS
// separate also means a future UI refactor can move live classes without
// silently breaking PDF output.
export const EXPORT_CSS = `
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: #ffffff;
color: #1f2328;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
font-size: 14px;
line-height: 1.6;
}
.orca-export-root {
padding: 0;
max-width: 100%;
}
.orca-export-root h1,
.orca-export-root h2,
.orca-export-root h3,
.orca-export-root h4,
.orca-export-root h5,
.orca-export-root h6 {
font-weight: 600;
line-height: 1.25;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.orca-export-root h1 { font-size: 1.9em; border-bottom: 1px solid #d0d7de; padding-bottom: 0.3em; }
.orca-export-root h2 { font-size: 1.5em; border-bottom: 1px solid #d0d7de; padding-bottom: 0.3em; }
.orca-export-root h3 { font-size: 1.25em; }
.orca-export-root h4 { font-size: 1em; }
.orca-export-root p,
.orca-export-root blockquote,
.orca-export-root ul,
.orca-export-root ol,
.orca-export-root pre,
.orca-export-root table {
margin-top: 0;
margin-bottom: 1em;
}
.orca-export-root a {
color: #0969da;
text-decoration: underline;
}
.orca-export-root blockquote {
padding: 0 1em;
color: #57606a;
border-left: 0.25em solid #d0d7de;
}
.orca-export-root code,
.orca-export-root pre {
font-family: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 0.9em;
}
.orca-export-root code {
background: #f6f8fa;
padding: 0.2em 0.4em;
border-radius: 4px;
}
.orca-export-root pre {
background: #f6f8fa;
padding: 12px 16px;
border-radius: 6px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.orca-export-root pre code {
background: transparent;
padding: 0;
border-radius: 0;
font-size: inherit;
}
.orca-export-root table {
border-collapse: collapse;
width: 100%;
}
.orca-export-root th,
.orca-export-root td {
border: 1px solid #d0d7de;
padding: 6px 12px;
text-align: left;
}
.orca-export-root th { background: #f6f8fa; }
.orca-export-root img,
.orca-export-root svg {
max-width: 100%;
height: auto;
}
.orca-export-root ul,
.orca-export-root ol { padding-left: 2em; }
.orca-export-root li { margin: 0.25em 0; }
.orca-export-root input[type="checkbox"] {
margin-right: 0.4em;
}
.orca-export-root hr {
border: 0;
border-top: 1px solid #d0d7de;
margin: 1.5em 0;
}
/* Why: the export subtree selection already excludes the big chrome (toolbar,
search bar, etc.), but in-document affordances like the code-copy button
can still leak. Hide the well-known offenders as a belt-and-suspenders
defense on top of DOM scrubbing. */
.code-block-copy-btn,
.markdown-preview-search,
.rich-markdown-toolbar,
[data-orca-export-hide="true"] {
display: none !important;
}
.code-block-wrapper { position: static !important; }
@media print {
pre, code, table, img, svg { page-break-inside: avoid; }
h1, h2, h3, h4, h5, h6 { page-break-after: avoid; }
}
`

View file

@ -0,0 +1,84 @@
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { buildMarkdownExportHtml } from './markdown-export-html'
export type MarkdownExportPayload = {
title: string
html: string
}
// Why: the export subtree is the smallest DOM that represents the rendered
// document. Preview mode uses `.markdown-body` (the .markdown-preview wrapper
// also contains the search bar chrome), and rich mode uses `.ProseMirror`
// (the surrounding .rich-markdown-editor-shell contains the toolbar, search
// bar, link bubble, and slash menu as siblings).
const DOCUMENT_SUBTREE_SELECTOR = '.ProseMirror, .markdown-body'
// Why: even after picking the smallest subtree, a few in-document UI leaks
// can remain. The design doc lists these by name and treats the cloned-scrub
// pass as a belt-and-suspenders defense so PDF output never shows copy
// buttons, per-block search highlights, or other transient affordances.
const UI_ONLY_SELECTORS = [
'.code-block-copy-btn',
'.markdown-preview-search',
'[class*="rich-markdown-search"]',
'[data-orca-export-hide="true"]'
]
function basenameWithoutExt(filePath: string): string {
const base = filePath.split(/[\\/]/).pop() ?? filePath
const dot = base.lastIndexOf('.')
return dot > 0 ? base.slice(0, dot) : base
}
/**
* Locate the active markdown document DOM subtree. v1 uses a scoped query
* over the whole document: there is only one active markdown surface at a
* time, and both preview and rich modes paint a uniquely-classed container.
* If multi-pane split view ever makes multiple surfaces visible at once,
* this contract must be revisited (see design doc §4).
*/
function findActiveDocumentSubtree(): Element | null {
return document.querySelector(DOCUMENT_SUBTREE_SELECTOR)
}
/**
* Extract a clean, self-contained HTML export payload from the active
* markdown surface. Returns null when no markdown document is active or the
* surface is in a mode (Monaco source) that does not render a document DOM.
*/
export function getActiveMarkdownExportPayload(): MarkdownExportPayload | null {
const state = useAppStore.getState()
if (state.activeTabType !== 'editor') {
return null
}
const activeFile = state.openFiles.find((f) => f.id === state.activeFileId)
if (!activeFile || activeFile.mode !== 'edit') {
return null
}
const language = detectLanguage(activeFile.filePath)
if (language !== 'markdown') {
return null
}
const subtree = findActiveDocumentSubtree()
if (!subtree) {
return null
}
const clone = subtree.cloneNode(true) as Element
for (const selector of UI_ONLY_SELECTORS) {
for (const node of clone.querySelectorAll(selector)) {
node.remove()
}
}
const renderedHtml = clone.innerHTML.trim()
if (!renderedHtml) {
return null
}
const title = basenameWithoutExt(activeFile.relativePath || activeFile.filePath)
const html = buildMarkdownExportHtml({ title, renderedHtml })
return { title, html }
}

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { buildMarkdownExportHtml } from './markdown-export-html'
describe('buildMarkdownExportHtml', () => {
it('wraps rendered html in a complete standalone document', () => {
const html = buildMarkdownExportHtml({
title: 'Hello',
renderedHtml: '<h1>Hello</h1><p>world</p>'
})
expect(html.startsWith('<!DOCTYPE html>')).toBe(true)
expect(html).toContain('<meta charset="utf-8"')
expect(html).toContain('<title>Hello</title>')
expect(html).toContain('<h1>Hello</h1><p>world</p>')
expect(html).toContain('class="orca-export-root"')
expect(html).toContain('<style>')
})
it('escapes HTML-unsafe characters in the title', () => {
const html = buildMarkdownExportHtml({
title: '<script>alert(1)</script>',
renderedHtml: '<p>x</p>'
})
expect(html).toContain('<title>&lt;script&gt;alert(1)&lt;/script&gt;</title>')
expect(html).not.toContain('<title><script>')
})
it('falls back to "Untitled" when the title is empty', () => {
const html = buildMarkdownExportHtml({ title: '', renderedHtml: '<p>x</p>' })
expect(html).toContain('<title>Untitled</title>')
})
})

View file

@ -0,0 +1,52 @@
import { EXPORT_CSS } from './export-css'
type BuildMarkdownExportHtmlArgs = {
title: string
renderedHtml: string
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* Wrap a rendered markdown fragment in a standalone HTML document suitable
* for Electron `webContents.printToPDF()`.
*
* The result is intentionally self-contained (inline CSS, no external links
* except whatever the rendered fragment already references) so that loading
* it from a temp file produces a stable paint regardless of the caller's
* working directory.
*/
export function buildMarkdownExportHtml(args: BuildMarkdownExportHtmlArgs): string {
const title = escapeHtml(args.title || 'Untitled')
// Why (CSP): the generated HTML is loaded in an Electron BrowserWindow with
// `javascript: true` (required for printToPDF layout). Without a CSP, any
// <script> tag that leaked into the cloned rendered subtree — e.g. from a
// malicious markdown paste or a compromised upstream renderer — would
// execute with renderer privileges during the export. Forbidding script-src
// entirely (no 'default-src' fallback to scripts) closes this hole while
// still allowing inline styles (for the <style> block and element-level
// style attributes the renderer emits), images from common schemes, and
// data/https fonts.
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: https: http: file:; style-src 'unsafe-inline'; font-src data: https:;" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>${EXPORT_CSS}</style>
</head>
<body>
<div class="orca-export-root">
${args.renderedHtml}
</div>
</body>
</html>`
}