feat: 实现 Pandoc DOCX 转换服务
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
export * from "./header-footer-transform.js";
|
||||
export * from "./ooxml.js";
|
||||
export * from "./pandoc-process.js";
|
||||
export * from "./pandoc-media.js";
|
||||
export * from "./pandoc-converter.js";
|
||||
export * from "./pandoc-runtime-manifest.js";
|
||||
export * from "./pandoc-runtime.js";
|
||||
export * from "./reference-builder.js";
|
||||
export * from "./reference-options.js";
|
||||
export * from "./reference-package.js";
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
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 ThemeManifest
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createDynamicReferenceCacheKey,
|
||||
createDynamicReferenceDocx,
|
||||
type DynamicReferenceDocxResult
|
||||
} from "./reference-builder.js";
|
||||
import type { DynamicReferenceDocxOptions } from "./reference-options.js";
|
||||
import { preparePandocMedia } from "./pandoc-media.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 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;
|
||||
metadata: MarkdownDocumentMetadata;
|
||||
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;
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
let luaFilterContent: Promise<Uint8Array> | undefined;
|
||||
|
||||
function loadLuaFilter() {
|
||||
luaFilterContent ??= readFile(luaFilterUrl);
|
||||
return luaFilterContent;
|
||||
}
|
||||
|
||||
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,
|
||||
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",
|
||||
"--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 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 动态模板缓存容量无效");
|
||||
}
|
||||
}
|
||||
|
||||
async convert(
|
||||
input: PandocDocxConversionInput,
|
||||
signal?: AbortSignal
|
||||
): Promise<PandocDocxConversionResult> {
|
||||
let preparedMedia;
|
||||
try {
|
||||
preparedMedia = preparePandocMedia(input.media);
|
||||
} 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(),
|
||||
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"),
|
||||
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"
|
||||
),
|
||||
...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
|
||||
},
|
||||
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 输出大小无效"
|
||||
);
|
||||
}
|
||||
const docx = await readFile(paths.output);
|
||||
let validation: DynamicReferenceValidation;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
DOCX_MEDIA_CSS_DPI,
|
||||
MAXIMUM_DOCX_MEDIA_BYTES,
|
||||
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||
MAXIMUM_DOCX_MEDIA_PIXELS,
|
||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
||||
type DocxMediaKind,
|
||||
type PreparedDocxMedia
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface PandocMediaMapItem {
|
||||
id: string;
|
||||
path: string;
|
||||
alt_text: string;
|
||||
caption?: string;
|
||||
width: string;
|
||||
height: string;
|
||||
}
|
||||
|
||||
export interface PandocMediaMap {
|
||||
image: PandocMediaMapItem[];
|
||||
mermaid: PandocMediaMapItem[];
|
||||
echarts: PandocMediaMapItem[];
|
||||
}
|
||||
|
||||
export interface PandocMediaFile {
|
||||
relativePath: string;
|
||||
content: Uint8Array;
|
||||
}
|
||||
|
||||
export interface PreparedPandocMedia {
|
||||
map: PandocMediaMap;
|
||||
files: PandocMediaFile[];
|
||||
}
|
||||
|
||||
function pixelsToMillimeters(value: number) {
|
||||
return (value * 25.4) / DOCX_MEDIA_CSS_DPI;
|
||||
}
|
||||
|
||||
function physicalLength(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0 || value > 10_000) {
|
||||
throw new Error("DOCX 媒体显示尺寸无效");
|
||||
}
|
||||
return `${pixelsToMillimeters(value).toFixed(3)}mm`;
|
||||
}
|
||||
|
||||
function assertPng(content: Uint8Array) {
|
||||
const signature = [
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||
];
|
||||
if (
|
||||
content.byteLength < 24 ||
|
||||
signature.some((value, index) => content[index] !== value) ||
|
||||
String.fromCharCode(...content.slice(12, 16)) !== "IHDR"
|
||||
) {
|
||||
throw new Error("DOCX 媒体不是有效 PNG");
|
||||
}
|
||||
const view = new DataView(
|
||||
content.buffer,
|
||||
content.byteOffset,
|
||||
content.byteLength
|
||||
);
|
||||
const width = view.getUint32(16);
|
||||
const height = view.getUint32(20);
|
||||
if (
|
||||
width < 1 ||
|
||||
height < 1 ||
|
||||
width > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
|
||||
height > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
|
||||
width * height > MAXIMUM_DOCX_MEDIA_PIXELS
|
||||
) {
|
||||
throw new Error("DOCX PNG 媒体像素尺寸超过限制");
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function preparePandocMedia(
|
||||
media: PreparedDocxMedia
|
||||
): PreparedPandocMedia {
|
||||
if (media.echartsErrors.length || media.mermaidErrors.length) {
|
||||
throw new Error("DOCX 图表渲染存在错误,已中止转换");
|
||||
}
|
||||
if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
|
||||
throw new Error("DOCX 媒体数量超过限制");
|
||||
}
|
||||
|
||||
const map: PandocMediaMap = {
|
||||
image: [],
|
||||
mermaid: [],
|
||||
echarts: []
|
||||
};
|
||||
const files: PandocMediaFile[] = [];
|
||||
const ids = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
const kindCounts: Record<DocxMediaKind, number> = {
|
||||
image: 0,
|
||||
mermaid: 0,
|
||||
echarts: 0
|
||||
};
|
||||
for (const [index, resource] of media.resources.entries()) {
|
||||
if (ids.has(resource.id)) {
|
||||
throw new Error(`DOCX 媒体 ID 重复:${resource.id}`);
|
||||
}
|
||||
ids.add(resource.id);
|
||||
kindCounts[resource.kind] += 1;
|
||||
if (
|
||||
resource.ordinal !== index + 1 ||
|
||||
resource.kindOrdinal !== kindCounts[resource.kind]
|
||||
) {
|
||||
throw new Error("DOCX 媒体顺序与捕获计划不一致");
|
||||
}
|
||||
if (resource.content.byteLength > MAXIMUM_DOCX_MEDIA_BYTES) {
|
||||
throw new Error(`DOCX 媒体 ${resource.id} 超过单文件大小限制`);
|
||||
}
|
||||
totalBytes += resource.content.byteLength;
|
||||
if (totalBytes > MAXIMUM_DOCX_TOTAL_MEDIA_BYTES) {
|
||||
throw new Error("DOCX PNG 媒体总大小超过限制");
|
||||
}
|
||||
const dimensions = assertPng(resource.content);
|
||||
if (
|
||||
dimensions.width !== resource.pixelWidth ||
|
||||
dimensions.height !== resource.pixelHeight
|
||||
) {
|
||||
throw new Error(
|
||||
`DOCX 媒体 ${resource.id} 的 PNG 像素声明不一致`
|
||||
);
|
||||
}
|
||||
const relativePath = `media/media-${String(
|
||||
resource.ordinal
|
||||
).padStart(3, "0")}.png`;
|
||||
map[resource.kind].push({
|
||||
id: resource.id,
|
||||
path: relativePath,
|
||||
alt_text: resource.altText || `${resource.kind} 图片`,
|
||||
...(resource.caption
|
||||
? { caption: resource.caption }
|
||||
: {}),
|
||||
width: physicalLength(resource.displayWidthPx),
|
||||
height: physicalLength(resource.displayHeightPx)
|
||||
});
|
||||
files.push({
|
||||
relativePath,
|
||||
content: resource.content
|
||||
});
|
||||
}
|
||||
if (media.totalBytes !== totalBytes) {
|
||||
throw new Error("DOCX 媒体总大小声明不一致");
|
||||
}
|
||||
return { map, files };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export type PandocProcessOutcome =
|
||||
| "completed"
|
||||
| "not-found"
|
||||
| "not-executable"
|
||||
| "timeout"
|
||||
| "aborted"
|
||||
| "output-limit";
|
||||
|
||||
export interface PandocProcessResult {
|
||||
outcome: PandocProcessOutcome;
|
||||
exitCode: number | null;
|
||||
stdout: Uint8Array;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface PandocProcessOptions {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs: number;
|
||||
maxStdoutBytes: number;
|
||||
maxStderrBytes: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type PandocProcessRunner = (
|
||||
executablePath: string,
|
||||
arguments_: readonly string[],
|
||||
options: PandocProcessOptions
|
||||
) => Promise<PandocProcessResult>;
|
||||
|
||||
function decodeStderr(chunks: Buffer[]) {
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
export const runPandocProcess: PandocProcessRunner = (
|
||||
executablePath,
|
||||
arguments_,
|
||||
options
|
||||
) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(executablePath, [...arguments_], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
let settled = false;
|
||||
let forcedOutcome: PandocProcessOutcome | undefined;
|
||||
|
||||
const settle = (
|
||||
outcome: PandocProcessOutcome,
|
||||
exitCode: number | null
|
||||
) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
options.signal?.removeEventListener("abort", abort);
|
||||
resolve({
|
||||
outcome,
|
||||
exitCode,
|
||||
stdout: Buffer.concat(stdout),
|
||||
stderr: decodeStderr(stderr)
|
||||
});
|
||||
};
|
||||
const stop = (outcome: PandocProcessOutcome) => {
|
||||
if (settled || forcedOutcome) {
|
||||
return;
|
||||
}
|
||||
forcedOutcome = outcome;
|
||||
child.kill();
|
||||
};
|
||||
const abort = () => stop("aborted");
|
||||
const timeout = setTimeout(
|
||||
() => stop("timeout"),
|
||||
options.timeoutMs
|
||||
);
|
||||
timeout.unref();
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
abort();
|
||||
} else {
|
||||
options.signal?.addEventListener("abort", abort, {
|
||||
once: true
|
||||
});
|
||||
}
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.byteLength;
|
||||
if (stdoutBytes > options.maxStdoutBytes) {
|
||||
stop("output-limit");
|
||||
return;
|
||||
}
|
||||
stdout.push(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrBytes += chunk.byteLength;
|
||||
if (stderrBytes > options.maxStderrBytes) {
|
||||
stop("output-limit");
|
||||
return;
|
||||
}
|
||||
stderr.push(chunk);
|
||||
});
|
||||
child.on("error", (error: NodeJS.ErrnoException) => {
|
||||
const outcome =
|
||||
error.code === "ENOENT"
|
||||
? "not-found"
|
||||
: error.code === "EACCES" || error.code === "EPERM"
|
||||
? "not-executable"
|
||||
: "not-executable";
|
||||
settle(outcome, null);
|
||||
});
|
||||
child.on("close", (exitCode) => {
|
||||
settle(forcedOutcome ?? "completed", exitCode);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { DOCX_PANDOC_VERSION } from "@md-to-pdf/core";
|
||||
|
||||
export interface PandocRuntimeArtifact {
|
||||
platform: "win32" | "linux";
|
||||
architecture: "x64";
|
||||
archiveType: "zip" | "tar.gz";
|
||||
downloadUrl: string;
|
||||
sha256: string;
|
||||
executableRelativePath: string;
|
||||
licenseFiles: readonly string[];
|
||||
}
|
||||
|
||||
export const pandocRuntimeManifest = {
|
||||
version: DOCX_PANDOC_VERSION,
|
||||
license: "GPL-2.0-or-later",
|
||||
projectUrl: "https://pandoc.org/",
|
||||
sourceArchiveUrl:
|
||||
"https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-source.tar.gz",
|
||||
artifacts: [
|
||||
{
|
||||
platform: "win32",
|
||||
architecture: "x64",
|
||||
archiveType: "zip",
|
||||
downloadUrl:
|
||||
"https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-windows-x86_64.zip",
|
||||
sha256:
|
||||
"C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A",
|
||||
executableRelativePath: "pandoc.exe",
|
||||
licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"]
|
||||
},
|
||||
{
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
archiveType: "tar.gz",
|
||||
downloadUrl:
|
||||
"https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-linux-amd64.tar.gz",
|
||||
sha256:
|
||||
"A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508",
|
||||
executableRelativePath: "bin/pandoc",
|
||||
licenseFiles: ["COPYING.md", "COPYRIGHT"]
|
||||
}
|
||||
] satisfies readonly PandocRuntimeArtifact[]
|
||||
} as const;
|
||||
|
||||
export function findPandocRuntimeArtifact(
|
||||
platform: NodeJS.Platform,
|
||||
architecture: string
|
||||
) {
|
||||
return pandocRuntimeManifest.artifacts.find(
|
||||
(artifact) =>
|
||||
artifact.platform === platform &&
|
||||
artifact.architecture === architecture
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
DOCX_PANDOC_VERSION,
|
||||
type DocxCapability,
|
||||
type DocxRuntimeStatus
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
MAXIMUM_REFERENCE_DOCX_BYTES,
|
||||
readReferenceDocxPackage
|
||||
} from "./reference-package.js";
|
||||
import {
|
||||
runPandocProcess,
|
||||
type PandocProcessOptions,
|
||||
type PandocProcessResult,
|
||||
type PandocProcessRunner
|
||||
} from "./pandoc-process.js";
|
||||
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
|
||||
const DEFAULT_REFERENCE_TIMEOUT_MS = 5_000;
|
||||
const MAXIMUM_PROBE_OUTPUT_BYTES = 16 * 1024;
|
||||
|
||||
export interface PandocRuntimeResolution {
|
||||
capability: DocxCapability;
|
||||
executablePath?: string;
|
||||
}
|
||||
|
||||
export interface PandocRuntimeOptions {
|
||||
configuredPath?: string;
|
||||
desktopResourcesPath?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
architecture?: string;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
probeTimeoutMs?: number;
|
||||
referenceTimeoutMs?: number;
|
||||
runner?: PandocProcessRunner;
|
||||
}
|
||||
|
||||
interface RuntimeCandidate {
|
||||
executablePath: string;
|
||||
exclusive: boolean;
|
||||
}
|
||||
|
||||
export class PandocRuntimeUnavailableError extends Error {
|
||||
constructor(
|
||||
readonly status: Exclude<DocxRuntimeStatus, "available">,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PandocRuntimeUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
function unavailableCapability(
|
||||
status: Exclude<DocxRuntimeStatus, "available">,
|
||||
message: string,
|
||||
detectedVersion?: string
|
||||
): DocxCapability {
|
||||
return {
|
||||
format: "docx",
|
||||
status,
|
||||
expectedVersion: DOCX_PANDOC_VERSION,
|
||||
...(detectedVersion ? { detectedVersion } : {}),
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
function availableCapability(): DocxCapability {
|
||||
return {
|
||||
format: "docx",
|
||||
status: "available",
|
||||
expectedVersion: DOCX_PANDOC_VERSION,
|
||||
detectedVersion: DOCX_PANDOC_VERSION
|
||||
};
|
||||
}
|
||||
|
||||
function parseDetectedVersion(result: PandocProcessResult) {
|
||||
const firstLine = new TextDecoder()
|
||||
.decode(result.stdout)
|
||||
.split(/\r?\n/u)[0]
|
||||
?.trim();
|
||||
return firstLine?.replace(/^pandoc\s+/u, "").trim() ?? "";
|
||||
}
|
||||
|
||||
function createProbeOptions(
|
||||
timeoutMs: number,
|
||||
environment: NodeJS.ProcessEnv
|
||||
): PandocProcessOptions {
|
||||
return {
|
||||
timeoutMs,
|
||||
maxStdoutBytes: MAXIMUM_PROBE_OUTPUT_BYTES,
|
||||
maxStderrBytes: MAXIMUM_PROBE_OUTPUT_BYTES,
|
||||
env: environment
|
||||
};
|
||||
}
|
||||
|
||||
function outcomeStatus(result: PandocProcessResult) {
|
||||
if (result.outcome === "not-found") {
|
||||
return "not-found" as const;
|
||||
}
|
||||
if (
|
||||
result.outcome === "timeout" ||
|
||||
result.outcome === "aborted"
|
||||
) {
|
||||
return "probe-timeout" as const;
|
||||
}
|
||||
return "not-executable" as const;
|
||||
}
|
||||
|
||||
function createCandidates(options: PandocRuntimeOptions) {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const architecture = options.architecture ?? process.arch;
|
||||
const environment = options.environment ?? process.env;
|
||||
const configuredPath =
|
||||
options.configuredPath ??
|
||||
environment.DOCX_PANDOC_PATH?.trim() ??
|
||||
"";
|
||||
if (configuredPath) {
|
||||
return [
|
||||
{
|
||||
executablePath: configuredPath,
|
||||
exclusive: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const candidates: RuntimeCandidate[] = [];
|
||||
if (
|
||||
platform === "win32" &&
|
||||
architecture === "x64" &&
|
||||
options.desktopResourcesPath
|
||||
) {
|
||||
candidates.push({
|
||||
executablePath: path.join(
|
||||
options.desktopResourcesPath,
|
||||
"pandoc",
|
||||
DOCX_PANDOC_VERSION,
|
||||
"windows-x86_64",
|
||||
"pandoc.exe"
|
||||
),
|
||||
exclusive: false
|
||||
});
|
||||
}
|
||||
if (platform === "linux" && architecture === "x64") {
|
||||
candidates.push({
|
||||
executablePath: `/opt/pandoc/${DOCX_PANDOC_VERSION}/bin/pandoc`,
|
||||
exclusive: false
|
||||
});
|
||||
}
|
||||
candidates.push({
|
||||
executablePath: platform === "win32" ? "pandoc.exe" : "pandoc",
|
||||
exclusive: false
|
||||
});
|
||||
return candidates.filter(
|
||||
(candidate, index, all) =>
|
||||
all.findIndex(
|
||||
(item) => item.executablePath === candidate.executablePath
|
||||
) === index
|
||||
);
|
||||
}
|
||||
|
||||
async function probeCandidate(
|
||||
candidate: RuntimeCandidate,
|
||||
runner: PandocProcessRunner,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
timeoutMs: number
|
||||
): Promise<PandocRuntimeResolution> {
|
||||
const result = await runner(
|
||||
candidate.executablePath,
|
||||
["--version"],
|
||||
createProbeOptions(timeoutMs, environment)
|
||||
);
|
||||
if (
|
||||
result.outcome !== "completed" ||
|
||||
result.exitCode !== 0
|
||||
) {
|
||||
const status =
|
||||
result.outcome === "completed"
|
||||
? "not-executable"
|
||||
: outcomeStatus(result);
|
||||
return {
|
||||
capability: unavailableCapability(
|
||||
status,
|
||||
status === "probe-timeout"
|
||||
? "Pandoc 版本探测超时"
|
||||
: status === "not-found"
|
||||
? "未找到 Pandoc 运行时"
|
||||
: "Pandoc 运行时无法执行"
|
||||
)
|
||||
};
|
||||
}
|
||||
const detectedVersion = parseDetectedVersion(result);
|
||||
if (detectedVersion !== DOCX_PANDOC_VERSION) {
|
||||
return {
|
||||
capability: unavailableCapability(
|
||||
"version-mismatch",
|
||||
`Pandoc 版本不匹配,需要 ${DOCX_PANDOC_VERSION}`,
|
||||
detectedVersion || "未知"
|
||||
)
|
||||
};
|
||||
}
|
||||
return {
|
||||
capability: availableCapability(),
|
||||
executablePath: candidate.executablePath
|
||||
};
|
||||
}
|
||||
|
||||
export async function probePandocRuntime(
|
||||
options: PandocRuntimeOptions = {}
|
||||
): Promise<PandocRuntimeResolution> {
|
||||
const environment = options.environment ?? process.env;
|
||||
const runner = options.runner ?? runPandocProcess;
|
||||
let lastMissing: PandocRuntimeResolution | undefined;
|
||||
for (const candidate of createCandidates(options)) {
|
||||
const resolution = await probeCandidate(
|
||||
candidate,
|
||||
runner,
|
||||
environment,
|
||||
options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
|
||||
);
|
||||
if (resolution.capability.status === "available") {
|
||||
return resolution;
|
||||
}
|
||||
if (
|
||||
resolution.capability.status !== "not-found" ||
|
||||
candidate.exclusive
|
||||
) {
|
||||
return resolution;
|
||||
}
|
||||
lastMissing = resolution;
|
||||
}
|
||||
return (
|
||||
lastMissing ?? {
|
||||
capability: unavailableCapability(
|
||||
"not-found",
|
||||
"未找到 Pandoc 运行时"
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export class PandocRuntime {
|
||||
private resolution:
|
||||
| Promise<PandocRuntimeResolution>
|
||||
| undefined = undefined;
|
||||
private defaultReference:
|
||||
| Promise<Uint8Array>
|
||||
| undefined = undefined;
|
||||
|
||||
constructor(private readonly options: PandocRuntimeOptions = {}) {}
|
||||
|
||||
probe() {
|
||||
this.resolution ??= probePandocRuntime(this.options);
|
||||
return this.resolution;
|
||||
}
|
||||
|
||||
async getExecutablePath() {
|
||||
const resolution = await this.probe();
|
||||
if (resolution.capability.status !== "available") {
|
||||
throw new PandocRuntimeUnavailableError(
|
||||
resolution.capability.status,
|
||||
resolution.capability.message
|
||||
);
|
||||
}
|
||||
if (!resolution.executablePath) {
|
||||
throw new PandocRuntimeUnavailableError(
|
||||
"not-found",
|
||||
"Pandoc capability 可用但缺少可执行路径"
|
||||
);
|
||||
}
|
||||
return resolution.executablePath;
|
||||
}
|
||||
|
||||
async getDefaultReferenceDocx() {
|
||||
this.defaultReference ??= this.loadDefaultReferenceDocx();
|
||||
try {
|
||||
return (await this.defaultReference).slice();
|
||||
} catch (error) {
|
||||
this.defaultReference = undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.resolution = undefined;
|
||||
this.defaultReference = undefined;
|
||||
}
|
||||
|
||||
private async loadDefaultReferenceDocx() {
|
||||
const executablePath = await this.getExecutablePath();
|
||||
const runner = this.options.runner ?? runPandocProcess;
|
||||
const result = await runner(
|
||||
executablePath,
|
||||
["--print-default-data-file", "reference.docx"],
|
||||
{
|
||||
timeoutMs:
|
||||
this.options.referenceTimeoutMs ??
|
||||
DEFAULT_REFERENCE_TIMEOUT_MS,
|
||||
maxStdoutBytes: MAXIMUM_REFERENCE_DOCX_BYTES,
|
||||
maxStderrBytes: MAXIMUM_PROBE_OUTPUT_BYTES,
|
||||
env: this.options.environment ?? process.env
|
||||
}
|
||||
);
|
||||
if (
|
||||
result.outcome !== "completed" ||
|
||||
result.exitCode !== 0
|
||||
) {
|
||||
const status =
|
||||
result.outcome === "timeout" ||
|
||||
result.outcome === "aborted"
|
||||
? "probe-timeout"
|
||||
: result.outcome === "not-found"
|
||||
? "not-found"
|
||||
: "not-executable";
|
||||
throw new PandocRuntimeUnavailableError(
|
||||
status,
|
||||
"无法读取 Pandoc 默认 reference.docx"
|
||||
);
|
||||
}
|
||||
readReferenceDocxPackage(result.stdout);
|
||||
return result.stdout;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,21 @@ export const MAXIMUM_REFERENCE_DOCX_BYTES = 2 * 1024 * 1024;
|
||||
export const MAXIMUM_REFERENCE_ENTRY_COUNT = 256;
|
||||
export const MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES =
|
||||
64 * 1024 * 1024;
|
||||
export const MAXIMUM_GENERATED_DOCX_ENTRY_COUNT = 512;
|
||||
export const MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES =
|
||||
128 * 1024 * 1024;
|
||||
|
||||
export interface DocxPackageLimits {
|
||||
maximumBytes: number;
|
||||
maximumEntryCount: number;
|
||||
maximumUncompressedBytes: number;
|
||||
}
|
||||
|
||||
export const referenceDocxPackageLimits: DocxPackageLimits = {
|
||||
maximumBytes: MAXIMUM_REFERENCE_DOCX_BYTES,
|
||||
maximumEntryCount: MAXIMUM_REFERENCE_ENTRY_COUNT,
|
||||
maximumUncompressedBytes: MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES
|
||||
};
|
||||
|
||||
export const requiredReferenceDocxParts = [
|
||||
"[Content_Types].xml",
|
||||
@@ -28,6 +43,23 @@ interface ZipDirectoryEntry {
|
||||
uncompressedSize: number;
|
||||
}
|
||||
|
||||
function fingerprintEntries(
|
||||
entries: ReadonlyMap<string, Uint8Array>
|
||||
) {
|
||||
const hash = createHash("sha256");
|
||||
for (const [name, content] of [...entries].sort(
|
||||
([left], [right]) => left.localeCompare(right)
|
||||
)) {
|
||||
const length = Buffer.alloc(8);
|
||||
length.writeBigUInt64BE(BigInt(content.byteLength));
|
||||
hash.update(name, "utf8");
|
||||
hash.update(Uint8Array.of(0));
|
||||
hash.update(length);
|
||||
hash.update(content);
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(content: Uint8Array) {
|
||||
const view = new DataView(
|
||||
content.buffer,
|
||||
@@ -59,10 +91,13 @@ function validateEntryName(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function inspectZipDirectory(content: Uint8Array) {
|
||||
function inspectZipDirectory(
|
||||
content: Uint8Array,
|
||||
limits: DocxPackageLimits
|
||||
) {
|
||||
if (
|
||||
content.byteLength < 22 ||
|
||||
content.byteLength > MAXIMUM_REFERENCE_DOCX_BYTES
|
||||
content.byteLength > limits.maximumBytes
|
||||
) {
|
||||
throw new Error("reference.docx 压缩包大小超过限制或内容不完整");
|
||||
}
|
||||
@@ -81,7 +116,7 @@ function inspectZipDirectory(content: Uint8Array) {
|
||||
if (
|
||||
diskNumber !== 0 ||
|
||||
directoryDisk !== 0 ||
|
||||
entryCount > MAXIMUM_REFERENCE_ENTRY_COUNT ||
|
||||
entryCount > limits.maximumEntryCount ||
|
||||
entryCount === 0 ||
|
||||
directoryOffset + directorySize > endOffset
|
||||
) {
|
||||
@@ -129,7 +164,7 @@ function inspectZipDirectory(content: Uint8Array) {
|
||||
totalUncompressedBytes += uncompressedSize;
|
||||
if (
|
||||
totalUncompressedBytes >
|
||||
MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES
|
||||
limits.maximumUncompressedBytes
|
||||
) {
|
||||
throw new Error("reference.docx 解压后总大小超过限制");
|
||||
}
|
||||
@@ -142,10 +177,11 @@ function inspectZipDirectory(content: Uint8Array) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function readReferenceDocxPackage(
|
||||
content: Uint8Array
|
||||
export function readDocxPackage(
|
||||
content: Uint8Array,
|
||||
limits: DocxPackageLimits
|
||||
): ReferenceDocxPackage {
|
||||
const directory = inspectZipDirectory(content);
|
||||
const directory = inspectZipDirectory(content, limits);
|
||||
const unzipped = unzipSync(content);
|
||||
const entries = new Map<string, Uint8Array>();
|
||||
for (const descriptor of directory) {
|
||||
@@ -169,13 +205,17 @@ export function readReferenceDocxPackage(
|
||||
}
|
||||
}
|
||||
return {
|
||||
fingerprint: createHash("sha256")
|
||||
.update(content)
|
||||
.digest("hex"),
|
||||
fingerprint: fingerprintEntries(entries),
|
||||
entries
|
||||
};
|
||||
}
|
||||
|
||||
export function readReferenceDocxPackage(
|
||||
content: Uint8Array
|
||||
): ReferenceDocxPackage {
|
||||
return readDocxPackage(content, referenceDocxPackageLimits);
|
||||
}
|
||||
|
||||
export function writeReferenceDocxPackage(
|
||||
entries: ReadonlyMap<string, Uint8Array>
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { MAXIMUM_DOCX_OUTPUT_BYTES } from "@md-to-pdf/core";
|
||||
import {
|
||||
CONTENT_TYPES_NAMESPACE,
|
||||
OFFICE_RELATIONSHIP_NAMESPACE,
|
||||
@@ -8,7 +9,12 @@ import {
|
||||
parseXmlPart,
|
||||
type XmlElement
|
||||
} from "./ooxml.js";
|
||||
import { readReferenceDocxPackage } from "./reference-package.js";
|
||||
import {
|
||||
MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
|
||||
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES,
|
||||
readDocxPackage,
|
||||
readReferenceDocxPackage
|
||||
} from "./reference-package.js";
|
||||
|
||||
const HEADER_RELATIONSHIP_TYPE =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
|
||||
@@ -263,10 +269,18 @@ function validateStyles(entries: ReadonlyMap<string, Uint8Array>) {
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDynamicReferenceDocx(
|
||||
content: Uint8Array
|
||||
function validateDocx(
|
||||
content: Uint8Array,
|
||||
generatedOutput: boolean
|
||||
): DynamicReferenceValidation {
|
||||
const reference = readReferenceDocxPackage(content);
|
||||
const reference = generatedOutput
|
||||
? readDocxPackage(content, {
|
||||
maximumBytes: MAXIMUM_DOCX_OUTPUT_BYTES,
|
||||
maximumEntryCount: MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
|
||||
maximumUncompressedBytes:
|
||||
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES
|
||||
})
|
||||
: readReferenceDocxPackage(content);
|
||||
let xmlPartCount = 0;
|
||||
let relationshipCount = 0;
|
||||
const relationshipParts = new Map<
|
||||
@@ -345,3 +359,15 @@ export function validateDynamicReferenceDocx(
|
||||
footerCount
|
||||
};
|
||||
}
|
||||
|
||||
export function validateDynamicReferenceDocx(
|
||||
content: Uint8Array
|
||||
): DynamicReferenceValidation {
|
||||
return validateDocx(content, false);
|
||||
}
|
||||
|
||||
export function validateGeneratedDocx(
|
||||
content: Uint8Array
|
||||
): DynamicReferenceValidation {
|
||||
return validateDocx(content, true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user