fix(desktop): export pdf via electron ipc (#4943)
This commit is contained in:
parent
032b66b8a0
commit
a9eaae8c88
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue