feat: 实现 Web 与 Desktop DOCX 导出交互
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"electron": "43.2.0",
|
"electron": "43.2.0",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.15.3",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { build } from "esbuild";
|
import { build } from "esbuild";
|
||||||
|
import { copyFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
await build({
|
await build({
|
||||||
entryPoints: ["src/main.ts"],
|
entryPoints: ["src/main.ts"],
|
||||||
@@ -14,3 +16,15 @@ await build({
|
|||||||
].join("\n")
|
].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 { 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 type { MarkdownRenderRequest } from "@md-to-pdf/application";
|
||||||
import {
|
import {
|
||||||
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||||
DESKTOP_CREATE_NEW_WINDOW,
|
DESKTOP_CREATE_NEW_WINDOW,
|
||||||
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||||
DESKTOP_GET_THEME_CSS,
|
DESKTOP_GET_THEME_CSS,
|
||||||
|
DESKTOP_GET_DOCX_CAPABILITY,
|
||||||
|
DESKTOP_EXPORT_DOCX,
|
||||||
DESKTOP_GENERATE_PDF,
|
DESKTOP_GENERATE_PDF,
|
||||||
DESKTOP_LIST_THEMES,
|
DESKTOP_LIST_THEMES,
|
||||||
DESKTOP_MARKDOWN_OPENED,
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
@@ -77,5 +82,9 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
|||||||
generatePdf: (payload: PagedDocumentPayload) =>
|
generatePdf: (payload: PagedDocumentPayload) =>
|
||||||
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
|
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
|
||||||
savePdf: (fileName: string, pdf: Uint8Array) =>
|
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";
|
"md-to-pdf:desktop:generate-pdf";
|
||||||
export const DESKTOP_SAVE_PDF =
|
export const DESKTOP_SAVE_PDF =
|
||||||
"md-to-pdf: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 =
|
export const PDF_RUNTIME_READY =
|
||||||
"md-to-pdf:desktop-pdf:ready";
|
"md-to-pdf:desktop-pdf:ready";
|
||||||
export const PDF_RUNTIME_RENDER =
|
export const PDF_RUNTIME_RENDER =
|
||||||
|
|||||||
@@ -16,14 +16,21 @@ import {
|
|||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
|
DocxExportService,
|
||||||
MAXIMUM_MARKDOWN_LENGTH,
|
MAXIMUM_MARKDOWN_LENGTH,
|
||||||
|
readDocxExportRuntimeLimits,
|
||||||
type ApplicationService
|
type ApplicationService
|
||||||
} from "@md-to-pdf/application";
|
} from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
PandocDocxConverter,
|
||||||
|
PandocRuntime
|
||||||
|
} from "@md-to-pdf/docx-engine";
|
||||||
import {
|
import {
|
||||||
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||||
DESKTOP_CREATE_NEW_WINDOW,
|
DESKTOP_CREATE_NEW_WINDOW,
|
||||||
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||||
DESKTOP_GENERATE_PDF,
|
DESKTOP_GENERATE_PDF,
|
||||||
|
DESKTOP_GET_DOCX_CAPABILITY,
|
||||||
DESKTOP_GET_THEME_CSS,
|
DESKTOP_GET_THEME_CSS,
|
||||||
DESKTOP_LIST_THEMES,
|
DESKTOP_LIST_THEMES,
|
||||||
DESKTOP_MARKDOWN_OPENED,
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
@@ -36,10 +43,16 @@ import {
|
|||||||
DESKTOP_SAVE_MARKDOWN,
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
DESKTOP_SAVE_MARKDOWN_AS,
|
DESKTOP_SAVE_MARKDOWN_AS,
|
||||||
DESKTOP_SAVE_PDF,
|
DESKTOP_SAVE_PDF,
|
||||||
|
DESKTOP_EXPORT_DOCX,
|
||||||
DESKTOP_SET_DOCUMENT_DIRTY,
|
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||||
DESKTOP_START_NEW_MARKDOWN,
|
DESKTOP_START_NEW_MARKDOWN,
|
||||||
DESKTOP_WINDOW_CLOSE_REQUESTED
|
DESKTOP_WINDOW_CLOSE_REQUESTED
|
||||||
} from "./channels.js";
|
} from "./channels.js";
|
||||||
|
import {
|
||||||
|
toDesktopDocxExportFailure,
|
||||||
|
type DesktopDocxExportOutcome
|
||||||
|
} from "./docx-contract.js";
|
||||||
|
import { ElectronDocxMediaEngine } from "./electron-docx-media-engine.js";
|
||||||
import {
|
import {
|
||||||
parseMarkdownRenderRequest,
|
parseMarkdownRenderRequest,
|
||||||
parseThemeId
|
parseThemeId
|
||||||
@@ -93,6 +106,8 @@ export interface DesktopApplicationControllerOptions {
|
|||||||
renderUrl: string;
|
renderUrl: string;
|
||||||
preloadPath: string;
|
preloadPath: string;
|
||||||
pdfPreloadPath: string;
|
pdfPreloadPath: string;
|
||||||
|
docxRenderUrl: string;
|
||||||
|
desktopResourcesPath: string;
|
||||||
iconPath: string;
|
iconPath: string;
|
||||||
themeDirectory: string;
|
themeDirectory: string;
|
||||||
windowStatePath: string;
|
windowStatePath: string;
|
||||||
@@ -118,6 +133,8 @@ export class DesktopApplicationController {
|
|||||||
readonly #themeDirectory: string;
|
readonly #themeDirectory: string;
|
||||||
readonly #windowStatePath: string;
|
readonly #windowStatePath: string;
|
||||||
readonly #pdfGenerator: ElectronPdfGenerator;
|
readonly #pdfGenerator: ElectronPdfGenerator;
|
||||||
|
readonly #docxExportService: DocxExportService;
|
||||||
|
readonly #docxMediaEngine: ElectronDocxMediaEngine;
|
||||||
readonly #sessions = new Map<number, WindowSession>();
|
readonly #sessions = new Map<number, WindowSession>();
|
||||||
readonly #documentSessions = new Map<string, WindowSession>();
|
readonly #documentSessions = new Map<string, WindowSession>();
|
||||||
#primarySession: WindowSession | undefined;
|
#primarySession: WindowSession | undefined;
|
||||||
@@ -138,6 +155,24 @@ export class DesktopApplicationController {
|
|||||||
renderUrl: options.renderUrl,
|
renderUrl: options.renderUrl,
|
||||||
preloadPath: options.pdfPreloadPath
|
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() {
|
async initialize() {
|
||||||
@@ -279,7 +314,11 @@ export class DesktopApplicationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async closeResources() {
|
async closeResources() {
|
||||||
await this.#pdfGenerator.close();
|
await Promise.all([
|
||||||
|
this.#pdfGenerator.close(),
|
||||||
|
this.#docxExportService.close()
|
||||||
|
]);
|
||||||
|
await this.#docxMediaEngine.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
#resolveInitialBounds(isPrimary: boolean): Rectangle {
|
#resolveInitialBounds(isPrimary: boolean): Rectangle {
|
||||||
@@ -874,6 +913,100 @@ export class DesktopApplicationController {
|
|||||||
return true;
|
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(
|
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
|
DesktopApplicationController
|
||||||
} from "./desktop-application-controller.js";
|
} from "./desktop-application-controller.js";
|
||||||
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
|
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
|
||||||
|
import { DESKTOP_DOCX_PARTITION } from "./electron-docx-media-engine.js";
|
||||||
import {
|
import {
|
||||||
findMarkdownFileArgument,
|
findMarkdownFileArgument,
|
||||||
isMarkdownFilePath
|
isMarkdownFilePath
|
||||||
@@ -182,6 +183,9 @@ async function registerApplicationProtocol(
|
|||||||
protocol.handle(APP_SCHEME, handleRequest),
|
protocol.handle(APP_SCHEME, handleRequest),
|
||||||
session
|
session
|
||||||
.fromPartition(DESKTOP_PDF_PARTITION)
|
.fromPartition(DESKTOP_PDF_PARTITION)
|
||||||
|
.protocol.handle(APP_SCHEME, handleRequest),
|
||||||
|
session
|
||||||
|
.fromPartition(DESKTOP_DOCX_PARTITION)
|
||||||
.protocol.handle(APP_SCHEME, handleRequest)
|
.protocol.handle(APP_SCHEME, handleRequest)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -219,16 +223,16 @@ if (!hasSingleInstanceLock) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void applicationController.flushWindowState().finally(() => {
|
void Promise.all([
|
||||||
windowStateReadyToQuit = true;
|
applicationController.flushWindowState(),
|
||||||
app.quit();
|
applicationController.closeResources()
|
||||||
setTimeout(() => {
|
]).finally(() => {
|
||||||
windowStateReadyToQuit = false;
|
windowStateReadyToQuit = true;
|
||||||
}, 1_000);
|
app.quit();
|
||||||
});
|
setTimeout(() => {
|
||||||
});
|
windowStateReadyToQuit = false;
|
||||||
app.on("will-quit", () => {
|
}, 1_000);
|
||||||
void applicationController?.closeResources();
|
});
|
||||||
});
|
});
|
||||||
app
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
@@ -260,6 +264,11 @@ if (!hasSingleInstanceLock) {
|
|||||||
currentDirectory,
|
currentDirectory,
|
||||||
"pdf-preload.cjs"
|
"pdf-preload.cjs"
|
||||||
),
|
),
|
||||||
|
docxRenderUrl: new URL(
|
||||||
|
"/preview-frame.html?target=continuous",
|
||||||
|
applicationUrl
|
||||||
|
).href,
|
||||||
|
desktopResourcesPath: process.resourcesPath,
|
||||||
iconPath: getApplicationIcon(),
|
iconPath: getApplicationIcon(),
|
||||||
themeDirectory,
|
themeDirectory,
|
||||||
windowStatePath: path.join(
|
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"');
|
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 构建前复制样例并保留到运行镜像", () => {
|
it("在 Docker Web 构建前复制样例并保留到运行镜像", () => {
|
||||||
const dockerfile = readFileSync(
|
const dockerfile = readFileSync(
|
||||||
`${projectRoot}/deploy/Dockerfile`,
|
`${projectRoot}/deploy/Dockerfile`,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
"@md-to-pdf/renderer": "0.1.0",
|
"@md-to-pdf/renderer": "0.1.0",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"playwright": "1.62.0"
|
"playwright": "1.62.0"
|
||||||
|
|||||||
+180
-13
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import {
|
import {
|
||||||
EXPORT_CONFIG_VERSION,
|
EXPORT_CONFIG_VERSION,
|
||||||
|
DOCX_MIME_TYPE,
|
||||||
createPagedDocumentPayload,
|
createPagedDocumentPayload,
|
||||||
defaultExportConfig,
|
defaultExportConfig,
|
||||||
exportConfigSchema,
|
exportConfigSchema,
|
||||||
@@ -11,10 +12,21 @@ import {
|
|||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
ApplicationRequestError,
|
ApplicationRequestError,
|
||||||
|
DocxExportService,
|
||||||
|
DocxExportServiceError,
|
||||||
createApplicationService,
|
createApplicationService,
|
||||||
|
readDocxExportRuntimeLimits,
|
||||||
type ApplicationService
|
type ApplicationService
|
||||||
} from "@md-to-pdf/application";
|
} from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
PandocDocxConverter,
|
||||||
|
PandocRuntime
|
||||||
|
} from "@md-to-pdf/docx-engine";
|
||||||
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
|
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
|
||||||
|
import {
|
||||||
|
createDocxMediaEngine,
|
||||||
|
type ServerDocxMediaCaptureAdapter
|
||||||
|
} from "./docx-media-engine.js";
|
||||||
import {
|
import {
|
||||||
createPdfGenerator,
|
createPdfGenerator,
|
||||||
PdfEngineClosedError,
|
PdfEngineClosedError,
|
||||||
@@ -68,7 +80,13 @@ export interface BuildAppOptions {
|
|||||||
logger?: boolean;
|
logger?: boolean;
|
||||||
pdfGenerator?: PdfGenerator;
|
pdfGenerator?: PdfGenerator;
|
||||||
prewarmPdfBrowser?: boolean;
|
prewarmPdfBrowser?: boolean;
|
||||||
|
prewarmDocxRuntime?: boolean;
|
||||||
applicationService?: ApplicationService;
|
applicationService?: ApplicationService;
|
||||||
|
docxExportService?: Pick<
|
||||||
|
DocxExportService,
|
||||||
|
"getCapability" | "generate" | "close"
|
||||||
|
>;
|
||||||
|
docxMediaAdapter?: ServerDocxMediaCaptureAdapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
function milliseconds(value: number) {
|
function milliseconds(value: number) {
|
||||||
@@ -106,6 +124,30 @@ function createPdfServerTiming(
|
|||||||
].join(", ");
|
].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 = {}) {
|
export function buildApp(options: BuildAppOptions = {}) {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: options.logger ?? true,
|
logger: options.logger ?? true,
|
||||||
@@ -121,6 +163,21 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
});
|
});
|
||||||
const pdfGenerator =
|
const pdfGenerator =
|
||||||
options.pdfGenerator ?? createPdfGenerator();
|
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) {
|
if (options.prewarmPdfBrowser) {
|
||||||
app.addHook("onReady", async () => {
|
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 () => {
|
app.addHook("onClose", async () => {
|
||||||
await pdfGenerator.close();
|
await pdfGenerator.close();
|
||||||
|
await docxExportService.close();
|
||||||
|
await docxMediaAdapter.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/health", async () => ({
|
app.get("/api/health", async () => ({
|
||||||
@@ -143,19 +219,28 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
rendererVersion: RENDERER_VERSION
|
rendererVersion: RENDERER_VERSION
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/capabilities", async () => ({
|
app.get("/api/capabilities", async () => {
|
||||||
defaultExportConfig,
|
const docx = await docxExportService.getCapability();
|
||||||
supportedPaperFormats,
|
return {
|
||||||
implemented: [
|
defaultExportConfig,
|
||||||
"project-skeleton",
|
supportedPaperFormats,
|
||||||
"export-config",
|
docx,
|
||||||
"theme-manifest",
|
implemented: [
|
||||||
"markdown-render",
|
"project-skeleton",
|
||||||
"html-preview",
|
"export-config",
|
||||||
"pdf-export"
|
"theme-manifest",
|
||||||
],
|
"markdown-render",
|
||||||
planned: []
|
"html-preview",
|
||||||
}));
|
"pdf-export",
|
||||||
|
"docx-export"
|
||||||
|
],
|
||||||
|
planned: []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/docx/capability", async () =>
|
||||||
|
docxExportService.getCapability()
|
||||||
|
);
|
||||||
|
|
||||||
app.get("/api/themes", async () =>
|
app.get("/api/themes", async () =>
|
||||||
applicationService.listThemes()
|
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;
|
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 port = Number.parseInt(process.env.PORT ?? "3001", 10);
|
||||||
const host = process.env.HOST ?? "0.0.0.0";
|
const host = process.env.HOST ?? "0.0.0.0";
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
prewarmPdfBrowser: true
|
prewarmPdfBrowser: true,
|
||||||
|
prewarmDocxRuntime: true
|
||||||
});
|
});
|
||||||
|
|
||||||
async function start() {
|
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,
|
createPagedDocumentPayload,
|
||||||
getPaperDimensionsMm,
|
getPaperDimensionsMm,
|
||||||
millimetersToCssPixels,
|
millimetersToCssPixels,
|
||||||
|
type DocxCapability,
|
||||||
type ExportConfig
|
type ExportConfig
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
|
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
|
||||||
@@ -77,6 +78,21 @@ import {
|
|||||||
type MarkdownEditorHandle
|
type MarkdownEditorHandle
|
||||||
} from "./MarkdownEditor";
|
} from "./MarkdownEditor";
|
||||||
import { MarkdownToolbar } from "./MarkdownToolbar";
|
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 PrecisePdfPreview = lazy(async () => {
|
||||||
const module = await import("./PrecisePdfPreview");
|
const module = await import("./PrecisePdfPreview");
|
||||||
@@ -141,6 +157,12 @@ export function App() {
|
|||||||
const [paginationError, setPaginationError] = useState("");
|
const [paginationError, setPaginationError] = useState("");
|
||||||
const [pdfError, setPdfError] = useState("");
|
const [pdfError, setPdfError] = useState("");
|
||||||
const [exportingPdf, setExportingPdf] = useState(false);
|
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] =
|
const [previewMode, setPreviewMode] =
|
||||||
useState<PreviewMode>("quick");
|
useState<PreviewMode>("quick");
|
||||||
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
|
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
|
||||||
@@ -168,6 +190,10 @@ export function App() {
|
|||||||
const documentDirtyRef = useRef(documentDirty);
|
const documentDirtyRef = useRef(documentDirty);
|
||||||
const themeRefreshTargetRef = useRef<number | undefined>(undefined);
|
const themeRefreshTargetRef = useRef<number | undefined>(undefined);
|
||||||
documentDirtyRef.current = documentDirty;
|
documentDirtyRef.current = documentDirty;
|
||||||
|
const dismissExportToast = useCallback(
|
||||||
|
() => setExportToast(undefined),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
function replaceMarkdownDocument(content: string) {
|
function replaceMarkdownDocument(content: string) {
|
||||||
setMarkdown(content);
|
setMarkdown(content);
|
||||||
@@ -244,6 +270,31 @@ export function App() {
|
|||||||
);
|
);
|
||||||
}, [documentDirty]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const bridge = window.mdToPdfDesktop;
|
const bridge = window.mdToPdfDesktop;
|
||||||
if (!bridge) {
|
if (!bridge) {
|
||||||
@@ -1226,9 +1277,15 @@ export function App() {
|
|||||||
|
|
||||||
setExportingPdf(true);
|
setExportingPdf(true);
|
||||||
setPdfError("");
|
setPdfError("");
|
||||||
setStatus(
|
if (download) {
|
||||||
download ? "正在生成 PDF…" : "正在生成精确预览…"
|
setExportToast({
|
||||||
);
|
kind: "progress",
|
||||||
|
title: "正在生成 PDF",
|
||||||
|
message: "正在渲染页面、图表和字体,请稍候"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setStatus("正在生成精确预览…");
|
||||||
|
}
|
||||||
const requestedKey = pdfCacheKey;
|
const requestedKey = pdfCacheKey;
|
||||||
try {
|
try {
|
||||||
const resolved = await getOrRequestPdfExport(
|
const resolved = await getOrRequestPdfExport(
|
||||||
@@ -1239,7 +1296,15 @@ export function App() {
|
|||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
if (pdfRequestKeyRef.current !== requestedKey) {
|
if (pdfRequestKeyRef.current !== requestedKey) {
|
||||||
setStatus("文档已更新,已丢弃过期 PDF");
|
if (download) {
|
||||||
|
setExportToast({
|
||||||
|
kind: "error",
|
||||||
|
title: "PDF 导出已停止",
|
||||||
|
message: "文档已更新,请重新导出"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setStatus("文档已更新,已丢弃过期 PDF");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPdfCache(resolved);
|
setPdfCache(resolved);
|
||||||
@@ -1247,7 +1312,7 @@ export function App() {
|
|||||||
if (download) {
|
if (download) {
|
||||||
const saved = await downloadPdf(exported.blob, exported.fileName);
|
const saved = await downloadPdf(exported.blob, exported.fileName);
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
setStatus("已取消 PDF 保存");
|
setExportToast(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1263,19 +1328,94 @@ export function App() {
|
|||||||
exported.echartsErrorCount > 0
|
exported.echartsErrorCount > 0
|
||||||
? `,${exported.echartsErrorCount} 个 ECharts 图表渲染失败`
|
? `,${exported.echartsErrorCount} 个 ECharts 图表渲染失败`
|
||||||
: "";
|
: "";
|
||||||
setStatus(
|
const resultMessage =
|
||||||
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`
|
`${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) {
|
} catch (reason) {
|
||||||
setPdfError(
|
const message =
|
||||||
reason instanceof Error ? reason.message : "PDF 导出失败"
|
reason instanceof Error ? reason.message : "PDF 导出失败";
|
||||||
);
|
if (download) {
|
||||||
setStatus(download ? "PDF 导出失败" : "精确预览失败");
|
setExportToast({
|
||||||
|
kind: "error",
|
||||||
|
title: "PDF 导出失败",
|
||||||
|
message
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setPdfError(message);
|
||||||
|
setStatus("精确预览失败");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setExportingPdf(false);
|
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) {
|
function handlePreviewModeChange(mode: PreviewMode) {
|
||||||
if (mode === previewMode) {
|
if (mode === previewMode) {
|
||||||
return;
|
return;
|
||||||
@@ -1299,8 +1439,51 @@ export function App() {
|
|||||||
updatePreviewCurrentPage();
|
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 (
|
return (
|
||||||
<main className="workspace">
|
<main className="workspace">
|
||||||
|
{exportToast ? (
|
||||||
|
<ExportToast
|
||||||
|
toast={exportToast}
|
||||||
|
onDismiss={dismissExportToast}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-primary">
|
<div className="topbar-primary">
|
||||||
<div className="topbar-brand">
|
<div className="topbar-brand">
|
||||||
@@ -1506,13 +1689,7 @@ export function App() {
|
|||||||
<button type="button" onClick={() => setSettingsOpen(true)}>
|
<button type="button" onClick={() => setSettingsOpen(true)}>
|
||||||
导出设置
|
导出设置
|
||||||
</button>
|
</button>
|
||||||
<button
|
<ExportMenu commands={exportCommands} />
|
||||||
type="button"
|
|
||||||
disabled={exportingPdf || !markdown.trim()}
|
|
||||||
onClick={() => void generatePdf(true)}
|
|
||||||
>
|
|
||||||
{exportingPdf ? "正在导出…" : "导出 PDF"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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(
|
export function parseContentDispositionFileName(
|
||||||
contentDisposition: string | null
|
contentDisposition: string | null,
|
||||||
|
fallbackFileName = "document.pdf"
|
||||||
) {
|
) {
|
||||||
const encoded = contentDisposition?.match(
|
const encoded = contentDisposition?.match(
|
||||||
/filename\*=UTF-8''([^;]+)/i
|
/filename\*=UTF-8''([^;]+)/i
|
||||||
@@ -82,7 +83,7 @@ export function parseContentDispositionFileName(
|
|||||||
return (
|
return (
|
||||||
contentDisposition
|
contentDisposition
|
||||||
?.match(/filename="([^"]+)"/i)?.[1]
|
?.match(/filename="([^"]+)"/i)?.[1]
|
||||||
?.trim() || "document.pdf"
|
?.trim() || fallbackFileName
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,45 @@ h1 {
|
|||||||
background: #edf3f0;
|
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 {
|
.more-menu-popover .menu-command {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -254,6 +293,100 @@ h1 {
|
|||||||
letter-spacing: 0.06em;
|
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 {
|
.editor-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
||||||
@@ -1363,6 +1496,11 @@ h1 {
|
|||||||
.editor-layout {
|
.editor-layout {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-toast-progress span {
|
||||||
|
width: 100%;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
@@ -1381,6 +1519,11 @@ h1 {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-menu-popover {
|
||||||
|
right: 0;
|
||||||
|
width: min(286px, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
.more-menu-submenu {
|
.more-menu-submenu {
|
||||||
position: static;
|
position: static;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|||||||
Vendored
+24
@@ -1,6 +1,11 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
DocxCapability,
|
||||||
|
DocxExportDiagnostics,
|
||||||
|
DocxExportErrorCode,
|
||||||
|
DocxExportRequestInput,
|
||||||
|
DocxGenerationTimings,
|
||||||
DocxMediaCapturePlan,
|
DocxMediaCapturePlan,
|
||||||
PagedDocumentPayload,
|
PagedDocumentPayload,
|
||||||
PagedDocumentRenderResult,
|
PagedDocumentRenderResult,
|
||||||
@@ -19,6 +24,21 @@ declare global {
|
|||||||
mermaidErrors: string[];
|
mermaidErrors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DesktopDocxExportOutcome =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
saved: boolean;
|
||||||
|
fileName: string;
|
||||||
|
diagnostics: DocxExportDiagnostics;
|
||||||
|
timings: DocxGenerationTimings;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: DocxExportErrorCode;
|
||||||
|
message: string;
|
||||||
|
retryable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface DesktopApplicationBridge {
|
interface DesktopApplicationBridge {
|
||||||
openMarkdown(): Promise<
|
openMarkdown(): Promise<
|
||||||
| {
|
| {
|
||||||
@@ -77,6 +97,10 @@ declare global {
|
|||||||
getThemeCss(themeId: string): Promise<string>;
|
getThemeCss(themeId: string): Promise<string>;
|
||||||
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
|
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
|
||||||
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
|
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
|
||||||
|
getDocxCapability(): Promise<DocxCapability>;
|
||||||
|
exportDocx(
|
||||||
|
request: DocxExportRequestInput
|
||||||
|
): Promise<DesktopDocxExportOutcome>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopPdfRuntimeBridge {
|
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、错误码和完整分阶段耗时;尚未新增
|
总超时、取消、关闭、capability、错误码和完整分阶段耗时;尚未新增
|
||||||
HTTP 路由、Desktop IPC 或导出按钮。
|
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. 已完成
|
||||||
|
|
||||||
### 2.1 项目骨架
|
### 2.1 项目骨架
|
||||||
@@ -1034,7 +1043,7 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
- 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器;
|
- 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器;
|
||||||
- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
|
- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
|
||||||
- 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务;
|
- 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务;
|
||||||
- 阶段 8:实现 Web/Desktop DOCX 导出交互;
|
- 阶段 8:已实现 Web/Desktop DOCX 导出交互;
|
||||||
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
||||||
|
|
||||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||||
|
|||||||
Generated
+2
@@ -25,6 +25,7 @@
|
|||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"electron": "43.2.0",
|
"electron": "43.2.0",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.15.3",
|
||||||
@@ -524,6 +525,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
"@md-to-pdf/renderer": "0.1.0",
|
"@md-to-pdf/renderer": "0.1.0",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"playwright": "1.62.0"
|
"playwright": "1.62.0"
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export const docxExportErrorCodeSchema = z.enum([
|
|||||||
"INVALID_EXPORT_CONFIG",
|
"INVALID_EXPORT_CONFIG",
|
||||||
"INVALID_FILE_NAME",
|
"INVALID_FILE_NAME",
|
||||||
"INVALID_MARKDOWN",
|
"INVALID_MARKDOWN",
|
||||||
|
"INVALID_FRONT_MATTER",
|
||||||
"MARKDOWN_TOO_LARGE",
|
"MARKDOWN_TOO_LARGE",
|
||||||
"INVALID_IMAGE_RESOURCES",
|
"INVALID_IMAGE_RESOURCES",
|
||||||
"IMAGE_RESOURCES_TOO_LARGE",
|
"IMAGE_RESOURCES_TOO_LARGE",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export interface PandocDocxConversionResult {
|
|||||||
|
|
||||||
export interface PandocDocxConverterOptions {
|
export interface PandocDocxConverterOptions {
|
||||||
runtime: PandocRuntimeProvider | PandocRuntime;
|
runtime: PandocRuntimeProvider | PandocRuntime;
|
||||||
|
luaFilterUrl?: URL;
|
||||||
runner?: PandocProcessRunner;
|
runner?: PandocProcessRunner;
|
||||||
environment?: NodeJS.ProcessEnv;
|
environment?: NodeJS.ProcessEnv;
|
||||||
temporaryRoot?: string;
|
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) {
|
function ensureOutputLimit(value: number | undefined) {
|
||||||
const limit = value ?? MAXIMUM_DOCX_OUTPUT_BYTES;
|
const limit = value ?? MAXIMUM_DOCX_OUTPUT_BYTES;
|
||||||
if (
|
if (
|
||||||
@@ -179,6 +173,7 @@ function pandocArguments(paths: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PandocDocxConverter {
|
export class PandocDocxConverter {
|
||||||
|
private luaFilterContent: Promise<Uint8Array> | undefined;
|
||||||
private readonly runner: PandocProcessRunner;
|
private readonly runner: PandocProcessRunner;
|
||||||
private readonly timeoutMs: number;
|
private readonly timeoutMs: number;
|
||||||
private readonly maximumOutputBytes: 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(
|
async convert(
|
||||||
input: PandocDocxConversionInput,
|
input: PandocDocxConversionInput,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
@@ -232,7 +234,7 @@ export class PandocDocxConverter {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.options.runtime.getExecutablePath(),
|
this.options.runtime.getExecutablePath(),
|
||||||
this.options.runtime.getDefaultReferenceDocx(),
|
this.options.runtime.getDefaultReferenceDocx(),
|
||||||
loadLuaFilter()
|
this.loadLuaFilter()
|
||||||
]);
|
]);
|
||||||
const reference = this.getOrCreateReference(baseline, input);
|
const reference = this.getOrCreateReference(baseline, input);
|
||||||
const referenceMs = performance.now() - referenceStarted;
|
const referenceMs = performance.now() - referenceStarted;
|
||||||
|
|||||||
Reference in New Issue
Block a user