Files
MorphDoc/packages/document-visual-diff/src/text.ts
T
SkyJourney 64445322eb release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。

问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。

兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。

验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
2026-08-26 10:50:20 +08:00

329 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
PdfDocumentSnapshot,
PdfEditableContentLine,
PdfPointBounds,
PdfTextItemSnapshot,
PdfTextLineSnapshot,
VisualPageSemanticExpectation,
} from "./types.js";
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufe0e\ufe0f\ufeff]/gu;
const CJK_RADICAL_VARIANTS = new Map([
["⺠", "民"],
["⻅", "见"],
["⻆", "角"],
["⻓", "长"],
["⻔", "门"],
["⻚", "页"],
["⻛", "风"],
["⻝", "食"],
["⻣", "骨"],
["⻋", "车"],
["⻬", "齐"],
]);
const PAGE_NUMBER_PATTERNS = [
/^(?:[-—–]\s*)?\d+(?:\s*[/]\s*\d+)?(?:\s*[-—–])?$/u,
/^第\s*\d+\s*页(?:\s*(?:[/]|共)\s*\d+\s*页)?$/u,
/^\d+\s*页(?:\s*(?:[/]|共)\s*\d+\s*页)?$/u,
];
export function normalizePdfText(value: string): string {
return value
.normalize("NFKC")
.replace(/戶/gu, "户")
.replace(/[\u2e80-\u2eff]/gu, (character) =>
CJK_RADICAL_VARIANTS.get(character) ?? character
)
// Chromium、Word 与 WPS 的 PDF 文本层会以不同方式保留弯引号;
// 统一为 ASCII 只用于可编辑文本定位,不改变栅格视觉比较。
.replace(/[“”]/gu, '"')
.replace(/[‘’]/gu, "'")
// Chromium 的部分 CJK 字体把 Markdown em dash 提取为 horizontal bar
// 二者在此仅作为语义定位字符归一,不影响后续栅格视觉比较。
.replace(/―/gu, "—")
// Chromium 的部分 CJK 字体会把数字区间中的 en dash 提取为两个
// ASCII hyphen。只归一数字/百分比区间,避免改写代码中的普通 `--`。
.replace(/(?<=[\d%])\s*--(?:\s*(?=\d)|\s*$)/gu, "")
.replace(ZERO_WIDTH_AND_CONTROL, "")
.replace(/\u00a0/gu, " ")
.replace(/[ \t]+/gu, " ")
.trim();
}
export function normalizePdfContentText(value: string): string {
return normalizePdfText(value).replace(/\s+/gu, "");
}
export function normalizePdfEditableText(value: string): string {
return normalizePdfContentText(value).replace(/[☐☑☒•◦▪\uf0b7]/gu, "");
}
function isExpectedHeaderLine(
line: PdfTextLineSnapshot,
pageHeightPt: number,
expectation: VisualPageSemanticExpectation | undefined,
): boolean {
if (
!expectation?.headerVisible ||
line.bounds.y + line.bounds.height / 2 > pageHeightPt * 0.12
) {
return false;
}
const lineText = normalizePdfEditableText(line.normalizedText);
return (expectation.headerSlots ?? []).some((slot) => {
const slotText = normalizePdfEditableText(slot.text);
return slotText.length > 0 && lineText.includes(slotText);
});
}
function isInternalLayoutSpacerLine(line: PdfTextLineSnapshot): boolean {
return (
normalizePdfEditableText(line.normalizedText) === "." &&
line.bounds.height <= 1.5 &&
line.items.length === 1
);
}
export function buildPdfEditableContentText(
snapshot: PdfDocumentSnapshot,
pageSemantics: readonly VisualPageSemanticExpectation[] = [],
): string {
return buildPdfEditableContentLines(snapshot, pageSemantics)
.map((item) => item.normalizedText)
.join("");
}
export function buildPdfEditableContentLines(
snapshot: PdfDocumentSnapshot,
pageSemantics: readonly VisualPageSemanticExpectation[] = [],
): PdfEditableContentLine[] {
return snapshot.pages.flatMap((page) => {
const expectation = pageSemantics.find(
(item) => item.physicalPageNumber === page.pageNumber,
);
return page.lines.flatMap((line) => {
if (
line.role !== "content" ||
isExpectedHeaderLine(line, page.heightPt, expectation) ||
isInternalLayoutSpacerLine(line)
) {
return [];
}
const normalizedText = normalizePdfEditableText(line.normalizedText);
return normalizedText
? [{ pageNumber: page.pageNumber, line, normalizedText }]
: [];
});
});
}
function unionBounds(items: readonly PdfTextItemSnapshot[]): PdfPointBounds {
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 median(values: readonly number[]): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
const value = sorted[middle] ?? 0;
if (sorted.length % 2 === 1) {
return value;
}
return ((sorted[middle - 1] ?? value) + value) / 2;
}
function mergeInlineScriptGroups(
sourceGroups: readonly PdfTextItemSnapshot[][],
typicalHeight: number,
): PdfTextItemSnapshot[][] {
const groups = sourceGroups.map((group) => [...group]);
for (let index = 0; index < groups.length; index += 1) {
const group = groups[index];
if (!group || group.length === 0) {
continue;
}
const groupHeight = median(group.map((item) => item.bounds.height));
if (groupHeight > typicalHeight * 0.9) {
continue;
}
const groupBounds = unionBounds(group);
for (const adjacentIndex of [index - 1, index + 1]) {
const adjacent = groups[adjacentIndex];
if (!adjacent || adjacent.length === 0) {
continue;
}
const adjacentHeight = median(
adjacent.map((item) => item.bounds.height),
);
if (adjacentHeight < groupHeight / 0.9) {
continue;
}
const adjacentBounds = unionBounds(adjacent);
const verticalOverlap = Math.min(
groupBounds.y + groupBounds.height,
adjacentBounds.y + adjacentBounds.height,
) - Math.max(groupBounds.y, adjacentBounds.y);
const horizontallyContained =
groupBounds.x >= adjacentBounds.x - typicalHeight * 0.5 &&
groupBounds.x + groupBounds.width <=
adjacentBounds.x + adjacentBounds.width + typicalHeight * 0.5;
// Chromium 会把行末公式按较小字号单独提取;公式片段可能从正文
// 最后一个字符的右缘开始,并向右超出正文组,因此不能只用“被正文
// 水平包含”判断同行。仅在两组真实垂直重叠且小字号组紧贴较大字号
// 组右缘时合并,避免把下一视觉行或相邻表格单元格误并入当前行。
const adjacentRight = adjacentBounds.x + adjacentBounds.width;
const touchesAdjacentRight =
groupBounds.x >= adjacentBounds.x &&
Math.abs(groupBounds.x - adjacentRight) <= typicalHeight * 0.5;
if (
verticalOverlap <= 0 ||
(!horizontallyContained && !touchesAdjacentRight)
) {
continue;
}
adjacent.push(...group);
groups[index] = [];
break;
}
}
return groups.filter((group) => group.length > 0);
}
function joinLineItems(items: readonly PdfTextItemSnapshot[]): string {
let result = "";
let previous: PdfTextItemSnapshot | undefined;
for (const item of items) {
const text = item.normalizedText;
if (!text) {
continue;
}
if (previous && result) {
const gap =
item.bounds.x - (previous.bounds.x + previous.bounds.width);
const referenceHeight = Math.max(
1,
Math.min(previous.bounds.height, item.bounds.height),
);
if (
gap > referenceHeight * 0.24 &&
!result.endsWith(" ") &&
!text.startsWith(" ")
) {
result += " ";
}
}
result += text;
previous = item;
}
return normalizePdfText(result);
}
export function isPageNumberLine(
lineText: string,
bounds: PdfPointBounds,
pageHeightPt: number,
): boolean {
const normalized = normalizePdfText(lineText);
if (!PAGE_NUMBER_PATTERNS.some((pattern) => pattern.test(normalized))) {
return false;
}
const lineMiddle = bounds.y + bounds.height / 2;
const topBand = pageHeightPt * 0.08;
const bottomBand = pageHeightPt * 0.85;
return lineMiddle <= topBand || lineMiddle >= bottomBand;
}
export function aggregatePdfTextLines(
sourceItems: readonly PdfTextItemSnapshot[],
pageHeightPt: number,
): PdfTextLineSnapshot[] {
const sourceOrder = new Map(
sourceItems.map((item, index) => [item, index] as const),
);
const items = sourceItems
.filter((item) => item.normalizedText.length > 0)
.sort(
(left, right) =>
left.bounds.y - right.bounds.y || left.bounds.x - right.bounds.x,
);
const typicalHeight = median(items.map((item) => item.bounds.height));
// Office/WPS 会让同一视觉行内不同字体或单元格的基线产生约 2pt
// 浮动;按 15% 聚合会把这些片段错误拆行并改变阅读顺序。
// 20% 仍远小于正常行距,同时能覆盖常见的 10–12pt 字号偏差。
const baselineTolerance = Math.max(2, typicalHeight * 0.2);
const initialGroups: PdfTextItemSnapshot[][] = [];
for (const item of items) {
const lastGroup = initialGroups.at(-1);
const lastBaseline = lastGroup
? median(lastGroup.map((entry) => entry.baselineY))
: undefined;
if (
lastGroup &&
lastBaseline !== undefined &&
Math.abs(item.baselineY - lastBaseline) <= baselineTolerance
) {
lastGroup.push(item);
} else {
initialGroups.push([item]);
}
}
const groups = mergeInlineScriptGroups(initialGroups, typicalHeight);
return groups.map((group) => {
const sortedItems = [...group].sort(
(left, right) => {
const leftRight = left.bounds.x + left.bounds.width;
const rightRight = right.bounds.x + right.bounds.width;
const horizontallyOverlapping =
left.bounds.x < rightRight && right.bounds.x < leftRight;
// PDF.js 的文本流保留字符阅读顺序,但不同字体的窄标点可能与
// 相邻全角字符发生水平重叠。此时单纯按 x 排序会把闭引号移到
// 左括号之后,破坏语义块定位;只对真实重叠片段保留文本流顺序,
// 其余片段仍按几何位置排序,兼容 Office 表格等非阅读序文本流。
return horizontallyOverlapping
? (sourceOrder.get(left) ?? 0) - (sourceOrder.get(right) ?? 0)
: left.bounds.x - right.bounds.x;
},
);
const bounds = unionBounds(sortedItems);
const normalizedText = joinLineItems(sortedItems);
return {
text: normalizedText,
normalizedText,
bounds,
baselineY: median(sortedItems.map((item) => item.baselineY)),
role: isPageNumberLine(normalizedText, bounds, pageHeightPt)
? "page-number"
: "content",
items: sortedItems,
};
});
}
export function buildPageContentText(
lines: readonly PdfTextLineSnapshot[],
): string {
return lines
.filter((line) => line.role === "content")
.map((line) => line.normalizedText)
.filter(Boolean)
.join("\n");
}