fix(desktop): export pdf via electron ipc (#4943)

This commit is contained in:
DIYgod 2026-04-02 12:37:03 +08:00 committed by GitHub
parent 032b66b8a0
commit a9eaae8c88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 149 additions and 9 deletions

View File

@ -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<string | null> {
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()

View File

@ -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"F<G>H|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()
})
})

View File

@ -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<string> => {
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<string | null>
}
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
}

View File

@ -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 })
})
},
},
{