feat: 完成 DOCX 视觉门禁与字体兼容映射

This commit is contained in:
SkyJourney
2026-07-31 20:13:19 +08:00
parent 10bc62cee4
commit e5da699ea3
32 changed files with 3015 additions and 17 deletions
+158
View File
@@ -0,0 +1,158 @@
import type {
PdfPointBounds,
PdfTextItemSnapshot,
PdfTextLineSnapshot,
} from "./types.js";
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
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(ZERO_WIDTH_AND_CONTROL, "")
.replace(/\u00a0/gu, " ")
.replace(/[ \t]+/gu, " ")
.trim();
}
export function normalizePdfContentText(value: string): string {
return normalizePdfText(value).replace(/\s+/gu, "");
}
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 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 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));
const baselineTolerance = Math.max(1.5, typicalHeight * 0.15);
const groups: PdfTextItemSnapshot[][] = [];
for (const item of items) {
const lastGroup = groups.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 {
groups.push([item]);
}
}
return groups.map((group) => {
const sortedItems = [...group].sort(
(left, right) => 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");
}