feat: 实现 Web 与 Desktop DOCX 导出交互
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user