diff --git a/apps/client/src/widgets/dialogs/print_preview.tsx b/apps/client/src/widgets/dialogs/print_preview.tsx index fedd2cd523..434313a99f 100644 --- a/apps/client/src/widgets/dialogs/print_preview.tsx +++ b/apps/client/src/widgets/dialogs/print_preview.tsx @@ -108,6 +108,8 @@ export default function PrintPreviewDialog() { const [printers, setPrinters] = useState([]); const [destination, setDestination] = useState(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>(); - 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>(); - 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>(); - - 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() { handleMarginPresetChange((e.target as HTMLSelectElement).value)} + onChange={(e) => setMarginsStr(serializeMargins((e.target as HTMLSelectElement).value as MarginPreset | "custom", customMargins))} disabled={loading} > @@ -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" }} /> diff --git a/apps/server/src/services/printing.ts b/apps/server/src/services/printing.ts new file mode 100644 index 0000000000..30e415999f --- /dev/null +++ b/apps/server/src/services/printing.ts @@ -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: `
`, + footerTemplate: ` +
+
+ ` + }); + } 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: `
`, + footerTemplate: ` +
+
+ ` + }); + + 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; + 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 + }); + } + }); +} diff --git a/apps/server/src/services/window.ts b/apps/server/src/services/window.ts index e4c33f434a..5b98af008b 100644 --- a/apps/server/src/services/window.ts +++ b/apps/server/src/services/window.ts @@ -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: `
`, - footerTemplate: ` -
-
- ` - }); - } 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: `
`, - footerTemplate: ` -
-
- ` - }); - - 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; - 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) {