feat: 实现 Web 与 Desktop DOCX 导出交互

This commit is contained in:
SkyJourney
2026-07-30 16:05:45 +08:00
parent bf43433d38
commit f2dfc6ef72
28 changed files with 2110 additions and 57 deletions
+255
View File
@@ -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;
});
});
+131
View File
@@ -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 媒体服务已关闭"
);
});
});