feat: 完成 DOCX 视觉门禁与字体兼容映射
This commit is contained in:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user