Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions scripts/issue261EditorPdf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,10 @@ test('print layout releases measured fold heights before text reflows', () => {
/@media print\s*\{[\s\S]*?\.foldable-content-wrapper\.is-collapsed\s*\{[\s\S]*?height:\s*0\s*!important;/,
);
});

test('print layout lets the viewer pane escape the interactive split flex ratio', () => {
assert.match(
styles,
/@media print\s*\{[\s\S]*?\.pane\.viewer-pane\s*\{[\s\S]*?flex:\s*none\s*!important;/,
);
});
21 changes: 21 additions & 0 deletions scripts/windowsPdfExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';

const exporter = readFileSync('src/lib/utils/export.ts', 'utf8');
const tauriLib = readFileSync('src-tauri/src/lib.rs', 'utf8');

test('Windows PDF export uses WebView2 settings that suppress print headers and footers', () => {
assert.match(exporter, /export async function exportAsPdf\(ctx: PdfExportContext\)/);
assert.match(exporter, /if \(ctx\.osType !== 'windows'\) \{\s*await invoke\('print_pdf'\);/);
assert.match(exporter, /filters: \[\{ name: 'PDF', extensions: \['pdf'\] \}\]/);
assert.match(exporter, /invoke\('export_pdf_windows', \{ path: selected \}\)/);
assert.match(tauriLib, /async fn export_pdf_windows\(/);
assert.match(tauriLib, /fn print_pdf\(window: tauri::WebviewWindow\) -> Result<\(\), String> \{\s*window\.print\(\)/);
assert.match(tauriLib, /save_file_content,\s*\n\s*export_pdf_windows,\s*\n\s*print_pdf,/);
assert.match(tauriLib, /SetShouldPrintHeaderAndFooter\(false\)/);
assert.match(tauriLib, /PrintToPdf\(/);
assert.match(tauriLib, /use webview2_com::\{\s*PrintToPdfCompletedHandler,/);
assert.doesNotMatch(tauriLib, /callback::PrintToPdfCompletedHandler/);
assert.match(tauriLib, /recv_timeout\(Duration::from_secs\(60\)\)/);
});
2 changes: 2 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ image = { version = "0.25", default-features = false, features = ["png"] }
[target.'cfg(windows)'.dependencies]
mslnk = "0.1"
winreg = "0.52"
webview2-com = "0.38.2"
windows = "0.61.3"

[profile.release]
panic = "abort" # Strip expensive panic clean-up logic
Expand Down
87 changes: 87 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,91 @@ fn save_file_content(path: String, content: String) -> Result<(), String> {
atomic_write(Path::new(&path), content.as_bytes()).map_err(|e| e.to_string())
}

#[tauri::command]
fn print_pdf(window: tauri::WebviewWindow) -> Result<(), String> {
window.print().map_err(|error| error.to_string())
}

#[tauri::command]
async fn export_pdf_windows(window: tauri::WebviewWindow, path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
use std::sync::mpsc::sync_channel;
use std::time::Duration;
use webview2_com::{
PrintToPdfCompletedHandler,
Microsoft::Web::WebView2::Win32::{ICoreWebView2Environment6, ICoreWebView2_7},
};
use windows::core::{Interface, HSTRING};

let (sender, receiver) = sync_channel(1);
window
.with_webview(move |platform_webview| unsafe {
let result = (|| -> Result<(), String> {
let controller = platform_webview.controller();
let webview = controller
.CoreWebView2()
.map_err(|error| format!("failed to access WebView2: {error}"))?
.cast::<ICoreWebView2_7>()
.map_err(|error| {
format!("WebView2 runtime does not support PDF export: {error}")
})?;
let settings = platform_webview
.environment()
.cast::<ICoreWebView2Environment6>()
.map_err(|error| {
format!("WebView2 runtime does not support print settings: {error}")
})?
.CreatePrintSettings()
.map_err(|error| format!("failed to create PDF print settings: {error}"))?;

settings
.SetShouldPrintHeaderAndFooter(false)
.map_err(|error| {
format!("failed to disable PDF headers and footers: {error}")
})?;
settings
.SetShouldPrintBackgrounds(true)
.map_err(|error| format!("failed to enable PDF backgrounds: {error}"))?;

let callback_sender = sender.clone();
let completion =
PrintToPdfCompletedHandler::create(Box::new(move |status, succeeded| {
let result = status
.map_err(|error| format!("WebView2 PDF export failed: {error}"))
.and_then(|_| {
succeeded.then_some(()).ok_or_else(|| {
"WebView2 did not create the PDF file".to_string()
})
});
let _ = callback_sender.send(result);
Ok(())
}));

webview
.PrintToPdf(&HSTRING::from(path), &settings, &completion)
.map_err(|error| format!("could not start PDF export: {error}"))
})();

if let Err(error) = result {
let _ = sender.send(Err(error));
}
})
.map_err(|error| format!("failed to schedule PDF export: {error}"))?;

tauri::async_runtime::spawn_blocking(move || receiver.recv_timeout(Duration::from_secs(60)))
.await
.map_err(|error| format!("PDF export task failed: {error}"))?
.map_err(|error| format!("PDF export callback failed or timed out: {error}"))?
}

#[cfg(not(target_os = "windows"))]
{
let _ = (window, path);
Err("controlled PDF export is only available on Windows".to_string())
}
}

#[tauri::command]
fn save_file_binary(path: String, data: Vec<u8>) -> Result<(), String> {
atomic_write(Path::new(&path), &data).map_err(|e| e.to_string())
Expand Down Expand Up @@ -1512,6 +1597,8 @@ pub fn run() {
read_file_content,
read_file_as_data_url,
save_file_content,
export_pdf_windows,
print_pdf,
save_file_binary,
get_app_mode,
setup::install_app,
Expand Down
15 changes: 14 additions & 1 deletion src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import Toc from './components/Toc.svelte';
import Toast from './components/Toast.svelte';
import FindBar from './components/FindBar.svelte';
import { exportAsHtml as _exportHtml, exportAsPdf } from './utils/export';
import { exportAsHtml as _exportHtml, exportAsPdf as _exportPdf } from './utils/export';
import { askToOpenExportedFile } from './utils/openExportedFile.js';
import ZoomOverlay from './components/ZoomOverlay.svelte';
import { processMarkdownHtml } from './utils/markdown';
Expand Down Expand Up @@ -1841,6 +1841,19 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
}
}

async function exportAsPdf() {
const tab = tabManager.activeTab;
try {
await _exportPdf({
tabPath: tab?.path || '',
osType: settings.osType,
});
} catch (error) {
console.error('Failed to export PDF', error);
addToast('Failed to export PDF', 'error');
}
}

function handleNewFile() {
tabManager.addNewTab();
showHome = false;
Expand Down
24 changes: 19 additions & 5 deletions src/lib/utils/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export type ExportHtmlResult = {
missingImages: number;
};

export interface PdfExportContext {
tabPath: string;
osType: 'macos' | 'windows' | 'linux' | 'unknown';
}

async function buildExportArticle(ctx: ExportContext): Promise<{ html: string; embeddedImages: number; missingImages: number }> {
const body = getMarkdownBodyWithoutFrontMatter(ctx.rawContent);
const rendered = (await invoke('render_markdown', { content: body })) as string;
Expand Down Expand Up @@ -162,9 +167,18 @@ ${article.html}
}
}

export function exportAsPdf() {
// The current PDF path delegates to the WebView/system print dialog. That
// API does not expose the saved PDF path, so prompt+open belongs in a future
// controlled PDF exporter that knows the target file.
window.print();
export async function exportAsPdf(ctx: PdfExportContext) {
if (ctx.osType !== 'windows') {
await invoke('print_pdf');
return;
}

const defaultName = ctx.tabPath ? ctx.tabPath.replace(/\.[^.]+$/, '.pdf') : 'export.pdf';
const selected = await save({
filters: [{ name: 'PDF', extensions: ['pdf'] }],
defaultPath: defaultName,
});
if (!selected) return;

await invoke('export_pdf_windows', { path: selected });
}
6 changes: 6 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,12 @@ body {
border-radius: 0 !important;
}

/* The viewer pane carries an inline split ratio during normal interaction.
Width alone cannot override that flex value in the print layout. */
.pane.viewer-pane {
flex: none !important;
}

.markdown-body {
padding: 0 !important;
margin: 0 !important;
Expand Down
Loading