Files
MorphDoc/packages/document-visual-diff/src/paragraph-layout.ts
T
SkyJourney 2c5c1bd317 release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 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 产物仍为未签名内部发行。
2026-08-04 10:30:44 +08:00

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,
};
});
}