diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9ab4235..ac877dd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@md-to-pdf/application": "0.4.1", + "@md-to-pdf/docx-engine": "0.1.0", "@types/node": "^24.10.1", "electron": "43.2.0", "electron-builder": "26.15.3", diff --git a/apps/desktop/scripts/build-main.mjs b/apps/desktop/scripts/build-main.mjs index c8de872..3bdc56d 100644 --- a/apps/desktop/scripts/build-main.mjs +++ b/apps/desktop/scripts/build-main.mjs @@ -1,4 +1,6 @@ import { build } from "esbuild"; +import { copyFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; await build({ entryPoints: ["src/main.ts"], @@ -14,3 +16,15 @@ await build({ ].join("\n") } }); + +await copyFile( + fileURLToPath( + new URL( + "../../../packages/docx-engine/assets/docx-media-filter.lua", + import.meta.url + ) + ), + fileURLToPath( + new URL("../dist/docx-media-filter.lua", import.meta.url) + ) +); diff --git a/apps/desktop/src/app-preload.ts b/apps/desktop/src/app-preload.ts index 18163f2..f007933 100644 --- a/apps/desktop/src/app-preload.ts +++ b/apps/desktop/src/app-preload.ts @@ -1,11 +1,16 @@ import { contextBridge, ipcRenderer } from "electron"; -import type { PagedDocumentPayload } from "@md-to-pdf/core"; +import type { + DocxExportRequestInput, + PagedDocumentPayload +} from "@md-to-pdf/core"; import type { MarkdownRenderRequest } from "@md-to-pdf/application"; import { DESKTOP_CONSUME_PENDING_MARKDOWN, DESKTOP_CREATE_NEW_WINDOW, DESKTOP_DISCARD_PENDING_MARKDOWN, DESKTOP_GET_THEME_CSS, + DESKTOP_GET_DOCX_CAPABILITY, + DESKTOP_EXPORT_DOCX, DESKTOP_GENERATE_PDF, DESKTOP_LIST_THEMES, DESKTOP_MARKDOWN_OPENED, @@ -77,5 +82,9 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", { generatePdf: (payload: PagedDocumentPayload) => ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload), savePdf: (fileName: string, pdf: Uint8Array) => - ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf) + ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf), + getDocxCapability: () => + ipcRenderer.invoke(DESKTOP_GET_DOCX_CAPABILITY), + exportDocx: (request: DocxExportRequestInput) => + ipcRenderer.invoke(DESKTOP_EXPORT_DOCX, request) }); diff --git a/apps/desktop/src/channels.ts b/apps/desktop/src/channels.ts index 4063aa0..85ff990 100644 --- a/apps/desktop/src/channels.ts +++ b/apps/desktop/src/channels.ts @@ -36,6 +36,10 @@ export const DESKTOP_GENERATE_PDF = "md-to-pdf:desktop:generate-pdf"; export const DESKTOP_SAVE_PDF = "md-to-pdf:desktop:save-pdf"; +export const DESKTOP_GET_DOCX_CAPABILITY = + "md-to-pdf:desktop:get-docx-capability"; +export const DESKTOP_EXPORT_DOCX = + "md-to-pdf:desktop:export-docx"; export const PDF_RUNTIME_READY = "md-to-pdf:desktop-pdf:ready"; export const PDF_RUNTIME_RENDER = diff --git a/apps/desktop/src/desktop-application-controller.ts b/apps/desktop/src/desktop-application-controller.ts index 33bd2f9..4d5db00 100644 --- a/apps/desktop/src/desktop-application-controller.ts +++ b/apps/desktop/src/desktop-application-controller.ts @@ -16,14 +16,21 @@ import { } from "node:fs/promises"; import path from "node:path"; import { + DocxExportService, MAXIMUM_MARKDOWN_LENGTH, + readDocxExportRuntimeLimits, type ApplicationService } from "@md-to-pdf/application"; +import { + PandocDocxConverter, + PandocRuntime +} from "@md-to-pdf/docx-engine"; import { DESKTOP_CONSUME_PENDING_MARKDOWN, DESKTOP_CREATE_NEW_WINDOW, DESKTOP_DISCARD_PENDING_MARKDOWN, DESKTOP_GENERATE_PDF, + DESKTOP_GET_DOCX_CAPABILITY, DESKTOP_GET_THEME_CSS, DESKTOP_LIST_THEMES, DESKTOP_MARKDOWN_OPENED, @@ -36,10 +43,16 @@ import { DESKTOP_SAVE_MARKDOWN, DESKTOP_SAVE_MARKDOWN_AS, DESKTOP_SAVE_PDF, + DESKTOP_EXPORT_DOCX, DESKTOP_SET_DOCUMENT_DIRTY, DESKTOP_START_NEW_MARKDOWN, DESKTOP_WINDOW_CLOSE_REQUESTED } from "./channels.js"; +import { + toDesktopDocxExportFailure, + type DesktopDocxExportOutcome +} from "./docx-contract.js"; +import { ElectronDocxMediaEngine } from "./electron-docx-media-engine.js"; import { parseMarkdownRenderRequest, parseThemeId @@ -93,6 +106,8 @@ export interface DesktopApplicationControllerOptions { renderUrl: string; preloadPath: string; pdfPreloadPath: string; + docxRenderUrl: string; + desktopResourcesPath: string; iconPath: string; themeDirectory: string; windowStatePath: string; @@ -118,6 +133,8 @@ export class DesktopApplicationController { readonly #themeDirectory: string; readonly #windowStatePath: string; readonly #pdfGenerator: ElectronPdfGenerator; + readonly #docxExportService: DocxExportService; + readonly #docxMediaEngine: ElectronDocxMediaEngine; readonly #sessions = new Map(); readonly #documentSessions = new Map(); #primarySession: WindowSession | undefined; @@ -138,6 +155,24 @@ export class DesktopApplicationController { renderUrl: options.renderUrl, preloadPath: options.pdfPreloadPath }); + const pandocRuntime = new PandocRuntime({ + desktopResourcesPath: options.desktopResourcesPath + }); + this.#docxExportService = new DocxExportService({ + application: this.#applicationService, + runtime: pandocRuntime, + converter: new PandocDocxConverter({ + runtime: pandocRuntime, + luaFilterUrl: new URL( + "./docx-media-filter.lua", + import.meta.url + ) + }), + limits: readDocxExportRuntimeLimits() + }); + this.#docxMediaEngine = new ElectronDocxMediaEngine({ + renderUrl: options.docxRenderUrl + }); } async initialize() { @@ -279,7 +314,11 @@ export class DesktopApplicationController { } async closeResources() { - await this.#pdfGenerator.close(); + await Promise.all([ + this.#pdfGenerator.close(), + this.#docxExportService.close() + ]); + await this.#docxMediaEngine.close(); } #resolveInitialBounds(isPrimary: boolean): Rectangle { @@ -874,6 +913,100 @@ export class DesktopApplicationController { return true; } ); + + ipcMain.handle( + DESKTOP_GET_DOCX_CAPABILITY, + async (event) => { + this.#getSession(event); + return this.#docxExportService.getCapability(); + } + ); + + ipcMain.handle( + DESKTOP_EXPORT_DOCX, + async ( + event, + unsafeRequest: unknown + ): Promise => { + 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( diff --git a/apps/desktop/src/docx-contract.ts b/apps/desktop/src/docx-contract.ts new file mode 100644 index 0000000..7f8aad0 --- /dev/null +++ b/apps/desktop/src/docx-contract.ts @@ -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 + }; +} diff --git a/apps/desktop/src/electron-docx-media-engine.ts b/apps/desktop/src/electron-docx-media-engine.ts new file mode 100644 index 0000000..22ca0dc --- /dev/null +++ b/apps/desktop/src/electron-docx-media-engine.ts @@ -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 | 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; + } + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 908f532..0100e77 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,6 +17,7 @@ import { DesktopApplicationController } from "./desktop-application-controller.js"; import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js"; +import { DESKTOP_DOCX_PARTITION } from "./electron-docx-media-engine.js"; import { findMarkdownFileArgument, isMarkdownFilePath @@ -182,6 +183,9 @@ async function registerApplicationProtocol( protocol.handle(APP_SCHEME, handleRequest), session .fromPartition(DESKTOP_PDF_PARTITION) + .protocol.handle(APP_SCHEME, handleRequest), + session + .fromPartition(DESKTOP_DOCX_PARTITION) .protocol.handle(APP_SCHEME, handleRequest) ]); } @@ -219,16 +223,16 @@ if (!hasSingleInstanceLock) { return; } event.preventDefault(); - void applicationController.flushWindowState().finally(() => { - windowStateReadyToQuit = true; - app.quit(); - setTimeout(() => { - windowStateReadyToQuit = false; - }, 1_000); - }); - }); - app.on("will-quit", () => { - void applicationController?.closeResources(); + void Promise.all([ + applicationController.flushWindowState(), + applicationController.closeResources() + ]).finally(() => { + windowStateReadyToQuit = true; + app.quit(); + setTimeout(() => { + windowStateReadyToQuit = false; + }, 1_000); + }); }); app .whenReady() @@ -260,6 +264,11 @@ if (!hasSingleInstanceLock) { currentDirectory, "pdf-preload.cjs" ), + docxRenderUrl: new URL( + "/preview-frame.html?target=continuous", + applicationUrl + ).href, + desktopResourcesPath: process.resourcesPath, iconPath: getApplicationIcon(), themeDirectory, windowStatePath: path.join( diff --git a/apps/desktop/tests/docx-contract.test.ts b/apps/desktop/tests/docx-contract.test.ts new file mode 100644 index 0000000..5575c57 --- /dev/null +++ b/apps/desktop/tests/docx-contract.test.ts @@ -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 + }); + }); +}); diff --git a/apps/desktop/tests/release-build.test.ts b/apps/desktop/tests/release-build.test.ts index fb06865..01b2d81 100644 --- a/apps/desktop/tests/release-build.test.ts +++ b/apps/desktop/tests/release-build.test.ts @@ -58,6 +58,18 @@ describe("桌面发行构建链", () => { expect(builderConfig).toContain('to: "samples"'); }); + it("将 DOCX Lua Filter 复制到桌面主进程资源目录", () => { + const buildScript = readFileSync( + `${desktopRoot}/scripts/build-main.mjs`, + "utf8" + ); + + expect(buildScript).toContain( + "packages/docx-engine/assets/docx-media-filter.lua" + ); + expect(buildScript).toContain("dist/docx-media-filter.lua"); + }); + it("在 Docker Web 构建前复制样例并保留到运行镜像", () => { const dockerfile = readFileSync( `${projectRoot}/deploy/Dockerfile`, diff --git a/apps/server/package.json b/apps/server/package.json index 90daf8c..501cf2b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -13,6 +13,7 @@ "dependencies": { "@md-to-pdf/application": "0.4.1", "@md-to-pdf/core": "0.1.0", + "@md-to-pdf/docx-engine": "0.1.0", "@md-to-pdf/renderer": "0.1.0", "fastify": "^5.6.2", "playwright": "1.62.0" diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index d651749..cdd0276 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks"; import Fastify from "fastify"; import { EXPORT_CONFIG_VERSION, + DOCX_MIME_TYPE, createPagedDocumentPayload, defaultExportConfig, exportConfigSchema, @@ -11,10 +12,21 @@ import { } from "@md-to-pdf/core"; import { ApplicationRequestError, + DocxExportService, + DocxExportServiceError, createApplicationService, + readDocxExportRuntimeLimits, type ApplicationService } from "@md-to-pdf/application"; +import { + PandocDocxConverter, + PandocRuntime +} from "@md-to-pdf/docx-engine"; import { RENDERER_VERSION } from "@md-to-pdf/renderer"; +import { + createDocxMediaEngine, + type ServerDocxMediaCaptureAdapter +} from "./docx-media-engine.js"; import { createPdfGenerator, PdfEngineClosedError, @@ -68,7 +80,13 @@ export interface BuildAppOptions { logger?: boolean; pdfGenerator?: PdfGenerator; prewarmPdfBrowser?: boolean; + prewarmDocxRuntime?: boolean; applicationService?: ApplicationService; + docxExportService?: Pick< + DocxExportService, + "getCapability" | "generate" | "close" + >; + docxMediaAdapter?: ServerDocxMediaCaptureAdapter; } function milliseconds(value: number) { @@ -106,6 +124,30 @@ function createPdfServerTiming( ].join(", "); } +function createDocxServerTiming( + timings: Awaited< + ReturnType + >["timings"] +) { + return [ + `queue;dur=${milliseconds(timings.queueMs)}`, + `runtime-probe;dur=${milliseconds(timings.probeMs)}`, + `prepare;dur=${milliseconds(timings.prepareMs)}`, + `media;dur=${milliseconds(timings.mediaMs)}`, + `reference;dur=${milliseconds(timings.referenceMs)}`, + `pandoc;dur=${milliseconds(timings.pandocMs)}`, + `validation;dur=${milliseconds(timings.validationMs)}`, + `total;dur=${milliseconds(timings.totalMs)}` + ].join(", "); +} + +export function createDocxContentDisposition(fileName: string) { + return ( + `attachment; filename="document.docx"; ` + + `filename*=UTF-8''${encodeRfc5987(fileName)}` + ); +} + export function buildApp(options: BuildAppOptions = {}) { const app = Fastify({ logger: options.logger ?? true, @@ -121,6 +163,21 @@ export function buildApp(options: BuildAppOptions = {}) { }); const pdfGenerator = options.pdfGenerator ?? createPdfGenerator(); + const pandocRuntime = options.docxExportService + ? undefined + : new PandocRuntime(); + const docxExportService = + options.docxExportService ?? + new DocxExportService({ + application: applicationService, + runtime: pandocRuntime!, + converter: new PandocDocxConverter({ + runtime: pandocRuntime! + }), + limits: readDocxExportRuntimeLimits() + }); + const docxMediaAdapter = + options.docxMediaAdapter ?? createDocxMediaEngine(); if (options.prewarmPdfBrowser) { app.addHook("onReady", async () => { @@ -132,8 +189,27 @@ export function buildApp(options: BuildAppOptions = {}) { }); } + if (options.prewarmDocxRuntime) { + app.addHook("onReady", async () => { + try { + const capability = + await docxExportService.getCapability(); + if (capability.status !== "available") { + app.log.warn( + { capability }, + "DOCX runtime unavailable" + ); + } + } catch (error) { + app.log.warn({ error }, "DOCX runtime probe failed"); + } + }); + } + app.addHook("onClose", async () => { await pdfGenerator.close(); + await docxExportService.close(); + await docxMediaAdapter.close(); }); app.get("/api/health", async () => ({ @@ -143,19 +219,28 @@ export function buildApp(options: BuildAppOptions = {}) { rendererVersion: RENDERER_VERSION })); - app.get("/api/capabilities", async () => ({ - defaultExportConfig, - supportedPaperFormats, - implemented: [ - "project-skeleton", - "export-config", - "theme-manifest", - "markdown-render", - "html-preview", - "pdf-export" - ], - planned: [] - })); + app.get("/api/capabilities", async () => { + const docx = await docxExportService.getCapability(); + return { + defaultExportConfig, + supportedPaperFormats, + docx, + implemented: [ + "project-skeleton", + "export-config", + "theme-manifest", + "markdown-render", + "html-preview", + "pdf-export", + "docx-export" + ], + planned: [] + }; + }); + + app.get("/api/docx/capability", async () => + docxExportService.getCapability() + ); app.get("/api/themes", async () => applicationService.listThemes() @@ -364,5 +449,87 @@ export function buildApp(options: BuildAppOptions = {}) { } ); + app.post<{ Body: unknown }>("/api/docx", async (request, reply) => { + const abortController = new AbortController(); + const abortRequest = () => + abortController.abort(new Error("HTTP 请求已中断")); + request.raw.once("aborted", abortRequest); + + try { + const generated = await docxExportService.generate( + request.body ?? {}, + { + mediaAdapter: docxMediaAdapter, + signal: abortController.signal + } + ); + request.log.info( + { + fileName: generated.fileName, + bytes: generated.docx.byteLength, + diagnostics: generated.diagnostics, + timings: generated.timings + }, + "DOCX generated" + ); + return reply + .header("content-type", DOCX_MIME_TYPE) + .header( + "content-disposition", + createDocxContentDisposition(generated.fileName) + ) + .header("cache-control", "no-store") + .header( + "server-timing", + createDocxServerTiming(generated.timings) + ) + .header( + "x-docx-warning-count", + String(generated.diagnostics.warnings.length) + ) + .header( + "x-echarts-error-count", + String(generated.diagnostics.echartsErrors.length) + ) + .header( + "x-mermaid-error-count", + String(generated.diagnostics.mermaidErrors.length) + ) + .send( + Buffer.from( + generated.docx.buffer, + generated.docx.byteOffset, + generated.docx.byteLength + ) + ); + } catch (error) { + if (error instanceof ApplicationRequestError) { + return reply.code(error.statusCode).send({ + error: error.code, + message: error.message, + retryable: false + }); + } + if (error instanceof DocxExportServiceError) { + if (error.retryable) { + reply.header("retry-after", "5"); + } + return reply.code(error.statusCode).send({ + error: error.code, + message: error.message, + retryable: error.retryable + }); + } + request.log.error({ error }, "DOCX generation failed"); + return reply.code(500).send({ + error: "DOCX_GENERATION_FAILED", + message: "DOCX 生成失败", + retryable: false + }); + } finally { + request.raw.removeListener("aborted", abortRequest); + } + }); + return app; } diff --git a/apps/server/src/docx-media-engine.ts b/apps/server/src/docx-media-engine.ts new file mode 100644 index 0000000..971102c --- /dev/null +++ b/apps/server/src/docx-media-engine.ts @@ -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; + close(): Promise; +} + +export interface PlaywrightDocxMediaEngineOptions { + renderOrigin?: string; + launchBrowser?: () => Promise; +} + +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; + private browserPromise: Promise | 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); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 17f6af7..f254c65 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,7 +3,8 @@ import { buildApp } from "./app.js"; const port = Number.parseInt(process.env.PORT ?? "3001", 10); const host = process.env.HOST ?? "0.0.0.0"; const app = buildApp({ - prewarmPdfBrowser: true + prewarmPdfBrowser: true, + prewarmDocxRuntime: true }); async function start() { diff --git a/apps/server/tests/docx-api.test.ts b/apps/server/tests/docx-api.test.ts new file mode 100644 index 0000000..85de7d9 --- /dev/null +++ b/apps/server/tests/docx-api.test.ts @@ -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; + } = {} +) { + 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; + }); +}); diff --git a/apps/server/tests/docx-media-engine.test.ts b/apps/server/tests/docx-media-engine.test.ts new file mode 100644 index 0000000..b11ae38 --- /dev/null +++ b/apps/server/tests/docx-media-engine.test.ts @@ -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: '
', + 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; + abort(reason: string): Promise; + }) => Promise + ) => { + 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 媒体服务已关闭" + ); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f322a6c..67e9d5a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -14,6 +14,7 @@ import { createPagedDocumentPayload, getPaperDimensionsMm, millimetersToCssPixels, + type DocxCapability, type ExportConfig } from "@md-to-pdf/core"; import { ExportSettingsDrawer } from "./ExportSettingsDrawer"; @@ -77,6 +78,21 @@ import { type MarkdownEditorHandle } from "./MarkdownEditor"; import { MarkdownToolbar } from "./MarkdownToolbar"; +import { + createDocxDiagnosticsMessage, + downloadDocx, + getDocxCapability, + requestDesktopDocxExport, + requestDocxExport +} from "./docx-export"; +import { + ExportMenu, + type ExportCommand +} from "./ExportMenu"; +import { + ExportToast, + type ExportToastState +} from "./ExportToast"; const PrecisePdfPreview = lazy(async () => { const module = await import("./PrecisePdfPreview"); @@ -141,6 +157,12 @@ export function App() { const [paginationError, setPaginationError] = useState(""); const [pdfError, setPdfError] = useState(""); const [exportingPdf, setExportingPdf] = useState(false); + const [exportingDocx, setExportingDocx] = useState(false); + const [docxCapability, setDocxCapability] = + useState(); + const [docxCapabilityError, setDocxCapabilityError] = useState(""); + const [exportToast, setExportToast] = + useState(); const [previewMode, setPreviewMode] = useState("quick"); const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom); @@ -168,6 +190,10 @@ export function App() { const documentDirtyRef = useRef(documentDirty); const themeRefreshTargetRef = useRef(undefined); documentDirtyRef.current = documentDirty; + const dismissExportToast = useCallback( + () => setExportToast(undefined), + [] + ); function replaceMarkdownDocument(content: string) { setMarkdown(content); @@ -244,6 +270,31 @@ export function App() { ); }, [documentDirty]); + useEffect(() => { + let active = true; + void getDocxCapability() + .then((capability) => { + if (!active) { + return; + } + setDocxCapability(capability); + setDocxCapabilityError(""); + }) + .catch((reason: unknown) => { + if (!active) { + return; + } + setDocxCapabilityError( + reason instanceof Error + ? reason.message + : "无法检测 DOCX 导出能力" + ); + }); + return () => { + active = false; + }; + }, []); + useEffect(() => { const bridge = window.mdToPdfDesktop; if (!bridge) { @@ -1226,9 +1277,15 @@ export function App() { setExportingPdf(true); setPdfError(""); - setStatus( - download ? "正在生成 PDF…" : "正在生成精确预览…" - ); + if (download) { + setExportToast({ + kind: "progress", + title: "正在生成 PDF", + message: "正在渲染页面、图表和字体,请稍候" + }); + } else { + setStatus("正在生成精确预览…"); + } const requestedKey = pdfCacheKey; try { const resolved = await getOrRequestPdfExport( @@ -1239,7 +1296,15 @@ export function App() { : undefined ); if (pdfRequestKeyRef.current !== requestedKey) { - setStatus("文档已更新,已丢弃过期 PDF"); + if (download) { + setExportToast({ + kind: "error", + title: "PDF 导出已停止", + message: "文档已更新,请重新导出" + }); + } else { + setStatus("文档已更新,已丢弃过期 PDF"); + } return; } setPdfCache(resolved); @@ -1247,7 +1312,7 @@ export function App() { if (download) { const saved = await downloadPdf(exported.blob, exported.fileName); if (!saved) { - setStatus("已取消 PDF 保存"); + setExportToast(undefined); return; } } @@ -1263,19 +1328,94 @@ export function App() { exported.echartsErrorCount > 0 ? `,${exported.echartsErrorCount} 个 ECharts 图表渲染失败` : ""; - setStatus( - `${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}` - ); + const resultMessage = + `${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`; + if (download) { + setExportToast({ + kind: "success", + title: `PDF 已导出${pageDescription}`, + message: + echartsDescription || mermaidDescription + ? `${echartsDescription}${mermaidDescription}`.replace( + /^,/u, + "" + ) + : "文件已保存" + }); + } else { + setStatus(resultMessage); + } } catch (reason) { - setPdfError( - reason instanceof Error ? reason.message : "PDF 导出失败" - ); - setStatus(download ? "PDF 导出失败" : "精确预览失败"); + const message = + reason instanceof Error ? reason.message : "PDF 导出失败"; + if (download) { + setExportToast({ + kind: "error", + title: "PDF 导出失败", + message + }); + } else { + setPdfError(message); + setStatus("精确预览失败"); + } } finally { setExportingPdf(false); } } + async function generateDocx() { + if ( + exportingDocx || + docxCapability?.status !== "available" + ) { + return; + } + setExportingDocx(true); + setExportToast({ + kind: "progress", + title: "正在生成 DOCX", + message: "正在转换样式并将图片和图表渲染为 PNG" + }); + try { + const request = { + markdown, + fileName: effectiveFileName, + language: "zh-CN", + resources: noImageResources, + exportConfig + }; + let diagnostics; + if (window.mdToPdfDesktop) { + const result = await requestDesktopDocxExport(request); + if (!result.saved) { + setExportToast(undefined); + return; + } + diagnostics = result; + } else { + const result = await requestDocxExport(request); + downloadDocx(result); + diagnostics = result; + } + setExportToast({ + kind: "success", + title: "DOCX 已导出", + message: createDocxDiagnosticsMessage(diagnostics) + }); + } catch (reason) { + setExportToast({ + kind: "error", + title: "DOCX 导出失败", + message: + reason instanceof Error + ? reason.message + : "DOCX 导出失败" + }); + } finally { + setExportingDocx(false); + } + } + function handlePreviewModeChange(mode: PreviewMode) { if (mode === previewMode) { return; @@ -1299,8 +1439,51 @@ export function App() { updatePreviewCurrentPage(); } + const docxDisabledReason = !markdown.trim() + ? "文档内容为空" + : docxCapabilityError + ? docxCapabilityError + : !docxCapability + ? "正在检测 DOCX 导出能力" + : docxCapability.status === "available" + ? undefined + : docxCapability.message; + const exportBusy = exportingPdf || exportingDocx; + const exportCommands: ExportCommand[] = [ + { + id: "pdf", + label: "PDF", + description: "固定版式,适合打印与归档", + disabled: exportBusy || !markdown.trim(), + disabledReason: !markdown.trim() + ? "文档内容为空" + : exportBusy + ? "已有导出任务正在进行" + : undefined, + busy: exportingPdf, + onSelect: () => generatePdf(true) + }, + { + id: "docx", + label: "DOCX", + description: "保留样式,可在 Word 与 WPS 中编辑", + disabled: exportBusy || Boolean(docxDisabledReason), + disabledReason: exportBusy + ? "已有导出任务正在进行" + : docxDisabledReason, + busy: exportingDocx, + onSelect: generateDocx + } + ]; + return (
+ {exportToast ? ( + + ) : null}
@@ -1506,13 +1689,7 @@ export function App() { - +
diff --git a/apps/web/src/ExportMenu.tsx b/apps/web/src/ExportMenu.tsx new file mode 100644 index 0000000..ec96472 --- /dev/null +++ b/apps/web/src/ExportMenu.tsx @@ -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; +} + +export interface ExportMenuProps { + commands: ExportCommand[]; +} + +export function ExportMenu({ commands }: ExportMenuProps) { + const [open, setOpen] = useState(false); + const menuRef = useRef(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 ( +
+ + {open ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/ExportToast.tsx b/apps/web/src/ExportToast.tsx new file mode 100644 index 0000000..d1c2409 --- /dev/null +++ b/apps/web/src/ExportToast.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/docx-export.ts b/apps/web/src/docx-export.ts new file mode 100644 index 0000000..d2cb8e3 --- /dev/null +++ b/apps/web/src/docx-export.ts @@ -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 { + 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 { + 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 { + 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(",") + : "纸张、样式和可编辑结构已写入文档"; +} diff --git a/apps/web/src/pdf-export.ts b/apps/web/src/pdf-export.ts index f0ac65f..3fd3b6f 100644 --- a/apps/web/src/pdf-export.ts +++ b/apps/web/src/pdf-export.ts @@ -66,7 +66,8 @@ function parseNumericHeader(value: string | null) { } export function parseContentDispositionFileName( - contentDisposition: string | null + contentDisposition: string | null, + fallbackFileName = "document.pdf" ) { const encoded = contentDisposition?.match( /filename\*=UTF-8''([^;]+)/i @@ -82,7 +83,7 @@ export function parseContentDispositionFileName( return ( contentDisposition ?.match(/filename="([^"]+)"/i)?.[1] - ?.trim() || "document.pdf" + ?.trim() || fallbackFileName ); } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 6ab5402..badb143 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -188,6 +188,45 @@ h1 { background: #edf3f0; } +.more-menu-popover button:disabled:hover { + background: transparent; +} + +.export-menu-popover { + width: 286px; +} + +.more-menu-popover .export-menu-command { + min-height: 58px; + padding: 9px 11px; +} + +.export-menu-command > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.export-menu-command strong { + color: inherit; + font-size: 0.82rem; +} + +.export-menu-command small { + overflow: hidden; + color: #74827b; + font-size: 0.68rem; + font-weight: 500; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.export-menu-command:disabled small { + color: #929b97; +} + .more-menu-popover .menu-command { display: flex; align-items: center; @@ -254,6 +293,100 @@ h1 { letter-spacing: 0.06em; } +.export-toast { + position: fixed; + z-index: 70; + top: 16px; + left: 50%; + display: flex; + width: min(440px, calc(100vw - 32px)); + min-height: 62px; + padding: 13px 46px 13px 16px; + overflow: hidden; + border: 1px solid #bfd0c7; + border-radius: 12px; + background: rgb(255 255 255 / 97%); + box-shadow: 0 14px 38px rgb(38 58 50 / 24%); + transform: translateX(-50%); +} + +.export-toast.is-success { + border-color: #9dc5b0; +} + +.export-toast.is-error { + border-color: #dfaaa7; + background: rgb(255 250 249 / 98%); +} + +.export-toast-content { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.export-toast-content strong { + color: #2d493c; + font-size: 0.84rem; +} + +.export-toast.is-error .export-toast-content strong { + color: #943d39; +} + +.export-toast-content span { + overflow: hidden; + color: #64736b; + font-size: 0.72rem; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.export-toast-close { + position: absolute; + top: 9px; + right: 9px; + width: 32px; + min-height: 32px; + padding: 0; + border: 0; + background: transparent; + color: #718078; + font-size: 1.25rem; + line-height: 1; +} + +.export-toast-progress { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + overflow: hidden; + background: #e2eae6; +} + +.export-toast-progress span { + display: block; + width: 42%; + height: 100%; + border-radius: 999px; + background: #4d8b6e; + animation: export-progress 1.25s ease-in-out infinite; +} + +@keyframes export-progress { + from { + transform: translateX(-110%); + } + + to { + transform: translateX(340%); + } +} + .editor-layout { display: grid; grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr); @@ -1363,6 +1496,11 @@ h1 { .editor-layout { transition: none; } + + .export-toast-progress span { + width: 100%; + animation: none; + } } @media (max-width: 520px) { @@ -1381,6 +1519,11 @@ h1 { overflow-y: auto; } + .export-menu-popover { + right: 0; + width: min(286px, calc(100vw - 24px)); + } + .more-menu-submenu { position: static; width: auto; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 902ead6..06d4d66 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,6 +1,11 @@ /// import type { + DocxCapability, + DocxExportDiagnostics, + DocxExportErrorCode, + DocxExportRequestInput, + DocxGenerationTimings, DocxMediaCapturePlan, PagedDocumentPayload, PagedDocumentRenderResult, @@ -19,6 +24,21 @@ declare global { mermaidErrors: string[]; } + type DesktopDocxExportOutcome = + | { + ok: true; + saved: boolean; + fileName: string; + diagnostics: DocxExportDiagnostics; + timings: DocxGenerationTimings; + } + | { + ok: false; + error: DocxExportErrorCode; + message: string; + retryable: boolean; + }; + interface DesktopApplicationBridge { openMarkdown(): Promise< | { @@ -77,6 +97,10 @@ declare global { getThemeCss(themeId: string): Promise; generatePdf(payload: PagedDocumentPayload): Promise; savePdf(fileName: string, pdf: Uint8Array): Promise; + getDocxCapability(): Promise; + exportDocx( + request: DocxExportRequestInput + ): Promise; } interface DesktopPdfRuntimeBridge { diff --git a/apps/web/tests/docx-export.test.ts b/apps/web/tests/docx-export.test.ts new file mode 100644 index 0000000..f714bd4 --- /dev/null +++ b/apps/web/tests/docx-export.test.ts @@ -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 图表失败" + ); + }); +}); diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index df2b70f..44db741 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -115,6 +115,15 @@ data 目录,完成后校验 OOXML 并清理。共享应用服务提供有界 总超时、取消、关闭、capability、错误码和完整分阶段耗时;尚未新增 HTTP 路由、Desktop IPC 或导出按钮。 +`v0.6.0` 阶段 8 已完成 Web 与 Desktop DOCX 导出交互:Server 提供 +DOCX capability 与导出 HTTP API,Desktop 通过最小权限 IPC 在主进程 +完成生成和原生另存为,两端复用共享服务、固定 Pandoc、PNG 媒体管线和 +错误协议。顶栏原“导出 PDF”按钮升级为可扩展“导出”菜单,当前包含 PDF +与 DOCX,后续可增加 HTML 等格式;导出进度、成功和失败使用独立顶部 +Toast 与非确定进度条,不污染预览状态或 PDF 精确预览缓存。Desktop +退出会等待 PDF、DOCX、Pandoc 与隐藏媒体窗口完成清理,Lua Filter 作为 +明确构建资源进入桌面主进程目录。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1034,7 +1043,7 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器; - 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证; - 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务; -- 阶段 8:实现 Web/Desktop DOCX 导出交互; +- 阶段 8:已实现 Web/Desktop DOCX 导出交互; - 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/package-lock.json b/package-lock.json index e5dceab..1d872fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "version": "0.5.1", "devDependencies": { "@md-to-pdf/application": "0.4.1", + "@md-to-pdf/docx-engine": "0.1.0", "@types/node": "^24.10.1", "electron": "43.2.0", "electron-builder": "26.15.3", @@ -524,6 +525,7 @@ "dependencies": { "@md-to-pdf/application": "0.4.1", "@md-to-pdf/core": "0.1.0", + "@md-to-pdf/docx-engine": "0.1.0", "@md-to-pdf/renderer": "0.1.0", "fastify": "^5.6.2", "playwright": "1.62.0" diff --git a/packages/core/src/docx.ts b/packages/core/src/docx.ts index eff42dc..e205f5f 100644 --- a/packages/core/src/docx.ts +++ b/packages/core/src/docx.ts @@ -154,6 +154,7 @@ export const docxExportErrorCodeSchema = z.enum([ "INVALID_EXPORT_CONFIG", "INVALID_FILE_NAME", "INVALID_MARKDOWN", + "INVALID_FRONT_MATTER", "MARKDOWN_TOO_LARGE", "INVALID_IMAGE_RESOURCES", "IMAGE_RESOURCES_TOO_LARGE", diff --git a/packages/docx-engine/src/pandoc-converter.ts b/packages/docx-engine/src/pandoc-converter.ts index 22be34a..13098e8 100644 --- a/packages/docx-engine/src/pandoc-converter.ts +++ b/packages/docx-engine/src/pandoc-converter.ts @@ -77,6 +77,7 @@ export interface PandocDocxConversionResult { export interface PandocDocxConverterOptions { runtime: PandocRuntimeProvider | PandocRuntime; + luaFilterUrl?: URL; runner?: PandocProcessRunner; environment?: NodeJS.ProcessEnv; temporaryRoot?: string; @@ -102,13 +103,6 @@ export class PandocDocxConversionError extends Error { } } -let luaFilterContent: Promise | undefined; - -function loadLuaFilter() { - luaFilterContent ??= readFile(luaFilterUrl); - return luaFilterContent; -} - function ensureOutputLimit(value: number | undefined) { const limit = value ?? MAXIMUM_DOCX_OUTPUT_BYTES; if ( @@ -179,6 +173,7 @@ function pandocArguments(paths: { } export class PandocDocxConverter { + private luaFilterContent: Promise | undefined; private readonly runner: PandocProcessRunner; private readonly timeoutMs: number; private readonly maximumOutputBytes: number; @@ -205,6 +200,13 @@ export class PandocDocxConverter { } } + private loadLuaFilter() { + this.luaFilterContent ??= readFile( + this.options.luaFilterUrl ?? luaFilterUrl + ); + return this.luaFilterContent; + } + async convert( input: PandocDocxConversionInput, signal?: AbortSignal @@ -232,7 +234,7 @@ export class PandocDocxConverter { await Promise.all([ this.options.runtime.getExecutablePath(), this.options.runtime.getDefaultReferenceDocx(), - loadLuaFilter() + this.loadLuaFilter() ]); const reference = this.getOrCreateReference(baseline, input); const referenceMs = performance.now() - referenceStarted;