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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user