mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 20:46:03 +02:00
refactor(print): simplify and modularize
This commit is contained in:
@@ -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" }}
|
||||
/>
|
||||
|
||||
381
apps/server/src/services/printing.ts
Normal file
381
apps/server/src/services/printing.ts
Normal 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
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user