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
@@ -0,0 +1,405 @@
import {
buildPdfEditableContentLines,
normalizePdfEditableText,
} from "./text.js";
import type {
EditableParagraphExpectation,
PdfDocumentSnapshot,
PdfEditableContentLine,
PdfParagraphLayoutComparison,
PdfParagraphLayoutObservation,
VisualDiffIssue,
VisualPageSemanticExpectation,
} from "./types.js";
const LINE_BREAK_CHARACTER_TOLERANCE = 1;
const LINE_X_TOLERANCE_PT = 2;
const COVER_LINE_X_TOLERANCE_PT = 12;
const LINE_HEIGHT_TOLERANCE_PT = 1;
const BASELINE_GAP_TOLERANCE_PT = 1.5;
const COVER_VERTICAL_TOLERANCE_PT = 6;
const BODY_NEAR_BOUNDARY_MIN_CHARACTERS = 30;
const BODY_NEAR_BOUNDARY_MAX_TRAILING_RATIO = 0.05;
interface MatchCursor {
lineIndex: number;
characterIndex: number;
}
function average(values: readonly number[]): number | undefined {
if (values.length === 0) {
return undefined;
}
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function advanceCursor(
cursor: MatchCursor,
lineCharacters: readonly string[][],
): boolean {
cursor.characterIndex += 1;
while (
cursor.lineIndex < lineCharacters.length &&
cursor.characterIndex >= (lineCharacters[cursor.lineIndex]?.length ?? 0)
) {
cursor.lineIndex += 1;
cursor.characterIndex = 0;
}
return cursor.lineIndex < lineCharacters.length;
}
function observeParagraph(
expectation: EditableParagraphExpectation,
lines: readonly PdfEditableContentLine[],
lineCharacters: readonly string[][],
cursor: MatchCursor,
): PdfParagraphLayoutObservation {
const localCursor: MatchCursor = { ...cursor };
const expectedCharacters = Array.from(
normalizePdfEditableText(expectation.text),
);
const matchedByLine = new Map<number, string[]>();
let matchedCharacterCount = 0;
for (const expectedCharacter of expectedCharacters) {
let found = false;
while (localCursor.lineIndex < lineCharacters.length) {
const actualCharacter =
lineCharacters[localCursor.lineIndex]?.[localCursor.characterIndex];
if (actualCharacter === expectedCharacter) {
const matches = matchedByLine.get(localCursor.lineIndex) ?? [];
matches.push(expectedCharacter);
matchedByLine.set(localCursor.lineIndex, matches);
matchedCharacterCount += 1;
advanceCursor(localCursor, lineCharacters);
found = true;
break;
}
if (!advanceCursor(localCursor, lineCharacters)) {
break;
}
}
if (!found) {
break;
}
}
const matchedLineIndexes = [...matchedByLine.keys()];
const matchedLines = matchedLineIndexes.flatMap((index) =>
lines[index] ? [lines[index]] : [],
);
const lineTexts = matchedLineIndexes.map((index) =>
(matchedByLine.get(index) ?? []).join(""),
);
let cumulativeCharacters = 0;
const lineBreakOffsets = lineTexts.slice(0, -1).map((text) => {
cumulativeCharacters += Array.from(text).length;
return cumulativeCharacters;
});
const baselineGaps = matchedLines.slice(1).flatMap((line, index) => {
const previous = matchedLines[index];
return previous && previous.pageNumber === line.pageNumber
? [line.line.baselineY - previous.line.baselineY]
: [];
});
const pageNumbers = [...new Set(matchedLines.map((line) => line.pageNumber))];
const firstLine = matchedLines[0];
const averageLineHeightPt = average(
matchedLines.map((line) => line.line.bounds.height),
);
const averageBaselineGapPt = average(baselineGaps);
const exclusiveGeometry = matchedLineIndexes.every(
(index) =>
Array.from(lines[index]?.normalizedText ?? "").length ===
(matchedByLine.get(index)?.length ?? 0),
);
const matched = matchedCharacterCount === expectedCharacters.length;
if (matched) {
cursor.lineIndex = localCursor.lineIndex;
cursor.characterIndex = localCursor.characterIndex;
}
return {
matched,
matchedCharacterCount,
expectedCharacterCount: expectedCharacters.length,
pageNumbers,
lineCount: matchedLines.length,
lineTexts,
lineBreakOffsets,
...(firstLine
? {
firstLineXPt: firstLine.line.bounds.x,
firstLineBaselineYPt: firstLine.line.baselineY,
}
: {}),
...(average(matchedLines.map((line) => line.line.bounds.width)) === undefined
? {}
: {
maximumLineWidthPt: Math.max(
...matchedLines.map((line) => line.line.bounds.width),
),
}),
...(averageLineHeightPt === undefined
? {}
: { averageLineHeightPt }),
...(averageBaselineGapPt === undefined
? {}
: { averageBaselineGapPt }),
exclusiveGeometry,
};
}
function delta(
baseline: number | undefined,
candidate: number | undefined,
): number | undefined {
return baseline === undefined || candidate === undefined
? undefined
: Math.abs(baseline - candidate);
}
function trailingLineCharacterCount(
observation: PdfParagraphLayoutObservation,
): number {
return Array.from(observation.lineTexts.at(-1) ?? "").length;
}
function isBodyNearBoundaryLineCountDifference(
expectation: EditableParagraphExpectation,
baseline: PdfParagraphLayoutObservation,
candidate: PdfParagraphLayoutObservation,
): boolean {
if (
expectation.section !== "body" ||
expectation.role !== "body" ||
baseline.expectedCharacterCount < BODY_NEAR_BOUNDARY_MIN_CHARACTERS ||
Math.abs(baseline.lineCount - candidate.lineCount) !== 1
) {
return false;
}
const moreLines =
baseline.lineCount > candidate.lineCount ? baseline : candidate;
const trailingCharacters = trailingLineCharacterCount(moreLines);
return (
trailingCharacters > 0 &&
trailingCharacters / baseline.expectedCharacterCount <=
BODY_NEAR_BOUNDARY_MAX_TRAILING_RATIO
);
}
function compareParagraph(
expectation: EditableParagraphExpectation,
baseline: PdfParagraphLayoutObservation,
candidate: PdfParagraphLayoutObservation,
): VisualDiffIssue[] {
const issues: VisualDiffIssue[] = [];
if (!baseline.matched || !candidate.matched) {
issues.push({
code: "EDITABLE_PARAGRAPH_MAPPING_MISMATCH",
severity: "failure",
message: `${expectation.index + 1} 个可编辑段落无法完整映射到 PDF 文本行`,
details: {
paragraphIndex: expectation.index,
baselineCoverage:
baseline.expectedCharacterCount === 0
? 1
: baseline.matchedCharacterCount / baseline.expectedCharacterCount,
candidateCoverage:
candidate.expectedCharacterCount === 0
? 1
: candidate.matchedCharacterCount / candidate.expectedCharacterCount,
},
});
return issues;
}
if (baseline.lineCount !== candidate.lineCount) {
const nearBoundary = isBodyNearBoundaryLineCountDifference(
expectation,
baseline,
candidate,
);
issues.push({
code: nearBoundary
? "BODY_LINE_COUNT_NEAR_BOUNDARY"
: "TEXT_BLOCK_LINE_COUNT_MISMATCH",
severity: nearBoundary ? "warning" : "failure",
message: nearBoundary
? `${expectation.index + 1} 个正文段落仅因末行边界产生一行差异`
: `${expectation.index + 1}${expectation.role === "title" ? "标题" : "段落"}行数不一致:基线 ${baseline.lineCount} 行,候选 ${candidate.lineCount}`,
details: {
paragraphIndex: expectation.index,
role: expectation.role,
baselineLineCount: baseline.lineCount,
candidateLineCount: candidate.lineCount,
...(nearBoundary
? {
trailingCharacters: trailingLineCharacterCount(
baseline.lineCount > candidate.lineCount
? baseline
: candidate,
),
trailingRatio:
trailingLineCharacterCount(
baseline.lineCount > candidate.lineCount
? baseline
: candidate,
) / baseline.expectedCharacterCount,
}
: {}),
},
});
} else {
const maximumBreakDelta = Math.max(
0,
...baseline.lineBreakOffsets.map((offset, index) =>
Math.abs(offset - (candidate.lineBreakOffsets[index] ?? offset)),
),
);
if (maximumBreakDelta > LINE_BREAK_CHARACTER_TOLERANCE) {
issues.push({
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
severity: "failure",
message: `${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
details: {
paragraphIndex: expectation.index,
maximumBreakDelta,
tolerance: LINE_BREAK_CHARACTER_TOLERANCE,
baselineBreaks: baseline.lineBreakOffsets.join(","),
candidateBreaks: candidate.lineBreakOffsets.join(","),
},
});
}
}
if (expectation.section === "cover") {
const baselineOnCover =
baseline.pageNumbers.length === 1 && baseline.pageNumbers[0] === 1;
const candidateOnCover =
candidate.pageNumbers.length === 1 && candidate.pageNumbers[0] === 1;
const verticalDelta = delta(
baseline.firstLineBaselineYPt,
candidate.firstLineBaselineYPt,
);
if (
!baselineOnCover ||
!candidateOnCover ||
(verticalDelta ?? 0) > COVER_VERTICAL_TOLERANCE_PT
) {
issues.push({
code: "COVER_LAYOUT_MISMATCH",
severity: "failure",
message: `${expectation.index + 1} 个封面段落未保持独立封面页或纵向位置`,
details: {
paragraphIndex: expectation.index,
baselinePages: baseline.pageNumbers.join(","),
candidatePages: candidate.pageNumbers.join(","),
verticalDeltaPt: verticalDelta ?? -1,
tolerancePt: COVER_VERTICAL_TOLERANCE_PT,
},
});
}
}
if (baseline.exclusiveGeometry && candidate.exclusiveGeometry) {
const xDelta = delta(baseline.firstLineXPt, candidate.firstLineXPt);
const xTolerancePt =
expectation.section === "cover"
? COVER_LINE_X_TOLERANCE_PT
: LINE_X_TOLERANCE_PT;
const widthDelta = delta(
baseline.maximumLineWidthPt,
candidate.maximumLineWidthPt,
);
const heightDelta = delta(
baseline.averageLineHeightPt,
candidate.averageLineHeightPt,
);
const baselineGapDelta = delta(
baseline.averageBaselineGapPt,
candidate.averageBaselineGapPt,
);
if (
(xDelta ?? 0) > xTolerancePt ||
(heightDelta ?? 0) > LINE_HEIGHT_TOLERANCE_PT ||
(baselineGapDelta ?? 0) > BASELINE_GAP_TOLERANCE_PT
) {
issues.push({
code: "PARAGRAPH_LINE_GEOMETRY_MISMATCH",
severity: "failure",
message: `${expectation.index + 1} 个段落的行几何偏差超限`,
details: {
paragraphIndex: expectation.index,
xDeltaPt: xDelta ?? 0,
xTolerancePt,
widthDeltaPt: widthDelta ?? 0,
widthDeltaDiagnosticOnly: true,
lineHeightDeltaPt: heightDelta ?? 0,
baselineGapDeltaPt: baselineGapDelta ?? 0,
},
});
}
}
return issues;
}
function status(issues: readonly VisualDiffIssue[]) {
return issues.some((issue) => issue.severity === "failure")
? ("failed" as const)
: issues.length > 0
? ("warning" as const)
: ("passed" as const);
}
export function comparePdfEditableParagraphLayouts(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
expectations: readonly EditableParagraphExpectation[],
baselinePageSemantics: readonly VisualPageSemanticExpectation[] = [],
candidatePageSemantics: readonly VisualPageSemanticExpectation[] =
baselinePageSemantics,
): PdfParagraphLayoutComparison[] {
const baselineLines = buildPdfEditableContentLines(
baseline,
baselinePageSemantics,
);
const candidateLines = buildPdfEditableContentLines(
candidate,
candidatePageSemantics,
);
const baselineCharacters = baselineLines.map((line) =>
Array.from(line.normalizedText),
);
const candidateCharacters = candidateLines.map((line) =>
Array.from(line.normalizedText),
);
const baselineCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
const candidateCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
return expectations.map((expectation) => {
const baselineObservation = observeParagraph(
expectation,
baselineLines,
baselineCharacters,
baselineCursor,
);
const candidateObservation = observeParagraph(
expectation,
candidateLines,
candidateCharacters,
candidateCursor,
);
const issues = compareParagraph(
expectation,
baselineObservation,
candidateObservation,
);
return {
expectation,
baseline: baselineObservation,
candidate: candidateObservation,
status: status(issues),
issues,
};
});
}