feat: 完成 DOCX 视觉门禁与字体兼容映射

This commit is contained in:
SkyJourney
2026-07-31 20:13:19 +08:00
parent 10bc62cee4
commit e5da699ea3
32 changed files with 3015 additions and 17 deletions
@@ -0,0 +1,348 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, realpath, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, extname, join, resolve } from "node:path";
import { performance } from "node:perf_hooks";
import { promisify } from "node:util";
import {
assertPdfBytes,
type PdfAdapterCapability,
type PdfAdapterGeneration,
type PdfArtifactAdapter,
} from "./adapters.js";
import type { PdfSnapshotSource } from "./types.js";
const execFileAsync = promisify(execFile);
const MAX_OFFICE_INPUT_BYTES = 250 * 1024 * 1024;
const MAX_OFFICE_OUTPUT_BYTES = 500 * 1024 * 1024;
export type OfficeClientKind = "word" | "wps";
export interface OfficePdfAdapterInput {
docxPath: string;
}
export interface OfficeExportMetadata {
pageCount?: number;
bytes: number;
}
export interface OfficeAutomationBackend {
probe(progId: string, timeoutMs: number): Promise<boolean>;
exportPdf(options: {
client: OfficeClientKind;
progId: string;
inputPath: string;
outputPath: string;
timeoutMs: number;
}): Promise<OfficeExportMetadata>;
}
export interface OfficePdfAdapterOptions {
label?: string;
timeoutMs?: number;
backend?: OfficeAutomationBackend;
}
interface OfficeDescriptor {
id: string;
client: OfficeClientKind;
progId: string;
defaultLabel: string;
}
const POWERSHELL_PROBE_SCRIPT = `
$ErrorActionPreference = 'Stop'
$progId = $env:MD_TO_PDF_OFFICE_PROGID
$type = [Type]::GetTypeFromProgID($progId, $false)
if ($null -eq $type) { exit 2 }
[pscustomobject]@{ available = $true; progId = $progId } |
ConvertTo-Json -Compress
`;
const POWERSHELL_EXPORT_SCRIPT = `
$ErrorActionPreference = 'Stop'
$client = $env:MD_TO_PDF_OFFICE_CLIENT
$progId = $env:MD_TO_PDF_OFFICE_PROGID
$resolvedInput = (Resolve-Path -LiteralPath $env:MD_TO_PDF_OFFICE_INPUT).Path
$resolvedOutput = [System.IO.Path]::GetFullPath($env:MD_TO_PDF_OFFICE_OUTPUT)
if ([System.IO.File]::Exists($resolvedOutput)) {
throw "Office PDF 输出已存在:$resolvedOutput"
}
$application = $null
$document = $null
try {
$application = New-Object -ComObject $progId
$application.Visible = $false
try { $application.DisplayAlerts = 0 } catch {}
if ($client -eq 'word') {
$application.AutomationSecurity = 3
$application.Options.SaveNormalPrompt = $false
$document = $application.Documents.OpenNoRepairDialog(
$resolvedInput, $false, $true, $false, '', '', $false,
'', '', 0, 0, $false, $false, 0, $true
)
} else {
try { $application.AutomationSecurity = 3 } catch {}
$document = $application.Documents.Open(
$resolvedInput, $false, $true, $false
)
}
$pageCount = $null
try { $pageCount = $document.ComputeStatistics(2) } catch {}
$document.ExportAsFixedFormat($resolvedOutput, 17)
[pscustomobject]@{
pages = $pageCount
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
} | ConvertTo-Json -Compress
} finally {
if ($null -ne $document) {
try { $document.Close($false) } finally {
[System.Runtime.InteropServices.Marshal]::ReleaseComObject(
$document
) | Out-Null
}
}
if ($null -ne $application) {
try { $application.Quit() } finally {
[System.Runtime.InteropServices.Marshal]::ReleaseComObject(
$application
) | Out-Null
}
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
`;
function encodePowerShell(script: string): string {
return Buffer.from(script, "utf16le").toString("base64");
}
function parseJsonLine(
stdout: string,
): Record<string, unknown> {
const line = stdout
.split(/\r?\n/u)
.map((entry) => entry.trim())
.filter(Boolean)
.at(-1);
if (!line) {
throw new Error("Office 自动化没有返回结果");
}
const parsed: unknown = JSON.parse(line);
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Office 自动化返回格式无效");
}
return parsed as Record<string, unknown>;
}
export class PowerShellOfficeAutomationBackend
implements OfficeAutomationBackend
{
async probe(progId: string, timeoutMs: number): Promise<boolean> {
if (process.platform !== "win32") {
return false;
}
try {
await execFileAsync(
"powershell.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(POWERSHELL_PROBE_SCRIPT),
],
{
timeout: Math.min(timeoutMs, 15_000),
windowsHide: true,
encoding: "utf8",
env: {
...process.env,
MD_TO_PDF_OFFICE_PROGID: progId,
},
},
);
return true;
} catch {
return false;
}
}
async exportPdf(options: {
client: OfficeClientKind;
progId: string;
inputPath: string;
outputPath: string;
timeoutMs: number;
}): Promise<OfficeExportMetadata> {
const { stdout } = await execFileAsync(
"powershell.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(POWERSHELL_EXPORT_SCRIPT),
],
{
timeout: options.timeoutMs,
windowsHide: true,
encoding: "utf8",
maxBuffer: 1024 * 1024,
env: {
...process.env,
MD_TO_PDF_OFFICE_CLIENT: options.client,
MD_TO_PDF_OFFICE_PROGID: options.progId,
MD_TO_PDF_OFFICE_INPUT: options.inputPath,
MD_TO_PDF_OFFICE_OUTPUT: options.outputPath,
},
},
);
const parsed = parseJsonLine(stdout);
const bytes = parsed.bytes;
const pages = parsed.pages;
if (typeof bytes !== "number" || !Number.isSafeInteger(bytes)) {
throw new Error("Office 自动化未返回有效的 PDF 字节数");
}
return {
bytes,
...(typeof pages === "number" && Number.isSafeInteger(pages)
? { pageCount: pages }
: {}),
};
}
}
async function validateDocxPath(inputPath: string): Promise<string> {
if (extname(inputPath).toLowerCase() !== ".docx") {
throw new Error("Office PDF 适配器只接受 .docx 文件");
}
const resolvedPath = await realpath(resolve(inputPath));
const inputStat = await stat(resolvedPath);
if (!inputStat.isFile()) {
throw new Error("Office PDF 输入必须是普通文件");
}
if (
inputStat.size <= 0 ||
inputStat.size > MAX_OFFICE_INPUT_BYTES
) {
throw new Error(
`Office PDF 输入大小超限:${inputStat.size}`,
);
}
return resolvedPath;
}
function createOfficePdfAdapter(
descriptor: OfficeDescriptor,
options: OfficePdfAdapterOptions = {},
): PdfArtifactAdapter<OfficePdfAdapterInput> {
const backend =
options.backend ?? new PowerShellOfficeAutomationBackend();
const timeoutMs = options.timeoutMs ?? 180_000;
const source: PdfSnapshotSource = {
kind: descriptor.client,
label: options.label ?? descriptor.defaultLabel,
};
return {
id: descriptor.id,
source,
async probe(): Promise<PdfAdapterCapability> {
const available = await backend.probe(
descriptor.progId,
timeoutMs,
);
return {
available,
adapterId: descriptor.id,
source,
detail: available
? `已注册 ${descriptor.progId}`
: `未找到 ${descriptor.progId}`,
};
},
async generate(
input: OfficePdfAdapterInput,
): Promise<PdfAdapterGeneration> {
const inputPath = await validateDocxPath(input.docxPath);
const temporaryDirectory = await mkdtemp(
join(tmpdir(), `md-to-pdf-${descriptor.client}-`),
);
const outputPath = join(
temporaryDirectory,
`${basename(inputPath, extname(inputPath))}.pdf`,
);
const startedAt = performance.now();
try {
const metadata = await backend.exportPdf({
client: descriptor.client,
progId: descriptor.progId,
inputPath,
outputPath,
timeoutMs,
});
const outputStat = await stat(outputPath);
if (
outputStat.size <= 0 ||
outputStat.size > MAX_OFFICE_OUTPUT_BYTES ||
outputStat.size !== metadata.bytes
) {
throw new Error(
`Office PDF 输出大小无效:${outputStat.size}`,
);
}
const pdf = Uint8Array.from(await readFile(outputPath));
assertPdfBytes(pdf, source.label);
return {
source,
pdf,
...(metadata.pageCount === undefined
? {}
: { pageCount: metadata.pageCount }),
elapsedMs: performance.now() - startedAt,
diagnostics: [],
};
} finally {
await rm(temporaryDirectory, {
recursive: true,
force: true,
});
}
},
};
}
export function createWordPdfAdapter(
options: OfficePdfAdapterOptions = {},
): PdfArtifactAdapter<OfficePdfAdapterInput> {
return createOfficePdfAdapter(
{
id: "word",
client: "word",
progId: "Word.Application",
defaultLabel: "Microsoft Word",
},
options,
);
}
export function createWpsPdfAdapter(
options: OfficePdfAdapterOptions = {},
): PdfArtifactAdapter<OfficePdfAdapterInput> {
return createOfficePdfAdapter(
{
id: "wps",
client: "wps",
progId: "KWPS.Application",
defaultLabel: "WPS Writer",
},
options,
);
}