Files
MorphDoc/packages/docx-engine/src/page-decoration-acceptance.ts
T
SkyJourney 58f87cc19f feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

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

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
2026-08-02 03:27:11 +08:00

441 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import path from "node:path";
import type {
ExportConfig,
SemanticDocumentModel
} from "@md-to-pdf/core";
import {
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
directChildren,
firstDirectChild,
parseXmlPart,
type XmlDocument,
type XmlElement
} from "./ooxml.js";
const HEADER_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
const FOOTER_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
type ReferenceType = "default" | "even" | "first";
export interface DocxPageDecorationExpectation {
exportConfig: Pick<ExportConfig, "header" | "footer">;
semanticDocument: SemanticDocumentModel;
}
export interface DocxPageDecorationAcceptanceReport {
hasCover: boolean;
usesDifferentFirstPage: boolean;
usesEvenAndOddPages: boolean;
bodyPageNumberStart: number;
headerReferenceTypes: ReferenceType[];
footerReferenceTypes: ReferenceType[];
totalPageField: "NUMPAGES" | "SECTIONPAGES" | "none";
}
interface Relationship {
type: string;
targetPart: string;
}
function fail(id: string, check: string, detail: string): never {
throw new Error(
`${id} 的 DOCX 页面装饰验收失败:${check}${detail}`
);
}
function parseRelationships(
entries: ReadonlyMap<string, Uint8Array>,
id: string
) {
const partName = "word/_rels/document.xml.rels";
const content = entries.get(partName);
if (!content) {
fail(id, "relationships", `缺少 ${partName}`);
}
const document = parseXmlPart(content, partName);
const relationships = new Map<string, Relationship>();
for (const element of Array.from(
document.getElementsByTagNameNS(
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)
)) {
const relationshipId = element.getAttribute("Id");
const type = element.getAttribute("Type");
const target = element.getAttribute("Target");
if (!relationshipId || !type || !target) {
fail(id, "relationships", "包含不完整的关系声明");
}
relationships.set(relationshipId, {
type,
targetPart: path.posix.normalize(
path.posix.join("word", target.replace(/^\/+/u, ""))
)
});
}
return relationships;
}
function referenceMap(
section: XmlElement,
kind: "header" | "footer",
relationships: ReadonlyMap<string, Relationship>,
entries: ReadonlyMap<string, Uint8Array>,
id: string
) {
const result = new Map<ReferenceType, string>();
const relationshipType =
kind === "header"
? HEADER_RELATIONSHIP_TYPE
: FOOTER_RELATIONSHIP_TYPE;
for (const reference of directChildren(
section,
WORD_NAMESPACE,
`${kind}Reference`
)) {
const type = reference.getAttributeNS(
WORD_NAMESPACE,
"type"
) as ReferenceType | null;
const relationshipId = reference.getAttributeNS(
OFFICE_RELATIONSHIP_NAMESPACE,
"id"
);
if (!type || !["default", "even", "first"].includes(type)) {
fail(id, `${kind}-reference-type`, type ?? "缺失");
}
if (!relationshipId || result.has(type)) {
fail(id, `${kind}-reference`, relationshipId ?? "缺失关系 ID");
}
const relationship = relationships.get(relationshipId);
const part = relationship
? entries.get(relationship.targetPart)
: undefined;
if (
!relationship ||
relationship.type !== relationshipType ||
!part
) {
fail(id, `${kind}-reference`, `无效关系 ${relationshipId}`);
}
result.set(type, relationship.targetPart);
}
return result;
}
function assertReferenceTypes(
id: string,
kind: "header" | "footer",
actual: ReadonlyMap<ReferenceType, string>,
expected: readonly ReferenceType[]
) {
const actualTypes = [...actual.keys()].sort();
const expectedTypes = [...expected].sort();
if (JSON.stringify(actualTypes) !== JSON.stringify(expectedTypes)) {
fail(
id,
`${kind}-reference-types`,
`期望 ${expectedTypes.join(",") || "无"},实际 ${actualTypes.join(",") || "无"}`
);
}
}
function partContent(
entries: ReadonlyMap<string, Uint8Array>,
partName: string
) {
return new TextDecoder().decode(entries.get(partName)!);
}
function fieldCount(content: string, field: "PAGE" | "NUMPAGES" | "SECTIONPAGES") {
return [...content.matchAll(/<w:instrText[^>]*>([\s\S]*?)<\/w:instrText>/gu)]
.filter((match) =>
new RegExp(`\\b${field}\\b`, "u").test(match[1] ?? "")
).length;
}
function assertEmptyFirstPart(
entries: ReadonlyMap<string, Uint8Array>,
partName: string,
id: string,
kind: "header" | "footer"
) {
const document = parseXmlPart(entries.get(partName)!, partName);
const hasText = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "t")
).some((element) => Boolean(element.textContent?.trim()));
const hasField =
document.getElementsByTagNameNS(WORD_NAMESPACE, "instrText").length > 0;
const hasBorder =
document.getElementsByTagNameNS(WORD_NAMESPACE, "pBdr").length > 0;
if (hasText || hasField || hasBorder) {
fail(
id,
`${kind}-first-empty`,
`${partName} 仍包含可见内容、字段或分隔线`
);
}
}
function assertFirstPartMatchesDefault(
entries: ReadonlyMap<string, Uint8Array>,
references: ReadonlyMap<ReferenceType, string>,
id: string,
kind: "header" | "footer"
) {
const defaultPart = references.get("default");
const firstPart = references.get("first");
if (
!defaultPart ||
!firstPart ||
partContent(entries, defaultPart) !== partContent(entries, firstPart)
) {
fail(id, `${kind}-first-content`, "首页部件未继承默认内容");
}
}
function footerAlignment(
entries: ReadonlyMap<string, Uint8Array>,
partName: string
) {
const document = parseXmlPart(entries.get(partName)!, partName);
return document
.getElementsByTagNameNS(WORD_NAMESPACE, "jc")[0]
?.getAttributeNS(WORD_NAMESPACE, "val");
}
function expectedTotalPageField(
footer: ExportConfig["footer"]
): "NUMPAGES" | "none" {
if (!footer.enabled) {
return "none";
}
const usesTotal =
footer.format === "page-total" ||
footer.format === "chinese-page-total" ||
(footer.format === "custom" &&
(footer.template ?? "${page} / ${pages}").includes("${pages}"));
return usesTotal ? "NUMPAGES" : "none";
}
export function inspectDocxPageDecorations(
entries: ReadonlyMap<string, Uint8Array>,
document: XmlDocument,
expectation: DocxPageDecorationExpectation,
id: string
): DocxPageDecorationAcceptanceReport {
const sections = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "sectPr")
);
const bodySection = sections.at(-1);
if (!bodySection) {
fail(id, "body-section", "缺少正文节");
}
const coverIntent = expectation.semanticDocument.regions.find(
(region) => region.kind === "cover" && region.section
)?.section;
const hasCover = Boolean(coverIntent);
const { header, footer } = expectation.exportConfig;
const usesEvenAndOddPages =
footer.enabled &&
footer.alignment === "outer";
const usesDifferentFirstPage =
(header.enabled && !header.showOnFirstPage) ||
(footer.enabled && !footer.showOnFirstPage);
const coverPageOffset = expectation.semanticDocument.regions.filter(
(region) => region.kind === "cover" && region.section
).length;
const bodyPageNumberStart =
(coverIntent?.followingPageNumberStart ?? footer.startFrom) +
(usesEvenAndOddPages ? coverPageOffset : 0);
if (hasCover) {
if (sections.length < 2) {
fail(id, "cover-section", "封面未形成独立节");
}
const coverSection = sections[0]!;
for (const localName of [
"headerReference",
"footerReference",
"pgNumType",
"titlePg"
]) {
if (firstDirectChild(coverSection, WORD_NAMESPACE, localName)) {
fail(id, "cover-section", `封面节不得包含 w:${localName}`);
}
}
const sectionType = firstDirectChild(
coverSection,
WORD_NAMESPACE,
"type"
)?.getAttributeNS(WORD_NAMESPACE, "val");
if (sectionType !== "nextPage") {
fail(id, "cover-section-break", `期望 nextPage,实际 ${sectionType}`);
}
}
const pageNumberStart = firstDirectChild(
bodySection,
WORD_NAMESPACE,
"pgNumType"
)?.getAttributeNS(WORD_NAMESPACE, "start");
if (pageNumberStart !== String(bodyPageNumberStart)) {
fail(
id,
"body-page-number-start",
`期望 ${bodyPageNumberStart},实际 ${pageNumberStart ?? "缺失"}`
);
}
const hasTitlePage = Boolean(
firstDirectChild(bodySection, WORD_NAMESPACE, "titlePg")
);
if (hasTitlePage !== usesDifferentFirstPage) {
fail(
id,
"body-title-page",
`期望 ${usesDifferentFirstPage},实际 ${hasTitlePage}`
);
}
const relationships = parseRelationships(entries, id);
const headerReferences = referenceMap(
bodySection,
"header",
relationships,
entries,
id
);
const footerReferences = referenceMap(
bodySection,
"footer",
relationships,
entries,
id
);
const expectedHeaderTypes: ReferenceType[] = header.enabled
? [
"default",
...(usesEvenAndOddPages ? (["even"] as const) : []),
...(usesDifferentFirstPage ? (["first"] as const) : [])
]
: [];
const expectedFooterTypes: ReferenceType[] = footer.enabled
? [
"default",
...(usesEvenAndOddPages ? (["even"] as const) : []),
...(usesDifferentFirstPage ? (["first"] as const) : [])
]
: [];
assertReferenceTypes(id, "header", headerReferences, expectedHeaderTypes);
assertReferenceTypes(id, "footer", footerReferences, expectedFooterTypes);
if (usesDifferentFirstPage && header.enabled) {
if (header.showOnFirstPage) {
assertFirstPartMatchesDefault(entries, headerReferences, id, "header");
} else {
assertEmptyFirstPart(entries, headerReferences.get("first")!, id, "header");
}
}
if (usesDifferentFirstPage && footer.enabled) {
if (footer.showOnFirstPage) {
assertFirstPartMatchesDefault(entries, footerReferences, id, "footer");
} else {
assertEmptyFirstPart(entries, footerReferences.get("first")!, id, "footer");
}
}
const defaultFooterAlignment =
footer.alignment === "outer"
? coverPageOffset % 2 === 0
? "right"
: "left"
: footer.alignment;
if (footer.enabled) {
const defaultPart = footerReferences.get("default")!;
if (
footerAlignment(entries, defaultPart) !== defaultFooterAlignment
) {
fail(id, "footer-default-alignment", defaultFooterAlignment);
}
const evenPart = footerReferences.get("even");
const evenFooterAlignment =
coverPageOffset % 2 === 0 ? "left" : "right";
if (
evenPart &&
footerAlignment(entries, evenPart) !== evenFooterAlignment
) {
fail(id, "footer-even-alignment", `期望 ${evenFooterAlignment}`);
}
const firstPart = footerReferences.get("first");
if (
firstPart &&
footer.showOnFirstPage &&
footerAlignment(entries, firstPart) !== defaultFooterAlignment
) {
fail(id, "footer-first-alignment", defaultFooterAlignment);
}
}
const settings = parseXmlPart(
entries.get("word/settings.xml")!,
"word/settings.xml"
);
const hasEvenAndOddSetting = Boolean(
settings.getElementsByTagNameNS(WORD_NAMESPACE, "evenAndOddHeaders")[0]
);
if (hasEvenAndOddSetting !== usesEvenAndOddPages) {
fail(
id,
"even-and-odd-setting",
`期望 ${usesEvenAndOddPages},实际 ${hasEvenAndOddSetting}`
);
}
const baseTotalField = expectedTotalPageField(footer);
const totalPageField =
baseTotalField === "none"
? "none"
: hasCover
? "SECTIONPAGES"
: "NUMPAGES";
for (const partName of footerReferences.values()) {
const content = partContent(entries, partName);
const numberFields = fieldCount(content, "PAGE");
if (partName !== footerReferences.get("first") || footer.showOnFirstPage) {
if (numberFields < 1) {
fail(id, "footer-page-field", `${partName} 缺少 PAGE 字段`);
}
}
if (fieldCount(content, "NUMPAGES") > 0 && totalPageField !== "NUMPAGES") {
fail(id, "footer-total-field", `${partName} 不应包含 NUMPAGES`);
}
if (
fieldCount(content, "SECTIONPAGES") > 0 &&
totalPageField !== "SECTIONPAGES"
) {
fail(id, "footer-total-field", `${partName} 不应包含 SECTIONPAGES`);
}
if (
totalPageField !== "none" &&
(partName !== footerReferences.get("first") || footer.showOnFirstPage) &&
fieldCount(content, totalPageField) < 1
) {
fail(id, "footer-total-field", `${partName} 缺少 ${totalPageField}`);
}
}
return {
hasCover,
usesDifferentFirstPage,
usesEvenAndOddPages,
bodyPageNumberStart,
headerReferenceTypes: [...headerReferences.keys()].sort(),
footerReferenceTypes: [...footerReferences.keys()].sort(),
totalPageField
};
}