feat: 实现 Web 与 Desktop DOCX 导出交互
This commit is contained in:
+180
-13
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
|
||||
import Fastify from "fastify";
|
||||
import {
|
||||
EXPORT_CONFIG_VERSION,
|
||||
DOCX_MIME_TYPE,
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig,
|
||||
exportConfigSchema,
|
||||
@@ -11,10 +12,21 @@ import {
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
DocxExportService,
|
||||
DocxExportServiceError,
|
||||
createApplicationService,
|
||||
readDocxExportRuntimeLimits,
|
||||
type ApplicationService
|
||||
} from "@md-to-pdf/application";
|
||||
import {
|
||||
PandocDocxConverter,
|
||||
PandocRuntime
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
|
||||
import {
|
||||
createDocxMediaEngine,
|
||||
type ServerDocxMediaCaptureAdapter
|
||||
} from "./docx-media-engine.js";
|
||||
import {
|
||||
createPdfGenerator,
|
||||
PdfEngineClosedError,
|
||||
@@ -68,7 +80,13 @@ export interface BuildAppOptions {
|
||||
logger?: boolean;
|
||||
pdfGenerator?: PdfGenerator;
|
||||
prewarmPdfBrowser?: boolean;
|
||||
prewarmDocxRuntime?: boolean;
|
||||
applicationService?: ApplicationService;
|
||||
docxExportService?: Pick<
|
||||
DocxExportService,
|
||||
"getCapability" | "generate" | "close"
|
||||
>;
|
||||
docxMediaAdapter?: ServerDocxMediaCaptureAdapter;
|
||||
}
|
||||
|
||||
function milliseconds(value: number) {
|
||||
@@ -106,6 +124,30 @@ function createPdfServerTiming(
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
function createDocxServerTiming(
|
||||
timings: Awaited<
|
||||
ReturnType<DocxExportService["generate"]>
|
||||
>["timings"]
|
||||
) {
|
||||
return [
|
||||
`queue;dur=${milliseconds(timings.queueMs)}`,
|
||||
`runtime-probe;dur=${milliseconds(timings.probeMs)}`,
|
||||
`prepare;dur=${milliseconds(timings.prepareMs)}`,
|
||||
`media;dur=${milliseconds(timings.mediaMs)}`,
|
||||
`reference;dur=${milliseconds(timings.referenceMs)}`,
|
||||
`pandoc;dur=${milliseconds(timings.pandocMs)}`,
|
||||
`validation;dur=${milliseconds(timings.validationMs)}`,
|
||||
`total;dur=${milliseconds(timings.totalMs)}`
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
export function createDocxContentDisposition(fileName: string) {
|
||||
return (
|
||||
`attachment; filename="document.docx"; ` +
|
||||
`filename*=UTF-8''${encodeRfc5987(fileName)}`
|
||||
);
|
||||
}
|
||||
|
||||
export function buildApp(options: BuildAppOptions = {}) {
|
||||
const app = Fastify({
|
||||
logger: options.logger ?? true,
|
||||
@@ -121,6 +163,21 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
});
|
||||
const pdfGenerator =
|
||||
options.pdfGenerator ?? createPdfGenerator();
|
||||
const pandocRuntime = options.docxExportService
|
||||
? undefined
|
||||
: new PandocRuntime();
|
||||
const docxExportService =
|
||||
options.docxExportService ??
|
||||
new DocxExportService({
|
||||
application: applicationService,
|
||||
runtime: pandocRuntime!,
|
||||
converter: new PandocDocxConverter({
|
||||
runtime: pandocRuntime!
|
||||
}),
|
||||
limits: readDocxExportRuntimeLimits()
|
||||
});
|
||||
const docxMediaAdapter =
|
||||
options.docxMediaAdapter ?? createDocxMediaEngine();
|
||||
|
||||
if (options.prewarmPdfBrowser) {
|
||||
app.addHook("onReady", async () => {
|
||||
@@ -132,8 +189,27 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
if (options.prewarmDocxRuntime) {
|
||||
app.addHook("onReady", async () => {
|
||||
try {
|
||||
const capability =
|
||||
await docxExportService.getCapability();
|
||||
if (capability.status !== "available") {
|
||||
app.log.warn(
|
||||
{ capability },
|
||||
"DOCX runtime unavailable"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
app.log.warn({ error }, "DOCX runtime probe failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
await pdfGenerator.close();
|
||||
await docxExportService.close();
|
||||
await docxMediaAdapter.close();
|
||||
});
|
||||
|
||||
app.get("/api/health", async () => ({
|
||||
@@ -143,19 +219,28 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
rendererVersion: RENDERER_VERSION
|
||||
}));
|
||||
|
||||
app.get("/api/capabilities", async () => ({
|
||||
defaultExportConfig,
|
||||
supportedPaperFormats,
|
||||
implemented: [
|
||||
"project-skeleton",
|
||||
"export-config",
|
||||
"theme-manifest",
|
||||
"markdown-render",
|
||||
"html-preview",
|
||||
"pdf-export"
|
||||
],
|
||||
planned: []
|
||||
}));
|
||||
app.get("/api/capabilities", async () => {
|
||||
const docx = await docxExportService.getCapability();
|
||||
return {
|
||||
defaultExportConfig,
|
||||
supportedPaperFormats,
|
||||
docx,
|
||||
implemented: [
|
||||
"project-skeleton",
|
||||
"export-config",
|
||||
"theme-manifest",
|
||||
"markdown-render",
|
||||
"html-preview",
|
||||
"pdf-export",
|
||||
"docx-export"
|
||||
],
|
||||
planned: []
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/api/docx/capability", async () =>
|
||||
docxExportService.getCapability()
|
||||
);
|
||||
|
||||
app.get("/api/themes", async () =>
|
||||
applicationService.listThemes()
|
||||
@@ -364,5 +449,87 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: unknown }>("/api/docx", async (request, reply) => {
|
||||
const abortController = new AbortController();
|
||||
const abortRequest = () =>
|
||||
abortController.abort(new Error("HTTP 请求已中断"));
|
||||
request.raw.once("aborted", abortRequest);
|
||||
|
||||
try {
|
||||
const generated = await docxExportService.generate(
|
||||
request.body ?? {},
|
||||
{
|
||||
mediaAdapter: docxMediaAdapter,
|
||||
signal: abortController.signal
|
||||
}
|
||||
);
|
||||
request.log.info(
|
||||
{
|
||||
fileName: generated.fileName,
|
||||
bytes: generated.docx.byteLength,
|
||||
diagnostics: generated.diagnostics,
|
||||
timings: generated.timings
|
||||
},
|
||||
"DOCX generated"
|
||||
);
|
||||
return reply
|
||||
.header("content-type", DOCX_MIME_TYPE)
|
||||
.header(
|
||||
"content-disposition",
|
||||
createDocxContentDisposition(generated.fileName)
|
||||
)
|
||||
.header("cache-control", "no-store")
|
||||
.header(
|
||||
"server-timing",
|
||||
createDocxServerTiming(generated.timings)
|
||||
)
|
||||
.header(
|
||||
"x-docx-warning-count",
|
||||
String(generated.diagnostics.warnings.length)
|
||||
)
|
||||
.header(
|
||||
"x-echarts-error-count",
|
||||
String(generated.diagnostics.echartsErrors.length)
|
||||
)
|
||||
.header(
|
||||
"x-mermaid-error-count",
|
||||
String(generated.diagnostics.mermaidErrors.length)
|
||||
)
|
||||
.send(
|
||||
Buffer.from(
|
||||
generated.docx.buffer,
|
||||
generated.docx.byteOffset,
|
||||
generated.docx.byteLength
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationRequestError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
retryable: false
|
||||
});
|
||||
}
|
||||
if (error instanceof DocxExportServiceError) {
|
||||
if (error.retryable) {
|
||||
reply.header("retry-after", "5");
|
||||
}
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
retryable: error.retryable
|
||||
});
|
||||
}
|
||||
request.log.error({ error }, "DOCX generation failed");
|
||||
return reply.code(500).send({
|
||||
error: "DOCX_GENERATION_FAILED",
|
||||
message: "DOCX 生成失败",
|
||||
retryable: false
|
||||
});
|
||||
} finally {
|
||||
request.raw.removeListener("aborted", abortRequest);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { chromium, type Browser, type BrowserContext } from "playwright";
|
||||
import type {
|
||||
DocxMediaCaptureAdapter,
|
||||
DocxMediaCaptureRequest
|
||||
} from "@md-to-pdf/application";
|
||||
import { captureDocxMediaWithPlaywrightPage } from "./playwright-docx-media-capture.js";
|
||||
import { isAllowedPdfRequestUrl } from "./pdf-engine.js";
|
||||
|
||||
export interface ServerDocxMediaCaptureAdapter
|
||||
extends DocxMediaCaptureAdapter {
|
||||
warmup?(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PlaywrightDocxMediaEngineOptions {
|
||||
renderOrigin?: string;
|
||||
launchBrowser?: () => Promise<Browser>;
|
||||
}
|
||||
|
||||
function normalizeRenderOrigin(value: string) {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("DOCX 媒体渲染地址必须使用 HTTP 或 HTTPS");
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
async function closeContext(context: BrowserContext) {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
export class PlaywrightDocxMediaEngine
|
||||
implements ServerDocxMediaCaptureAdapter
|
||||
{
|
||||
private readonly renderOrigin: string;
|
||||
private readonly renderUrl: string;
|
||||
private readonly launchBrowser: () => Promise<Browser>;
|
||||
private browserPromise: Promise<Browser> | undefined;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: PlaywrightDocxMediaEngineOptions = {}) {
|
||||
this.renderOrigin = normalizeRenderOrigin(
|
||||
options.renderOrigin ??
|
||||
process.env.PDF_RENDER_ORIGIN ??
|
||||
"http://localhost:5173"
|
||||
);
|
||||
this.renderUrl = new URL(
|
||||
"/preview-frame.html?target=continuous",
|
||||
this.renderOrigin
|
||||
).href;
|
||||
this.launchBrowser =
|
||||
options.launchBrowser ??
|
||||
(() =>
|
||||
chromium.launch({
|
||||
headless: true
|
||||
}));
|
||||
}
|
||||
|
||||
async warmup() {
|
||||
if (this.closed) {
|
||||
throw new Error("DOCX 媒体服务已关闭");
|
||||
}
|
||||
await this.getBrowser();
|
||||
}
|
||||
|
||||
async capture(
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (this.closed) {
|
||||
throw new Error("DOCX 媒体服务已关闭");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const browser = await this.getBrowser();
|
||||
signal?.throwIfAborted();
|
||||
const context = await browser.newContext({
|
||||
locale: request.payload.metadata.language || "zh-CN",
|
||||
serviceWorkers: "block"
|
||||
});
|
||||
const abort = () => {
|
||||
void closeContext(context);
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
|
||||
try {
|
||||
await context.route("**/*", async (route) => {
|
||||
if (
|
||||
isAllowedPdfRequestUrl(
|
||||
route.request().url(),
|
||||
this.renderOrigin
|
||||
)
|
||||
) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.abort("blockedbyclient");
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(this.renderUrl, {
|
||||
waitUntil: "domcontentloaded"
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.documentElement.dataset.runtimeReady === "true"
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return await captureDocxMediaWithPlaywrightPage(
|
||||
page,
|
||||
request,
|
||||
signal
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
await closeContext(context);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed = true;
|
||||
const browserPromise = this.browserPromise;
|
||||
this.browserPromise = undefined;
|
||||
if (!browserPromise) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const browser = await browserPromise;
|
||||
await browser.close();
|
||||
} catch {
|
||||
// 浏览器启动失败时没有可关闭的实例。
|
||||
}
|
||||
}
|
||||
|
||||
private getBrowser() {
|
||||
if (!this.browserPromise) {
|
||||
const browserPromise = this.launchBrowser();
|
||||
this.browserPromise = browserPromise;
|
||||
void browserPromise
|
||||
.then((browser) => {
|
||||
browser.on("disconnected", () => {
|
||||
if (this.browserPromise === browserPromise) {
|
||||
this.browserPromise = undefined;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.browserPromise === browserPromise) {
|
||||
this.browserPromise = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.browserPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDocxMediaEngine(
|
||||
options: PlaywrightDocxMediaEngineOptions = {}
|
||||
) {
|
||||
return new PlaywrightDocxMediaEngine(options);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { buildApp } from "./app.js";
|
||||
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const app = buildApp({
|
||||
prewarmPdfBrowser: true
|
||||
prewarmPdfBrowser: true,
|
||||
prewarmDocxRuntime: true
|
||||
});
|
||||
|
||||
async function start() {
|
||||
|
||||
Reference in New Issue
Block a user