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
@@ -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(