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
@@ -17,6 +17,16 @@ import {
validateGeneratedDocx,
type DynamicReferenceValidation
} from "./validator.js";
import {
inspectDocxPageDecorations,
type DocxPageDecorationAcceptanceReport,
type DocxPageDecorationExpectation
} from "./page-decoration-acceptance.js";
import {
inspectDocxMediaAcceptance,
type DocxMediaAcceptanceExpectation,
type DocxMediaAcceptanceReport
} from "./media-acceptance.js";
const OFFICE_MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
@@ -74,6 +84,8 @@ export interface DocxAcceptanceExpectation {
minimumFullWidthTables?: number;
maximumTextOccurrences?: Readonly<Record<string, number>>;
forbidInternalMarkers?: boolean;
pageDecorations?: DocxPageDecorationExpectation;
media?: DocxMediaAcceptanceExpectation;
}
export interface DocxAcceptanceReport {
@@ -111,6 +123,8 @@ export interface DocxAcceptanceReport {
imageAltText: string[];
styleIds: string[];
appliedParagraphStyleIds: string[];
pageDecorations?: DocxPageDecorationAcceptanceReport;
media?: DocxMediaAcceptanceReport;
checks: Record<string, true>;
}
@@ -290,6 +304,21 @@ export function inspectDocxAcceptance(
if (!section) {
fail(expectation.id, "page-section", "缺少最终节");
}
const pageDecorations = expectation.pageDecorations
? inspectDocxPageDecorations(
entries,
document,
expectation.pageDecorations,
expectation.id
)
: undefined;
const media = expectation.media
? inspectDocxMediaAcceptance(
content,
expectation.media,
expectation.id
)
: undefined;
const pageSize = Array.from(
section.getElementsByTagNameNS(WORD_NAMESPACE, "pgSz")
)[0];
@@ -401,6 +430,17 @@ export function inspectDocxAcceptance(
);
}
if (expectation.finalPageNumberStart !== undefined) {
const parityOffset = expectation.pageDecorations
? expectation.pageDecorations.exportConfig.footer.enabled &&
expectation.pageDecorations.exportConfig.footer.alignment ===
"outer"
? expectation.pageDecorations.semanticDocument.regions.filter(
(region) => region.kind === "cover" && region.section
).length
: 0
: 0;
const expectedPageNumberStart =
expectation.finalPageNumberStart + parityOffset;
const pageNumber = firstDirectChild(
section,
WORD_NAMESPACE,
@@ -422,7 +462,7 @@ export function inspectDocxAcceptance(
expectation.id,
"final-page-number-start"
),
expectation.finalPageNumberStart
expectedPageNumberStart
);
}
@@ -734,6 +774,8 @@ export function inspectDocxAcceptance(
imageAltText,
styleIds,
appliedParagraphStyleIds,
...(pageDecorations ? { pageDecorations } : {}),
...(media ? { media } : {}),
checks: {
package: true,
page: true,
@@ -742,6 +784,8 @@ export function inspectDocxAcceptance(
pngMedia: true,
styles: true,
sections: true,
...(pageDecorations ? { pageDecorations: true } : {}),
...(media ? { media: true } : {}),
tables: true,
textOccurrences: true,
noAltChunk: true
File diff suppressed because it is too large Load Diff
@@ -145,27 +145,89 @@ function appendField(
});
}
function footerTemplate(footer: FooterConfig) {
return footer.format === "page"
? "${page}"
: footer.format === "page-total"
? "${page} / ${pages}"
: footer.format === "chinese-page-total"
? "第 ${page} 页 / 共 ${pages} 页"
: footer.format === "dash-page"
? "- ${page} -"
: footer.format === "official-page"
? "— ${page} —"
: footer.template || "${page} / ${pages}";
}
function appendInstruction(
paragraph: XmlElement,
value: string,
style: RunStyle
) {
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(run, style);
const instruction = appendElement(
run,
WORD_NAMESPACE,
"w:instrText"
);
instruction.setAttributeNS(
XML_NAMESPACE,
"xml:space",
"preserve"
);
instruction.appendChild(
instruction.ownerDocument!.createTextNode(value)
);
}
function appendFieldCharacter(
paragraph: XmlElement,
type: "begin" | "separate" | "end",
style: RunStyle
) {
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(run, style);
appendElement(run, WORD_NAMESPACE, "w:fldChar", {
"w:fldCharType": type,
...(type === "begin" ? { "w:dirty": "true" } : {})
});
}
function appendOffsetPageField(
paragraph: XmlElement,
offset: number,
style: RunStyle
) {
if (offset <= 0) {
appendField(paragraph, "PAGE", style);
return;
}
appendFieldCharacter(paragraph, "begin", style);
appendInstruction(paragraph, " = ", style);
appendFieldCharacter(paragraph, "begin", style);
appendInstruction(paragraph, " PAGE \\* MERGEFORMAT ", style);
appendFieldCharacter(paragraph, "separate", style);
appendText(paragraph, String(offset + 1), style);
appendFieldCharacter(paragraph, "end", style);
appendInstruction(paragraph, ` - ${offset} `, style);
appendFieldCharacter(paragraph, "separate", style);
appendText(paragraph, "1", style);
appendFieldCharacter(paragraph, "end", style);
}
function appendFooterContent(
paragraph: XmlElement,
footer: FooterConfig,
style: RunStyle
style: RunStyle,
pageNumberOffset = 0
) {
const template =
footer.format === "page"
? "${page}"
: footer.format === "page-total"
? "${page} / ${pages}"
: footer.format === "chinese-page-total"
? "第 ${page} 页 / 共 ${pages} 页"
: footer.format === "dash-page"
? "- ${page} -"
: footer.format === "official-page"
? "— ${page} —"
: footer.template || "${page} / ${pages}";
const tokens = template.split(/(\$\{page\}|\$\{pages\})/gu);
const tokens = footerTemplate(footer).split(
/(\$\{page\}|\$\{pages\})/gu
);
for (const token of tokens) {
if (token === "${page}") {
appendField(paragraph, "PAGE", style);
appendOffsetPageField(paragraph, pageNumberOffset, style);
} else if (token === "${pages}") {
appendField(paragraph, "NUMPAGES", style);
} else {
@@ -179,6 +241,7 @@ function appendParagraphProperties(
options: {
alignment?: "left" | "center" | "right";
position: "header" | "footer";
lineHeightTwips: number;
divider: boolean;
dividerColor: string;
centerTabTwips?: number;
@@ -192,7 +255,9 @@ function appendParagraphProperties(
);
appendElement(properties, WORD_NAMESPACE, "w:spacing", {
"w:before": "0",
"w:after": "0"
"w:after": "0",
"w:line": String(options.lineHeightTwips),
"w:lineRule": "exact"
});
if (options.alignment) {
appendElement(properties, WORD_NAMESPACE, "w:jc", {
@@ -200,18 +265,22 @@ function appendParagraphProperties(
});
}
if (
options.centerTabTwips !== undefined &&
options.centerTabTwips !== undefined ||
options.rightTabTwips !== undefined
) {
const tabs = appendElement(properties, WORD_NAMESPACE, "w:tabs");
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "center",
"w:pos": String(options.centerTabTwips)
});
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "right",
"w:pos": String(options.rightTabTwips)
});
if (options.centerTabTwips !== undefined) {
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "center",
"w:pos": String(options.centerTabTwips)
});
}
if (options.rightTabTwips !== undefined) {
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "right",
"w:pos": String(options.rightTabTwips)
});
}
}
if (options.divider) {
const borders = appendElement(
@@ -244,6 +313,7 @@ function appendHeaderParagraph(
divider: boolean;
dividerColor: string;
contentWidthMm: number;
lineHeightTwips: number;
render: (
paragraph: XmlElement,
alignment: "left" | "center" | "right"
@@ -254,6 +324,7 @@ function appendHeaderParagraph(
const contentWidthTwips = millimetersToTwips(options.contentWidthMm);
appendParagraphProperties(paragraph, {
position: "header",
lineHeightTwips: options.lineHeightTwips,
divider: options.divider,
dividerColor: options.dividerColor,
centerTabTwips: Math.round(contentWidthTwips / 2),
@@ -270,7 +341,8 @@ function createHeaderPart(
header: HeaderConfig,
options: DynamicReferenceDocxOptions,
fallbackFont: string,
contentWidthMm: number
contentWidthMm: number,
empty = false
) {
const document = createWordPart("hdr");
const style: RunStyle = {
@@ -279,12 +351,13 @@ function createHeaderPart(
color: header.color
};
appendHeaderParagraph(document.documentElement!, {
divider: header.showDivider,
divider: !empty && header.showDivider,
dividerColor: header.color,
contentWidthMm,
lineHeightTwips: Math.round(style.sizePt * 1.25 * 20),
render: (paragraph, alignment) => {
const slot = header[alignment];
if (slot.enabled) {
if (!empty && slot.enabled) {
appendText(
paragraph,
resolveHeaderTemplate(slot.content, options),
@@ -300,7 +373,8 @@ function createFooterPart(
footer: FooterConfig,
alignment: "left" | "center" | "right",
fallbackFont: string,
empty = false
empty = false,
pageNumberOffset = 0
) {
const document = createWordPart("ftr");
const style: RunStyle = {
@@ -315,12 +389,13 @@ function createFooterPart(
);
appendParagraphProperties(paragraph, {
position: "footer",
divider: footer.showDivider,
lineHeightTwips: Math.round(style.sizePt * 1.25 * 20),
divider: !empty && footer.showDivider,
dividerColor: footer.color,
alignment
});
if (!empty) {
appendFooterContent(paragraph, footer, style);
appendFooterContent(paragraph, footer, style, pageNumberOffset);
}
return serializeXmlPart(document);
}
@@ -460,10 +535,15 @@ export function createHeaderFooterParts(
}> = [];
let headerIndex = 1;
let footerIndex = 1;
const coverPageOffset = options.semanticDocument?.regions.filter(
(region) => region.kind === "cover" && region.section
).length ?? 0;
const usesEvenAndOddPages =
footer.enabled && footer.alignment === "outer";
footer.enabled &&
footer.alignment === "outer";
const usesDifferentFirstPage =
footer.enabled && !footer.showOnFirstPage;
(header.enabled && !header.showOnFirstPage) ||
(footer.enabled && !footer.showOnFirstPage);
if (header.enabled) {
const headerContent = createHeaderPart(
@@ -474,25 +554,50 @@ export function createHeaderFooterParts(
);
for (const type of [
"default",
...(usesEvenAndOddPages ? ["even"] : []),
...(usesDifferentFirstPage ? ["first"] : [])
...(usesEvenAndOddPages ? ["even"] : [])
] as HeaderFooterReferenceType[]) {
const partName = `word/header${headerIndex++}.xml`;
entries.set(partName, headerContent);
parts.push({ kind: "header", type, partName });
}
if (usesDifferentFirstPage) {
const firstPartName = `word/header${headerIndex++}.xml`;
entries.set(
firstPartName,
createHeaderPart(
header,
options,
fallbackFont,
contentWidthMm,
!header.showOnFirstPage
)
);
parts.push({
kind: "header",
type: "first",
partName: firstPartName
});
}
}
if (footer.enabled) {
const defaultAlignment =
footer.alignment === "outer" ? "right" : footer.alignment;
footer.alignment === "outer"
? coverPageOffset % 2 === 0
? "right"
: "left"
: footer.alignment;
const evenAlignment =
coverPageOffset % 2 === 0 ? "left" : "right";
const defaultPartName = `word/footer${footerIndex++}.xml`;
entries.set(
defaultPartName,
createFooterPart(
footer,
defaultAlignment,
fallbackFont
fallbackFont,
false,
usesEvenAndOddPages ? coverPageOffset : 0
)
);
parts.push({
@@ -504,7 +609,13 @@ export function createHeaderFooterParts(
const evenPartName = `word/footer${footerIndex++}.xml`;
entries.set(
evenPartName,
createFooterPart(footer, "left", fallbackFont)
createFooterPart(
footer,
evenAlignment,
fallbackFont,
false,
coverPageOffset
)
);
parts.push({
kind: "footer",
@@ -520,7 +631,8 @@ export function createHeaderFooterParts(
footer,
defaultAlignment,
fallbackFont,
true
!footer.showOnFirstPage,
usesEvenAndOddPages ? coverPageOffset : 0
)
);
parts.push({
+2
View File
@@ -3,7 +3,9 @@ export * from "./document-structure-transform.js";
export * from "./font-embedding.js";
export * from "./font-package-transform.js";
export * from "./header-footer-transform.js";
export * from "./media-acceptance.js";
export * from "./ooxml.js";
export * from "./page-decoration-acceptance.js";
export * from "./pandoc-process.js";
export * from "./pandoc-media.js";
export * from "./pandoc-structure.js";
@@ -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
}
};
}
+15
View File
@@ -525,6 +525,21 @@ export function pointsToHalfPoints(value: number) {
return String(Math.round(value * 2));
}
export function wordCharacterSpacingTwips(
fontSizePt: number | undefined,
letterSpacingPt = 0
) {
const quantizationCompensation =
fontSizePt === undefined
? 0
: fontSizePt - Math.round(fontSizePt * 2) / 2;
return String(
Math.round(
(letterSpacingPt + quantizationCompensation) * 20
)
);
}
export function pointsToTwips(value: number) {
return String(Math.round(value * 20));
}
@@ -0,0 +1,440 @@
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
};
}
+3 -1
View File
@@ -151,6 +151,7 @@ function referenceOptions(
exportConfig: input.exportConfig,
theme: input.theme,
themeTokens: input.themeTokens,
semanticDocument: input.semanticDocument,
fileName: input.fileName,
metadata: {
title: input.metadata.title,
@@ -373,7 +374,8 @@ export class PandocDocxConverter {
const finalized = finalizeGeneratedDocxStructure(
pandocDocx,
structurePlan,
input.themeTokens
input.themeTokens,
preparedMedia.layout
);
docx = embedFontsInGeneratedDocx(
finalized.content,
+38 -1
View File
@@ -11,6 +11,7 @@ import {
export interface PandocMediaMapItem {
id: string;
binding: string;
path: string;
alt_text: string;
caption?: string;
@@ -18,6 +19,19 @@ export interface PandocMediaMapItem {
height: string;
}
export interface PandocMediaLayoutItem {
id: string;
binding: string;
kind: DocxMediaKind;
ordinal: number;
altText: string;
alignment: PreparedDocxMedia["resources"][number]["alignment"];
displayWidthPx: number;
displayHeightPx: number;
widthEmu: number;
heightEmu: number;
}
export interface PandocMediaMap {
image: PandocMediaMapItem[];
mermaid: PandocMediaMapItem[];
@@ -32,8 +46,12 @@ export interface PandocMediaFile {
export interface PreparedPandocMedia {
map: PandocMediaMap;
files: PandocMediaFile[];
layout: PandocMediaLayoutItem[];
}
export const DOCX_MEDIA_BINDING_PREFIX = "mdtp-media:";
const EMUS_PER_CSS_PIXEL = 9_525;
function pixelsToMillimeters(value: number) {
return (value * 25.4) / DOCX_MEDIA_CSS_DPI;
}
@@ -91,6 +109,7 @@ export function preparePandocMedia(
echarts: []
};
const files: PandocMediaFile[] = [];
const layout: PandocMediaLayoutItem[] = [];
const ids = new Set<string>();
let totalBytes = 0;
const kindCounts: Record<DocxMediaKind, number> = {
@@ -129,8 +148,10 @@ export function preparePandocMedia(
const relativePath = `media/media-${String(
resource.ordinal
).padStart(3, "0")}.png`;
const binding = `${DOCX_MEDIA_BINDING_PREFIX}${resource.id}`;
map[resource.kind].push({
id: resource.id,
binding,
path: relativePath,
alt_text: resource.altText || `${resource.kind} 图片`,
...(resource.caption
@@ -139,6 +160,22 @@ export function preparePandocMedia(
width: physicalLength(resource.displayWidthPx),
height: physicalLength(resource.displayHeightPx)
});
layout.push({
id: resource.id,
binding,
kind: resource.kind,
ordinal: resource.ordinal,
altText: resource.altText || `${resource.kind} 图片`,
alignment: resource.alignment,
displayWidthPx: resource.displayWidthPx,
displayHeightPx: resource.displayHeightPx,
widthEmu: Math.round(
resource.displayWidthPx * EMUS_PER_CSS_PIXEL
),
heightEmu: Math.round(
resource.displayHeightPx * EMUS_PER_CSS_PIXEL
)
});
files.push({
relativePath,
content: resource.content
@@ -147,5 +184,5 @@ export function preparePandocMedia(
if (media.totalBytes !== totalBytes) {
throw new Error("DOCX 媒体总大小声明不一致");
}
return { map, files };
return { map, files, layout };
}
@@ -18,6 +18,7 @@ import {
export interface PandocStructureParagraph {
kind: "paragraph";
styleId?: string | undefined;
layout?: "space-between" | undefined;
segments: string[];
separator: "space" | "tab";
}
@@ -47,6 +48,9 @@ export type PandocStructureBlock =
export interface PandocStructurePlan {
schemaVersion: 1;
metadataPolicy: {
author: "emit" | "suppress";
};
titlePolicy: SemanticDocumentModel["titlePolicy"];
prefix: PandocStructureBlock[];
suffix: PandocStructureBlock[];
@@ -158,6 +162,7 @@ function projectGroupNode(
? {
kind: "paragraph",
...(styleId ? { styleId } : {}),
...(node.layout ? { layout: node.layout } : {}),
segments,
separator: segments.length > 1 ? "tab" : "space"
}
@@ -233,6 +238,9 @@ export function createPandocStructurePlan(
}
return {
schemaVersion: 1,
metadataPolicy: {
author: "suppress"
},
titlePolicy: model.titlePolicy,
prefix,
suffix
@@ -72,6 +72,7 @@ export function createDynamicReferenceCacheKey(
pageDefaults: options.theme.pageDefaults
},
themeTokens: options.themeTokens,
semanticDocument: options.semanticDocument,
paper: options.exportConfig.paper,
pageDecorationsMode:
options.exportConfig.pageDecorationsMode,
@@ -143,9 +144,15 @@ export function createDynamicReferenceDocx(
headerHeightMm: page.header.enabled
? lengthToMillimeters(page.header.height)
: 0,
headerFontSizeMm: page.header.enabled
? lengthToMillimeters(page.header.fontSize)
: 0,
footerHeightMm: page.footer.enabled
? lengthToMillimeters(page.footer.height)
: 0,
footerFontSizeMm: page.footer.enabled
? lengthToMillimeters(page.footer.fontSize)
: 0,
pageNumberStart: page.footer.startFrom,
references: withDecorations.references,
usesDifferentFirstPage:
@@ -5,6 +5,7 @@ import {
resolvePageMargins,
type ExportConfig,
type MarkdownDocumentMetadata,
type SemanticDocumentModel,
type ThemeManifest
} from "@md-to-pdf/core";
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
@@ -15,6 +16,7 @@ export interface DynamicReferenceDocxOptions {
fileName: string;
metadata: Pick<MarkdownDocumentMetadata, "title" | "author">;
themeTokens?: DocxThemeTokenSet;
semanticDocument?: SemanticDocumentModel;
}
export function resolveReferencePageOptions(
@@ -23,7 +23,9 @@ export interface ReferenceSectionOptions {
};
orientation: "portrait" | "landscape";
headerHeightMm: number;
headerFontSizeMm: number;
footerHeightMm: number;
footerFontSizeMm: number;
pageNumberStart: number;
references: HeaderFooterReference[];
usesDifferentFirstPage: boolean;
@@ -104,7 +106,9 @@ export function transformDocumentSectionXml(
millimetersToTwips(
Math.max(
0,
options.margins.top - options.headerHeightMm
options.margins.top -
options.headerHeightMm +
options.headerFontSizeMm * 0.25
)
)
),
@@ -112,7 +116,9 @@ export function transformDocumentSectionXml(
millimetersToTwips(
Math.max(
0,
options.margins.bottom - options.footerHeightMm
options.margins.bottom -
options.footerHeightMm -
options.footerFontSizeMm * 0.25
)
)
),
+122 -23
View File
@@ -25,6 +25,7 @@ import {
pointsToTwips,
removeDirectChildren,
serializeXmlPart,
wordCharacterSpacingTwips,
type XmlElement
} from "./ooxml.js";
@@ -62,7 +63,8 @@ function setRunStyle(
"i",
"iCs",
"u",
"shd"
"shd",
"spacing"
);
if (options.fonts) {
setFonts(parent, options.fonts);
@@ -100,6 +102,9 @@ function setRunStyle(
appendElement(parent, WORD_NAMESPACE, "w:szCs", {
"w:val": size
});
appendElement(parent, WORD_NAMESPACE, "w:spacing", {
"w:val": wordCharacterSpacingTwips(options.sizePt)
});
}
}
@@ -283,10 +288,16 @@ function applyTokenRunStyle(
});
}
}
if (token.letterSpacingPt !== undefined) {
if (
token.letterSpacingPt !== undefined ||
token.fontSizePt !== undefined
) {
removeDirectChildren(parent, WORD_NAMESPACE, "spacing");
appendElement(parent, WORD_NAMESPACE, "w:spacing", {
"w:val": pointsToTwips(token.letterSpacingPt)
"w:val": wordCharacterSpacingTwips(
token.fontSizePt,
token.letterSpacingPt ?? 0
)
});
}
toggleProperty(parent, "b", token.bold);
@@ -299,7 +310,8 @@ function applyTokenRunStyle(
function applyTokenBorders(
parent: XmlElement,
token: DocxSlotStyleToken
token: DocxSlotStyleToken,
horizontalContentPaddingInBorderSpace = false
) {
if (!token.borders) {
return;
@@ -330,7 +342,18 @@ function applyTokenBorders(
0,
Math.min(
31,
Math.round(token.paddingPt?.[side] ?? 0)
Math.round(
horizontalContentPaddingInBorderSpace && side === "left"
? Math.floor(
Math.max(
0,
(token.paddingPt?.left ?? 0) +
(token.contentPaddingLeftPt ?? 0) -
border.widthPt * 2
)
)
: token.paddingPt?.[side] ?? 0
)
)
)
),
@@ -342,11 +365,34 @@ function applyTokenBorders(
function applyTokenParagraphStyle(
parent: XmlElement,
token: DocxSlotStyleToken,
fallbackFontSizePt: number
fallbackFontSizePt: number,
horizontalPaddingThroughBorderSpace = false
) {
if (
for (const propertyName of ["autoSpaceDE", "autoSpaceDN"] as const) {
const automaticSpacing = ensureDirectElement(
parent,
WORD_NAMESPACE,
`w:${propertyName}`
);
setWordAttribute(automaticSpacing, "val", "0");
}
const spacingBeforePt =
token.spacingBeforePt !== undefined ||
(token.paddingPt?.top ?? 0) > 0
? (token.spacingBeforePt ?? 0) +
(token.borders?.top ? 0 : token.paddingPt?.top ?? 0)
: undefined;
const spacingAfterPt =
token.spacingAfterPt !== undefined ||
(token.paddingPt?.bottom ?? 0) > 0
? (token.spacingAfterPt ?? 0) +
(token.borders?.bottom
? 0
: token.paddingPt?.bottom ?? 0)
: undefined;
if (
spacingBeforePt !== undefined ||
spacingAfterPt !== undefined ||
token.lineSpacing !== undefined
) {
const spacing = ensureDirectElement(
@@ -354,18 +400,18 @@ function applyTokenParagraphStyle(
WORD_NAMESPACE,
"w:spacing"
);
if (token.spacingBeforePt !== undefined) {
if (spacingBeforePt !== undefined) {
setWordAttribute(
spacing,
"before",
pointsToTwips(token.spacingBeforePt)
pointsToTwips(spacingBeforePt)
);
}
if (token.spacingAfterPt !== undefined) {
if (spacingAfterPt !== undefined) {
setWordAttribute(
spacing,
"after",
pointsToTwips(token.spacingAfterPt)
pointsToTwips(spacingAfterPt)
);
}
if (token.lineSpacing !== undefined) {
@@ -380,10 +426,28 @@ function applyTokenParagraphStyle(
setWordAttribute(spacing, "lineRule", "exact");
}
}
const leftIndentPt =
token.leftIndentPt !== undefined ||
(token.paddingPt?.left ?? 0) > 0 ||
(token.contentPaddingLeftPt ?? 0) > 0 ||
(token.borders?.left?.widthPt ?? 0) > 0
? (token.leftIndentPt ?? 0) +
(token.paddingPt?.left ?? 0) +
(token.contentPaddingLeftPt ?? 0) +
(token.borders?.left?.widthPt ?? 0)
: undefined;
const rightIndentPt =
token.rightIndentPt !== undefined ||
(token.paddingPt?.right ?? 0) > 0 ||
(token.borders?.right?.widthPt ?? 0) > 0
? (token.rightIndentPt ?? 0) +
(token.paddingPt?.right ?? 0) +
(token.borders?.right?.widthPt ?? 0)
: undefined;
if (
token.firstLineIndentPt !== undefined ||
token.leftIndentPt !== undefined ||
token.rightIndentPt !== undefined
leftIndentPt !== undefined ||
rightIndentPt !== undefined
) {
const indent = ensureDirectElement(
parent,
@@ -398,19 +462,19 @@ function applyTokenParagraphStyle(
);
indent.removeAttributeNS(WORD_NAMESPACE, "firstLineChars");
}
if (token.leftIndentPt !== undefined) {
if (leftIndentPt !== undefined) {
setWordAttribute(
indent,
"left",
pointsToTwips(token.leftIndentPt)
pointsToTwips(leftIndentPt)
);
indent.removeAttributeNS(WORD_NAMESPACE, "leftChars");
}
if (token.rightIndentPt !== undefined) {
if (rightIndentPt !== undefined) {
setWordAttribute(
indent,
"right",
pointsToTwips(token.rightIndentPt)
pointsToTwips(rightIndentPt)
);
}
}
@@ -431,7 +495,11 @@ function applyTokenParagraphStyle(
"w:fill": colorValue(token.backgroundColor)
});
}
applyTokenBorders(parent, token);
applyTokenBorders(
parent,
token,
horizontalPaddingThroughBorderSpace
);
toggleProperty(parent, "keepLines", token.keepLines);
toggleProperty(parent, "keepNext", token.keepWithNext);
toggleProperty(
@@ -864,7 +932,8 @@ function applyTableAndCaption(
});
removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
appendElement(pPr, WORD_NAMESPACE, "w:jc", {
"w:val": style.caption.alignment
"w:val":
styleId === "ImageCaption" ? "center" : style.caption.alignment
});
setRunStyle(
ensureDirectElement(caption, WORD_NAMESPACE, "w:rPr"),
@@ -940,7 +1009,14 @@ function applyTableTokenStyles(
});
}
}
if (tableToken?.borders) {
const tableBorders =
tableToken?.borders || cellToken?.borders
? {
...cellToken?.borders,
...tableToken?.borders
}
: undefined;
if (tableBorders) {
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblBorders");
const borders = appendElement(
tblPr,
@@ -953,7 +1029,25 @@ function applyTableTokenStyles(
"bottom",
"left"
] as const) {
const border = tableToken.borders[side];
const border = tableBorders[side];
if (!border) {
continue;
}
appendElement(borders, WORD_NAMESPACE, `w:${side}`, {
"w:val": border.style,
"w:sz": String(Math.round(border.widthPt * 8)),
"w:space": "0",
"w:color": colorValue(border.color)
});
}
const insideHorizontal =
cellToken?.borders?.top ?? cellToken?.borders?.bottom;
const insideVertical =
cellToken?.borders?.left ?? cellToken?.borders?.right;
for (const [side, border] of [
["insideH", insideHorizontal],
["insideV", insideVertical]
] as const) {
if (!border) {
continue;
}
@@ -1067,14 +1161,19 @@ function applyTokenStyles(
binding.basedOn
);
if (binding.type === "paragraph") {
const paragraphToken =
binding.styleId === "ImageCaption"
? { ...entry.style, alignment: "center" as const }
: entry.style;
applyTokenParagraphStyle(
ensureDirectElement(
target,
WORD_NAMESPACE,
"w:pPr"
),
entry.style,
fallbackFontSizeForSlot(slot, fallback)
paragraphToken,
fallbackFontSizeForSlot(slot, fallback),
binding.styleId === "SourceCode"
);
}
applyTokenRunStyle(
@@ -98,7 +98,17 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
"official-signatory": [paragraph("MdOfficialSignatory")],
"official-title": [paragraph("MdOfficialTitle")],
"official-signature": [paragraph("MdOfficialSignature")],
"official-signature-issuer": [
paragraph("MdOfficialSignatureIssuer")
],
"official-signature-date": [
paragraph("MdOfficialSignatureDate")
],
"official-edition": [paragraph("MdOfficialEdition")],
"official-copy-to": [paragraph("MdOfficialCopyTo")],
"official-printing-row": [
paragraph("MdOfficialPrintingRow")
],
"briefing-masthead": [paragraph("MdBriefingMasthead")],
"briefing-meta": [paragraph("MdBriefingMeta")],
"briefing-title": [paragraph("MdBriefingTitle")],