feat: 实现 Web 与 Desktop DOCX 导出交互
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { PagedDocumentPayload } from "@md-to-pdf/core";
|
||||
import type {
|
||||
DocxExportRequestInput,
|
||||
PagedDocumentPayload
|
||||
} from "@md-to-pdf/core";
|
||||
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
|
||||
import {
|
||||
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||
DESKTOP_CREATE_NEW_WINDOW,
|
||||
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||
DESKTOP_GET_THEME_CSS,
|
||||
DESKTOP_GET_DOCX_CAPABILITY,
|
||||
DESKTOP_EXPORT_DOCX,
|
||||
DESKTOP_GENERATE_PDF,
|
||||
DESKTOP_LIST_THEMES,
|
||||
DESKTOP_MARKDOWN_OPENED,
|
||||
@@ -77,5 +82,9 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
||||
generatePdf: (payload: PagedDocumentPayload) =>
|
||||
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
|
||||
savePdf: (fileName: string, pdf: Uint8Array) =>
|
||||
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf)
|
||||
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf),
|
||||
getDocxCapability: () =>
|
||||
ipcRenderer.invoke(DESKTOP_GET_DOCX_CAPABILITY),
|
||||
exportDocx: (request: DocxExportRequestInput) =>
|
||||
ipcRenderer.invoke(DESKTOP_EXPORT_DOCX, request)
|
||||
});
|
||||
|
||||
@@ -36,6 +36,10 @@ export const DESKTOP_GENERATE_PDF =
|
||||
"md-to-pdf:desktop:generate-pdf";
|
||||
export const DESKTOP_SAVE_PDF =
|
||||
"md-to-pdf:desktop:save-pdf";
|
||||
export const DESKTOP_GET_DOCX_CAPABILITY =
|
||||
"md-to-pdf:desktop:get-docx-capability";
|
||||
export const DESKTOP_EXPORT_DOCX =
|
||||
"md-to-pdf:desktop:export-docx";
|
||||
export const PDF_RUNTIME_READY =
|
||||
"md-to-pdf:desktop-pdf:ready";
|
||||
export const PDF_RUNTIME_RENDER =
|
||||
|
||||
@@ -16,14 +16,21 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
DocxExportService,
|
||||
MAXIMUM_MARKDOWN_LENGTH,
|
||||
readDocxExportRuntimeLimits,
|
||||
type ApplicationService
|
||||
} from "@md-to-pdf/application";
|
||||
import {
|
||||
PandocDocxConverter,
|
||||
PandocRuntime
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import {
|
||||
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||
DESKTOP_CREATE_NEW_WINDOW,
|
||||
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||
DESKTOP_GENERATE_PDF,
|
||||
DESKTOP_GET_DOCX_CAPABILITY,
|
||||
DESKTOP_GET_THEME_CSS,
|
||||
DESKTOP_LIST_THEMES,
|
||||
DESKTOP_MARKDOWN_OPENED,
|
||||
@@ -36,10 +43,16 @@ import {
|
||||
DESKTOP_SAVE_MARKDOWN,
|
||||
DESKTOP_SAVE_MARKDOWN_AS,
|
||||
DESKTOP_SAVE_PDF,
|
||||
DESKTOP_EXPORT_DOCX,
|
||||
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||
DESKTOP_START_NEW_MARKDOWN,
|
||||
DESKTOP_WINDOW_CLOSE_REQUESTED
|
||||
} from "./channels.js";
|
||||
import {
|
||||
toDesktopDocxExportFailure,
|
||||
type DesktopDocxExportOutcome
|
||||
} from "./docx-contract.js";
|
||||
import { ElectronDocxMediaEngine } from "./electron-docx-media-engine.js";
|
||||
import {
|
||||
parseMarkdownRenderRequest,
|
||||
parseThemeId
|
||||
@@ -93,6 +106,8 @@ export interface DesktopApplicationControllerOptions {
|
||||
renderUrl: string;
|
||||
preloadPath: string;
|
||||
pdfPreloadPath: string;
|
||||
docxRenderUrl: string;
|
||||
desktopResourcesPath: string;
|
||||
iconPath: string;
|
||||
themeDirectory: string;
|
||||
windowStatePath: string;
|
||||
@@ -118,6 +133,8 @@ export class DesktopApplicationController {
|
||||
readonly #themeDirectory: string;
|
||||
readonly #windowStatePath: string;
|
||||
readonly #pdfGenerator: ElectronPdfGenerator;
|
||||
readonly #docxExportService: DocxExportService;
|
||||
readonly #docxMediaEngine: ElectronDocxMediaEngine;
|
||||
readonly #sessions = new Map<number, WindowSession>();
|
||||
readonly #documentSessions = new Map<string, WindowSession>();
|
||||
#primarySession: WindowSession | undefined;
|
||||
@@ -138,6 +155,24 @@ export class DesktopApplicationController {
|
||||
renderUrl: options.renderUrl,
|
||||
preloadPath: options.pdfPreloadPath
|
||||
});
|
||||
const pandocRuntime = new PandocRuntime({
|
||||
desktopResourcesPath: options.desktopResourcesPath
|
||||
});
|
||||
this.#docxExportService = new DocxExportService({
|
||||
application: this.#applicationService,
|
||||
runtime: pandocRuntime,
|
||||
converter: new PandocDocxConverter({
|
||||
runtime: pandocRuntime,
|
||||
luaFilterUrl: new URL(
|
||||
"./docx-media-filter.lua",
|
||||
import.meta.url
|
||||
)
|
||||
}),
|
||||
limits: readDocxExportRuntimeLimits()
|
||||
});
|
||||
this.#docxMediaEngine = new ElectronDocxMediaEngine({
|
||||
renderUrl: options.docxRenderUrl
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
@@ -279,7 +314,11 @@ export class DesktopApplicationController {
|
||||
}
|
||||
|
||||
async closeResources() {
|
||||
await this.#pdfGenerator.close();
|
||||
await Promise.all([
|
||||
this.#pdfGenerator.close(),
|
||||
this.#docxExportService.close()
|
||||
]);
|
||||
await this.#docxMediaEngine.close();
|
||||
}
|
||||
|
||||
#resolveInitialBounds(isPrimary: boolean): Rectangle {
|
||||
@@ -874,6 +913,100 @@ export class DesktopApplicationController {
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
DESKTOP_GET_DOCX_CAPABILITY,
|
||||
async (event) => {
|
||||
this.#getSession(event);
|
||||
return this.#docxExportService.getCapability();
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
DESKTOP_EXPORT_DOCX,
|
||||
async (
|
||||
event,
|
||||
unsafeRequest: unknown
|
||||
): Promise<DesktopDocxExportOutcome> => {
|
||||
const session = this.#getSession(event);
|
||||
const abortController = new AbortController();
|
||||
const abort = () =>
|
||||
abortController.abort(
|
||||
new Error("桌面应用窗口已关闭")
|
||||
);
|
||||
event.sender.once("destroyed", abort);
|
||||
try {
|
||||
const generated =
|
||||
await this.#docxExportService.generate(
|
||||
unsafeRequest,
|
||||
{
|
||||
mediaAdapter: this.#docxMediaEngine,
|
||||
imageContext: session.documentRoot
|
||||
? {
|
||||
localRoot: session.documentRoot,
|
||||
allowUnrestrictedLocalFiles: true
|
||||
}
|
||||
: {},
|
||||
signal: abortController.signal
|
||||
}
|
||||
);
|
||||
if (
|
||||
session.window.isDestroyed() ||
|
||||
event.sender.isDestroyed()
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "DOCX_RENDER_TIMEOUT",
|
||||
message: "DOCX 导出已取消",
|
||||
retryable: false
|
||||
};
|
||||
}
|
||||
|
||||
const selection = await dialog.showSaveDialog(
|
||||
session.window,
|
||||
{
|
||||
title: "导出 DOCX",
|
||||
defaultPath: path.join(
|
||||
app.getPath("documents"),
|
||||
generated.fileName
|
||||
),
|
||||
filters: [
|
||||
{
|
||||
name: "Word 文档",
|
||||
extensions: ["docx"]
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
if (selection.canceled || !selection.filePath) {
|
||||
return {
|
||||
ok: true,
|
||||
saved: false,
|
||||
fileName: generated.fileName,
|
||||
diagnostics: generated.diagnostics,
|
||||
timings: generated.timings
|
||||
};
|
||||
}
|
||||
const targetPath = selection.filePath
|
||||
.toLowerCase()
|
||||
.endsWith(".docx")
|
||||
? selection.filePath
|
||||
: `${selection.filePath}.docx`;
|
||||
await writeFile(targetPath, generated.docx);
|
||||
return {
|
||||
ok: true,
|
||||
saved: true,
|
||||
fileName: path.basename(targetPath),
|
||||
diagnostics: generated.diagnostics,
|
||||
timings: generated.timings
|
||||
};
|
||||
} catch (error) {
|
||||
return toDesktopDocxExportFailure(error);
|
||||
} finally {
|
||||
event.sender.removeListener("destroyed", abort);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async #openDocumentLink(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
DocxExportServiceError
|
||||
} from "@md-to-pdf/application";
|
||||
import type {
|
||||
DocxCapability,
|
||||
DocxExportDiagnostics,
|
||||
DocxExportErrorCode,
|
||||
DocxGenerationTimings
|
||||
} from "@md-to-pdf/core";
|
||||
import { docxExportErrorCodeSchema } from "@md-to-pdf/core";
|
||||
|
||||
export interface DesktopDocxExportSuccess {
|
||||
ok: true;
|
||||
saved: boolean;
|
||||
fileName: string;
|
||||
diagnostics: DocxExportDiagnostics;
|
||||
timings: DocxGenerationTimings;
|
||||
}
|
||||
|
||||
export interface DesktopDocxExportFailure {
|
||||
ok: false;
|
||||
error: DocxExportErrorCode;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
export type DesktopDocxExportOutcome =
|
||||
| DesktopDocxExportSuccess
|
||||
| DesktopDocxExportFailure;
|
||||
|
||||
export type DesktopDocxCapability = DocxCapability;
|
||||
|
||||
export function toDesktopDocxExportFailure(
|
||||
error: unknown
|
||||
): DesktopDocxExportFailure {
|
||||
if (error instanceof ApplicationRequestError) {
|
||||
const parsedCode = docxExportErrorCodeSchema.safeParse(
|
||||
error.code
|
||||
);
|
||||
if (!parsedCode.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "DOCX_GENERATION_FAILED",
|
||||
message: "DOCX 导出失败",
|
||||
retryable: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: parsedCode.data,
|
||||
message: error.message,
|
||||
retryable: false
|
||||
};
|
||||
}
|
||||
if (error instanceof DocxExportServiceError) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
retryable: error.retryable
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "DOCX_GENERATION_FAILED",
|
||||
message: "DOCX 导出失败",
|
||||
retryable: false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import type {
|
||||
DocxMediaCaptureAdapter,
|
||||
DocxMediaCaptureRequest
|
||||
} from "@md-to-pdf/application";
|
||||
import { captureDocxMediaWithElectronWebContents } from "./electron-docx-media-capture.js";
|
||||
import { isAllowedPdfRuntimeUrl } from "./pdf-contract.js";
|
||||
|
||||
export const DESKTOP_DOCX_PARTITION =
|
||||
"md-to-pdf-desktop-docx";
|
||||
|
||||
export interface ElectronDocxMediaEngineOptions {
|
||||
renderUrl: string;
|
||||
}
|
||||
|
||||
export class ElectronDocxMediaEngine
|
||||
implements DocxMediaCaptureAdapter
|
||||
{
|
||||
private readonly renderUrl: string;
|
||||
private windowPromise: Promise<BrowserWindow> | undefined;
|
||||
private queue = Promise.resolve();
|
||||
private closed = false;
|
||||
|
||||
constructor(options: ElectronDocxMediaEngineOptions) {
|
||||
this.renderUrl = options.renderUrl;
|
||||
}
|
||||
|
||||
capture(
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error("桌面 DOCX 媒体引擎已关闭"));
|
||||
}
|
||||
const operation = this.queue.then(() =>
|
||||
this.captureNow(request, signal)
|
||||
);
|
||||
this.queue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed = true;
|
||||
await this.queue;
|
||||
const windowPromise = this.windowPromise;
|
||||
this.windowPromise = undefined;
|
||||
if (!windowPromise) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const window = await windowPromise;
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
} catch {
|
||||
// 创建失败时没有需要关闭的窗口。
|
||||
}
|
||||
}
|
||||
|
||||
private async captureNow(
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
const window = await this.getWindow();
|
||||
signal?.throwIfAborted();
|
||||
const abort = () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
return await captureDocxMediaWithElectronWebContents(
|
||||
window.webContents,
|
||||
request,
|
||||
signal
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
private getWindow() {
|
||||
if (!this.windowPromise) {
|
||||
this.windowPromise = this.createWindow().catch((error) => {
|
||||
this.windowPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return this.windowPromise;
|
||||
}
|
||||
|
||||
private async createWindow() {
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
focusable: false,
|
||||
opacity: 0,
|
||||
skipTaskbar: true,
|
||||
x: -32_000,
|
||||
y: -32_000,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
paintWhenInitiallyHidden: true,
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
partition: DESKTOP_DOCX_PARTITION
|
||||
}
|
||||
});
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
const target = new URL(url);
|
||||
const renderTarget = new URL(this.renderUrl);
|
||||
if (
|
||||
target.origin !== renderTarget.origin ||
|
||||
target.pathname !== renderTarget.pathname
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
window.webContents.on("destroyed", () => {
|
||||
this.windowPromise = undefined;
|
||||
});
|
||||
window.webContents.session.webRequest.onBeforeRequest(
|
||||
{ urls: ["*://*/*"] },
|
||||
(details, callback) => {
|
||||
callback({
|
||||
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await window.loadURL(this.renderUrl);
|
||||
await window.webContents.executeJavaScript(`
|
||||
new Promise((resolve, reject) => {
|
||||
if (document.documentElement.dataset.runtimeReady === "true") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
reject(new Error("桌面 DOCX 媒体运行时启动超时"));
|
||||
}, 10000);
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.documentElement.dataset.runtimeReady === "true") {
|
||||
clearTimeout(timeout);
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-runtime-ready"]
|
||||
});
|
||||
})
|
||||
`);
|
||||
window.webContents.setZoomFactor(1);
|
||||
return window;
|
||||
} catch (error) {
|
||||
window.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-10
@@ -17,6 +17,7 @@ import {
|
||||
DesktopApplicationController
|
||||
} from "./desktop-application-controller.js";
|
||||
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
|
||||
import { DESKTOP_DOCX_PARTITION } from "./electron-docx-media-engine.js";
|
||||
import {
|
||||
findMarkdownFileArgument,
|
||||
isMarkdownFilePath
|
||||
@@ -182,6 +183,9 @@ async function registerApplicationProtocol(
|
||||
protocol.handle(APP_SCHEME, handleRequest),
|
||||
session
|
||||
.fromPartition(DESKTOP_PDF_PARTITION)
|
||||
.protocol.handle(APP_SCHEME, handleRequest),
|
||||
session
|
||||
.fromPartition(DESKTOP_DOCX_PARTITION)
|
||||
.protocol.handle(APP_SCHEME, handleRequest)
|
||||
]);
|
||||
}
|
||||
@@ -219,16 +223,16 @@ if (!hasSingleInstanceLock) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void applicationController.flushWindowState().finally(() => {
|
||||
windowStateReadyToQuit = true;
|
||||
app.quit();
|
||||
setTimeout(() => {
|
||||
windowStateReadyToQuit = false;
|
||||
}, 1_000);
|
||||
});
|
||||
});
|
||||
app.on("will-quit", () => {
|
||||
void applicationController?.closeResources();
|
||||
void Promise.all([
|
||||
applicationController.flushWindowState(),
|
||||
applicationController.closeResources()
|
||||
]).finally(() => {
|
||||
windowStateReadyToQuit = true;
|
||||
app.quit();
|
||||
setTimeout(() => {
|
||||
windowStateReadyToQuit = false;
|
||||
}, 1_000);
|
||||
});
|
||||
});
|
||||
app
|
||||
.whenReady()
|
||||
@@ -260,6 +264,11 @@ if (!hasSingleInstanceLock) {
|
||||
currentDirectory,
|
||||
"pdf-preload.cjs"
|
||||
),
|
||||
docxRenderUrl: new URL(
|
||||
"/preview-frame.html?target=continuous",
|
||||
applicationUrl
|
||||
).href,
|
||||
desktopResourcesPath: process.resourcesPath,
|
||||
iconPath: getApplicationIcon(),
|
||||
themeDirectory,
|
||||
windowStatePath: path.join(
|
||||
|
||||
Reference in New Issue
Block a user