新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。 新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。 重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。 源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。 验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
636 lines
19 KiB
TypeScript
636 lines
19 KiB
TypeScript
import {
|
|
buildPdfEditableContentLines,
|
|
normalizePdfEditableText,
|
|
} from "./text.js";
|
|
import type {
|
|
EditableParagraphExpectation,
|
|
PdfDocumentSnapshot,
|
|
PdfEditableContentLine,
|
|
PdfParagraphLayoutComparison,
|
|
PdfParagraphLayoutObservation,
|
|
PdfPointBounds,
|
|
VisualDiffIssue,
|
|
VisualPageSemanticExpectation,
|
|
} from "./types.js";
|
|
|
|
const LINE_BREAK_CHARACTER_TOLERANCE = 1;
|
|
const BODY_REFLOW_BREAK_CHARACTER_TOLERANCE = 3;
|
|
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;
|
|
}
|
|
|
|
interface MatchedCharacterRange {
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
interface MatchedCharacterPosition {
|
|
lineIndex: number;
|
|
characterIndex: number;
|
|
character: string;
|
|
}
|
|
|
|
function average(values: readonly number[]): number | undefined {
|
|
if (values.length === 0) {
|
|
return undefined;
|
|
}
|
|
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
}
|
|
|
|
function unionItemBounds(
|
|
items: readonly { bounds: PdfPointBounds }[],
|
|
): PdfPointBounds | undefined {
|
|
if (items.length === 0) {
|
|
return undefined;
|
|
}
|
|
const left = Math.min(...items.map((item) => item.bounds.x));
|
|
const top = Math.min(...items.map((item) => item.bounds.y));
|
|
const right = Math.max(
|
|
...items.map((item) => item.bounds.x + item.bounds.width),
|
|
);
|
|
const bottom = Math.max(
|
|
...items.map((item) => item.bounds.y + item.bounds.height),
|
|
);
|
|
return {
|
|
x: left,
|
|
y: top,
|
|
width: Math.max(0, right - left),
|
|
height: Math.max(0, bottom - top),
|
|
};
|
|
}
|
|
|
|
function characterAdvanceWeight(character: string): number {
|
|
if (/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]$/u.test(character)) {
|
|
return 1;
|
|
}
|
|
if (/^[,。、;:!?()《》【】「」『』〔〕[]{}]$/u.test(character)) {
|
|
return 1;
|
|
}
|
|
if (/^[A-Za-z0-9]$/u.test(character)) {
|
|
return 0.55;
|
|
}
|
|
if (/^[.,:;!'"`·]$/u.test(character)) {
|
|
return 0.28;
|
|
}
|
|
if (/^[-–—/\\]$/u.test(character)) {
|
|
return 0.4;
|
|
}
|
|
return 0.6;
|
|
}
|
|
|
|
function weightedCharacterRatio(
|
|
characters: readonly string[],
|
|
offset: number,
|
|
): number {
|
|
const weights = characters.map(characterAdvanceWeight);
|
|
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
|
if (total <= 0) {
|
|
return offset / Math.max(1, characters.length);
|
|
}
|
|
return weights
|
|
.slice(0, offset)
|
|
.reduce((sum, weight) => sum + weight, 0) / total;
|
|
}
|
|
|
|
function matchedLineItems(
|
|
line: PdfEditableContentLine["line"],
|
|
normalizedLineText: string,
|
|
range: MatchedCharacterRange | undefined,
|
|
) {
|
|
if (!range || line.items.length === 0) {
|
|
return { bounds: line.bounds, items: line.items };
|
|
}
|
|
const selected = [];
|
|
let searchFrom = 0;
|
|
for (const item of line.items) {
|
|
const text = normalizePdfEditableText(item.normalizedText);
|
|
if (!text) {
|
|
continue;
|
|
}
|
|
const start = normalizedLineText.indexOf(text, searchFrom);
|
|
if (start < 0) {
|
|
continue;
|
|
}
|
|
const end = start + Array.from(text).length;
|
|
searchFrom = end;
|
|
const overlapStart = Math.max(start, range.start);
|
|
const overlapEnd = Math.min(end, range.end);
|
|
if (overlapStart >= overlapEnd) {
|
|
continue;
|
|
}
|
|
const characters = Array.from(text);
|
|
const leftRatio = weightedCharacterRatio(
|
|
characters,
|
|
overlapStart - start,
|
|
);
|
|
const rightRatio = weightedCharacterRatio(
|
|
characters,
|
|
overlapEnd - start,
|
|
);
|
|
selected.push({
|
|
item,
|
|
bounds: {
|
|
...item.bounds,
|
|
x: item.bounds.x + item.bounds.width * leftRatio,
|
|
width: item.bounds.width * (rightRatio - leftRatio),
|
|
},
|
|
});
|
|
}
|
|
return {
|
|
bounds: unionItemBounds(selected) ?? line.bounds,
|
|
items: selected.length > 0
|
|
? selected.map((entry) => entry.item)
|
|
: line.items,
|
|
};
|
|
}
|
|
|
|
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 matchContiguousCharacters(
|
|
start: MatchCursor,
|
|
expectedCharacters: readonly string[],
|
|
lineCharacters: readonly string[][],
|
|
) {
|
|
const cursor = { ...start };
|
|
const positions: MatchedCharacterPosition[] = [];
|
|
for (const expectedCharacter of expectedCharacters) {
|
|
const actualCharacter =
|
|
lineCharacters[cursor.lineIndex]?.[cursor.characterIndex];
|
|
if (actualCharacter !== expectedCharacter) {
|
|
break;
|
|
}
|
|
positions.push({
|
|
lineIndex: cursor.lineIndex,
|
|
characterIndex: cursor.characterIndex,
|
|
character: expectedCharacter,
|
|
});
|
|
advanceCursor(cursor, lineCharacters);
|
|
}
|
|
return { cursor, positions };
|
|
}
|
|
|
|
function observeParagraph(
|
|
expectation: EditableParagraphExpectation,
|
|
lines: readonly PdfEditableContentLine[],
|
|
lineCharacters: readonly string[][],
|
|
cursor: MatchCursor,
|
|
): PdfParagraphLayoutObservation {
|
|
const expectedCharacters = Array.from(
|
|
normalizePdfEditableText(expectation.text),
|
|
);
|
|
const matchedByLine = new Map<number, string[]>();
|
|
const matchedRanges = new Map<number, MatchedCharacterRange>();
|
|
let matchedPositions: MatchedCharacterPosition[] = [];
|
|
let matchedCursor: MatchCursor = { ...cursor };
|
|
let matched = expectedCharacters.length === 0;
|
|
const searchCursor: MatchCursor = { ...cursor };
|
|
while (!matched && searchCursor.lineIndex < lineCharacters.length) {
|
|
if (
|
|
lineCharacters[searchCursor.lineIndex]?.[
|
|
searchCursor.characterIndex
|
|
] === expectedCharacters[0]
|
|
) {
|
|
const attempt = matchContiguousCharacters(
|
|
searchCursor,
|
|
expectedCharacters,
|
|
lineCharacters,
|
|
);
|
|
if (attempt.positions.length > matchedPositions.length) {
|
|
matchedPositions = attempt.positions;
|
|
}
|
|
if (attempt.positions.length === expectedCharacters.length) {
|
|
matched = true;
|
|
matchedPositions = attempt.positions;
|
|
matchedCursor = attempt.cursor;
|
|
break;
|
|
}
|
|
}
|
|
if (!advanceCursor(searchCursor, lineCharacters)) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (const position of matchedPositions) {
|
|
const matches = matchedByLine.get(position.lineIndex) ?? [];
|
|
matches.push(position.character);
|
|
matchedByLine.set(position.lineIndex, matches);
|
|
const range = matchedRanges.get(position.lineIndex);
|
|
matchedRanges.set(position.lineIndex, {
|
|
start: Math.min(
|
|
range?.start ?? position.characterIndex,
|
|
position.characterIndex,
|
|
),
|
|
end: Math.max(
|
|
range?.end ?? position.characterIndex + 1,
|
|
position.characterIndex + 1,
|
|
),
|
|
});
|
|
}
|
|
const matchedCharacterCount = matchedPositions.length;
|
|
|
|
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 visualLines = matchedLines.map((line, index) => {
|
|
const matched = matchedLineItems(
|
|
line.line,
|
|
line.normalizedText,
|
|
matchedRanges.get(matchedLineIndexes[index]!),
|
|
);
|
|
return {
|
|
pageNumber: line.pageNumber,
|
|
bounds: matched.bounds,
|
|
fontFamilies: [
|
|
...new Set(
|
|
matched.items.flatMap((item) =>
|
|
item.fontFamily ? [item.fontFamily] : [],
|
|
),
|
|
),
|
|
].sort(),
|
|
};
|
|
});
|
|
if (matched) {
|
|
cursor.lineIndex = matchedCursor.lineIndex;
|
|
cursor.characterIndex = matchedCursor.characterIndex;
|
|
}
|
|
return {
|
|
matched,
|
|
matchedCharacterCount,
|
|
expectedCharacterCount: expectedCharacters.length,
|
|
pageNumbers,
|
|
lineCount: matchedLines.length,
|
|
lineTexts,
|
|
lineBreakOffsets,
|
|
...(firstLine
|
|
? {
|
|
firstLineXPt: visualLines[0]!.bounds.x,
|
|
firstLineBaselineYPt: firstLine.line.baselineY,
|
|
}
|
|
: {}),
|
|
...(average(visualLines.map((line) => line.bounds.width)) === undefined
|
|
? {}
|
|
: {
|
|
maximumLineWidthPt: Math.max(
|
|
...visualLines.map((line) => line.bounds.width),
|
|
),
|
|
}),
|
|
...(averageLineHeightPt === undefined
|
|
? {}
|
|
: { averageLineHeightPt }),
|
|
...(averageBaselineGapPt === undefined
|
|
? {}
|
|
: { averageBaselineGapPt }),
|
|
exclusiveGeometry,
|
|
visualLines,
|
|
};
|
|
}
|
|
|
|
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 allowsBodyTextReflow(expectation: EditableParagraphExpectation) {
|
|
return expectation.section === "body" && [
|
|
"paragraph",
|
|
"list-item",
|
|
"block-quote",
|
|
].includes(expectation.blockKind ?? "paragraph");
|
|
}
|
|
|
|
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,
|
|
baselineCoverPageCount: number,
|
|
candidateCoverPageCount: number,
|
|
): 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) {
|
|
const toleratedReflow =
|
|
allowsBodyTextReflow(expectation) &&
|
|
maximumBreakDelta <= BODY_REFLOW_BREAK_CHARACTER_TOLERANCE;
|
|
issues.push({
|
|
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
|
severity: toleratedReflow ? "warning" : "failure",
|
|
message: toleratedReflow
|
|
? `第 ${expectation.index + 1} 个正文块发生允许的跨引擎行内重排,边界偏差 ${maximumBreakDelta} 个字符`
|
|
: `第 ${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
|
|
details: {
|
|
paragraphIndex: expectation.index,
|
|
maximumBreakDelta,
|
|
tolerance: LINE_BREAK_CHARACTER_TOLERANCE,
|
|
reflowTolerance: BODY_REFLOW_BREAK_CHARACTER_TOLERANCE,
|
|
toleratedReflow,
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
} else if (baselineCoverPageCount > 0 || candidateCoverPageCount > 0) {
|
|
const baselineOutsideCover = baseline.pageNumbers.every(
|
|
(pageNumber) => pageNumber > baselineCoverPageCount,
|
|
);
|
|
const candidateOutsideCover = candidate.pageNumbers.every(
|
|
(pageNumber) => pageNumber > candidateCoverPageCount,
|
|
);
|
|
if (!baselineOutsideCover || !candidateOutsideCover) {
|
|
issues.push({
|
|
code: "COVER_LAYOUT_MISMATCH",
|
|
severity: "failure",
|
|
message: `第 ${expectation.index + 1} 个正文段落回流到独立封面页`,
|
|
details: {
|
|
paragraphIndex: expectation.index,
|
|
baselinePages: baseline.pageNumbers.join(","),
|
|
candidatePages: candidate.pageNumbers.join(","),
|
|
baselineCoverPageCount,
|
|
candidateCoverPageCount,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
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 baselineCoverPageCount = baselinePageSemantics.filter(
|
|
(page) => page.kind === "cover",
|
|
).length;
|
|
const candidateCoverPageCount = candidatePageSemantics.filter(
|
|
(page) => page.kind === "cover",
|
|
).length;
|
|
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,
|
|
baselineCoverPageCount,
|
|
candidateCoverPageCount,
|
|
);
|
|
return {
|
|
expectation,
|
|
baseline: baselineObservation,
|
|
candidate: candidateObservation,
|
|
status: status(issues),
|
|
issues,
|
|
};
|
|
});
|
|
}
|