feat: 完成 DOCX 视觉门禁与字体兼容映射
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user