From a9eaae8c88f6cd06da1822b95844e330bbf9d29f Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 2 Apr 2026 12:37:03 +0800 Subject: [PATCH] fix(desktop): export pdf via electron ipc (#4943) --- .../layer/main/src/ipc/services/app.ts | 50 +++++++++++++--- .../renderer/src/lib/__tests__/export.test.ts | 45 ++++++++++++++ apps/desktop/layer/renderer/src/lib/export.ts | 58 +++++++++++++++++++ .../src/modules/command/commands/entry.tsx | 5 +- 4 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/lib/__tests__/export.test.ts diff --git a/apps/desktop/layer/main/src/ipc/services/app.ts b/apps/desktop/layer/main/src/ipc/services/app.ts index fe1d649e7..134ed9bb8 100644 --- a/apps/desktop/layer/main/src/ipc/services/app.ts +++ b/apps/desktop/layer/main/src/ipc/services/app.ts @@ -30,10 +30,18 @@ interface SearchInput { options: Electron.FindInPageOptions } +interface ExportCurrentPageAsPdfInput { + defaultPath?: string +} + interface Sender extends Electron.WebContents { getOwnerBrowserWindow: () => Electron.BrowserWindow | null } +const ensurePdfExtension = (filePath: string) => { + return path.extname(filePath).toLowerCase() === ".pdf" ? filePath : `${filePath}.pdf` +} + export class AppService extends IpcService { static override readonly groupName = "app" @@ -171,22 +179,48 @@ export class AppService extends IpcService { } } + @IpcMethod() + async exportCurrentPageAsPdf( + context: IpcContext, + input: ExportCurrentPageAsPdfInput = {}, + ): Promise { + const senderWindow = (context.sender as Sender).getOwnerBrowserWindow() + const dialogOptions: Electron.SaveDialogOptions = { + defaultPath: ensurePdfExtension(input.defaultPath || "Untitled.pdf"), + filters: [{ name: "PDF", extensions: ["pdf"] }], + properties: ["createDirectory", "showOverwriteConfirmation"], + } + const result = senderWindow + ? await dialog.showSaveDialog(senderWindow, dialogOptions) + : await dialog.showSaveDialog(dialogOptions) + + if (result.canceled || !result.filePath) return null + + const pdfData = await context.sender.printToPDF({ + printBackground: true, + preferCSSPageSize: true, + }) + + const filePath = ensurePdfExtension(result.filePath) + await fsp.writeFile(filePath, pdfData) + + return filePath + } + @IpcMethod() getAppPath(_context: IpcContext): string { return app.getAppPath() } @IpcMethod() - resolveAppAsarPath(context: IpcContext, input: string): string { - if (input.startsWith("file://")) { - input = fileURLToPath(input) + resolveAppAsarPath(_context: IpcContext, input: string): string { + const resolvedInput = input.startsWith("file://") ? fileURLToPath(input) : input + + if (path.isAbsolute(resolvedInput)) { + return resolvedInput } - if (path.isAbsolute(input)) { - return input - } - - return path.join(app.getAppPath(), input) + return path.join(app.getAppPath(), resolvedInput) } @IpcMethod() diff --git a/apps/desktop/layer/renderer/src/lib/__tests__/export.test.ts b/apps/desktop/layer/renderer/src/lib/__tests__/export.test.ts new file mode 100644 index 000000000..3656f0bf1 --- /dev/null +++ b/apps/desktop/layer/renderer/src/lib/__tests__/export.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest" + +import { createPdfFileName, exportPageAsPdf } from "../export" + +describe("createPdfFileName", () => { + it("sanitizes invalid filename characters", () => { + expect(createPdfFileName('A/B:C*D?E"FH|I')).toBe("A B C D E F G H I.pdf") + }) + + it("falls back to Untitled when title is empty", () => { + expect(createPdfFileName(" ")).toBe("Untitled.pdf") + }) +}) + +describe("exportPageAsPdf", () => { + it("uses Electron PDF export when running in Electron", async () => { + const print = vi.fn() + const exportAsPdf = vi.fn().mockResolvedValue("/tmp/Article.pdf") + + await exportPageAsPdf({ + title: "Article:/Title", + isElectron: true, + print, + exportAsPdf, + }) + + expect(exportAsPdf).toHaveBeenCalledWith({ defaultPath: "Article Title.pdf" }) + expect(print).not.toHaveBeenCalled() + }) + + it("falls back to window.print in browser mode", async () => { + const print = vi.fn() + const exportAsPdf = vi.fn() + + await exportPageAsPdf({ + title: "Article Title", + isElectron: false, + print, + exportAsPdf, + }) + + expect(print).toHaveBeenCalledOnce() + expect(exportAsPdf).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/layer/renderer/src/lib/export.ts b/apps/desktop/layer/renderer/src/lib/export.ts index 7df39a660..30860fe32 100644 --- a/apps/desktop/layer/renderer/src/lib/export.ts +++ b/apps/desktop/layer/renderer/src/lib/export.ts @@ -1,3 +1,23 @@ +import { IN_ELECTRON } from "@follow/shared/constants" + +import { ipcServices } from "./client" + +const PDF_EXTENSION = ".pdf" + +const sanitizeFileName = (value: string) => { + return Array.from(value) + .map((character) => { + const charCode = character.codePointAt(0) ?? 0 + + if (`<>:"/\\|?*`.includes(character) || charCode <= 31) { + return " " + } + + return character + }) + .join("") +} + export const downloadJsonFile = (content: string, filename: string) => { const blob = new Blob([content], { type: "application/json" }) const url = URL.createObjectURL(blob) @@ -32,3 +52,41 @@ export const selectJsonFile = (): Promise => { input.click() }) } + +export const createPdfFileName = (title?: string) => { + const sanitizedTitle = title?.trim() + ? sanitizeFileName(title.trim()).replaceAll(/\s+/g, " ").trim() + : "" + const baseName = sanitizedTitle || "Untitled" + + return baseName.toLowerCase().endsWith(PDF_EXTENSION) ? baseName : `${baseName}${PDF_EXTENSION}` +} + +interface ExportPageAsPdfOptions { + title?: string + isElectron?: boolean + print?: () => void + exportAsPdf?: (input: { defaultPath: string }) => Promise +} + +export const exportPageAsPdf = async ({ + title, + isElectron = IN_ELECTRON, + print = () => { + window.print() + }, + exportAsPdf = async (input) => { + if (!ipcServices) { + throw new Error("Electron IPC is not available") + } + + return ipcServices.app.exportCurrentPageAsPdf(input) + }, +}: ExportPageAsPdfOptions = {}) => { + if (isElectron) { + return exportAsPdf({ defaultPath: createPdfFileName(title) }) + } + + print() + return null +} diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx index 06373308e..a4ffbbbfb 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx @@ -23,6 +23,7 @@ import { toggleEntryReadability } from "~/hooks/biz/useEntryActions" import { navigateEntry } from "~/hooks/biz/useNavigateEntry" import { getRouteParams } from "~/hooks/biz/useRouteParams" import { copyToClipboard } from "~/lib/clipboard" +import { exportPageAsPdf } from "~/lib/export" import { markAllByRoute } from "~/modules/entry-column/hooks/useMarkAll" import { useGalleryModal } from "~/modules/entry-content/hooks" import { playEntryTts } from "~/modules/player/entry-tts" @@ -177,7 +178,9 @@ export const useRegisterEntryCommands = () => { return } - window.print() + void exportPageAsPdf({ title: entry.title || entry.url || undefined }).catch(() => { + toast.error("Failed to export as pdf", { duration: 3000 }) + }) }, }, {