refactor(print): simplify and modularize

This commit is contained in:
Elian Doran
2026-04-15 18:51:20 +03:00
parent 6225f92a7d
commit 0ebec2ce5d
3 changed files with 435 additions and 456 deletions

View File

@@ -108,6 +108,8 @@ export default function PrintPreviewDialog() {
const [printers, setPrinters] = useState<PrinterInfo[]>([]);
const [destination, setDestination] = useState<string>(DESTINATION_PDF);
const skipNextRegenRef = useRef(false);
useEffect(() => {
if (!shown || !isElectron()) return;
const { ipcRenderer } = dynamicRequire("electron");
@@ -131,12 +133,54 @@ export default function PrintPreviewDialog() {
}, [pdfUrl]);
useTriliumEvent("showPrintPreview", (data: PrintPreviewData) => {
skipNextRegenRef.current = true;
setNote(data.note);
notePathRef.current = data.notePath;
updatePreview(data.pdfBuffer);
setShown(true);
});
const regeneratePreview = useCallback((opts: PreviewOpts) => {
if (!isElectron()) return;
setLoading(true);
const { ipcRenderer } = dynamicRequire("electron");
const onResult = (_e: any, { buffer, error }: { buffer?: Uint8Array; error?: string }) => {
toast.closePersistent("printing");
if (error) {
setLoading(false);
toast.showError(t("print_preview.render_error"));
return;
}
if (buffer) {
updatePreview(buffer);
}
};
ipcRenderer.once("export-as-pdf-preview-result", onResult);
ipcRenderer.send("export-as-pdf-preview", {
notePath: notePathRef.current,
pageSize: opts.pageSize,
landscape: opts.landscape,
scale: opts.scale,
margins: opts.margins,
pageRanges: opts.pageRanges
});
}, [updatePreview]);
useEffect(() => {
if (!shown || !pageRangesValid) return;
if (skipNextRegenRef.current) {
skipNextRegenRef.current = false;
return;
}
const handle = setTimeout(() => {
regeneratePreview({ landscape, pageSize, scale, margins: marginsStr, pageRanges: pageRanges.trim() });
}, 400);
return () => clearTimeout(handle);
}, [shown, landscape, pageSize, scale, marginsStr, pageRanges, pageRangesValid, regeneratePreview]);
function handleClose() {
setShown(false);
if (pdfUrl) {
@@ -183,90 +227,14 @@ export default function PrintPreviewDialog() {
}
}
function handleOrientationChange(newLandscape: boolean) {
if (newLandscape === landscape) return;
setLandscape(newLandscape);
regeneratePreview({ landscape: newLandscape, pageSize, scale, margins: marginsStr, pageRanges });
}
function handlePageSizeChange(newPageSize: string) {
if (newPageSize === pageSize) return;
setPageSize(newPageSize);
regeneratePreview({ landscape, pageSize: newPageSize, scale, margins: marginsStr, pageRanges });
}
const scaleDebounceRef = useRef<ReturnType<typeof setTimeout>>();
function handleScaleChange(newScale: number) {
const clamped = Math.min(2, Math.max(0.1, Math.round(newScale * 10) / 10));
setScaleStr(String(clamped));
clearTimeout(scaleDebounceRef.current);
scaleDebounceRef.current = setTimeout(() => {
regeneratePreview({ landscape, pageSize, scale: clamped, margins: marginsStr, pageRanges });
}, 500);
}
function handleMarginPresetChange(newPreset: string) {
if (newPreset === marginPreset) return;
const newValue = serializeMargins(newPreset as MarginPreset | "custom", customMargins);
setMarginsStr(newValue);
regeneratePreview({ landscape, pageSize, scale, margins: newValue, pageRanges });
}
const marginDebounceRef = useRef<ReturnType<typeof setTimeout>>();
function handleCustomMarginChange(side: keyof CustomMargins, value: number) {
const newCustom = { ...customMargins, [side]: Math.max(0, value) };
const newValue = serializeMargins("custom", newCustom);
setMarginsStr(newValue);
clearTimeout(marginDebounceRef.current);
marginDebounceRef.current = setTimeout(() => {
regeneratePreview({ landscape, pageSize, scale, margins: newValue, pageRanges });
}, 500);
}
const pageRangesDebounceRef = useRef<ReturnType<typeof setTimeout>>();
function handlePageRangesChange(newValue: string) {
setPageRanges(newValue);
clearTimeout(pageRangesDebounceRef.current);
if (!isValidPageRanges(newValue)) return;
pageRangesDebounceRef.current = setTimeout(() => {
regeneratePreview({ landscape, pageSize, scale, margins: marginsStr, pageRanges: newValue.trim() });
}, 600);
}
function regeneratePreview(opts: PreviewOpts) {
if (!isElectron()) return;
setLoading(true);
const { ipcRenderer } = dynamicRequire("electron");
const onResult = (_e: any, { buffer, error }: { buffer?: Uint8Array; error?: string }) => {
toast.closePersistent("printing");
if (error) {
setLoading(false);
toast.showError(t("print_preview.render_error"));
return;
}
if (buffer) {
updatePreview(buffer);
}
};
ipcRenderer.once("export-as-pdf-preview-result", onResult);
ipcRenderer.send("export-as-pdf-preview", {
notePath: notePathRef.current,
pageSize: opts.pageSize,
landscape: opts.landscape,
scale: opts.scale,
margins: opts.margins,
pageRanges: opts.pageRanges
});
setMarginsStr(serializeMargins("custom", newCustom));
}
return (
@@ -338,7 +306,7 @@ export default function PrintPreviewDialog() {
text={t("print_preview.portrait")}
icon="bx-rectangle bx-rotate-90"
className={!landscape ? "active" : ""}
onClick={() => handleOrientationChange(false)}
onClick={() => setLandscape(false)}
disabled={loading}
size="small"
/>
@@ -346,7 +314,7 @@ export default function PrintPreviewDialog() {
text={t("print_preview.landscape")}
icon="bx-rectangle"
className={landscape ? "active" : ""}
onClick={() => handleOrientationChange(true)}
onClick={() => setLandscape(true)}
disabled={loading}
size="small"
/>
@@ -357,7 +325,7 @@ export default function PrintPreviewDialog() {
<select
class="form-select form-select-sm"
value={pageSize}
onChange={(e) => handlePageSizeChange((e.target as HTMLSelectElement).value)}
onChange={(e) => setPageSize((e.target as HTMLSelectElement).value)}
disabled={loading}
>
{PAGE_SIZES.map((size) => (
@@ -380,7 +348,7 @@ export default function PrintPreviewDialog() {
<select
class="form-select form-select-sm"
value={marginPreset}
onChange={(e) => handleMarginPresetChange((e.target as HTMLSelectElement).value)}
onChange={(e) => setMarginsStr(serializeMargins((e.target as HTMLSelectElement).value as MarginPreset | "custom", customMargins))}
disabled={loading}
>
<option value="default">{t("print_preview.margins_default")}</option>
@@ -404,7 +372,7 @@ export default function PrintPreviewDialog() {
class={`form-control form-control-sm ${!pageRangesValid ? "is-invalid" : ""}`}
value={pageRanges}
placeholder={t("print_preview.page_ranges_placeholder")}
onInput={(e) => handlePageRangesChange((e.target as HTMLInputElement).value)}
onInput={(e) => setPageRanges((e.target as HTMLInputElement).value)}
disabled={loading}
style={{ width: "140px" }}
/>

View File

@@ -0,0 +1,381 @@
import { default as electron, ipcMain, type IpcMainEvent } from "electron";
import fs from "fs/promises";
import { t } from "i18next";
import log from "./log.js";
import port from "./port.js";
import { formatDownloadTitle } from "./utils.js";
interface PrintOpts {
notePath: string;
printToPdf: boolean;
}
interface ExportAsPdfOpts {
notePath: string;
title: string;
landscape: boolean;
pageSize: "A0" | "A1" | "A2" | "A3" | "A4" | "A5" | "A6" | "Legal" | "Letter" | "Tabloid" | "Ledger";
scale: number;
margins: string;
pageRanges: string;
}
interface PrintFromPreviewOpts extends ExportAsPdfOpts {
silent: boolean;
deviceName?: string;
}
/** Parses the printMargins attribute into Electron margins.
* Values are stored in mm and converted to inches for Electron.
* Presets expand to explicit numeric margins since Electron's `marginType` aliases
* (especially `none` and `printableArea`) behave inconsistently for PDF output. */
function parseMargins(margins: string): Electron.Margins {
const mmToInches = (mm: number) => mm / 25.4;
const uniform = (mm: number): Electron.Margins => ({
marginType: "custom",
top: mmToInches(mm),
right: mmToInches(mm),
bottom: mmToInches(mm),
left: mmToInches(mm)
});
if (!margins || margins === "default") return uniform(20); // 2cm
if (margins === "none") return uniform(0);
if (margins === "minimum") return uniform(5);
const parts = margins.split(",").map(Number);
if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
return {
marginType: "custom",
top: mmToInches(parts[0]),
right: mmToInches(parts[1]),
bottom: mmToInches(parts[2]),
left: mmToInches(parts[3])
};
}
return uniform(10);
}
/** Convert "1-5, 8, 11-13" into PageRanges array form expected by webContents.print. */
function parsePageRangesForPrint(pageRanges: string): { from: number; to: number }[] | undefined {
if (!pageRanges?.trim()) return undefined;
const ranges: { from: number; to: number }[] = [];
for (const part of pageRanges.split(",")) {
const trimmed = part.trim();
if (!trimmed) continue;
const [fromStr, toStr] = trimmed.split("-").map(s => s.trim());
const from = parseInt(fromStr, 10);
const to = toStr ? parseInt(toStr, 10) : from;
if (!isNaN(from) && !isNaN(to)) ranges.push({ from, to });
}
return ranges.length ? ranges : undefined;
}
async function getBrowserWindowForPrinting(e: IpcMainEvent, notePath: string, action: "printing" | "exporting_pdf") {
// Offscreen rendering crashes on Wayland due to a Chromium bug where the OSR surface
// lacks a valid xdg_toplevel role, causing a fatal zxdg_exporter_v2 protocol error.
// On Linux we work around this by creating a regular window positioned off-screen,
// since `show: false` without OSR causes Chromium to skip rendering entirely.
const useOffscreen = process.platform !== "linux";
const browserWindow = new electron.BrowserWindow({
show: !useOffscreen,
...(useOffscreen ? {} : {
width: 1,
height: 1,
frame: false,
skipTaskbar: true,
focusable: false,
}),
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
offscreen: useOffscreen,
devTools: false,
session: e.sender.session
},
});
const progressCallback = (_e: IpcMainEvent, progress: number) => e.sender.send("print-progress", { progress, action });
ipcMain.on("print-progress", progressCallback);
// Capture ALL console output (including errors) for debugging
browserWindow.webContents.on("console-message", (event, message, line, sourceId) => {
if (event.level === "debug") return;
if (event.level === "error") {
log.error(`[Print Window ${sourceId}:${line}] ${message}`);
return;
}
log.info(`[Print Window ${sourceId}:${line}] ${message}`);
});
try {
await browserWindow.loadURL(`http://127.0.0.1:${port}/?print#${notePath}`);
} catch (err) {
log.error(`Failed to load print window: ${err}`);
ipcMain.off("print-progress", progressCallback);
throw err;
}
// Set up error tracking and logging in the renderer process
await browserWindow.webContents.executeJavaScript(`
(function() {
window._printWindowErrors = [];
window.addEventListener("error", (e) => {
const errorMsg = "Uncaught error: " + e.message + " at " + e.filename + ":" + e.lineno + ":" + e.colno;
console.error(errorMsg);
if (e.error?.stack) console.error(e.error.stack);
window._printWindowErrors.push({
type: 'error',
message: errorMsg,
stack: e.error?.stack
});
});
window.addEventListener("unhandledrejection", (e) => {
const errorMsg = "Unhandled rejection: " + String(e.reason);
console.error(errorMsg);
if (e.reason?.stack) console.error(e.reason.stack);
window._printWindowErrors.push({
type: 'rejection',
message: errorMsg,
stack: e.reason?.stack
});
});
})();
`).catch(err => log.error(`Failed to set up error handlers in print window: ${err}`));
let printReport;
try {
printReport = await browserWindow.webContents.executeJavaScript(`
new Promise((resolve, reject) => {
if (window._noteReady) return resolve(window._noteReady);
// Check for errors periodically
const errorChecker = setInterval(() => {
if (window._printWindowErrors && window._printWindowErrors.length > 0) {
clearInterval(errorChecker);
const errors = window._printWindowErrors.map(e => e.message).join('; ');
reject(new Error("Print window errors: " + errors));
}
}, 100);
window.addEventListener("note-ready", (data) => {
clearInterval(errorChecker);
resolve(data.detail);
});
});
`);
} catch (err) {
log.error(`Print window promise failed for ${notePath}: ${err}`);
ipcMain.off("print-progress", progressCallback);
throw err;
}
ipcMain.off("print-progress", progressCallback);
return { browserWindow, printReport };
}
/** Registers all printing-related IPC handlers. Call once on Electron startup. */
export function initPrintingHandlers() {
electron.ipcMain.on("print-note", async (e, { notePath }: PrintOpts) => {
try {
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "printing");
browserWindow.webContents.print({}, (success, failureReason) => {
if (!success && failureReason !== "Print job canceled") {
electron.dialog.showErrorBox(t("pdf.unable-to-print"), failureReason);
}
e.sender.send("print-done", printReport);
browserWindow.destroy();
});
} catch (err) {
e.sender.send("print-done", {
type: "error",
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
});
}
});
electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize, scale, margins, pageRanges }: ExportAsPdfOpts) => {
try {
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf");
async function print() {
const filePath = electron.dialog.showSaveDialogSync(browserWindow, {
defaultPath: formatDownloadTitle(title, "file", "application/pdf"),
filters: [
{
name: t("pdf.export_filter"),
extensions: ["pdf"]
}
]
});
if (!filePath) return;
let buffer: Buffer;
try {
buffer = await browserWindow.webContents.printToPDF({
landscape,
pageSize,
scale,
margins: parseMargins(margins),
pageRanges: pageRanges || undefined,
generateDocumentOutline: true,
generateTaggedPDF: true,
printBackground: true,
displayHeaderFooter: true,
headerTemplate: `<div></div>`,
footerTemplate: `
<div class="pageNumber" style="width: 100%; text-align: center; font-size: 10pt;">
</div>
`
});
} catch (_e) {
electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-export-message"));
return;
}
try {
await fs.writeFile(filePath, buffer);
} catch (_e) {
electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-save-message"));
return;
}
electron.shell.openPath(filePath);
}
try {
await print();
} finally {
e.sender.send("print-done", printReport);
browserWindow.destroy();
}
} catch (err) {
e.sender.send("print-done", {
type: "error",
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
});
}
});
electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pageSize, scale, margins, pageRanges }: ExportAsPdfOpts) => {
try {
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf");
try {
const buffer = await browserWindow.webContents.printToPDF({
landscape,
pageSize,
scale,
margins: parseMargins(margins),
pageRanges: pageRanges || undefined,
generateDocumentOutline: true,
generateTaggedPDF: true,
printBackground: true,
displayHeaderFooter: true,
headerTemplate: `<div></div>`,
footerTemplate: `
<div class="pageNumber" style="width: 100%; text-align: center; font-size: 10pt;">
</div>
`
});
e.sender.send("export-as-pdf-preview-result", { buffer, notePath });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
e.sender.send("export-as-pdf-preview-result", { notePath, error: message });
} finally {
e.sender.send("print-done", printReport);
browserWindow.destroy();
}
} catch (err) {
e.sender.send("print-done", {
type: "error",
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
});
}
});
electron.ipcMain.on("save-pdf", async (_e, { title, buffer }: { title: string; buffer: Buffer }) => {
const focusedWindow = electron.BrowserWindow.getFocusedWindow();
if (!focusedWindow) return;
const filePath = electron.dialog.showSaveDialogSync(focusedWindow, {
defaultPath: formatDownloadTitle(title, "file", "application/pdf"),
filters: [
{
name: t("pdf.export_filter"),
extensions: ["pdf"]
}
]
});
if (!filePath) return;
try {
await fs.writeFile(filePath, Buffer.from(buffer));
} catch (_e) {
electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-save-message"));
return;
}
electron.shell.openPath(filePath);
});
electron.ipcMain.handle("get-printers", async (e) => {
try {
const printers = await e.sender.getPrintersAsync();
return printers.map((p) => {
// Platform-specific: CUPS uses "printer-location", Windows/mac often expose "location".
const opts = (p.options ?? {}) as Record<string, string>;
return {
name: p.name,
displayName: p.displayName,
description: p.description,
location: opts["printer-location"] || opts.location || "",
isDefault: (p as unknown as { isDefault?: boolean }).isDefault ?? false
};
});
} catch {
return [];
}
});
electron.ipcMain.on("print-from-preview", async (e, { notePath, landscape, pageSize, scale, margins, pageRanges, silent, deviceName }: PrintFromPreviewOpts) => {
try {
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "printing");
// print() accepts most of the same options as printToPDF, but typing differs
// slightly (e.g. no "Ledger" pageSize). Cast to keep this concise.
const printOpts: Electron.WebContentsPrintOptions = {
silent,
deviceName,
landscape,
pageSize: pageSize === "Ledger" ? "Tabloid" : pageSize,
scaleFactor: Math.round(scale * 100),
margins: parseMargins(margins),
pageRanges: parsePageRangesForPrint(pageRanges),
printBackground: true
};
browserWindow.webContents.print(printOpts, (success, failureReason) => {
if (!success && failureReason !== "Print job canceled") {
electron.dialog.showErrorBox(t("pdf.unable-to-print"), failureReason);
}
e.sender.send("print-from-preview-done");
e.sender.send("print-done", printReport);
browserWindow.destroy();
});
} catch (err) {
e.sender.send("print-from-preview-done");
e.sender.send("print-done", {
type: "error",
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
});
}
});
}

View File

@@ -1,19 +1,18 @@
import { type App, type BrowserWindow, type BrowserWindowConstructorOptions, default as electron, ipcMain, type IpcMainEvent, type Session, type WebContents } from "electron";
import fs from "fs/promises";
import { t } from "i18next";
import { type App, type BrowserWindow, type BrowserWindowConstructorOptions, default as electron, type Session, type WebContents } from "electron";
import path from "path";
import url from "url";
import app_info from "./app_info.js";
import cls from "./cls.js";
import customDictionary from "./custom_dictionary.js";
import { initPrintingHandlers } from "./printing.js";
import keyboardActionsService from "./keyboard_actions.js";
import log from "./log.js";
import optionService from "./options.js";
import port from "./port.js";
import { RESOURCE_DIR } from "./resource_dir.js";
import sqlInit from "./sql_init.js";
import { formatDownloadTitle, isMac, isWindows } from "./utils.js";
import { isMac, isWindows } from "./utils.js";
// Prevent the window being garbage collected
let mainWindow: BrowserWindow | null;
@@ -76,376 +75,7 @@ electron.ipcMain.on("add-word-to-dictionary", (event, word: string) => {
customDictionary.addWord(word);
});
interface PrintOpts {
notePath: string;
printToPdf: boolean;
}
interface ExportAsPdfOpts {
notePath: string;
title: string;
landscape: boolean;
pageSize: "A0" | "A1" | "A2" | "A3" | "A4" | "A5" | "A6" | "Legal" | "Letter" | "Tabloid" | "Ledger";
scale: number;
margins: string;
pageRanges: string;
}
/** Parses the printMargins attribute into Electron margins.
* Values are stored in mm and converted to inches for Electron.
* Presets expand to explicit numeric margins since Electron's `marginType` aliases
* (especially `none` and `printableArea`) behave inconsistently for PDF output. */
function parseMargins(margins: string): Electron.Margins {
const mmToInches = (mm: number) => mm / 25.4;
const uniform = (mm: number): Electron.Margins => ({
marginType: "custom",
top: mmToInches(mm),
right: mmToInches(mm),
bottom: mmToInches(mm),
left: mmToInches(mm)
});
if (!margins || margins === "default") return uniform(20); // 2cm
if (margins === "none") return uniform(0);
if (margins === "minimum") return uniform(5);
const parts = margins.split(",").map(Number);
if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
return {
marginType: "custom",
top: mmToInches(parts[0]),
right: mmToInches(parts[1]),
bottom: mmToInches(parts[2]),
left: mmToInches(parts[3])
};
}
return uniform(10);
}
electron.ipcMain.on("print-note", async (e, { notePath }: PrintOpts) => {
try {
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "printing");
browserWindow.webContents.print({}, (success, failureReason) => {
if (!success && failureReason !== "Print job canceled") {
electron.dialog.showErrorBox(t("pdf.unable-to-print"), failureReason);
}
e.sender.send("print-done", printReport);
browserWindow.destroy();
});
} catch (err) {
e.sender.send("print-done", {
type: "error",
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
});
}
});
electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize, scale, margins, pageRanges }: ExportAsPdfOpts) => {
try {
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf");
async function print() {
const filePath = electron.dialog.showSaveDialogSync(browserWindow, {
defaultPath: formatDownloadTitle(title, "file", "application/pdf"),
filters: [
{
name: t("pdf.export_filter"),
extensions: ["pdf"]
}
]
});
if (!filePath) return;
let buffer: Buffer;
try {
buffer = await browserWindow.webContents.printToPDF({
landscape,
pageSize,
scale,
margins: parseMargins(margins),
pageRanges: pageRanges || undefined,
generateDocumentOutline: true,
generateTaggedPDF: true,
printBackground: true,
displayHeaderFooter: true,
headerTemplate: `<div></div>`,
footerTemplate: `
<div class="pageNumber" style="width: 100%; text-align: center; font-size: 10pt;">
</div>
`
});
} catch (_e) {
electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-export-message"));
return;
}
try {
await fs.writeFile(filePath, buffer);
} catch (_e) {
electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-save-message"));
return;
}
electron.shell.openPath(filePath);
}
try {
await print();
} finally {
e.sender.send("print-done", printReport);
browserWindow.destroy();
}
} catch (err) {
e.sender.send("print-done", {
type: "error",
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
});
}
});
electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pageSize, scale, margins, pageRanges }: ExportAsPdfOpts) => {
try {
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf");
try {
const buffer = await browserWindow.webContents.printToPDF({
landscape,
pageSize,
scale,
margins: parseMargins(margins),
pageRanges: pageRanges || undefined,
generateDocumentOutline: true,
generateTaggedPDF: true,
printBackground: true,
displayHeaderFooter: true,
headerTemplate: `<div></div>`,
footerTemplate: `
<div class="pageNumber" style="width: 100%; text-align: center; font-size: 10pt;">
</div>
`
});
e.sender.send("export-as-pdf-preview-result", { buffer, notePath });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
e.sender.send("export-as-pdf-preview-result", { notePath, error: message });
} finally {
e.sender.send("print-done", printReport);
browserWindow.destroy();
}
} catch (err) {
e.sender.send("print-done", {
type: "error",
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
});
}
});
electron.ipcMain.on("save-pdf", async (_e, { title, buffer }: { title: string; buffer: Buffer }) => {
const focusedWindow = electron.BrowserWindow.getFocusedWindow();
if (!focusedWindow) return;
const filePath = electron.dialog.showSaveDialogSync(focusedWindow, {
defaultPath: formatDownloadTitle(title, "file", "application/pdf"),
filters: [
{
name: t("pdf.export_filter"),
extensions: ["pdf"]
}
]
});
if (!filePath) return;
try {
await fs.writeFile(filePath, Buffer.from(buffer));
} catch (_e) {
electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-save-message"));
return;
}
electron.shell.openPath(filePath);
});
interface PrintFromPreviewOpts extends ExportAsPdfOpts {
silent: boolean;
deviceName?: string;
}
electron.ipcMain.handle("get-printers", async (e) => {
try {
const printers = await e.sender.getPrintersAsync();
return printers.map((p) => {
// Platform-specific: CUPS uses "printer-location", Windows/mac often expose "location".
const opts = (p.options ?? {}) as Record<string, string>;
return {
name: p.name,
displayName: p.displayName,
description: p.description,
location: opts["printer-location"] || opts.location || "",
isDefault: (p as unknown as { isDefault?: boolean }).isDefault ?? false
};
});
} catch {
return [];
}
});
electron.ipcMain.on("print-from-preview", async (e, { notePath, landscape, pageSize, scale, margins, pageRanges, silent, deviceName }: PrintFromPreviewOpts) => {
try {
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "printing");
// print() accepts most of the same options as printToPDF, but typing differs
// slightly (e.g. no "Ledger" pageSize). Cast to keep this concise.
const printOpts: Electron.WebContentsPrintOptions = {
silent,
deviceName,
landscape,
pageSize: pageSize === "Ledger" ? "Tabloid" : pageSize,
scaleFactor: Math.round(scale * 100),
margins: parseMargins(margins),
pageRanges: parsePageRangesForPrint(pageRanges),
printBackground: true
};
browserWindow.webContents.print(printOpts, (success, failureReason) => {
if (!success && failureReason !== "Print job canceled") {
electron.dialog.showErrorBox(t("pdf.unable-to-print"), failureReason);
}
e.sender.send("print-from-preview-done");
e.sender.send("print-done", printReport);
browserWindow.destroy();
});
} catch (err) {
e.sender.send("print-from-preview-done");
e.sender.send("print-done", {
type: "error",
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
});
}
});
/** Convert "1-5, 8, 11-13" into PageRanges array form expected by webContents.print. */
function parsePageRangesForPrint(pageRanges: string): { from: number; to: number }[] | undefined {
if (!pageRanges?.trim()) return undefined;
const ranges: { from: number; to: number }[] = [];
for (const part of pageRanges.split(",")) {
const trimmed = part.trim();
if (!trimmed) continue;
const [fromStr, toStr] = trimmed.split("-").map(s => s.trim());
const from = parseInt(fromStr, 10);
const to = toStr ? parseInt(toStr, 10) : from;
if (!isNaN(from) && !isNaN(to)) ranges.push({ from, to });
}
return ranges.length ? ranges : undefined;
}
async function getBrowserWindowForPrinting(e: IpcMainEvent, notePath: string, action: "printing" | "exporting_pdf") {
// Offscreen rendering crashes on Wayland due to a Chromium bug where the OSR surface
// lacks a valid xdg_toplevel role, causing a fatal zxdg_exporter_v2 protocol error.
// On Linux we work around this by creating a regular window positioned off-screen,
// since `show: false` without OSR causes Chromium to skip rendering entirely.
const useOffscreen = process.platform !== "linux";
const browserWindow = new electron.BrowserWindow({
show: !useOffscreen,
...(useOffscreen ? {} : {
width: 1,
height: 1,
frame: false,
skipTaskbar: true,
focusable: false,
}),
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
offscreen: useOffscreen,
devTools: false,
session: e.sender.session
},
});
const progressCallback = (_e, progress: number) => e.sender.send("print-progress", { progress, action });
ipcMain.on("print-progress", progressCallback);
// Capture ALL console output (including errors) for debugging
browserWindow.webContents.on("console-message", (e, message, line, sourceId) => {
if (e.level === "debug") return;
if (e.level === "error") {
log.error(`[Print Window ${sourceId}:${line}] ${message}`);
return;
}
log.info(`[Print Window ${sourceId}:${line}] ${message}`);
});
try {
await browserWindow.loadURL(`http://127.0.0.1:${port}/?print#${notePath}`);
} catch (err) {
log.error(`Failed to load print window: ${err}`);
ipcMain.off("print-progress", progressCallback);
throw err;
}
// Set up error tracking and logging in the renderer process
await browserWindow.webContents.executeJavaScript(`
(function() {
window._printWindowErrors = [];
window.addEventListener("error", (e) => {
const errorMsg = "Uncaught error: " + e.message + " at " + e.filename + ":" + e.lineno + ":" + e.colno;
console.error(errorMsg);
if (e.error?.stack) console.error(e.error.stack);
window._printWindowErrors.push({
type: 'error',
message: errorMsg,
stack: e.error?.stack
});
});
window.addEventListener("unhandledrejection", (e) => {
const errorMsg = "Unhandled rejection: " + String(e.reason);
console.error(errorMsg);
if (e.reason?.stack) console.error(e.reason.stack);
window._printWindowErrors.push({
type: 'rejection',
message: errorMsg,
stack: e.reason?.stack
});
});
})();
`).catch(err => log.error(`Failed to set up error handlers in print window: ${err}`));
let printReport;
try {
printReport = await browserWindow.webContents.executeJavaScript(`
new Promise((resolve, reject) => {
if (window._noteReady) return resolve(window._noteReady);
// Check for errors periodically
const errorChecker = setInterval(() => {
if (window._printWindowErrors && window._printWindowErrors.length > 0) {
clearInterval(errorChecker);
const errors = window._printWindowErrors.map(e => e.message).join('; ');
reject(new Error("Print window errors: " + errors));
}
}, 100);
window.addEventListener("note-ready", (data) => {
clearInterval(errorChecker);
resolve(data.detail);
});
});
`);
} catch (err) {
log.error(`Print window promise failed for ${notePath}: ${err}`);
ipcMain.off("print-progress", progressCallback);
throw err;
}
ipcMain.off("print-progress", progressCallback);
return { browserWindow, printReport };
}
initPrintingHandlers();
async function createMainWindow(app: App) {
if ("setUserTasks" in app) {