Files
MorphDoc/packages/docx-engine/src/pandoc-converter.ts
T
SkyJourney 2c5c1bd317 release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。

新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。

重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。

源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。

验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
2026-08-04 10:30:44 +08:00

461 lines
13 KiB
TypeScript

import {
randomUUID
} from "node:crypto";
import {
mkdir,
mkdtemp,
readFile,
rm,
stat,
writeFile
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { performance } from "node:perf_hooks";
import {
MAXIMUM_DOCX_OUTPUT_BYTES,
type DocxExportErrorCode,
type ExportConfig,
type MarkdownDocumentMetadata,
type PreparedDocxMedia,
type SemanticDocumentModel,
type ThemeManifest
} from "@md-to-pdf/core";
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
import {
createDynamicReferenceCacheKey,
createDynamicReferenceDocx,
type DynamicReferenceDocxResult
} from "./reference-builder.js";
import { finalizeGeneratedDocxStructure } from "./document-structure-transform.js";
import {
prepareDocxFonts,
type DocxFontSource
} from "./font-embedding.js";
import { embedFontsInGeneratedDocx } from "./font-package-transform.js";
import type { DynamicReferenceDocxOptions } from "./reference-options.js";
import { preparePandocMedia } from "./pandoc-media.js";
import { createPandocStructurePlan } from "./pandoc-structure.js";
import {
runPandocProcess,
type PandocProcessRunner
} from "./pandoc-process.js";
import { readReferenceDocxPackage } from "./reference-package.js";
import type { PandocRuntime } from "./pandoc-runtime.js";
import {
validateGeneratedDocx,
type DynamicReferenceValidation
} from "./validator.js";
const DEFAULT_CONVERSION_TIMEOUT_MS = 60_000;
const MAXIMUM_PANDOC_STDOUT_BYTES = 1024 * 1024;
const MAXIMUM_PANDOC_STDERR_BYTES = 64 * 1024;
const DEFAULT_REFERENCE_CACHE_SIZE = 32;
const temporaryDirectoryPrefix = "md-to-pdf-docx-";
const mediaMapEnvironmentName = "MD_TO_PDF_DOCX_MEDIA_MAP";
const structurePlanEnvironmentName =
"MD_TO_PDF_DOCX_STRUCTURE_PLAN";
const luaFilterUrl = new URL(
"../assets/docx-media-filter.lua",
import.meta.url
);
export interface PandocRuntimeProvider {
getExecutablePath(): Promise<string>;
getDefaultReferenceDocx(): Promise<Uint8Array>;
}
export interface PandocDocxConversionInput {
markdown: string;
fileName: string;
language: string;
exportConfig: ExportConfig;
theme: ThemeManifest;
themeTokens: DocxThemeTokenSet;
fonts: DocxFontSource[];
metadata: MarkdownDocumentMetadata;
semanticDocument: SemanticDocumentModel;
media: PreparedDocxMedia;
}
export interface PandocDocxConversionTimings {
referenceMs: number;
pandocMs: number;
validationMs: number;
}
export interface PandocDocxConversionResult {
docx: Uint8Array;
templateFingerprint: string;
templateCacheKey: string;
validation: DynamicReferenceValidation;
timings: PandocDocxConversionTimings;
}
export interface PandocDocxConverterOptions {
runtime: PandocRuntimeProvider | PandocRuntime;
luaFilterUrl?: URL;
runner?: PandocProcessRunner;
environment?: NodeJS.ProcessEnv;
temporaryRoot?: string;
timeoutMs?: number;
maximumOutputBytes?: number;
referenceCacheSize?: number;
}
export class PandocDocxConversionError extends Error {
constructor(
readonly code: Extract<
DocxExportErrorCode,
| "DOCX_RUNTIME_NOT_FOUND"
| "DOCX_RENDER_TIMEOUT"
| "DOCX_GENERATION_FAILED"
| "DOCX_OUTPUT_INVALID"
>,
message: string,
options?: ErrorOptions
) {
super(message, options);
this.name = "PandocDocxConversionError";
}
}
function ensureOutputLimit(value: number | undefined) {
const limit = value ?? MAXIMUM_DOCX_OUTPUT_BYTES;
if (
!Number.isInteger(limit) ||
limit < 1024 ||
limit > MAXIMUM_DOCX_OUTPUT_BYTES
) {
throw new Error("DOCX 输出大小上限无效");
}
return limit;
}
function ensureTimeout(value: number | undefined) {
const timeout = value ?? DEFAULT_CONVERSION_TIMEOUT_MS;
if (
!Number.isInteger(timeout) ||
timeout < 1_000 ||
timeout > 10 * 60_000
) {
throw new Error("DOCX 转换超时配置无效");
}
return timeout;
}
function referenceOptions(
input: PandocDocxConversionInput
): DynamicReferenceDocxOptions {
return {
exportConfig: input.exportConfig,
theme: input.theme,
themeTokens: input.themeTokens,
semanticDocument: input.semanticDocument,
fileName: input.fileName,
metadata: {
title: input.metadata.title,
author: input.metadata.author
}
};
}
function pandocArguments(paths: {
markdown: string;
output: string;
reference: string;
luaFilter: string;
resourceDirectory: string;
dataDirectory: string;
language: string;
}) {
return [
paths.markdown,
"--from",
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html",
"--to",
"docx",
"--standalone",
"--reference-doc",
paths.reference,
"--lua-filter",
paths.luaFilter,
"--resource-path",
paths.resourceDirectory,
"--data-dir",
paths.dataDirectory,
"--metadata",
`lang=${paths.language}`,
"--output",
paths.output
];
}
export class PandocDocxConverter {
private luaFilterContent: Promise<Uint8Array> | undefined;
private readonly runner: PandocProcessRunner;
private readonly timeoutMs: number;
private readonly maximumOutputBytes: number;
private readonly referenceCacheSize: number;
private readonly referenceCache = new Map<
string,
DynamicReferenceDocxResult
>();
constructor(private readonly options: PandocDocxConverterOptions) {
this.runner = options.runner ?? runPandocProcess;
this.timeoutMs = ensureTimeout(options.timeoutMs);
this.maximumOutputBytes = ensureOutputLimit(
options.maximumOutputBytes
);
this.referenceCacheSize =
options.referenceCacheSize ?? DEFAULT_REFERENCE_CACHE_SIZE;
if (
!Number.isInteger(this.referenceCacheSize) ||
this.referenceCacheSize < 1 ||
this.referenceCacheSize > 128
) {
throw new Error("DOCX 动态模板缓存容量无效");
}
}
private loadLuaFilter() {
this.luaFilterContent ??= readFile(
this.options.luaFilterUrl ?? luaFilterUrl
);
return this.luaFilterContent;
}
async convert(
input: PandocDocxConversionInput,
signal?: AbortSignal
): Promise<PandocDocxConversionResult> {
let preparedMedia;
let structurePlan;
let preparedFonts;
try {
preparedMedia = preparePandocMedia(input.media);
preparedFonts = await prepareDocxFonts(input.fonts);
structurePlan = createPandocStructurePlan(
input.semanticDocument,
input.themeTokens,
{ markerSeed: randomUUID() }
);
} catch (error) {
throw new PandocDocxConversionError(
"DOCX_GENERATION_FAILED",
"DOCX 媒体或语义结构映射无效",
{ cause: error }
);
}
const temporaryDirectory = await mkdtemp(
path.join(
this.options.temporaryRoot ?? os.tmpdir(),
temporaryDirectoryPrefix
)
);
try {
const referenceStarted = performance.now();
const [executablePath, baseline, filterContent] =
await Promise.all([
this.options.runtime.getExecutablePath(),
this.options.runtime.getDefaultReferenceDocx(),
this.loadLuaFilter()
]);
const reference = this.getOrCreateReference(baseline, input);
const referenceMs = performance.now() - referenceStarted;
const paths = {
markdown: path.join(temporaryDirectory, "document.md"),
output: path.join(temporaryDirectory, "document.docx"),
reference: path.join(temporaryDirectory, "reference.docx"),
luaFilter: path.join(
temporaryDirectory,
"docx-media-filter.lua"
),
mediaMap: path.join(temporaryDirectory, "media-map.json"),
structurePlan: path.join(
temporaryDirectory,
"structure-plan.json"
),
mediaDirectory: path.join(temporaryDirectory, "media"),
dataDirectory: path.join(temporaryDirectory, "pandoc-data")
};
await Promise.all([
mkdir(paths.mediaDirectory),
mkdir(paths.dataDirectory)
]);
await Promise.all([
writeFile(paths.markdown, input.markdown, "utf8"),
writeFile(paths.reference, reference.content),
writeFile(paths.luaFilter, filterContent),
writeFile(
paths.mediaMap,
JSON.stringify(preparedMedia.map),
"utf8"
),
writeFile(
paths.structurePlan,
JSON.stringify(structurePlan),
"utf8"
),
...preparedMedia.files.map((file) =>
writeFile(
path.join(temporaryDirectory, file.relativePath),
file.content
)
)
]);
const pandocStarted = performance.now();
const processResult = await this.runner(
executablePath,
pandocArguments({
markdown: paths.markdown,
output: paths.output,
reference: paths.reference,
luaFilter: paths.luaFilter,
resourceDirectory: temporaryDirectory,
dataDirectory: paths.dataDirectory,
language: input.language
}),
{
cwd: temporaryDirectory,
env: {
...(this.options.environment ?? process.env),
[mediaMapEnvironmentName]: paths.mediaMap,
[structurePlanEnvironmentName]: paths.structurePlan
},
timeoutMs: this.timeoutMs,
maxStdoutBytes: MAXIMUM_PANDOC_STDOUT_BYTES,
maxStderrBytes: MAXIMUM_PANDOC_STDERR_BYTES,
...(signal ? { signal } : {})
}
);
const pandocMs = performance.now() - pandocStarted;
if (
processResult.outcome === "timeout" ||
processResult.outcome === "aborted"
) {
throw new PandocDocxConversionError(
"DOCX_RENDER_TIMEOUT",
"DOCX 转换超时或已取消"
);
}
if (
processResult.outcome !== "completed" ||
processResult.exitCode !== 0
) {
throw new PandocDocxConversionError(
processResult.outcome === "not-found"
? "DOCX_RUNTIME_NOT_FOUND"
: "DOCX_GENERATION_FAILED",
"Pandoc 未能生成 DOCX"
);
}
const validationStarted = performance.now();
const outputStat = await stat(paths.output);
if (
!outputStat.isFile() ||
outputStat.size < 1 ||
outputStat.size > this.maximumOutputBytes
) {
throw new PandocDocxConversionError(
"DOCX_OUTPUT_INVALID",
"DOCX 输出大小无效"
);
}
let docx: Uint8Array;
let validation: DynamicReferenceValidation;
try {
const pandocDocx = await readFile(paths.output);
const finalized = finalizeGeneratedDocxStructure(
pandocDocx,
structurePlan,
input.themeTokens,
preparedMedia.layout,
preparedMedia.documentLayout
);
docx = embedFontsInGeneratedDocx(
finalized.content,
preparedFonts
).content;
if (docx.byteLength > this.maximumOutputBytes) {
throw new Error("DOCX 结构收口后的输出大小无效");
}
validation = validateGeneratedDocx(docx);
} catch (error) {
throw new PandocDocxConversionError(
"DOCX_OUTPUT_INVALID",
"DOCX 输出结构无效",
{ cause: error }
);
}
const validationMs = performance.now() - validationStarted;
return {
docx,
templateFingerprint: reference.templateFingerprint,
templateCacheKey: reference.cacheKey,
validation,
timings: {
referenceMs,
pandocMs,
validationMs
}
};
} catch (error) {
if (error instanceof PandocDocxConversionError) {
throw error;
}
throw new PandocDocxConversionError(
"DOCX_GENERATION_FAILED",
"DOCX 转换失败",
{ cause: error }
);
} finally {
await rm(temporaryDirectory, {
recursive: true,
force: true
});
}
}
clearReferenceCache() {
this.referenceCache.clear();
}
private getOrCreateReference(
baseline: Uint8Array,
input: PandocDocxConversionInput
) {
const options = referenceOptions(input);
const baselineFingerprint =
readReferenceDocxPackage(baseline).fingerprint;
const cacheKey = createDynamicReferenceCacheKey(
baselineFingerprint,
options
);
const cached = this.referenceCache.get(cacheKey);
if (cached) {
this.referenceCache.delete(cacheKey);
this.referenceCache.set(cacheKey, cached);
return cached;
}
const reference = createDynamicReferenceDocx(
baseline,
options
);
this.referenceCache.set(cacheKey, reference);
while (this.referenceCache.size > this.referenceCacheSize) {
const oldest = this.referenceCache.keys().next().value;
if (oldest === undefined) {
break;
}
this.referenceCache.delete(oldest);
}
return reference;
}
}