新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。 问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。 兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。 验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
613 lines
18 KiB
TypeScript
613 lines
18 KiB
TypeScript
import { fileURLToPath } from "node:url";
|
|
import { resolve } from "node:path";
|
|
import { performance } from "node:perf_hooks";
|
|
import Fastify from "fastify";
|
|
import {
|
|
EXPORT_CONFIG_VERSION,
|
|
DOCX_MIME_TYPE,
|
|
createPagedDocumentPayload,
|
|
defaultExportConfig,
|
|
exportConfigSchema,
|
|
supportedPaperFormats
|
|
} from "@md-to-pdf/core";
|
|
import {
|
|
ApplicationRequestError,
|
|
DocxExportService,
|
|
DocxExportServiceError,
|
|
DocxThemeTokenService,
|
|
createApplicationService,
|
|
readDocxExportRuntimeLimits,
|
|
type ApplicationService,
|
|
type ApplicationServiceOptions
|
|
} 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,
|
|
PdfEngineOverloadedError,
|
|
PdfRenderTimeoutError,
|
|
type PdfGenerator
|
|
} from "./pdf-engine.js";
|
|
|
|
interface RenderRequestBody {
|
|
markdown?: unknown;
|
|
language?: unknown;
|
|
resources?: unknown;
|
|
}
|
|
|
|
interface PdfRequestBody extends RenderRequestBody {
|
|
fileName?: unknown;
|
|
exportConfig?: unknown;
|
|
}
|
|
|
|
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
|
function encodeRfc5987(value: string) {
|
|
return encodeURIComponent(value).replace(
|
|
/[!'()*]/g,
|
|
(character) =>
|
|
`%${character.charCodeAt(0).toString(16).toUpperCase()}`
|
|
);
|
|
}
|
|
|
|
export function createPdfFileName(fileName: unknown) {
|
|
const source =
|
|
typeof fileName === "string"
|
|
? fileName.split(/[\\/]/).at(-1) ?? ""
|
|
: "";
|
|
const stem = source
|
|
.replace(/\.(?:md|markdown)$/i, "")
|
|
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
|
|
.replace(/[.\s]+$/g, "")
|
|
.trim();
|
|
return `${stem || "文档"}.pdf`;
|
|
}
|
|
|
|
export function createPdfContentDisposition(fileName: string) {
|
|
return (
|
|
`attachment; filename="document.pdf"; ` +
|
|
`filename*=UTF-8''${encodeRfc5987(fileName)}`
|
|
);
|
|
}
|
|
|
|
export interface BuildAppOptions {
|
|
localThemeRoot?: string;
|
|
logger?: boolean;
|
|
pdfGenerator?: PdfGenerator;
|
|
prewarmPdfBrowser?: boolean;
|
|
prewarmDocxRuntime?: boolean;
|
|
applicationService?: ApplicationService;
|
|
docxExportService?: Pick<
|
|
DocxExportService,
|
|
"getCapability" | "generate" | "close"
|
|
>;
|
|
docxMediaAdapter?: ServerDocxMediaCaptureAdapter;
|
|
fontPacks?: NonNullable<ApplicationServiceOptions["fontPacks"]>;
|
|
}
|
|
|
|
function milliseconds(value: number) {
|
|
return Math.max(0, value).toFixed(1);
|
|
}
|
|
|
|
function createPdfServerTiming(
|
|
themeMs: number,
|
|
markdownMs: number,
|
|
generated: Awaited<ReturnType<PdfGenerator["generate"]>>,
|
|
requestMs: number
|
|
) {
|
|
const { timings } = generated;
|
|
return [
|
|
`theme;dur=${milliseconds(themeMs)}`,
|
|
`markdown;dur=${milliseconds(markdownMs)}`,
|
|
`queue;dur=${milliseconds(timings.queueMs)}`,
|
|
`browser;dur=${milliseconds(timings.browserMs)}`,
|
|
`context;dur=${milliseconds(timings.contextMs)}`,
|
|
`navigation;dur=${milliseconds(timings.navigationMs)}`,
|
|
`document-render;dur=${milliseconds(timings.documentRenderMs)}`,
|
|
`runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`,
|
|
`echarts;dur=${milliseconds(timings.echartsMs)}`,
|
|
`echarts-fit;dur=${milliseconds(timings.echartsFitMs)}`,
|
|
`mermaid;dur=${milliseconds(timings.mermaidMs)}`,
|
|
`mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`,
|
|
`mermaid-conversion;dur=${milliseconds(
|
|
timings.mermaidConversionMs
|
|
)}`,
|
|
`resources;dur=${milliseconds(timings.resourceWaitMs)}`,
|
|
`pagination;dur=${milliseconds(timings.paginationMs)}`,
|
|
`pagination-finalize;dur=${milliseconds(timings.finalizeMs)}`,
|
|
`pdf-print;dur=${milliseconds(timings.pdfPrintMs)}`,
|
|
`total;dur=${milliseconds(requestMs)}`
|
|
].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)}`,
|
|
`theme-style;dur=${milliseconds(timings.themeStyleMs)}`,
|
|
`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,
|
|
bodyLimit: 24 * 1024 * 1024
|
|
});
|
|
const applicationService =
|
|
options.applicationService ??
|
|
createApplicationService({
|
|
bundledRoot: resolve(projectRoot, "themes"),
|
|
localRoot:
|
|
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
|
|
onWarning: (message) => app.log.warn(message),
|
|
...(options.fontPacks ? { fontPacks: options.fontPacks } : {})
|
|
});
|
|
const pdfGenerator =
|
|
options.pdfGenerator ?? createPdfGenerator();
|
|
const pandocRuntime = options.docxExportService
|
|
? undefined
|
|
: new PandocRuntime();
|
|
const docxMediaAdapter =
|
|
options.docxMediaAdapter ?? createDocxMediaEngine();
|
|
const docxExportService =
|
|
options.docxExportService ??
|
|
new DocxExportService({
|
|
application: applicationService,
|
|
runtime: pandocRuntime!,
|
|
converter: new PandocDocxConverter({
|
|
runtime: pandocRuntime!
|
|
}),
|
|
themeTokens: new DocxThemeTokenService(
|
|
docxMediaAdapter
|
|
),
|
|
limits: readDocxExportRuntimeLimits()
|
|
});
|
|
|
|
if (options.prewarmPdfBrowser) {
|
|
app.addHook("onReady", async () => {
|
|
try {
|
|
await pdfGenerator.warmup?.();
|
|
} catch (error) {
|
|
app.log.warn({ error }, "PDF browser warmup failed");
|
|
}
|
|
});
|
|
}
|
|
|
|
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 () => ({
|
|
status: "ok",
|
|
service: "md-to-pdf",
|
|
configVersion: EXPORT_CONFIG_VERSION,
|
|
rendererVersion: RENDERER_VERSION
|
|
}));
|
|
|
|
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()
|
|
);
|
|
|
|
app.get<{ Params: { themeId: string } }>(
|
|
"/api/themes/:themeId/css",
|
|
async (request, reply) => {
|
|
const { themeId } = request.params;
|
|
|
|
const css = await applicationService.getThemeCss(themeId);
|
|
if (css === undefined) {
|
|
return reply.code(404).send({
|
|
error: "THEME_NOT_FOUND",
|
|
message: "未找到指定主题"
|
|
});
|
|
}
|
|
|
|
return reply
|
|
.header("content-type", "text/css; charset=utf-8")
|
|
.header("cache-control", "public, max-age=300")
|
|
.send(css);
|
|
}
|
|
);
|
|
|
|
app.get<{ Params: { themeId: string; "*": string } }>(
|
|
"/api/themes/:themeId/assets/*",
|
|
async (request, reply) => {
|
|
try {
|
|
const asset = await applicationService.getThemeAsset(
|
|
request.params.themeId,
|
|
request.params["*"]
|
|
);
|
|
if (!asset) {
|
|
return reply.code(404).send({
|
|
error: "THEME_NOT_FOUND",
|
|
message: "未找到指定主题"
|
|
});
|
|
}
|
|
|
|
return reply
|
|
.header("content-type", asset.contentType)
|
|
.header("cache-control", "public, max-age=300")
|
|
.header("content-security-policy", "default-src 'none'; sandbox")
|
|
.header("x-content-type-options", "nosniff")
|
|
.send(asset.content);
|
|
} catch {
|
|
return reply.code(404).send({
|
|
error: "THEME_ASSET_NOT_FOUND",
|
|
message: "未找到指定主题资源"
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
app.get<{
|
|
Params: {
|
|
packId: string;
|
|
packVersion: string;
|
|
faceId: string;
|
|
};
|
|
}>(
|
|
"/api/font-packs/:packId/:packVersion/:faceId/web",
|
|
async (request, reply) => {
|
|
try {
|
|
const asset = await applicationService.getFontPackAsset(
|
|
request.params.packId,
|
|
request.params.packVersion,
|
|
request.params.faceId,
|
|
"web"
|
|
);
|
|
if (!asset) {
|
|
return reply.code(404).send({
|
|
error: "FONT_PACK_ASSET_NOT_FOUND",
|
|
message: "未找到指定字体包资源"
|
|
});
|
|
}
|
|
return reply
|
|
.header("content-type", asset.contentType)
|
|
.header("cache-control", "public, max-age=31536000, immutable")
|
|
.header("content-security-policy", "default-src 'none'; sandbox")
|
|
.header("x-content-type-options", "nosniff")
|
|
.header("etag", `\"${asset.sha256}\"`)
|
|
.send(asset.content);
|
|
} catch {
|
|
return reply.code(404).send({
|
|
error: "FONT_PACK_ASSET_NOT_FOUND",
|
|
message: "未找到指定字体包资源"
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
app.post<{ Body: RenderRequestBody }>(
|
|
"/api/render",
|
|
async (request, reply) => {
|
|
try {
|
|
return await applicationService.render(request.body ?? {});
|
|
} catch (error) {
|
|
if (error instanceof ApplicationRequestError) {
|
|
return reply.code(error.statusCode).send({
|
|
error: error.code,
|
|
message: error.message
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
);
|
|
|
|
app.post<{ Body: PdfRequestBody }>(
|
|
"/api/pdf",
|
|
async (request, reply) => {
|
|
const requestStartedAt = performance.now();
|
|
const parsedConfig = exportConfigSchema.safeParse(
|
|
request.body?.exportConfig
|
|
);
|
|
if (!parsedConfig.success) {
|
|
return reply.code(400).send({
|
|
error: "INVALID_EXPORT_CONFIG",
|
|
message: "导出配置无效",
|
|
issues: parsedConfig.error.issues
|
|
});
|
|
}
|
|
if (
|
|
request.body?.fileName !== undefined &&
|
|
(typeof request.body.fileName !== "string" ||
|
|
request.body.fileName.length > 500)
|
|
) {
|
|
return reply.code(400).send({
|
|
error: "INVALID_FILE_NAME",
|
|
message: "fileName 必须是长度不超过 500 的字符串"
|
|
});
|
|
}
|
|
|
|
const themeStartedAt = performance.now();
|
|
const themeCss = await applicationService.getThemeCss(
|
|
parsedConfig.data.themeId
|
|
);
|
|
const themeMs = performance.now() - themeStartedAt;
|
|
if (themeCss === undefined) {
|
|
return reply.code(404).send({
|
|
error: "THEME_NOT_FOUND",
|
|
message: "未找到指定主题"
|
|
});
|
|
}
|
|
|
|
const markdownStartedAt = performance.now();
|
|
let rendered;
|
|
try {
|
|
rendered = await applicationService.render(request.body ?? {});
|
|
} catch (error) {
|
|
if (error instanceof ApplicationRequestError) {
|
|
return reply.code(error.statusCode).send({
|
|
error: error.code,
|
|
message: error.message
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
const markdownMs = performance.now() - markdownStartedAt;
|
|
|
|
try {
|
|
const generated = await pdfGenerator.generate(
|
|
createPagedDocumentPayload({
|
|
document: rendered,
|
|
fileName:
|
|
typeof request.body.fileName === "string"
|
|
? request.body.fileName
|
|
: "文档.md",
|
|
themeCss,
|
|
exportConfig: parsedConfig.data
|
|
})
|
|
);
|
|
const pdfFileName = createPdfFileName(request.body.fileName);
|
|
const requestMs = performance.now() - requestStartedAt;
|
|
const serverTiming = createPdfServerTiming(
|
|
themeMs,
|
|
markdownMs,
|
|
generated,
|
|
requestMs
|
|
);
|
|
|
|
request.log.info(
|
|
{
|
|
pageCount: generated.pageCount,
|
|
timings: {
|
|
themeMs,
|
|
markdownMs,
|
|
...generated.timings,
|
|
requestMs
|
|
}
|
|
},
|
|
"PDF generated"
|
|
);
|
|
|
|
return reply
|
|
.header("content-type", "application/pdf")
|
|
.header(
|
|
"content-disposition",
|
|
createPdfContentDisposition(pdfFileName)
|
|
)
|
|
.header("cache-control", "no-store")
|
|
.header("server-timing", serverTiming)
|
|
.header("x-pdf-page-count", String(generated.pageCount))
|
|
.header(
|
|
"x-echarts-error-count",
|
|
String(generated.echartsErrors.length)
|
|
)
|
|
.header(
|
|
"x-mermaid-error-count",
|
|
String(generated.mermaidErrors.length)
|
|
)
|
|
.send(generated.pdf);
|
|
} catch (error) {
|
|
if (error instanceof PdfEngineOverloadedError) {
|
|
return reply
|
|
.code(503)
|
|
.header("retry-after", "5")
|
|
.send({
|
|
error: "PDF_QUEUE_FULL",
|
|
message: error.message
|
|
});
|
|
}
|
|
if (error instanceof PdfRenderTimeoutError) {
|
|
return reply.code(504).send({
|
|
error: "PDF_RENDER_TIMEOUT",
|
|
message: "PDF 生成超时,请缩短文档或稍后重试"
|
|
});
|
|
}
|
|
if (error instanceof PdfEngineClosedError) {
|
|
return reply.code(503).send({
|
|
error: "PDF_ENGINE_UNAVAILABLE",
|
|
message: "PDF 生成服务暂不可用"
|
|
});
|
|
}
|
|
|
|
request.log.error(
|
|
{
|
|
error,
|
|
errorMessage:
|
|
error instanceof Error ? error.message : String(error),
|
|
errorStack:
|
|
error instanceof Error ? error.stack : undefined
|
|
},
|
|
"PDF generation failed"
|
|
);
|
|
return reply.code(500).send({
|
|
error: "PDF_GENERATION_FAILED",
|
|
message: "PDF 生成失败"
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
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.statusCode >= 500) {
|
|
request.log.error(
|
|
{
|
|
error,
|
|
cause: error.cause,
|
|
causeMessage:
|
|
error.cause instanceof Error
|
|
? error.cause.message
|
|
: undefined,
|
|
causeStack:
|
|
error.cause instanceof Error
|
|
? error.cause.stack
|
|
: undefined,
|
|
causeDiagnostics:
|
|
error.cause instanceof Error &&
|
|
"diagnostics" in error.cause
|
|
? error.cause.diagnostics
|
|
: undefined
|
|
},
|
|
"DOCX generation service failed"
|
|
);
|
|
}
|
|
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;
|
|
}
|