Files
MorphDoc/packages/document-visual-diff/src/office-adapter.ts
T
SkyJourney 64445322eb release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 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 均已生成并校验。
2026-08-26 10:50:20 +08:00

709 lines
20 KiB
TypeScript

import { execFile } from "node:child_process";
import { mkdtemp, readFile, realpath, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, 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 OfficeInlineShapeObservation {
index: number;
widthPt: number;
heightPt: number;
type?: number;
}
export interface OfficeDocxRoundTripMetadata {
bytes: number;
before: OfficeInlineShapeObservation[];
saved: OfficeInlineShapeObservation[];
resizedShapeIndex: number;
resizeScale: number;
}
export interface OfficeDocxRoundTripGeneration
extends OfficeDocxRoundTripMetadata {
client: OfficeClientKind;
docx: Uint8Array;
elapsedMs: number;
}
export interface OfficeDocxRoundTripAdapter {
id: string;
client: OfficeClientKind;
probe(): Promise<PdfAdapterCapability>;
generate(
input: OfficePdfAdapterInput,
): Promise<OfficeDocxRoundTripGeneration>;
}
export interface OfficeAutomationBackend {
probe(progId: string, timeoutMs: number): Promise<boolean>;
exportPdf(options: {
client: OfficeClientKind;
progId: string;
inputPath: string;
outputPath: string;
timeoutMs: number;
}): Promise<OfficeExportMetadata>;
roundTripDocx?(options: {
client: OfficeClientKind;
progId: string;
inputPath: string;
outputPath: string;
timeoutMs: number;
resizeScale: number;
}): Promise<OfficeDocxRoundTripMetadata>;
}
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)
$deadline = [DateTime]::UtcNow.AddSeconds(10)
do {
if ([System.IO.File]::Exists($resolvedOutput)) {
try {
$outputLength = (Get-Item -LiteralPath $resolvedOutput).Length
if ($outputLength -gt 0) { break }
} catch {}
}
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if (
-not [System.IO.File]::Exists($resolvedOutput) -or
(Get-Item -LiteralPath $resolvedOutput).Length -le 0
) {
throw "Office PDF 导出完成后未生成有效文件:$resolvedOutput"
}
[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()
}
`;
const POWERSHELL_ROUND_TRIP_SCRIPT = `
$ErrorActionPreference = 'Stop'
$client = $env:MD_TO_PDF_OFFICE_CLIENT
$progId = $env:MD_TO_PDF_OFFICE_PROGID
$resizeScale = [double]::Parse(
$env:MD_TO_PDF_OFFICE_RESIZE_SCALE,
[Globalization.CultureInfo]::InvariantCulture
)
$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 DOCX 互存输出已存在:$resolvedOutput"
}
[System.IO.File]::Copy($resolvedInput, $resolvedOutput, $false)
$application = $null
$document = $null
function Read-InlineShapes($value) {
$result = @()
for ($index = 1; $index -le $value.InlineShapes.Count; $index += 1) {
$shape = $value.InlineShapes.Item($index)
$result += [pscustomobject]@{
index = $index
widthPt = [double]$shape.Width
heightPt = [double]$shape.Height
type = [int]$shape.Type
}
}
return @($result)
}
function Open-Document($application, $client, $path, $readOnly) {
if ($client -eq 'word' -and $readOnly) {
return $application.Documents.OpenNoRepairDialog(
$path, $false, $readOnly, $false, '', '', $false,
'', '', 0, 0, $false, $false, 0, $true
)
}
return $application.Documents.Open($path, $false, $readOnly, $false)
}
try {
$application = New-Object -ComObject $progId
$application.Visible = $false
try { $application.DisplayAlerts = 0 } catch {}
try { $application.AutomationSecurity = 3 } catch {}
$document = Open-Document $application $client $resolvedOutput $false
try { $document.EmbedTrueTypeFonts = $false } catch {}
try { $document.SaveSubsetFonts = $false } catch {}
$before = @(Read-InlineShapes $document)
if ($before.Count -lt 1) {
throw 'Office DOCX 不包含可缩放的内联图片'
}
$shape = $document.InlineShapes.Item(1)
$originalWidth = [double]$shape.Width
$originalHeight = [double]$shape.Height
$shape.Width = [single]($originalWidth * $resizeScale)
$expectedHeight = $originalHeight * $resizeScale
if ([Math]::Abs([double]$shape.Height - $expectedHeight) -gt 0.5) {
$shape.Height = [single]$expectedHeight
}
if (
[Math]::Abs([double]$shape.Width - ($originalWidth * $resizeScale)) -gt 0.5 -or
[Math]::Abs([double]$shape.Height - $expectedHeight) -gt 0.5
) {
throw (
'Office 未在内存中按比例缩放首张图片:' +
"readOnly=$($document.ReadOnly), " +
"before=$originalWidth/$originalHeight, " +
"actual=$([double]$shape.Width)/$([double]$shape.Height), " +
"expected=$($originalWidth * $resizeScale)/$expectedHeight"
)
}
$document.Close($true)
[System.Runtime.InteropServices.Marshal]::ReleaseComObject(
$document
) | Out-Null
$document = $null
$document = Open-Document $application $client $resolvedOutput $true
$saved = @(Read-InlineShapes $document)
[pscustomobject]@{
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
before = $before
saved = $saved
resizedShapeIndex = 1
resizeScale = $resizeScale
} | ConvertTo-Json -Compress -Depth 5
} 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 roundTripDocx(options: {
client: OfficeClientKind;
progId: string;
inputPath: string;
outputPath: string;
timeoutMs: number;
resizeScale: number;
}): Promise<OfficeDocxRoundTripMetadata> {
const { stdout } = await execFileAsync(
"powershell.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(POWERSHELL_ROUND_TRIP_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,
MD_TO_PDF_OFFICE_RESIZE_SCALE: String(options.resizeScale),
},
},
);
const parsed = parseJsonLine(stdout);
const bytes = parsed.bytes;
const before = parsed.before;
const saved = parsed.saved;
if (
typeof bytes !== "number" ||
!Number.isSafeInteger(bytes) ||
!Array.isArray(before) ||
!Array.isArray(saved)
) {
throw new Error("Office DOCX 互存返回格式无效");
}
const parseShapes = (value: unknown[]) =>
value.map((entry) => {
if (
typeof entry !== "object" ||
entry === null ||
typeof (entry as Record<string, unknown>).index !== "number" ||
typeof (entry as Record<string, unknown>).widthPt !== "number" ||
typeof (entry as Record<string, unknown>).heightPt !== "number"
) {
throw new Error("Office 内联图片观测格式无效");
}
const shape = entry as Record<string, unknown>;
return {
index: shape.index as number,
widthPt: shape.widthPt as number,
heightPt: shape.heightPt as number,
...(typeof shape.type === "number"
? { type: shape.type }
: {}),
};
});
return {
bytes,
before: parseShapes(before),
saved: parseShapes(saved),
resizedShapeIndex: 1,
resizeScale: options.resizeScale,
};
}
}
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 {
let metadata: OfficeExportMetadata | undefined;
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
metadata = await backend.exportPdf({
client: descriptor.client,
progId: descriptor.progId,
inputPath,
outputPath,
timeoutMs,
});
break;
} catch (error) {
lastError = error;
await rm(outputPath, { force: true });
if (attempt < 3) {
await new Promise<void>((resolve) => {
setTimeout(resolve, attempt * 250);
});
}
}
}
if (!metadata) {
throw lastError;
}
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,
);
}
function createOfficeDocxRoundTripAdapter(
descriptor: OfficeDescriptor,
options: OfficePdfAdapterOptions = {},
): OfficeDocxRoundTripAdapter {
const backend =
options.backend ?? new PowerShellOfficeAutomationBackend();
const timeoutMs = options.timeoutMs ?? 180_000;
const resizeScale = 0.9;
return {
id: `${descriptor.id}-docx-round-trip`,
client: descriptor.client,
async probe() {
const available = await backend.probe(
descriptor.progId,
timeoutMs,
);
return {
available,
adapterId: `${descriptor.id}-docx-round-trip`,
source: {
kind: descriptor.client,
label: `${descriptor.defaultLabel} DOCX 互存`,
},
detail: available
? `已注册 ${descriptor.progId}`
: `未找到 ${descriptor.progId}`,
};
},
async generate(input) {
if (!backend.roundTripDocx) {
throw new Error("Office 自动化后端不支持 DOCX 互存");
}
const inputPath = await validateDocxPath(input.docxPath);
const temporaryDirectory = await mkdtemp(
join(
dirname(inputPath),
`.md-to-pdf-${descriptor.client}-round-trip-`,
),
);
const outputPath = join(
temporaryDirectory,
`${basename(inputPath, extname(inputPath))}-round-trip.docx`,
);
const startedAt = performance.now();
try {
const metadata = await backend.roundTripDocx({
client: descriptor.client,
progId: descriptor.progId,
inputPath,
outputPath,
timeoutMs,
resizeScale,
});
const outputStat = await stat(outputPath);
if (
outputStat.size <= 0 ||
outputStat.size > MAX_OFFICE_OUTPUT_BYTES ||
outputStat.size !== metadata.bytes
) {
throw new Error(
`Office DOCX 互存输出大小无效:${outputStat.size}`,
);
}
return {
client: descriptor.client,
docx: Uint8Array.from(await readFile(outputPath)),
...metadata,
elapsedMs: performance.now() - startedAt,
};
} finally {
await rm(temporaryDirectory, {
recursive: true,
force: true,
});
}
},
};
}
export function createWordDocxRoundTripAdapter(
options: OfficePdfAdapterOptions = {},
) {
return createOfficeDocxRoundTripAdapter(
{
id: "word",
client: "word",
progId: "Word.Application",
defaultLabel: "Microsoft Word",
},
options,
);
}
export function createWpsDocxRoundTripAdapter(
options: OfficePdfAdapterOptions = {},
) {
return createOfficeDocxRoundTripAdapter(
{
id: "wps",
client: "wps",
progId: "KWPS.Application",
defaultLabel: "WPS Writer",
},
options,
);
}