feat: 完成 DOCX 通用结构映射与严格收口

This commit is contained in:
SkyJourney
2026-07-31 15:19:34 +08:00
parent 6e40374200
commit f3847f3e4b
31 changed files with 3175 additions and 64 deletions
@@ -4,6 +4,7 @@ import {
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
firstDirectChild,
parseXmlPart,
type XmlElement
} from "./ooxml.js";
@@ -64,7 +65,15 @@ export interface DocxAcceptanceExpectation {
minimumPngImages?: number;
requiredImageAltText?: readonly string[];
requiredStyleIds?: readonly string[];
requiredParagraphStyleIds?: readonly string[];
requirePageField?: boolean;
minimumSections?: number;
requireFirstSectionWithoutHeaderFooter?: boolean;
finalPageNumberStart?: number;
requireSectionPagesField?: boolean;
minimumFullWidthTables?: number;
maximumTextOccurrences?: Readonly<Record<string, number>>;
forbidInternalMarkers?: boolean;
}
export interface DocxAcceptanceReport {
@@ -93,10 +102,15 @@ export interface DocxAcceptanceReport {
drawingCount: number;
pngImageCount: number;
pageFieldCount: number;
sectionPageFieldCount: number;
sectionCount: number;
fullWidthTableCount: number;
internalMarkerCount: number;
altChunkCount: number;
};
imageAltText: string[];
styleIds: string[];
appliedParagraphStyleIds: string[];
checks: Record<string, true>;
}
@@ -213,6 +227,48 @@ function collectPageFieldCount(
return count;
}
function collectFieldCount(
entries: ReadonlyMap<string, Uint8Array>,
field: "SECTIONPAGES"
) {
let count = 0;
for (const [partName, content] of entries) {
if (!/^word\/(?:document|header\d+|footer\d+)\.xml$/u.test(partName)) {
continue;
}
const document = parseXmlPart(content, partName);
for (const element of Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "instrText")
)) {
if (
new RegExp(`\\b${field}\\b`, "u").test(
element.textContent ?? ""
)
) {
count += 1;
}
}
}
return count;
}
function countTextOccurrences(value: string, search: string) {
if (!search) {
return 0;
}
let count = 0;
let offset = 0;
while (offset <= value.length - search.length) {
const index = value.indexOf(search, offset);
if (index < 0) {
break;
}
count += 1;
offset = index + search.length;
}
return count;
}
export function inspectDocxAcceptance(
content: Uint8Array,
expectation: DocxAcceptanceExpectation
@@ -313,6 +369,62 @@ export function inspectDocxAcceptance(
expectation.page.marginsTwips[side]
);
}
assertMinimum(
expectation.id,
"sections",
sections.length,
expectation.minimumSections
);
if (expectation.requireFirstSectionWithoutHeaderFooter) {
const firstSection = sections[0]!;
const headerReferences =
firstSection.getElementsByTagNameNS(
WORD_NAMESPACE,
"headerReference"
).length;
const footerReferences =
firstSection.getElementsByTagNameNS(
WORD_NAMESPACE,
"footerReference"
).length;
assertEqual(
expectation.id,
"first-section-header-references",
headerReferences,
0
);
assertEqual(
expectation.id,
"first-section-footer-references",
footerReferences,
0
);
}
if (expectation.finalPageNumberStart !== undefined) {
const pageNumber = firstDirectChild(
section,
WORD_NAMESPACE,
"pgNumType"
);
if (!pageNumber) {
fail(
expectation.id,
"final-page-number-start",
"最终节缺少 w:pgNumType"
);
}
assertEqual(
expectation.id,
"final-page-number-start",
numericAttribute(
pageNumber,
"start",
expectation.id,
"final-page-number-start"
),
expectation.finalPageNumberStart
);
}
const paragraphCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
@@ -326,6 +438,22 @@ export function inspectDocxAcceptance(
WORD_NAMESPACE,
"tbl"
).length;
const fullWidthTableCount = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "tbl")
).filter((table) => {
const properties = firstDirectChild(
table,
WORD_NAMESPACE,
"tblPr"
);
const width = properties
? firstDirectChild(properties, WORD_NAMESPACE, "tblW")
: undefined;
return (
width?.getAttributeNS(WORD_NAMESPACE, "type") === "pct" &&
width.getAttributeNS(WORD_NAMESPACE, "w") === "5000"
);
}).length;
const numberedParagraphCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"numPr"
@@ -368,6 +496,12 @@ export function inspectDocxAcceptance(
tableCount,
expectation.minimumTables
);
assertMinimum(
expectation.id,
"full-width-tables",
fullWidthTableCount,
expectation.minimumFullWidthTables
);
assertMinimum(
expectation.id,
"numbering",
@@ -410,6 +544,30 @@ export function inspectDocxAcceptance(
);
}
}
for (const [text, maximum] of Object.entries(
expectation.maximumTextOccurrences ?? {}
)) {
const actual = countTextOccurrences(bodyText, text);
if (actual > maximum) {
fail(
expectation.id,
"text-occurrences",
`${JSON.stringify(text)} 最多允许 ${maximum} 次,实际 ${actual}`
);
}
}
const internalMarkerCount = countTextOccurrences(
bodyText,
"MD_TO_PDF_"
);
if (expectation.forbidInternalMarkers) {
assertEqual(
expectation.id,
"internal-markers",
internalMarkerCount,
0
);
}
if ((expectation.requiredFootnoteText?.length ?? 0) > 0) {
const footnotesPart = entries.get("word/footnotes.xml");
if (!footnotesPart) {
@@ -517,11 +675,40 @@ export function inspectDocxAcceptance(
);
}
}
const appliedParagraphStyleIds = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "pStyle")
)
.map((element) =>
element.getAttributeNS(WORD_NAMESPACE, "val")
)
.filter((value): value is string => Boolean(value));
for (const requiredStyleId of
expectation.requiredParagraphStyleIds ?? []) {
if (!appliedParagraphStyleIds.includes(requiredStyleId)) {
fail(
expectation.id,
"applied-paragraph-styles",
`正文未使用样式 ${requiredStyleId}`
);
}
}
const pageFieldCount = collectPageFieldCount(entries);
const sectionPageFieldCount = collectFieldCount(
entries,
"SECTIONPAGES"
);
if (expectation.requirePageField) {
assertMinimum(expectation.id, "page-field", pageFieldCount, 1);
}
if (expectation.requireSectionPagesField) {
assertMinimum(
expectation.id,
"section-pages-field",
sectionPageFieldCount,
1
);
}
return {
id: expectation.id,
package: packageValidation,
@@ -538,10 +725,15 @@ export function inspectDocxAcceptance(
drawingCount,
pngImageCount: pngParts.size,
pageFieldCount,
sectionPageFieldCount,
sectionCount: sections.length,
fullWidthTableCount,
internalMarkerCount,
altChunkCount
},
imageAltText,
styleIds,
appliedParagraphStyleIds,
checks: {
package: true,
page: true,
@@ -549,6 +741,9 @@ export function inspectDocxAcceptance(
relationships: true,
pngMedia: true,
styles: true,
sections: true,
tables: true,
textOccurrences: true,
noAltChunk: true
}
};