feat: 完成 DOCX 通用结构映射与严格收口
This commit is contained in:
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user