feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user