feat: 实现 Web 与 Desktop DOCX 导出交互

This commit is contained in:
SkyJourney
2026-07-30 16:05:45 +08:00
parent bf43433d38
commit f2dfc6ef72
28 changed files with 2110 additions and 57 deletions
+11 -2
View File
@@ -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)
});
+4
View File
@@ -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(
+70
View File
@@ -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
View File
@@ -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(