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