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

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

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

453 lines
12 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 { 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
}
};
}