feat: 实现 Web 与 Desktop DOCX 导出交互
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@md-to-pdf/application": "0.4.1",
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"electron": "43.2.0",
|
||||
"electron-builder": "26.15.3",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { build } from "esbuild";
|
||||
import { copyFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
await build({
|
||||
entryPoints: ["src/main.ts"],
|
||||
@@ -14,3 +16,15 @@ await build({
|
||||
].join("\n")
|
||||
}
|
||||
});
|
||||
|
||||
await copyFile(
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../packages/docx-engine/assets/docx-media-filter.lua",
|
||||
import.meta.url
|
||||
)
|
||||
),
|
||||
fileURLToPath(
|
||||
new URL("../dist/docx-media-filter.lua", import.meta.url)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
DocxExportServiceError
|
||||
} from "@md-to-pdf/application";
|
||||
import { toDesktopDocxExportFailure } from "../src/docx-contract.js";
|
||||
|
||||
describe("Desktop DOCX IPC 契约", () => {
|
||||
it("映射应用请求错误", () => {
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new ApplicationRequestError(
|
||||
400,
|
||||
"INVALID_EXPORT_CONFIG",
|
||||
"导出配置无效"
|
||||
)
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: "INVALID_EXPORT_CONFIG",
|
||||
message: "导出配置无效",
|
||||
retryable: false
|
||||
});
|
||||
});
|
||||
|
||||
it("保留共享服务的重试语义", () => {
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new DocxExportServiceError(
|
||||
429,
|
||||
"DOCX_QUEUE_FULL",
|
||||
"DOCX 生成队列已满",
|
||||
true
|
||||
)
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: "DOCX_QUEUE_FULL",
|
||||
message: "DOCX 生成队列已满",
|
||||
retryable: true
|
||||
});
|
||||
});
|
||||
|
||||
it("保留 Front Matter 解析错误", () => {
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new ApplicationRequestError(
|
||||
400,
|
||||
"INVALID_FRONT_MATTER",
|
||||
"无法解析 Markdown Front Matter"
|
||||
)
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: "INVALID_FRONT_MATTER",
|
||||
message: "无法解析 Markdown Front Matter",
|
||||
retryable: false
|
||||
});
|
||||
});
|
||||
|
||||
it("隐藏未知主进程错误细节", () => {
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new Error("C:\\secret\\pandoc.exe 启动失败")
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: "DOCX_GENERATION_FAILED",
|
||||
message: "DOCX 导出失败",
|
||||
retryable: false
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,18 @@ describe("桌面发行构建链", () => {
|
||||
expect(builderConfig).toContain('to: "samples"');
|
||||
});
|
||||
|
||||
it("将 DOCX Lua Filter 复制到桌面主进程资源目录", () => {
|
||||
const buildScript = readFileSync(
|
||||
`${desktopRoot}/scripts/build-main.mjs`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
expect(buildScript).toContain(
|
||||
"packages/docx-engine/assets/docx-media-filter.lua"
|
||||
);
|
||||
expect(buildScript).toContain("dist/docx-media-filter.lua");
|
||||
});
|
||||
|
||||
it("在 Docker Web 构建前复制样例并保留到运行镜像", () => {
|
||||
const dockerfile = readFileSync(
|
||||
`${projectRoot}/deploy/Dockerfile`,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@md-to-pdf/application": "0.4.1",
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0",
|
||||
"fastify": "^5.6.2",
|
||||
"playwright": "1.62.0"
|
||||
|
||||
+180
-13
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
|
||||
import Fastify from "fastify";
|
||||
import {
|
||||
EXPORT_CONFIG_VERSION,
|
||||
DOCX_MIME_TYPE,
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig,
|
||||
exportConfigSchema,
|
||||
@@ -11,10 +12,21 @@ import {
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
DocxExportService,
|
||||
DocxExportServiceError,
|
||||
createApplicationService,
|
||||
readDocxExportRuntimeLimits,
|
||||
type ApplicationService
|
||||
} from "@md-to-pdf/application";
|
||||
import {
|
||||
PandocDocxConverter,
|
||||
PandocRuntime
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
|
||||
import {
|
||||
createDocxMediaEngine,
|
||||
type ServerDocxMediaCaptureAdapter
|
||||
} from "./docx-media-engine.js";
|
||||
import {
|
||||
createPdfGenerator,
|
||||
PdfEngineClosedError,
|
||||
@@ -68,7 +80,13 @@ export interface BuildAppOptions {
|
||||
logger?: boolean;
|
||||
pdfGenerator?: PdfGenerator;
|
||||
prewarmPdfBrowser?: boolean;
|
||||
prewarmDocxRuntime?: boolean;
|
||||
applicationService?: ApplicationService;
|
||||
docxExportService?: Pick<
|
||||
DocxExportService,
|
||||
"getCapability" | "generate" | "close"
|
||||
>;
|
||||
docxMediaAdapter?: ServerDocxMediaCaptureAdapter;
|
||||
}
|
||||
|
||||
function milliseconds(value: number) {
|
||||
@@ -106,6 +124,30 @@ function createPdfServerTiming(
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
function createDocxServerTiming(
|
||||
timings: Awaited<
|
||||
ReturnType<DocxExportService["generate"]>
|
||||
>["timings"]
|
||||
) {
|
||||
return [
|
||||
`queue;dur=${milliseconds(timings.queueMs)}`,
|
||||
`runtime-probe;dur=${milliseconds(timings.probeMs)}`,
|
||||
`prepare;dur=${milliseconds(timings.prepareMs)}`,
|
||||
`media;dur=${milliseconds(timings.mediaMs)}`,
|
||||
`reference;dur=${milliseconds(timings.referenceMs)}`,
|
||||
`pandoc;dur=${milliseconds(timings.pandocMs)}`,
|
||||
`validation;dur=${milliseconds(timings.validationMs)}`,
|
||||
`total;dur=${milliseconds(timings.totalMs)}`
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
export function createDocxContentDisposition(fileName: string) {
|
||||
return (
|
||||
`attachment; filename="document.docx"; ` +
|
||||
`filename*=UTF-8''${encodeRfc5987(fileName)}`
|
||||
);
|
||||
}
|
||||
|
||||
export function buildApp(options: BuildAppOptions = {}) {
|
||||
const app = Fastify({
|
||||
logger: options.logger ?? true,
|
||||
@@ -121,6 +163,21 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
});
|
||||
const pdfGenerator =
|
||||
options.pdfGenerator ?? createPdfGenerator();
|
||||
const pandocRuntime = options.docxExportService
|
||||
? undefined
|
||||
: new PandocRuntime();
|
||||
const docxExportService =
|
||||
options.docxExportService ??
|
||||
new DocxExportService({
|
||||
application: applicationService,
|
||||
runtime: pandocRuntime!,
|
||||
converter: new PandocDocxConverter({
|
||||
runtime: pandocRuntime!
|
||||
}),
|
||||
limits: readDocxExportRuntimeLimits()
|
||||
});
|
||||
const docxMediaAdapter =
|
||||
options.docxMediaAdapter ?? createDocxMediaEngine();
|
||||
|
||||
if (options.prewarmPdfBrowser) {
|
||||
app.addHook("onReady", async () => {
|
||||
@@ -132,8 +189,27 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
if (options.prewarmDocxRuntime) {
|
||||
app.addHook("onReady", async () => {
|
||||
try {
|
||||
const capability =
|
||||
await docxExportService.getCapability();
|
||||
if (capability.status !== "available") {
|
||||
app.log.warn(
|
||||
{ capability },
|
||||
"DOCX runtime unavailable"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
app.log.warn({ error }, "DOCX runtime probe failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
await pdfGenerator.close();
|
||||
await docxExportService.close();
|
||||
await docxMediaAdapter.close();
|
||||
});
|
||||
|
||||
app.get("/api/health", async () => ({
|
||||
@@ -143,19 +219,28 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
rendererVersion: RENDERER_VERSION
|
||||
}));
|
||||
|
||||
app.get("/api/capabilities", async () => ({
|
||||
defaultExportConfig,
|
||||
supportedPaperFormats,
|
||||
implemented: [
|
||||
"project-skeleton",
|
||||
"export-config",
|
||||
"theme-manifest",
|
||||
"markdown-render",
|
||||
"html-preview",
|
||||
"pdf-export"
|
||||
],
|
||||
planned: []
|
||||
}));
|
||||
app.get("/api/capabilities", async () => {
|
||||
const docx = await docxExportService.getCapability();
|
||||
return {
|
||||
defaultExportConfig,
|
||||
supportedPaperFormats,
|
||||
docx,
|
||||
implemented: [
|
||||
"project-skeleton",
|
||||
"export-config",
|
||||
"theme-manifest",
|
||||
"markdown-render",
|
||||
"html-preview",
|
||||
"pdf-export",
|
||||
"docx-export"
|
||||
],
|
||||
planned: []
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/api/docx/capability", async () =>
|
||||
docxExportService.getCapability()
|
||||
);
|
||||
|
||||
app.get("/api/themes", async () =>
|
||||
applicationService.listThemes()
|
||||
@@ -364,5 +449,87 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: unknown }>("/api/docx", async (request, reply) => {
|
||||
const abortController = new AbortController();
|
||||
const abortRequest = () =>
|
||||
abortController.abort(new Error("HTTP 请求已中断"));
|
||||
request.raw.once("aborted", abortRequest);
|
||||
|
||||
try {
|
||||
const generated = await docxExportService.generate(
|
||||
request.body ?? {},
|
||||
{
|
||||
mediaAdapter: docxMediaAdapter,
|
||||
signal: abortController.signal
|
||||
}
|
||||
);
|
||||
request.log.info(
|
||||
{
|
||||
fileName: generated.fileName,
|
||||
bytes: generated.docx.byteLength,
|
||||
diagnostics: generated.diagnostics,
|
||||
timings: generated.timings
|
||||
},
|
||||
"DOCX generated"
|
||||
);
|
||||
return reply
|
||||
.header("content-type", DOCX_MIME_TYPE)
|
||||
.header(
|
||||
"content-disposition",
|
||||
createDocxContentDisposition(generated.fileName)
|
||||
)
|
||||
.header("cache-control", "no-store")
|
||||
.header(
|
||||
"server-timing",
|
||||
createDocxServerTiming(generated.timings)
|
||||
)
|
||||
.header(
|
||||
"x-docx-warning-count",
|
||||
String(generated.diagnostics.warnings.length)
|
||||
)
|
||||
.header(
|
||||
"x-echarts-error-count",
|
||||
String(generated.diagnostics.echartsErrors.length)
|
||||
)
|
||||
.header(
|
||||
"x-mermaid-error-count",
|
||||
String(generated.diagnostics.mermaidErrors.length)
|
||||
)
|
||||
.send(
|
||||
Buffer.from(
|
||||
generated.docx.buffer,
|
||||
generated.docx.byteOffset,
|
||||
generated.docx.byteLength
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationRequestError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
retryable: false
|
||||
});
|
||||
}
|
||||
if (error instanceof DocxExportServiceError) {
|
||||
if (error.retryable) {
|
||||
reply.header("retry-after", "5");
|
||||
}
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
retryable: error.retryable
|
||||
});
|
||||
}
|
||||
request.log.error({ error }, "DOCX generation failed");
|
||||
return reply.code(500).send({
|
||||
error: "DOCX_GENERATION_FAILED",
|
||||
message: "DOCX 生成失败",
|
||||
retryable: false
|
||||
});
|
||||
} finally {
|
||||
request.raw.removeListener("aborted", abortRequest);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { chromium, type Browser, type BrowserContext } from "playwright";
|
||||
import type {
|
||||
DocxMediaCaptureAdapter,
|
||||
DocxMediaCaptureRequest
|
||||
} from "@md-to-pdf/application";
|
||||
import { captureDocxMediaWithPlaywrightPage } from "./playwright-docx-media-capture.js";
|
||||
import { isAllowedPdfRequestUrl } from "./pdf-engine.js";
|
||||
|
||||
export interface ServerDocxMediaCaptureAdapter
|
||||
extends DocxMediaCaptureAdapter {
|
||||
warmup?(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PlaywrightDocxMediaEngineOptions {
|
||||
renderOrigin?: string;
|
||||
launchBrowser?: () => Promise<Browser>;
|
||||
}
|
||||
|
||||
function normalizeRenderOrigin(value: string) {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("DOCX 媒体渲染地址必须使用 HTTP 或 HTTPS");
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
async function closeContext(context: BrowserContext) {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
export class PlaywrightDocxMediaEngine
|
||||
implements ServerDocxMediaCaptureAdapter
|
||||
{
|
||||
private readonly renderOrigin: string;
|
||||
private readonly renderUrl: string;
|
||||
private readonly launchBrowser: () => Promise<Browser>;
|
||||
private browserPromise: Promise<Browser> | undefined;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: PlaywrightDocxMediaEngineOptions = {}) {
|
||||
this.renderOrigin = normalizeRenderOrigin(
|
||||
options.renderOrigin ??
|
||||
process.env.PDF_RENDER_ORIGIN ??
|
||||
"http://localhost:5173"
|
||||
);
|
||||
this.renderUrl = new URL(
|
||||
"/preview-frame.html?target=continuous",
|
||||
this.renderOrigin
|
||||
).href;
|
||||
this.launchBrowser =
|
||||
options.launchBrowser ??
|
||||
(() =>
|
||||
chromium.launch({
|
||||
headless: true
|
||||
}));
|
||||
}
|
||||
|
||||
async warmup() {
|
||||
if (this.closed) {
|
||||
throw new Error("DOCX 媒体服务已关闭");
|
||||
}
|
||||
await this.getBrowser();
|
||||
}
|
||||
|
||||
async capture(
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (this.closed) {
|
||||
throw new Error("DOCX 媒体服务已关闭");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const browser = await this.getBrowser();
|
||||
signal?.throwIfAborted();
|
||||
const context = await browser.newContext({
|
||||
locale: request.payload.metadata.language || "zh-CN",
|
||||
serviceWorkers: "block"
|
||||
});
|
||||
const abort = () => {
|
||||
void closeContext(context);
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
|
||||
try {
|
||||
await context.route("**/*", async (route) => {
|
||||
if (
|
||||
isAllowedPdfRequestUrl(
|
||||
route.request().url(),
|
||||
this.renderOrigin
|
||||
)
|
||||
) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.abort("blockedbyclient");
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(this.renderUrl, {
|
||||
waitUntil: "domcontentloaded"
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.documentElement.dataset.runtimeReady === "true"
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return await captureDocxMediaWithPlaywrightPage(
|
||||
page,
|
||||
request,
|
||||
signal
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
await closeContext(context);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed = true;
|
||||
const browserPromise = this.browserPromise;
|
||||
this.browserPromise = undefined;
|
||||
if (!browserPromise) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const browser = await browserPromise;
|
||||
await browser.close();
|
||||
} catch {
|
||||
// 浏览器启动失败时没有可关闭的实例。
|
||||
}
|
||||
}
|
||||
|
||||
private getBrowser() {
|
||||
if (!this.browserPromise) {
|
||||
const browserPromise = this.launchBrowser();
|
||||
this.browserPromise = browserPromise;
|
||||
void browserPromise
|
||||
.then((browser) => {
|
||||
browser.on("disconnected", () => {
|
||||
if (this.browserPromise === browserPromise) {
|
||||
this.browserPromise = undefined;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.browserPromise === browserPromise) {
|
||||
this.browserPromise = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.browserPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDocxMediaEngine(
|
||||
options: PlaywrightDocxMediaEngineOptions = {}
|
||||
) {
|
||||
return new PlaywrightDocxMediaEngine(options);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { buildApp } from "./app.js";
|
||||
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const app = buildApp({
|
||||
prewarmPdfBrowser: true
|
||||
prewarmPdfBrowser: true,
|
||||
prewarmDocxRuntime: true
|
||||
});
|
||||
|
||||
async function start() {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
DocxExportServiceError,
|
||||
type DocxMediaCaptureAdapter
|
||||
} from "@md-to-pdf/application";
|
||||
import {
|
||||
DOCX_MIME_TYPE,
|
||||
defaultExportConfig,
|
||||
type DocxCapability,
|
||||
type DocxExportResult
|
||||
} from "@md-to-pdf/core";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import type { PdfGenerator } from "../src/pdf-engine.js";
|
||||
import type { ServerDocxMediaCaptureAdapter } from "../src/docx-media-engine.js";
|
||||
|
||||
let app: FastifyInstance | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close();
|
||||
app = undefined;
|
||||
});
|
||||
|
||||
const availableCapability: DocxCapability = {
|
||||
format: "docx",
|
||||
status: "available",
|
||||
expectedVersion: "3.9.0.2",
|
||||
detectedVersion: "3.9.0.2"
|
||||
};
|
||||
|
||||
function generatedDocx(): DocxExportResult {
|
||||
return {
|
||||
docx: new Uint8Array([80, 75, 3, 4]),
|
||||
fileName: "测试报告.docx",
|
||||
diagnostics: {
|
||||
warnings: ["字体可能被替换"],
|
||||
echartsErrors: ["图表 1"],
|
||||
mermaidErrors: []
|
||||
},
|
||||
timings: {
|
||||
queueMs: 1,
|
||||
probeMs: 2,
|
||||
prepareMs: 3,
|
||||
mediaMs: 4,
|
||||
referenceMs: 5,
|
||||
pandocMs: 6,
|
||||
validationMs: 7,
|
||||
totalMs: 28
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createDocxService(
|
||||
overrides: {
|
||||
capability?: DocxCapability;
|
||||
generate?: (request: unknown) => Promise<DocxExportResult>;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
getCapability: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
overrides.capability ?? availableCapability
|
||||
),
|
||||
generate: vi.fn(
|
||||
overrides.generate ?? (async () => generatedDocx())
|
||||
),
|
||||
close: vi.fn(async () => undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function createMediaAdapter() {
|
||||
return {
|
||||
capture: vi.fn<
|
||||
DocxMediaCaptureAdapter["capture"]
|
||||
>(async () => ({
|
||||
plan: {
|
||||
targets: [],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
},
|
||||
captures: []
|
||||
})),
|
||||
close: vi.fn(async () => undefined)
|
||||
} satisfies ServerDocxMediaCaptureAdapter;
|
||||
}
|
||||
|
||||
function createPdfGenerator() {
|
||||
return {
|
||||
generate: vi.fn(),
|
||||
close: vi.fn(async () => undefined)
|
||||
} as unknown as PdfGenerator;
|
||||
}
|
||||
|
||||
function createTestApp(
|
||||
service = createDocxService(),
|
||||
mediaAdapter = createMediaAdapter()
|
||||
) {
|
||||
app = buildApp({
|
||||
logger: false,
|
||||
pdfGenerator: createPdfGenerator(),
|
||||
docxExportService: service,
|
||||
docxMediaAdapter: mediaAdapter
|
||||
});
|
||||
return { app, service, mediaAdapter };
|
||||
}
|
||||
|
||||
describe("DOCX HTTP API", () => {
|
||||
it("在统一 capability 中报告 DOCX 运行时状态", async () => {
|
||||
const { app: instance, service } = createTestApp();
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "GET",
|
||||
url: "/api/capabilities"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().docx).toEqual(availableCapability);
|
||||
expect(response.json().implemented).toContain("docx-export");
|
||||
expect(service.getCapability).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("通过独立端点返回不可用 capability", async () => {
|
||||
const capability: DocxCapability = {
|
||||
format: "docx",
|
||||
status: "not-found",
|
||||
expectedVersion: "3.9.0.2",
|
||||
message: "未找到 Pandoc 运行时"
|
||||
};
|
||||
const { app: instance } = createTestApp(
|
||||
createDocxService({ capability })
|
||||
);
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "GET",
|
||||
url: "/api/docx/capability"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual(capability);
|
||||
});
|
||||
|
||||
it("生成 DOCX 并返回安全文件名、诊断和耗时", async () => {
|
||||
const { app: instance, service, mediaAdapter } =
|
||||
createTestApp();
|
||||
const payload = {
|
||||
markdown: "# 测试报告",
|
||||
fileName: "目录/测试报告.md",
|
||||
language: "zh-CN",
|
||||
resources: [],
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "POST",
|
||||
url: "/api/docx",
|
||||
payload
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain(
|
||||
DOCX_MIME_TYPE
|
||||
);
|
||||
expect(response.headers["content-disposition"]).toContain(
|
||||
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%8A%A5%E5%91%8A.docx"
|
||||
);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(response.headers["x-docx-warning-count"]).toBe("1");
|
||||
expect(response.headers["x-echarts-error-count"]).toBe("1");
|
||||
expect(response.headers["x-mermaid-error-count"]).toBe("0");
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"runtime-probe;dur=2.0"
|
||||
);
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"pandoc;dur=6.0"
|
||||
);
|
||||
expect(response.rawPayload).toEqual(
|
||||
Buffer.from([80, 75, 3, 4])
|
||||
);
|
||||
expect(service.generate).toHaveBeenCalledWith(payload, {
|
||||
mediaAdapter,
|
||||
signal: expect.any(AbortSignal)
|
||||
});
|
||||
});
|
||||
|
||||
it("映射应用请求错误", async () => {
|
||||
const service = createDocxService({
|
||||
generate: async () => {
|
||||
throw new ApplicationRequestError(
|
||||
400,
|
||||
"INVALID_EXPORT_CONFIG",
|
||||
"导出配置无效"
|
||||
);
|
||||
}
|
||||
});
|
||||
const { app: instance } = createTestApp(service);
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "POST",
|
||||
url: "/api/docx",
|
||||
payload: {}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({
|
||||
error: "INVALID_EXPORT_CONFIG",
|
||||
message: "导出配置无效",
|
||||
retryable: false
|
||||
});
|
||||
});
|
||||
|
||||
it("映射可重试的队列错误", async () => {
|
||||
const service = createDocxService({
|
||||
generate: async () => {
|
||||
throw new DocxExportServiceError(
|
||||
429,
|
||||
"DOCX_QUEUE_FULL",
|
||||
"DOCX 生成队列已满",
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
const { app: instance } = createTestApp(service);
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "POST",
|
||||
url: "/api/docx",
|
||||
payload: {}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(429);
|
||||
expect(response.headers["retry-after"]).toBe("5");
|
||||
expect(response.json()).toEqual({
|
||||
error: "DOCX_QUEUE_FULL",
|
||||
message: "DOCX 生成队列已满",
|
||||
retryable: true
|
||||
});
|
||||
});
|
||||
|
||||
it("关闭应用时按顺序释放共享服务与媒体浏览器", async () => {
|
||||
const service = createDocxService();
|
||||
const mediaAdapter = createMediaAdapter();
|
||||
const { app: instance } = createTestApp(
|
||||
service,
|
||||
mediaAdapter
|
||||
);
|
||||
|
||||
await instance.close();
|
||||
|
||||
expect(service.close).toHaveBeenCalledTimes(1);
|
||||
expect(mediaAdapter.close).toHaveBeenCalledTimes(1);
|
||||
app = undefined;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Browser } from "playwright";
|
||||
import {
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig,
|
||||
type DocxMediaCapturePlan
|
||||
} from "@md-to-pdf/core";
|
||||
import { PlaywrightDocxMediaEngine } from "../src/docx-media-engine.js";
|
||||
|
||||
const emptyPlan: DocxMediaCapturePlan = {
|
||||
targets: [],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
};
|
||||
|
||||
const request = {
|
||||
payload: createPagedDocumentPayload({
|
||||
document: {
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write"></article>',
|
||||
bodyHtml: "",
|
||||
metadata: {
|
||||
title: "",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: [],
|
||||
warnings: []
|
||||
},
|
||||
fileName: "test.md",
|
||||
themeCss: "",
|
||||
exportConfig: defaultExportConfig
|
||||
}),
|
||||
dimensions: {
|
||||
contentWidthPx: 600,
|
||||
contentHeightPx: 900
|
||||
}
|
||||
};
|
||||
|
||||
describe("Server DOCX 媒体浏览器", () => {
|
||||
it("使用请求级上下文并阻止非同源网络访问", async () => {
|
||||
const continueRoute = vi.fn(async () => undefined);
|
||||
const abortRoute = vi.fn(async () => undefined);
|
||||
const detach = vi.fn(async () => undefined);
|
||||
const page = {
|
||||
goto: vi.fn(async () => undefined),
|
||||
waitForFunction: vi.fn(async () => undefined),
|
||||
evaluate: vi.fn(async () => emptyPlan),
|
||||
context: () => context
|
||||
};
|
||||
const context = {
|
||||
route: vi.fn(
|
||||
async (
|
||||
_pattern: string,
|
||||
handler: (route: {
|
||||
request(): { url(): string };
|
||||
continue(): Promise<void>;
|
||||
abort(reason: string): Promise<void>;
|
||||
}) => Promise<void>
|
||||
) => {
|
||||
await handler({
|
||||
request: () => ({
|
||||
url: () => "http://renderer.test/assets/runtime.js"
|
||||
}),
|
||||
continue: continueRoute,
|
||||
abort: abortRoute
|
||||
});
|
||||
await handler({
|
||||
request: () => ({
|
||||
url: () => "https://external.test/tracker.js"
|
||||
}),
|
||||
continue: continueRoute,
|
||||
abort: abortRoute
|
||||
});
|
||||
}
|
||||
),
|
||||
newPage: vi.fn(async () => page),
|
||||
newCDPSession: vi.fn(async () => ({
|
||||
send: vi.fn(),
|
||||
detach
|
||||
})),
|
||||
close: vi.fn(async () => undefined)
|
||||
};
|
||||
const browser = {
|
||||
newContext: vi.fn(async () => context),
|
||||
on: vi.fn(),
|
||||
close: vi.fn(async () => undefined)
|
||||
} as unknown as Browser;
|
||||
const engine = new PlaywrightDocxMediaEngine({
|
||||
renderOrigin: "http://renderer.test",
|
||||
launchBrowser: vi.fn(async () => browser)
|
||||
});
|
||||
|
||||
const result = await engine.capture(request);
|
||||
|
||||
expect(result.plan).toEqual(emptyPlan);
|
||||
expect(browser.newContext).toHaveBeenCalledWith({
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block"
|
||||
});
|
||||
expect(page.goto).toHaveBeenCalledWith(
|
||||
"http://renderer.test/preview-frame.html?target=continuous",
|
||||
{ waitUntil: "domcontentloaded" }
|
||||
);
|
||||
expect(continueRoute).toHaveBeenCalledTimes(1);
|
||||
expect(abortRoute).toHaveBeenCalledWith("blockedbyclient");
|
||||
expect(detach).toHaveBeenCalledOnce();
|
||||
expect(context.close).toHaveBeenCalledOnce();
|
||||
|
||||
await engine.close();
|
||||
expect(browser.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("关闭后拒绝预热和媒体捕获", async () => {
|
||||
const engine = new PlaywrightDocxMediaEngine({
|
||||
renderOrigin: "http://renderer.test",
|
||||
launchBrowser: vi.fn()
|
||||
});
|
||||
|
||||
await engine.close();
|
||||
|
||||
await expect(engine.warmup()).rejects.toThrow(
|
||||
"DOCX 媒体服务已关闭"
|
||||
);
|
||||
await expect(engine.capture(request)).rejects.toThrow(
|
||||
"DOCX 媒体服务已关闭"
|
||||
);
|
||||
});
|
||||
});
|
||||
+196
-19
@@ -14,6 +14,7 @@ import {
|
||||
createPagedDocumentPayload,
|
||||
getPaperDimensionsMm,
|
||||
millimetersToCssPixels,
|
||||
type DocxCapability,
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
|
||||
@@ -77,6 +78,21 @@ import {
|
||||
type MarkdownEditorHandle
|
||||
} from "./MarkdownEditor";
|
||||
import { MarkdownToolbar } from "./MarkdownToolbar";
|
||||
import {
|
||||
createDocxDiagnosticsMessage,
|
||||
downloadDocx,
|
||||
getDocxCapability,
|
||||
requestDesktopDocxExport,
|
||||
requestDocxExport
|
||||
} from "./docx-export";
|
||||
import {
|
||||
ExportMenu,
|
||||
type ExportCommand
|
||||
} from "./ExportMenu";
|
||||
import {
|
||||
ExportToast,
|
||||
type ExportToastState
|
||||
} from "./ExportToast";
|
||||
|
||||
const PrecisePdfPreview = lazy(async () => {
|
||||
const module = await import("./PrecisePdfPreview");
|
||||
@@ -141,6 +157,12 @@ export function App() {
|
||||
const [paginationError, setPaginationError] = useState("");
|
||||
const [pdfError, setPdfError] = useState("");
|
||||
const [exportingPdf, setExportingPdf] = useState(false);
|
||||
const [exportingDocx, setExportingDocx] = useState(false);
|
||||
const [docxCapability, setDocxCapability] =
|
||||
useState<DocxCapability>();
|
||||
const [docxCapabilityError, setDocxCapabilityError] = useState("");
|
||||
const [exportToast, setExportToast] =
|
||||
useState<ExportToastState>();
|
||||
const [previewMode, setPreviewMode] =
|
||||
useState<PreviewMode>("quick");
|
||||
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
|
||||
@@ -168,6 +190,10 @@ export function App() {
|
||||
const documentDirtyRef = useRef(documentDirty);
|
||||
const themeRefreshTargetRef = useRef<number | undefined>(undefined);
|
||||
documentDirtyRef.current = documentDirty;
|
||||
const dismissExportToast = useCallback(
|
||||
() => setExportToast(undefined),
|
||||
[]
|
||||
);
|
||||
|
||||
function replaceMarkdownDocument(content: string) {
|
||||
setMarkdown(content);
|
||||
@@ -244,6 +270,31 @@ export function App() {
|
||||
);
|
||||
}, [documentDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getDocxCapability()
|
||||
.then((capability) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setDocxCapability(capability);
|
||||
setDocxCapabilityError("");
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setDocxCapabilityError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "无法检测 DOCX 导出能力"
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = window.mdToPdfDesktop;
|
||||
if (!bridge) {
|
||||
@@ -1226,9 +1277,15 @@ export function App() {
|
||||
|
||||
setExportingPdf(true);
|
||||
setPdfError("");
|
||||
setStatus(
|
||||
download ? "正在生成 PDF…" : "正在生成精确预览…"
|
||||
);
|
||||
if (download) {
|
||||
setExportToast({
|
||||
kind: "progress",
|
||||
title: "正在生成 PDF",
|
||||
message: "正在渲染页面、图表和字体,请稍候"
|
||||
});
|
||||
} else {
|
||||
setStatus("正在生成精确预览…");
|
||||
}
|
||||
const requestedKey = pdfCacheKey;
|
||||
try {
|
||||
const resolved = await getOrRequestPdfExport(
|
||||
@@ -1239,7 +1296,15 @@ export function App() {
|
||||
: undefined
|
||||
);
|
||||
if (pdfRequestKeyRef.current !== requestedKey) {
|
||||
setStatus("文档已更新,已丢弃过期 PDF");
|
||||
if (download) {
|
||||
setExportToast({
|
||||
kind: "error",
|
||||
title: "PDF 导出已停止",
|
||||
message: "文档已更新,请重新导出"
|
||||
});
|
||||
} else {
|
||||
setStatus("文档已更新,已丢弃过期 PDF");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setPdfCache(resolved);
|
||||
@@ -1247,7 +1312,7 @@ export function App() {
|
||||
if (download) {
|
||||
const saved = await downloadPdf(exported.blob, exported.fileName);
|
||||
if (!saved) {
|
||||
setStatus("已取消 PDF 保存");
|
||||
setExportToast(undefined);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1263,19 +1328,94 @@ export function App() {
|
||||
exported.echartsErrorCount > 0
|
||||
? `,${exported.echartsErrorCount} 个 ECharts 图表渲染失败`
|
||||
: "";
|
||||
setStatus(
|
||||
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`
|
||||
);
|
||||
const resultMessage =
|
||||
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`;
|
||||
if (download) {
|
||||
setExportToast({
|
||||
kind: "success",
|
||||
title: `PDF 已导出${pageDescription}`,
|
||||
message:
|
||||
echartsDescription || mermaidDescription
|
||||
? `${echartsDescription}${mermaidDescription}`.replace(
|
||||
/^,/u,
|
||||
""
|
||||
)
|
||||
: "文件已保存"
|
||||
});
|
||||
} else {
|
||||
setStatus(resultMessage);
|
||||
}
|
||||
} catch (reason) {
|
||||
setPdfError(
|
||||
reason instanceof Error ? reason.message : "PDF 导出失败"
|
||||
);
|
||||
setStatus(download ? "PDF 导出失败" : "精确预览失败");
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : "PDF 导出失败";
|
||||
if (download) {
|
||||
setExportToast({
|
||||
kind: "error",
|
||||
title: "PDF 导出失败",
|
||||
message
|
||||
});
|
||||
} else {
|
||||
setPdfError(message);
|
||||
setStatus("精确预览失败");
|
||||
}
|
||||
} finally {
|
||||
setExportingPdf(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateDocx() {
|
||||
if (
|
||||
exportingDocx ||
|
||||
docxCapability?.status !== "available"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setExportingDocx(true);
|
||||
setExportToast({
|
||||
kind: "progress",
|
||||
title: "正在生成 DOCX",
|
||||
message: "正在转换样式并将图片和图表渲染为 PNG"
|
||||
});
|
||||
try {
|
||||
const request = {
|
||||
markdown,
|
||||
fileName: effectiveFileName,
|
||||
language: "zh-CN",
|
||||
resources: noImageResources,
|
||||
exportConfig
|
||||
};
|
||||
let diagnostics;
|
||||
if (window.mdToPdfDesktop) {
|
||||
const result = await requestDesktopDocxExport(request);
|
||||
if (!result.saved) {
|
||||
setExportToast(undefined);
|
||||
return;
|
||||
}
|
||||
diagnostics = result;
|
||||
} else {
|
||||
const result = await requestDocxExport(request);
|
||||
downloadDocx(result);
|
||||
diagnostics = result;
|
||||
}
|
||||
setExportToast({
|
||||
kind: "success",
|
||||
title: "DOCX 已导出",
|
||||
message: createDocxDiagnosticsMessage(diagnostics)
|
||||
});
|
||||
} catch (reason) {
|
||||
setExportToast({
|
||||
kind: "error",
|
||||
title: "DOCX 导出失败",
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "DOCX 导出失败"
|
||||
});
|
||||
} finally {
|
||||
setExportingDocx(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewModeChange(mode: PreviewMode) {
|
||||
if (mode === previewMode) {
|
||||
return;
|
||||
@@ -1299,8 +1439,51 @@ export function App() {
|
||||
updatePreviewCurrentPage();
|
||||
}
|
||||
|
||||
const docxDisabledReason = !markdown.trim()
|
||||
? "文档内容为空"
|
||||
: docxCapabilityError
|
||||
? docxCapabilityError
|
||||
: !docxCapability
|
||||
? "正在检测 DOCX 导出能力"
|
||||
: docxCapability.status === "available"
|
||||
? undefined
|
||||
: docxCapability.message;
|
||||
const exportBusy = exportingPdf || exportingDocx;
|
||||
const exportCommands: ExportCommand[] = [
|
||||
{
|
||||
id: "pdf",
|
||||
label: "PDF",
|
||||
description: "固定版式,适合打印与归档",
|
||||
disabled: exportBusy || !markdown.trim(),
|
||||
disabledReason: !markdown.trim()
|
||||
? "文档内容为空"
|
||||
: exportBusy
|
||||
? "已有导出任务正在进行"
|
||||
: undefined,
|
||||
busy: exportingPdf,
|
||||
onSelect: () => generatePdf(true)
|
||||
},
|
||||
{
|
||||
id: "docx",
|
||||
label: "DOCX",
|
||||
description: "保留样式,可在 Word 与 WPS 中编辑",
|
||||
disabled: exportBusy || Boolean(docxDisabledReason),
|
||||
disabledReason: exportBusy
|
||||
? "已有导出任务正在进行"
|
||||
: docxDisabledReason,
|
||||
busy: exportingDocx,
|
||||
onSelect: generateDocx
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="workspace">
|
||||
{exportToast ? (
|
||||
<ExportToast
|
||||
toast={exportToast}
|
||||
onDismiss={dismissExportToast}
|
||||
/>
|
||||
) : null}
|
||||
<header className="topbar">
|
||||
<div className="topbar-primary">
|
||||
<div className="topbar-brand">
|
||||
@@ -1506,13 +1689,7 @@ export function App() {
|
||||
<button type="button" onClick={() => setSettingsOpen(true)}>
|
||||
导出设置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingPdf || !markdown.trim()}
|
||||
onClick={() => void generatePdf(true)}
|
||||
>
|
||||
{exportingPdf ? "正在导出…" : "导出 PDF"}
|
||||
</button>
|
||||
<ExportMenu commands={exportCommands} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface ExportCommand {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | undefined;
|
||||
busy?: boolean;
|
||||
onSelect(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ExportMenuProps {
|
||||
commands: ExportCommand[];
|
||||
}
|
||||
|
||||
export function ExportMenu({ commands }: ExportMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!menuRef.current?.contains(event.target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="export-menu more-menu" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
aria-controls="export-actions-menu"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
导出 ▾
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
id="export-actions-menu"
|
||||
className="export-menu-popover more-menu-popover"
|
||||
role="menu"
|
||||
aria-label="导出格式"
|
||||
>
|
||||
{commands.map((command) => (
|
||||
<button
|
||||
type="button"
|
||||
className="export-menu-command"
|
||||
role="menuitem"
|
||||
key={command.id}
|
||||
disabled={command.disabled}
|
||||
title={command.disabledReason}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void command.onSelect();
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>
|
||||
{command.busy
|
||||
? `正在生成 ${command.label}…`
|
||||
: `导出 ${command.label}`}
|
||||
</strong>
|
||||
<small>
|
||||
{command.disabledReason ?? command.description}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface ExportToastState {
|
||||
kind: "progress" | "success" | "error";
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ExportToastProps {
|
||||
toast: ExportToastState;
|
||||
onDismiss(): void;
|
||||
}
|
||||
|
||||
export function ExportToast({
|
||||
toast,
|
||||
onDismiss
|
||||
}: ExportToastProps) {
|
||||
useEffect(() => {
|
||||
if (toast.kind !== "success") {
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(onDismiss, 3_500);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [onDismiss, toast]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`export-toast is-${toast.kind}`}
|
||||
role={toast.kind === "error" ? "alert" : "status"}
|
||||
aria-live={toast.kind === "error" ? "assertive" : "polite"}
|
||||
>
|
||||
<div className="export-toast-content">
|
||||
<strong>{toast.title}</strong>
|
||||
{toast.message ? <span>{toast.message}</span> : null}
|
||||
</div>
|
||||
{toast.kind !== "progress" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="export-toast-close"
|
||||
aria-label="关闭导出提示"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
{toast.kind === "progress" ? (
|
||||
<div className="export-toast-progress" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
DOCX_MIME_TYPE,
|
||||
type DocxCapability,
|
||||
type DocxExportRequestInput
|
||||
} from "@md-to-pdf/core";
|
||||
import { parseContentDispositionFileName } from "./pdf-export";
|
||||
|
||||
export interface DocxExportResult {
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
warningCount: number;
|
||||
echartsErrorCount: number;
|
||||
mermaidErrorCount: number;
|
||||
}
|
||||
|
||||
export interface DesktopDocxExportResult {
|
||||
saved: boolean;
|
||||
fileName: string;
|
||||
warningCount: number;
|
||||
echartsErrorCount: number;
|
||||
mermaidErrorCount: number;
|
||||
}
|
||||
|
||||
function parseNumericHeader(value: string | null) {
|
||||
if (value === null) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const payload = (await response.json()) as {
|
||||
message?: unknown;
|
||||
};
|
||||
if (typeof payload.message === "string") {
|
||||
return payload.message;
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 错误响应使用通用提示。
|
||||
}
|
||||
return "DOCX 导出失败";
|
||||
}
|
||||
|
||||
export async function getDocxCapability(
|
||||
fetcher: typeof fetch = fetch
|
||||
): Promise<DocxCapability> {
|
||||
const bridge = window.mdToPdfDesktop;
|
||||
if (bridge) {
|
||||
return bridge.getDocxCapability();
|
||||
}
|
||||
const response = await fetcher("/api/docx/capability");
|
||||
if (!response.ok) {
|
||||
throw new Error("无法检测 DOCX 导出能力");
|
||||
}
|
||||
return (await response.json()) as DocxCapability;
|
||||
}
|
||||
|
||||
export async function requestDocxExport(
|
||||
request: DocxExportRequestInput,
|
||||
fetcher: typeof fetch = fetch
|
||||
): Promise<DocxExportResult> {
|
||||
const response = await fetcher("/api/docx", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
const contentType =
|
||||
response.headers.get("content-type")?.split(";")[0]?.trim();
|
||||
if (contentType !== DOCX_MIME_TYPE) {
|
||||
throw new Error("DOCX 服务返回了无效文件类型");
|
||||
}
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
fileName: parseContentDispositionFileName(
|
||||
response.headers.get("content-disposition"),
|
||||
"document.docx"
|
||||
),
|
||||
warningCount: parseNumericHeader(
|
||||
response.headers.get("x-docx-warning-count")
|
||||
),
|
||||
echartsErrorCount: parseNumericHeader(
|
||||
response.headers.get("x-echarts-error-count")
|
||||
),
|
||||
mermaidErrorCount: parseNumericHeader(
|
||||
response.headers.get("x-mermaid-error-count")
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestDesktopDocxExport(
|
||||
request: DocxExportRequestInput
|
||||
): Promise<DesktopDocxExportResult> {
|
||||
const bridge = window.mdToPdfDesktop;
|
||||
if (!bridge) {
|
||||
throw new Error("桌面 DOCX 引擎不可用");
|
||||
}
|
||||
const outcome = await bridge.exportDocx(request);
|
||||
if (!outcome.ok) {
|
||||
throw new Error(outcome.message);
|
||||
}
|
||||
return {
|
||||
saved: outcome.saved,
|
||||
fileName: outcome.fileName,
|
||||
warningCount: outcome.diagnostics.warnings.length,
|
||||
echartsErrorCount: outcome.diagnostics.echartsErrors.length,
|
||||
mermaidErrorCount: outcome.diagnostics.mermaidErrors.length
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadDocx(result: DocxExportResult) {
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = result.fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export function createDocxDiagnosticsMessage(result: {
|
||||
warningCount: number;
|
||||
echartsErrorCount: number;
|
||||
mermaidErrorCount: number;
|
||||
}) {
|
||||
const messages = [];
|
||||
if (result.warningCount > 0) {
|
||||
messages.push(`${result.warningCount} 条提示`);
|
||||
}
|
||||
if (result.echartsErrorCount > 0) {
|
||||
messages.push(`${result.echartsErrorCount} 个 ECharts 图表失败`);
|
||||
}
|
||||
if (result.mermaidErrorCount > 0) {
|
||||
messages.push(`${result.mermaidErrorCount} 个 Mermaid 图表失败`);
|
||||
}
|
||||
return messages.length > 0
|
||||
? messages.join(",")
|
||||
: "纸张、样式和可编辑结构已写入文档";
|
||||
}
|
||||
@@ -66,7 +66,8 @@ function parseNumericHeader(value: string | null) {
|
||||
}
|
||||
|
||||
export function parseContentDispositionFileName(
|
||||
contentDisposition: string | null
|
||||
contentDisposition: string | null,
|
||||
fallbackFileName = "document.pdf"
|
||||
) {
|
||||
const encoded = contentDisposition?.match(
|
||||
/filename\*=UTF-8''([^;]+)/i
|
||||
@@ -82,7 +83,7 @@ export function parseContentDispositionFileName(
|
||||
return (
|
||||
contentDisposition
|
||||
?.match(/filename="([^"]+)"/i)?.[1]
|
||||
?.trim() || "document.pdf"
|
||||
?.trim() || fallbackFileName
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -188,6 +188,45 @@ h1 {
|
||||
background: #edf3f0;
|
||||
}
|
||||
|
||||
.more-menu-popover button:disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.export-menu-popover {
|
||||
width: 286px;
|
||||
}
|
||||
|
||||
.more-menu-popover .export-menu-command {
|
||||
min-height: 58px;
|
||||
padding: 9px 11px;
|
||||
}
|
||||
|
||||
.export-menu-command > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.export-menu-command strong {
|
||||
color: inherit;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.export-menu-command small {
|
||||
overflow: hidden;
|
||||
color: #74827b;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.export-menu-command:disabled small {
|
||||
color: #929b97;
|
||||
}
|
||||
|
||||
.more-menu-popover .menu-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -254,6 +293,100 @@ h1 {
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.export-toast {
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: min(440px, calc(100vw - 32px));
|
||||
min-height: 62px;
|
||||
padding: 13px 46px 13px 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #bfd0c7;
|
||||
border-radius: 12px;
|
||||
background: rgb(255 255 255 / 97%);
|
||||
box-shadow: 0 14px 38px rgb(38 58 50 / 24%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.export-toast.is-success {
|
||||
border-color: #9dc5b0;
|
||||
}
|
||||
|
||||
.export-toast.is-error {
|
||||
border-color: #dfaaa7;
|
||||
background: rgb(255 250 249 / 98%);
|
||||
}
|
||||
|
||||
.export-toast-content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.export-toast-content strong {
|
||||
color: #2d493c;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.export-toast.is-error .export-toast-content strong {
|
||||
color: #943d39;
|
||||
}
|
||||
|
||||
.export-toast-content span {
|
||||
overflow: hidden;
|
||||
color: #64736b;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.export-toast-close {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #718078;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.export-toast-progress {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
background: #e2eae6;
|
||||
}
|
||||
|
||||
.export-toast-progress span {
|
||||
display: block;
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: #4d8b6e;
|
||||
animation: export-progress 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes export-progress {
|
||||
from {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(340%);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
||||
@@ -1363,6 +1496,11 @@ h1 {
|
||||
.editor-layout {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.export-toast-progress span {
|
||||
width: 100%;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -1381,6 +1519,11 @@ h1 {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.export-menu-popover {
|
||||
right: 0;
|
||||
width: min(286px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.more-menu-submenu {
|
||||
position: static;
|
||||
width: auto;
|
||||
|
||||
Vendored
+24
@@ -1,6 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type {
|
||||
DocxCapability,
|
||||
DocxExportDiagnostics,
|
||||
DocxExportErrorCode,
|
||||
DocxExportRequestInput,
|
||||
DocxGenerationTimings,
|
||||
DocxMediaCapturePlan,
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult,
|
||||
@@ -19,6 +24,21 @@ declare global {
|
||||
mermaidErrors: string[];
|
||||
}
|
||||
|
||||
type DesktopDocxExportOutcome =
|
||||
| {
|
||||
ok: true;
|
||||
saved: boolean;
|
||||
fileName: string;
|
||||
diagnostics: DocxExportDiagnostics;
|
||||
timings: DocxGenerationTimings;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: DocxExportErrorCode;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
};
|
||||
|
||||
interface DesktopApplicationBridge {
|
||||
openMarkdown(): Promise<
|
||||
| {
|
||||
@@ -77,6 +97,10 @@ declare global {
|
||||
getThemeCss(themeId: string): Promise<string>;
|
||||
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
|
||||
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
|
||||
getDocxCapability(): Promise<DocxCapability>;
|
||||
exportDocx(
|
||||
request: DocxExportRequestInput
|
||||
): Promise<DesktopDocxExportOutcome>;
|
||||
}
|
||||
|
||||
interface DesktopPdfRuntimeBridge {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DOCX_MIME_TYPE,
|
||||
defaultExportConfig,
|
||||
type DocxCapability
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createDocxDiagnosticsMessage,
|
||||
getDocxCapability,
|
||||
requestDesktopDocxExport,
|
||||
requestDocxExport
|
||||
} from "../src/docx-export";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const availableCapability: DocxCapability = {
|
||||
format: "docx",
|
||||
status: "available",
|
||||
expectedVersion: "3.9.0.2",
|
||||
detectedVersion: "3.9.0.2"
|
||||
};
|
||||
|
||||
const request = {
|
||||
markdown: "# 测试",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
resources: [],
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
|
||||
describe("DOCX 导出客户端", () => {
|
||||
it("在 Web 环境读取服务端 capability", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json(availableCapability)
|
||||
);
|
||||
|
||||
await expect(getDocxCapability(fetcher)).resolves.toEqual(
|
||||
availableCapability
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledWith("/api/docx/capability");
|
||||
});
|
||||
|
||||
it("在 Desktop 环境通过最小 IPC 读取 capability", async () => {
|
||||
const getDesktopCapability = vi.fn(
|
||||
async () => availableCapability
|
||||
);
|
||||
vi.stubGlobal("window", {
|
||||
mdToPdfDesktop: {
|
||||
getDocxCapability: getDesktopCapability
|
||||
}
|
||||
});
|
||||
const fetcher = vi.fn();
|
||||
|
||||
await expect(getDocxCapability(fetcher)).resolves.toEqual(
|
||||
availableCapability
|
||||
);
|
||||
expect(getDesktopCapability).toHaveBeenCalledOnce();
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("请求 DOCX 并读取文件名与诊断头", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
const fetcher = vi.fn(async () => {
|
||||
return new Response(new Blob(["PK-test"]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": DOCX_MIME_TYPE,
|
||||
"content-disposition":
|
||||
"attachment; filename=\"document.docx\"; " +
|
||||
"filename*=UTF-8''%E6%B5%8B%E8%AF%95.docx",
|
||||
"x-docx-warning-count": "2",
|
||||
"x-echarts-error-count": "1",
|
||||
"x-mermaid-error-count": "0"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const result = await requestDocxExport(request, fetcher);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
"/api/docx",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expect(result.fileName).toBe("测试.docx");
|
||||
expect(result.warningCount).toBe(2);
|
||||
expect(result.echartsErrorCount).toBe(1);
|
||||
expect(result.mermaidErrorCount).toBe(0);
|
||||
expect(await result.blob.text()).toBe("PK-test");
|
||||
});
|
||||
|
||||
it("拒绝服务端返回的非 DOCX 文件", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
const fetcher = vi.fn(async () =>
|
||||
new Response("error page", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" }
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestDocxExport(request, fetcher)
|
||||
).rejects.toThrow("DOCX 服务返回了无效文件类型");
|
||||
});
|
||||
|
||||
it("显示服务端结构化错误", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json(
|
||||
{ message: "Pandoc 版本不匹配" },
|
||||
{ status: 503 }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestDocxExport(request, fetcher)
|
||||
).rejects.toThrow("Pandoc 版本不匹配");
|
||||
});
|
||||
|
||||
it("Desktop 保存取消保持为正常结果", async () => {
|
||||
const exportDocx = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
saved: false,
|
||||
fileName: "测试.docx",
|
||||
diagnostics: {
|
||||
warnings: [],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
},
|
||||
timings: {
|
||||
queueMs: 0,
|
||||
probeMs: 0,
|
||||
prepareMs: 0,
|
||||
mediaMs: 0,
|
||||
referenceMs: 0,
|
||||
pandocMs: 0,
|
||||
validationMs: 0,
|
||||
totalMs: 0
|
||||
}
|
||||
}));
|
||||
vi.stubGlobal("window", {
|
||||
mdToPdfDesktop: { exportDocx }
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestDesktopDocxExport(request)
|
||||
).resolves.toEqual({
|
||||
saved: false,
|
||||
fileName: "测试.docx",
|
||||
warningCount: 0,
|
||||
echartsErrorCount: 0,
|
||||
mermaidErrorCount: 0
|
||||
});
|
||||
});
|
||||
|
||||
it("Desktop 失败结果转换为可展示错误", async () => {
|
||||
vi.stubGlobal("window", {
|
||||
mdToPdfDesktop: {
|
||||
exportDocx: vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: "DOCX_QUEUE_FULL" as const,
|
||||
message: "DOCX 生成队列已满",
|
||||
retryable: true
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestDesktopDocxExport(request)
|
||||
).rejects.toThrow("DOCX 生成队列已满");
|
||||
});
|
||||
|
||||
it("生成简洁的 DOCX 诊断摘要", () => {
|
||||
expect(
|
||||
createDocxDiagnosticsMessage({
|
||||
warningCount: 0,
|
||||
echartsErrorCount: 0,
|
||||
mermaidErrorCount: 0
|
||||
})
|
||||
).toBe("纸张、样式和可编辑结构已写入文档");
|
||||
expect(
|
||||
createDocxDiagnosticsMessage({
|
||||
warningCount: 2,
|
||||
echartsErrorCount: 1,
|
||||
mermaidErrorCount: 3
|
||||
})
|
||||
).toBe(
|
||||
"2 条提示,1 个 ECharts 图表失败,3 个 Mermaid 图表失败"
|
||||
);
|
||||
});
|
||||
});
|
||||
+10
-1
@@ -115,6 +115,15 @@ data 目录,完成后校验 OOXML 并清理。共享应用服务提供有界
|
||||
总超时、取消、关闭、capability、错误码和完整分阶段耗时;尚未新增
|
||||
HTTP 路由、Desktop IPC 或导出按钮。
|
||||
|
||||
`v0.6.0` 阶段 8 已完成 Web 与 Desktop DOCX 导出交互:Server 提供
|
||||
DOCX capability 与导出 HTTP API,Desktop 通过最小权限 IPC 在主进程
|
||||
完成生成和原生另存为,两端复用共享服务、固定 Pandoc、PNG 媒体管线和
|
||||
错误协议。顶栏原“导出 PDF”按钮升级为可扩展“导出”菜单,当前包含 PDF
|
||||
与 DOCX,后续可增加 HTML 等格式;导出进度、成功和失败使用独立顶部
|
||||
Toast 与非确定进度条,不污染预览状态或 PDF 精确预览缓存。Desktop
|
||||
退出会等待 PDF、DOCX、Pandoc 与隐藏媒体窗口完成清理,Lua Filter 作为
|
||||
明确构建资源进入桌面主进程目录。
|
||||
|
||||
## 2. 已完成
|
||||
|
||||
### 2.1 项目骨架
|
||||
@@ -1034,7 +1043,7 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
- 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器;
|
||||
- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
|
||||
- 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务;
|
||||
- 阶段 8:实现 Web/Desktop DOCX 导出交互;
|
||||
- 阶段 8:已实现 Web/Desktop DOCX 导出交互;
|
||||
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
||||
|
||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||
|
||||
Generated
+2
@@ -25,6 +25,7 @@
|
||||
"version": "0.5.1",
|
||||
"devDependencies": {
|
||||
"@md-to-pdf/application": "0.4.1",
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"electron": "43.2.0",
|
||||
"electron-builder": "26.15.3",
|
||||
@@ -524,6 +525,7 @@
|
||||
"dependencies": {
|
||||
"@md-to-pdf/application": "0.4.1",
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0",
|
||||
"fastify": "^5.6.2",
|
||||
"playwright": "1.62.0"
|
||||
|
||||
@@ -154,6 +154,7 @@ export const docxExportErrorCodeSchema = z.enum([
|
||||
"INVALID_EXPORT_CONFIG",
|
||||
"INVALID_FILE_NAME",
|
||||
"INVALID_MARKDOWN",
|
||||
"INVALID_FRONT_MATTER",
|
||||
"MARKDOWN_TOO_LARGE",
|
||||
"INVALID_IMAGE_RESOURCES",
|
||||
"IMAGE_RESOURCES_TOO_LARGE",
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface PandocDocxConversionResult {
|
||||
|
||||
export interface PandocDocxConverterOptions {
|
||||
runtime: PandocRuntimeProvider | PandocRuntime;
|
||||
luaFilterUrl?: URL;
|
||||
runner?: PandocProcessRunner;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
temporaryRoot?: string;
|
||||
@@ -102,13 +103,6 @@ export class PandocDocxConversionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
let luaFilterContent: Promise<Uint8Array> | undefined;
|
||||
|
||||
function loadLuaFilter() {
|
||||
luaFilterContent ??= readFile(luaFilterUrl);
|
||||
return luaFilterContent;
|
||||
}
|
||||
|
||||
function ensureOutputLimit(value: number | undefined) {
|
||||
const limit = value ?? MAXIMUM_DOCX_OUTPUT_BYTES;
|
||||
if (
|
||||
@@ -179,6 +173,7 @@ function pandocArguments(paths: {
|
||||
}
|
||||
|
||||
export class PandocDocxConverter {
|
||||
private luaFilterContent: Promise<Uint8Array> | undefined;
|
||||
private readonly runner: PandocProcessRunner;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly maximumOutputBytes: number;
|
||||
@@ -205,6 +200,13 @@ export class PandocDocxConverter {
|
||||
}
|
||||
}
|
||||
|
||||
private loadLuaFilter() {
|
||||
this.luaFilterContent ??= readFile(
|
||||
this.options.luaFilterUrl ?? luaFilterUrl
|
||||
);
|
||||
return this.luaFilterContent;
|
||||
}
|
||||
|
||||
async convert(
|
||||
input: PandocDocxConversionInput,
|
||||
signal?: AbortSignal
|
||||
@@ -232,7 +234,7 @@ export class PandocDocxConverter {
|
||||
await Promise.all([
|
||||
this.options.runtime.getExecutablePath(),
|
||||
this.options.runtime.getDefaultReferenceDocx(),
|
||||
loadLuaFilter()
|
||||
this.loadLuaFilter()
|
||||
]);
|
||||
const reference = this.getOrCreateReference(baseline, input);
|
||||
const referenceMs = performance.now() - referenceStarted;
|
||||
|
||||
Reference in New Issue
Block a user