feat: 完成 DOCX R4 元素级视觉门禁

建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
SkyJourney
2026-08-02 03:27:11 +08:00
parent f9f5fccfc9
commit 58f87cc19f
100 changed files with 10409 additions and 386 deletions
+74
View File
@@ -1,10 +1,17 @@
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\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,
@@ -14,6 +21,10 @@ const PAGE_NUMBER_PATTERNS = [
export function normalizePdfText(value: string): string {
return value
.normalize("NFKC")
.replace(//gu, "")
.replace(/[\u2e80-\u2eff]/gu, (character) =>
CJK_RADICAL_VARIANTS.get(character) ?? character
)
.replace(ZERO_WIDTH_AND_CONTROL, "")
.replace(/\u00a0/gu, " ")
.replace(/[ \t]+/gu, " ")
@@ -24,6 +35,69 @@ 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));