feat: 完成 DOCX R4 元素级视觉门禁

建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
SkyJourney
2026-08-02 03:27:11 +08:00
parent f9f5fccfc9
commit 58f87cc19f
100 changed files with 10409 additions and 386 deletions
@@ -1,7 +1,7 @@
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 { basename, dirname, extname, join, resolve } from "node:path";
import { performance } from "node:perf_hooks";
import { promisify } from "node:util";
@@ -28,6 +28,37 @@ export interface OfficeExportMetadata {
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: {
@@ -37,6 +68,14 @@ export interface OfficeAutomationBackend {
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 {
@@ -116,6 +155,110 @@ try {
}
`;
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");
}
@@ -218,6 +361,82 @@ export class PowerShellOfficeAutomationBackend
: {}),
};
}
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> {
@@ -346,3 +565,110 @@ export function createWpsPdfAdapter(
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,
);
}