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
@@ -0,0 +1,452 @@
import path from "node:path";
import type { PreparedDocxMedia } from "@md-to-pdf/core";
import {
DRAWING_NAMESPACE,
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
firstDirectChild,
parseXmlPart,
type XmlElement
} from "./ooxml.js";
import {
preparePandocMedia,
type PandocMediaLayoutItem
} from "./pandoc-media.js";
import { readGeneratedDocxPackage } from "./reference-package.js";
const WORDPROCESSING_DRAWING_NAMESPACE =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
const IMAGE_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
const EMUS_PER_TWIP = 635;
const EMU_TOLERANCE = 20;
const ASPECT_RATIO_TOLERANCE = 0.000_001;
const PNG_SIGNATURE = Uint8Array.of(
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a
);
export interface DocxMediaAcceptancePage {
widthTwips: number;
heightTwips: number;
marginsTwips: {
top: number;
right: number;
bottom: number;
left: number;
};
}
export interface DocxMediaAcceptanceExpectation {
items: readonly PandocMediaLayoutItem[];
maximumWidthEmu: number;
maximumHeightEmu: number;
}
export interface DocxMediaAcceptanceObservation {
id: string;
kind: PandocMediaLayoutItem["kind"];
ordinal: number;
altText: string;
alignment: PandocMediaLayoutItem["alignment"];
widthEmu: number;
heightEmu: number;
relationshipId: string;
partName: string;
}
export interface DocxMediaAcceptanceReport {
expectedCount: number;
drawingCount: number;
inlineCount: number;
anchorCount: number;
maximumWidthEmu: number;
maximumHeightEmu: number;
observations: DocxMediaAcceptanceObservation[];
checks: {
exactCount: true;
inlineLayout: true;
dimensions: true;
aspectRatio: true;
alignment: true;
relationships: true;
pngMedia: true;
noCroppingOrTransform: true;
};
}
interface Relationship {
id: string;
type: string;
target: string;
external: boolean;
}
function fail(id: string, check: string, detail: string): never {
throw new Error(
`${id} 的 DOCX 媒体验收失败:${check}${detail}`
);
}
function numericAttribute(
element: XmlElement,
name: string,
id: string,
check: string
) {
const raw = element.getAttribute(name);
const value = raw === null ? Number.NaN : Number(raw);
if (!Number.isSafeInteger(value) || value <= 0) {
fail(id, check, `${name}=${JSON.stringify(raw)}`);
}
return value;
}
function withinTolerance(actual: number, expected: number) {
return Math.abs(actual - expected) <= EMU_TOLERANCE;
}
function paragraphForDrawing(
drawing: XmlElement,
id: string
) {
let current = drawing.parentNode;
while (current) {
const element = current as XmlElement;
if (
element.nodeType === 1 &&
element.namespaceURI === WORD_NAMESPACE &&
element.localName === "p"
) {
return element;
}
current = current.parentNode;
}
fail(id, "paragraph", "Drawing 不在正文段落中");
}
function parseRelationships(content: Uint8Array, id: string) {
const document = parseXmlPart(
content,
"word/_rels/document.xml.rels"
);
return Array.from(
document.getElementsByTagNameNS(
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)
).map(
(element): Relationship => ({
id: element.getAttribute("Id") ?? "",
type: element.getAttribute("Type") ?? "",
target: element.getAttribute("Target") ?? "",
external: element.getAttribute("TargetMode") === "External"
})
).map((relationship) => {
if (!relationship.id || !relationship.target) {
fail(id, "relationships", "图片关系缺少 ID 或 Target");
}
return relationship;
});
}
function resolveImagePart(target: string, id: string) {
const partName = path.posix.normalize(
path.posix.join("word", target.replaceAll("\\", "/"))
);
if (!partName.startsWith("word/") || partName.includes("../")) {
fail(id, "relationships", `图片关系越界:${target}`);
}
return partName;
}
function hasPngSignature(content: Uint8Array) {
return (
content.byteLength >= PNG_SIGNATURE.byteLength &&
PNG_SIGNATURE.every((value, index) => content[index] === value)
);
}
export function createDocxMediaAcceptanceExpectation(
media: PreparedDocxMedia,
page: DocxMediaAcceptancePage
): DocxMediaAcceptanceExpectation {
const contentWidthTwips =
page.widthTwips -
page.marginsTwips.left -
page.marginsTwips.right;
const contentHeightTwips =
page.heightTwips -
page.marginsTwips.top -
page.marginsTwips.bottom;
if (contentWidthTwips <= 0 || contentHeightTwips <= 0) {
throw new Error("DOCX 媒体验收页面内容区无效");
}
return {
items: preparePandocMedia(media).layout,
maximumWidthEmu: contentWidthTwips * EMUS_PER_TWIP,
maximumHeightEmu: contentHeightTwips * EMUS_PER_TWIP
};
}
export function inspectDocxMediaAcceptance(
content: Uint8Array,
expectation: DocxMediaAcceptanceExpectation,
id = "document"
): DocxMediaAcceptanceReport {
const package_ = readGeneratedDocxPackage(content);
const entries = package_.entries;
const document = parseXmlPart(
entries.get("word/document.xml")!,
"word/document.xml"
);
const relationshipContent = entries.get(
"word/_rels/document.xml.rels"
);
if (!relationshipContent) {
fail(id, "relationships", "缺少 document.xml.rels");
}
const relationships = parseRelationships(relationshipContent, id);
const relationshipsById = new Map(
relationships.map((relationship) => [relationship.id, relationship])
);
if (relationshipsById.size !== relationships.length) {
fail(id, "relationships", "存在重复关系 ID");
}
const drawings = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "drawing")
);
const inlineCount = document.getElementsByTagNameNS(
WORDPROCESSING_DRAWING_NAMESPACE,
"inline"
).length;
const anchorCount = document.getElementsByTagNameNS(
WORDPROCESSING_DRAWING_NAMESPACE,
"anchor"
).length;
if (
drawings.length !== expectation.items.length ||
inlineCount !== expectation.items.length ||
anchorCount !== 0
) {
fail(
id,
"inline-layout",
`expected=${expectation.items.length}, drawings=${drawings.length}, inline=${inlineCount}, anchor=${anchorCount}`
);
}
const usedRelationshipIds = new Set<string>();
const usedParts = new Set<string>();
const observations: DocxMediaAcceptanceObservation[] = [];
for (const [index, item] of expectation.items.entries()) {
const drawing = drawings[index]!;
const inline = drawing.getElementsByTagNameNS(
WORDPROCESSING_DRAWING_NAMESPACE,
"inline"
)[0];
if (!inline) {
fail(id, "inline-layout", `${item.id} 缺少 wp:inline`);
}
for (const attribute of ["distT", "distB", "distL", "distR"]) {
if (inline.getAttribute(attribute) !== "0") {
fail(id, "inline-layout", `${item.id}${attribute} 非零`);
}
}
const extent = firstDirectChild(
inline,
WORDPROCESSING_DRAWING_NAMESPACE,
"extent"
);
const documentProperties = firstDirectChild(
inline,
WORDPROCESSING_DRAWING_NAMESPACE,
"docPr"
);
if (!extent || !documentProperties) {
fail(id, "drawing-properties", `${item.id} 的属性不完整`);
}
const widthEmu = numericAttribute(
extent,
"cx",
id,
"dimensions"
);
const heightEmu = numericAttribute(
extent,
"cy",
id,
"dimensions"
);
if (
!withinTolerance(widthEmu, item.widthEmu) ||
!withinTolerance(heightEmu, item.heightEmu) ||
widthEmu > expectation.maximumWidthEmu + EMU_TOLERANCE ||
heightEmu > expectation.maximumHeightEmu + EMU_TOLERANCE
) {
fail(
id,
"dimensions",
`${item.id}=${widthEmu}x${heightEmu}, expected=${item.widthEmu}x${item.heightEmu}`
);
}
const ratioDelta = Math.abs(
widthEmu / heightEmu - item.widthEmu / item.heightEmu
) / (item.widthEmu / item.heightEmu);
if (ratioDelta > ASPECT_RATIO_TOLERANCE) {
fail(id, "aspect-ratio", `${item.id} delta=${ratioDelta}`);
}
const transforms = Array.from(
drawing.getElementsByTagNameNS(DRAWING_NAMESPACE, "xfrm")
);
const transformedExtent =
transforms.length === 1
? firstDirectChild(
transforms[0]!,
DRAWING_NAMESPACE,
"ext"
)
: undefined;
if (!transformedExtent) {
fail(id, "drawing-transform", `${item.id} 缺少唯一 a:xfrm/a:ext`);
}
if (
numericAttribute(transformedExtent, "cx", id, "dimensions") !==
widthEmu ||
numericAttribute(transformedExtent, "cy", id, "dimensions") !==
heightEmu
) {
fail(id, "dimensions", `${item.id} 的双层尺寸不一致`);
}
const transform = transforms[0]!;
if (
transform.hasAttribute("rot") ||
transform.hasAttribute("flipH") ||
transform.hasAttribute("flipV") ||
drawing.getElementsByTagNameNS(DRAWING_NAMESPACE, "srcRect")
.length > 0
) {
fail(id, "cropping-transform", `${item.id} 存在裁切、旋转或翻转`);
}
const locks = drawing.getElementsByTagNameNS(
DRAWING_NAMESPACE,
"graphicFrameLocks"
);
if (
locks.length !== 1 ||
locks[0]!.getAttribute("noChangeAspect") !== "1"
) {
fail(id, "aspect-lock", `${item.id} 未锁定宽高比`);
}
if (
documentProperties.getAttribute("descr") !== item.altText ||
(documentProperties.getAttribute("title") ?? "").includes(
"mdtp-media:"
)
) {
fail(id, "alternative-text", `${item.id} 的替代文本或标题无效`);
}
const paragraph = paragraphForDrawing(drawing, id);
const properties = firstDirectChild(
paragraph,
WORD_NAMESPACE,
"pPr"
);
const justification = properties
? firstDirectChild(properties, WORD_NAMESPACE, "jc")
: undefined;
const alignment = justification?.getAttributeNS(
WORD_NAMESPACE,
"val"
);
if (alignment !== item.alignment) {
fail(
id,
"alignment",
`${item.id}=${alignment ?? "missing"}, expected=${item.alignment}`
);
}
const blips = Array.from(
drawing.getElementsByTagNameNS(DRAWING_NAMESPACE, "blip")
);
const relationshipId =
blips.length === 1
? blips[0]!.getAttributeNS(
OFFICE_RELATIONSHIP_NAMESPACE,
"embed"
)
: null;
if (!relationshipId || usedRelationshipIds.has(relationshipId)) {
fail(id, "relationships", `${item.id} 的图片关系缺失或重复`);
}
const relationship = relationshipsById.get(relationshipId);
if (
!relationship ||
relationship.type !== IMAGE_RELATIONSHIP_TYPE ||
relationship.external
) {
fail(id, "relationships", `${item.id} 的图片关系类型无效`);
}
const partName = resolveImagePart(relationship.target, id);
const image = entries.get(partName);
if (
!partName.toLowerCase().endsWith(".png") ||
!image ||
!hasPngSignature(image) ||
usedParts.has(partName)
) {
fail(id, "png-media", `${item.id} 未绑定唯一有效 PNG`);
}
usedRelationshipIds.add(relationshipId);
usedParts.add(partName);
observations.push({
id: item.id,
kind: item.kind,
ordinal: item.ordinal,
altText: item.altText,
alignment: item.alignment,
widthEmu,
heightEmu,
relationshipId,
partName
});
}
const imageRelationships = relationships.filter(
(relationship) => relationship.type === IMAGE_RELATIONSHIP_TYPE
);
if (imageRelationships.length !== usedRelationshipIds.size) {
fail(id, "relationships", "存在未使用或额外的正文图片关系");
}
return {
expectedCount: expectation.items.length,
drawingCount: drawings.length,
inlineCount,
anchorCount,
maximumWidthEmu: expectation.maximumWidthEmu,
maximumHeightEmu: expectation.maximumHeightEmu,
observations,
checks: {
exactCount: true,
inlineLayout: true,
dimensions: true,
aspectRatio: true,
alignment: true,
relationships: true,
pngMedia: true,
noCroppingOrTransform: true
}
};
}