feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
@@ -64,7 +64,7 @@ npm run verify:docx-themes
|
||||
仅需重跑三配置矩阵时可使用 `npm run verify:docx-matrix`。
|
||||
|
||||
`verify:docx-theme-styles` 使用 Playwright 与 Electron 分别采集 14 套
|
||||
内置主题的 56 个槽位,检查跨引擎令牌一致性,并使用固定 Pandoc 默认
|
||||
内置主题的 60 个槽位,检查跨引擎令牌一致性,并使用固定 Pandoc 默认
|
||||
模板生成 14 份动态 `reference.docx`。模板矩阵检查标准 Markdown 样式、
|
||||
结构化 `Md*` 样式、正文字体、字号、字体表和缓存指纹。
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ local function media_image(media)
|
||||
return pandoc.Image(
|
||||
pandoc.Inlines { pandoc.Str(media.alt_text) },
|
||||
media.path,
|
||||
"",
|
||||
media.binding,
|
||||
{
|
||||
width = media.width,
|
||||
height = media.height
|
||||
@@ -69,6 +69,7 @@ local function replace_image(image)
|
||||
local media = next_media("image")
|
||||
image.src = media.path
|
||||
image.caption = pandoc.Inlines { pandoc.Str(media.alt_text) }
|
||||
image.title = media.binding
|
||||
image.attributes.width = media.width
|
||||
image.attributes.height = media.height
|
||||
return image
|
||||
@@ -181,6 +182,9 @@ return {
|
||||
if structure_plan.titlePolicy.metadataTitle == "suppress" then
|
||||
transformed.meta.title = nil
|
||||
end
|
||||
if structure_plan.metadataPolicy.author == "suppress" then
|
||||
transformed.meta.author = nil
|
||||
end
|
||||
if structure_plan.titlePolicy.firstBodyHeading == "suppress" then
|
||||
suppress_first_body_heading(transformed.blocks)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: DOCX 媒体视觉专项验收
|
||||
author: Markdown PDF 导出器研发组
|
||||
lang: zh-CN
|
||||
---
|
||||
|
||||
# DOCX 媒体视觉专项验收
|
||||
|
||||
本样例验证普通图片、宽图、高图、Mermaid 与 ECharts 在 Chromium、
|
||||
Microsoft Word 和 WPS Writer 中的物理尺寸、宽高比与水平对齐。
|
||||
|
||||
## 普通图片
|
||||
|
||||

|
||||
|
||||
## 宽图
|
||||
|
||||

|
||||
|
||||
## 高图
|
||||
|
||||

|
||||
|
||||
## Mermaid
|
||||
|
||||
```mermaid
|
||||
%%{init: {"theme":"base","themeVariables":{"primaryColor":"#C4B5FD","primaryBorderColor":"#6D28D9","lineColor":"#6D28D9","fontFamily":"Microsoft YaHei"}}}%%
|
||||
flowchart LR
|
||||
A[Markdown] --> B[高分辨率 PNG]
|
||||
B --> C[可编辑 DOCX]
|
||||
```
|
||||
|
||||
## ECharts
|
||||
|
||||
```echarts
|
||||
version: 1
|
||||
caption: ECharts 紫色柱状图
|
||||
option:
|
||||
backgroundColor: "#F9A8D4"
|
||||
xAxis:
|
||||
type: category
|
||||
data: [一月, 二月, 三月, 四月]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: bar
|
||||
itemStyle:
|
||||
color: "#7C3AED"
|
||||
data: [12, 20, 16, 24]
|
||||
```
|
||||
|
||||
最后一段用于确认媒体周围的正文仍保持 Word 原生段落,可以继续编辑。
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
import {
|
||||
PandocDocxConverter,
|
||||
PandocRuntime,
|
||||
inspectDocxAcceptance
|
||||
createDocxMediaAcceptanceExpectation,
|
||||
inspectDocxAcceptance,
|
||||
resolveReferencePageOptions
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import { DOCX_STYLE_SLOT_NAMES } from "@md-to-pdf/docx-theme-engine";
|
||||
|
||||
@@ -49,6 +51,7 @@ function mediaResource(kind, ordinal, dimensions, altText, caption) {
|
||||
kindOrdinal: 1,
|
||||
altText,
|
||||
...(caption ? { caption } : {}),
|
||||
alignment: "center",
|
||||
displayWidthPx: dimensions.width,
|
||||
displayHeightPx: dimensions.height,
|
||||
captureX: 0,
|
||||
@@ -115,6 +118,58 @@ function createThemeTokens(theme) {
|
||||
|
||||
const technicalTheme = loadTheme("typora-like");
|
||||
const officialTheme = loadTheme("gov-red-standard");
|
||||
const coverTheme = loadTheme("formal-feasibility");
|
||||
const semanticDocument = {
|
||||
schemaVersion: 1,
|
||||
titlePolicy: {
|
||||
metadataTitle: "emit",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: []
|
||||
};
|
||||
const coverSemanticDocument = {
|
||||
schemaVersion: 1,
|
||||
profile: "project-report",
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: [
|
||||
{
|
||||
kind: "cover",
|
||||
nodes: [
|
||||
{
|
||||
kind: "group",
|
||||
role: "project-report-cover",
|
||||
children: [
|
||||
{
|
||||
kind: "text",
|
||||
role: "project-report-project-name",
|
||||
text: "智慧园区建设项目"
|
||||
},
|
||||
{
|
||||
kind: "text",
|
||||
role: "project-report-title",
|
||||
text: "可行性研究报告"
|
||||
},
|
||||
{
|
||||
kind: "text",
|
||||
role: "project-report-owner",
|
||||
label: "建设单位:",
|
||||
text: "示例建设集团"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
section: {
|
||||
headerFooter: "none",
|
||||
pageNumber: "hidden",
|
||||
breakAfter: "next-page",
|
||||
followingPageNumberStart: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
const commonExpectation = {
|
||||
requiredText: [
|
||||
"可编辑的中文正文",
|
||||
@@ -214,6 +269,18 @@ const landscapeLetterConfig = {
|
||||
startFrom: 3
|
||||
}
|
||||
};
|
||||
const coverA4Config = {
|
||||
...defaultExportConfig,
|
||||
name: "可研封面 A4 纵向验收",
|
||||
themeId: coverTheme.id,
|
||||
pageDecorationsMode: "theme",
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
format: "A4",
|
||||
orientation: "portrait",
|
||||
marginMode: "theme"
|
||||
}
|
||||
};
|
||||
|
||||
const variants = [
|
||||
{
|
||||
@@ -275,6 +342,41 @@ const variants = [
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "project-cover-a4-portrait",
|
||||
theme: coverTheme,
|
||||
exportConfig: coverA4Config,
|
||||
semanticDocument: coverSemanticDocument,
|
||||
expectation: {
|
||||
...commonExpectation,
|
||||
id: "project-cover-a4-portrait",
|
||||
requiredText: [
|
||||
...commonExpectation.requiredText,
|
||||
"智慧园区建设项目",
|
||||
"可行性研究报告"
|
||||
],
|
||||
page: {
|
||||
widthTwips: 11906,
|
||||
heightTwips: 16838,
|
||||
orientation: "portrait",
|
||||
marginsTwips: {
|
||||
top: 1417,
|
||||
right: 1134,
|
||||
bottom: 1417,
|
||||
left: 1701
|
||||
}
|
||||
},
|
||||
minimumSections: 2,
|
||||
requireFirstSectionWithoutHeaderFooter: true,
|
||||
finalPageNumberStart: 1,
|
||||
requireSectionPagesField: true,
|
||||
requiredParagraphStyleIds: [
|
||||
"MdProjectReportProjectName",
|
||||
"MdProjectReportTitle",
|
||||
"MdProjectReportOwner"
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -299,33 +401,51 @@ fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
|
||||
const results = [];
|
||||
for (const variant of variants) {
|
||||
const variantSemanticDocument =
|
||||
variant.semanticDocument ?? semanticDocument;
|
||||
const fileName = `${variant.id}.md`;
|
||||
const metadata = {
|
||||
title: "v0.6.0 DOCX 自动验收",
|
||||
author: "Markdown PDF 导出器研发组",
|
||||
subject: "Word 与 WPS 可编辑性验收",
|
||||
keywords: ["DOCX", "Pandoc", "OOXML"],
|
||||
language: "zh-CN"
|
||||
};
|
||||
const resolvedPage = resolveReferencePageOptions({
|
||||
exportConfig: variant.exportConfig,
|
||||
theme: variant.theme,
|
||||
fileName,
|
||||
metadata
|
||||
});
|
||||
const media = createMedia();
|
||||
const result = await converter.convert({
|
||||
markdown,
|
||||
fileName: `${variant.id}.md`,
|
||||
fileName,
|
||||
language: "zh-CN",
|
||||
exportConfig: variant.exportConfig,
|
||||
theme: variant.theme,
|
||||
themeTokens: createThemeTokens(variant.theme),
|
||||
metadata: {
|
||||
title: "v0.6.0 DOCX 自动验收",
|
||||
author: "Markdown PDF 导出器研发组",
|
||||
subject: "Word 与 WPS 可编辑性验收",
|
||||
keywords: ["DOCX", "Pandoc", "OOXML"],
|
||||
language: "zh-CN"
|
||||
},
|
||||
semanticDocument: {
|
||||
schemaVersion: 1,
|
||||
titlePolicy: {
|
||||
metadataTitle: "emit",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: []
|
||||
},
|
||||
media: createMedia()
|
||||
fonts: [],
|
||||
metadata,
|
||||
semanticDocument: variantSemanticDocument,
|
||||
media
|
||||
});
|
||||
const report = inspectDocxAcceptance(
|
||||
result.docx,
|
||||
variant.expectation
|
||||
{
|
||||
...variant.expectation,
|
||||
media: createDocxMediaAcceptanceExpectation(
|
||||
media,
|
||||
variant.expectation.page
|
||||
),
|
||||
pageDecorations: {
|
||||
exportConfig: {
|
||||
header: resolvedPage.header,
|
||||
footer: resolvedPage.footer
|
||||
},
|
||||
semanticDocument: variantSemanticDocument
|
||||
}
|
||||
}
|
||||
);
|
||||
const outputPath = path.join(outputDirectory, `${variant.id}.docx`);
|
||||
fs.writeFileSync(outputPath, result.docx);
|
||||
|
||||
@@ -56,6 +56,7 @@ function resource(kind, ordinal, dimensions, caption) {
|
||||
? "Mermaid 流程图"
|
||||
: "ECharts 柱状图",
|
||||
...(caption ? { caption } : {}),
|
||||
alignment: "center",
|
||||
displayWidthPx: dimensions.width,
|
||||
displayHeightPx: dimensions.height,
|
||||
captureX: 0,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PandocRuntime,
|
||||
resolveReferencePageOptions
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import { DOCX_STYLE_SLOT_NAMES } from "@md-to-pdf/docx-theme-engine";
|
||||
import { renderMarkdown } from "@md-to-pdf/renderer";
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
@@ -185,10 +186,17 @@ function inspectDocument(
|
||||
/<w:sectPr(?:\s[^>]*)?>[\s\S]*?<\/w:sectPr>/gu
|
||||
)
|
||||
].map((match) => match[0]);
|
||||
const fullWidthTableCount = countMatches(
|
||||
documentXml,
|
||||
/<w:tblW\s+w:w="5000"\s+w:type="pct"\s*\/>/gu
|
||||
const tables = [
|
||||
...documentXml.matchAll(
|
||||
/<w:tbl(?:\s|>)[\s\S]*?<\/w:tbl>/gu
|
||||
)
|
||||
].map((match) => match[0]);
|
||||
const contentTables = tables.filter((table) =>
|
||||
/<w:tblStyle\s+w:val="Table"\s*\/>/u.test(table)
|
||||
);
|
||||
const fullWidthTableCount = contentTables.filter((table) =>
|
||||
/<w:tblW\s+w:w="5000"\s+w:type="pct"\s*\/>/u.test(table)
|
||||
).length;
|
||||
const internalMarkerCount = countMatches(
|
||||
documentXml,
|
||||
/MD_TO_PDF_(?:CONTAINER|SECTION)_/gu
|
||||
@@ -216,7 +224,9 @@ function inspectDocument(
|
||||
bytes: content.byteLength,
|
||||
page: pageOptions,
|
||||
paragraphCount: countMatches(documentXml, /<w:p(?:\s|>)/gu),
|
||||
tableCount: countMatches(documentXml, /<w:tbl(?:\s|>)/gu),
|
||||
tableCount: tables.length,
|
||||
contentTableCount: contentTables.length,
|
||||
structuralTableCount: tables.length - contentTables.length,
|
||||
drawingCount: countMatches(documentXml, /<w:drawing(?:\s|>)/gu),
|
||||
explicitPageBreaks,
|
||||
sectionCount: sections.length,
|
||||
@@ -280,6 +290,7 @@ function createMediaResource(kind, ordinal, kindOrdinal) {
|
||||
...(kind === "image"
|
||||
? {}
|
||||
: { caption: `${kind} 主题验收图 ${kindOrdinal}` }),
|
||||
alignment: "center",
|
||||
displayWidthPx: dimensions.width,
|
||||
displayHeightPx: dimensions.height,
|
||||
captureX: 0,
|
||||
@@ -345,8 +356,8 @@ function readThemeTokenReport(themes) {
|
||||
const tokensByTheme = new Map(
|
||||
report.tokenSets.map((tokens) => {
|
||||
assert(
|
||||
tokens.slots?.length === 56,
|
||||
`主题 ${tokens.themeId} 的真实令牌槽位不是 56`
|
||||
tokens.slots?.length === DOCX_STYLE_SLOT_NAMES.length,
|
||||
`主题 ${tokens.themeId} 的真实令牌槽位不是 ${DOCX_STYLE_SLOT_NAMES.length}`
|
||||
);
|
||||
assert(
|
||||
/^[a-f0-9]{64}$/u.test(tokens.themeFingerprint ?? ""),
|
||||
@@ -483,7 +494,7 @@ for (const result of results) {
|
||||
`主题 ${result.id} 存在重复标题`
|
||||
);
|
||||
assert(
|
||||
inspection.fullWidthTableCount === inspection.tableCount,
|
||||
inspection.fullWidthTableCount === inspection.contentTableCount,
|
||||
`主题 ${result.id} 存在非内容区全宽表格`
|
||||
);
|
||||
if (inspection.profile) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { zipSync } from "fflate";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import { inspectDocxAcceptance } from "../src/index.js";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
@@ -38,36 +39,53 @@ function createAcceptanceDocx(
|
||||
duplicateText?: boolean;
|
||||
internalMarker?: boolean;
|
||||
invalidPropertyOrder?: boolean;
|
||||
coverPageNumber?: boolean;
|
||||
bodyTitlePage?: boolean;
|
||||
footerAlignment?: "left" | "center" | "right";
|
||||
evenAndOddHeaders?: boolean;
|
||||
logicalOuterFooter?: boolean;
|
||||
nativeOuterFooter?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
return zipSync({
|
||||
"[Content_Types].xml": xml(
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/></Types>'
|
||||
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>${overrides.nativeOuterFooter ? '<Override PartName="/word/footer2.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>' : ""}</Types>`
|
||||
),
|
||||
"_rels/.rels": xml(
|
||||
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
|
||||
),
|
||||
"word/document.xml": xml(
|
||||
`<w:document xmlns:w="${word}" xmlns:r="${relationships}" xmlns:m="${math}" xmlns:wp="${drawing}"><w:body><w:p><w:pPr><w:pStyle w:val="Heading1"/><w:numPr><w:numId w:val="1"/></w:numPr></w:pPr><w:hyperlink r:id="rIdLink"><w:r><w:t>可编辑正文</w:t></w:r></w:hyperlink><w:r><w:footnoteReference w:id="1"/></w:r><m:oMath><m:r><m:t>x</m:t></m:r></m:oMath></w:p>${overrides.invalidPropertyOrder ? '<w:p><w:pPr><w:jc w:val="left"/><w:spacing w:after="0"/></w:pPr></w:p>' : ""}${overrides.duplicateText ? "<w:p><w:r><w:t>可编辑正文</w:t></w:r></w:p>" : ""}${overrides.internalMarker ? "<w:p><w:r><w:t>MD_TO_PDF_INTERNAL</w:t></w:r></w:p>" : ""}<w:p><w:pPr><w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/></w:sectPr></w:pPr></w:p><w:tbl><w:tblPr><w:tblW w:w="5000" w:type="pct"/></w:tblPr><w:tr><w:tc><w:p><w:r><w:t>原生表格</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="图片 1" descr="普通图片"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/></wp:inline></w:drawing></w:r></w:p>${overrides.altChunk ? '<w:altChunk r:id="rIdChunk"/>' : ""}<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/><w:pgNumType w:start="1"/></w:sectPr></w:body></w:document>`
|
||||
`<w:document xmlns:w="${word}" xmlns:r="${relationships}" xmlns:m="${math}" xmlns:wp="${drawing}"><w:body><w:p><w:pPr><w:pStyle w:val="Heading1"/><w:numPr><w:numId w:val="1"/></w:numPr></w:pPr><w:hyperlink r:id="rIdLink"><w:r><w:t>可编辑正文</w:t></w:r></w:hyperlink><w:r><w:footnoteReference w:id="1"/></w:r><m:oMath><m:r><m:t>x</m:t></m:r></m:oMath></w:p>${overrides.invalidPropertyOrder ? '<w:p><w:pPr><w:jc w:val="left"/><w:spacing w:after="0"/></w:pPr></w:p>' : ""}${overrides.duplicateText ? "<w:p><w:r><w:t>可编辑正文</w:t></w:r></w:p>" : ""}${overrides.internalMarker ? "<w:p><w:r><w:t>MD_TO_PDF_INTERNAL</w:t></w:r></w:p>" : ""}<w:p><w:pPr><w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/>${overrides.coverPageNumber ? '<w:pgNumType w:start="1"/>' : ""}</w:sectPr></w:pPr></w:p><w:tbl><w:tblPr><w:tblW w:w="5000" w:type="pct"/></w:tblPr><w:tr><w:tc><w:p><w:r><w:t>原生表格</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="图片 1" descr="普通图片"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/></wp:inline></w:drawing></w:r></w:p>${overrides.altChunk ? '<w:altChunk r:id="rIdChunk"/>' : ""}<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/>${overrides.nativeOuterFooter ? '<w:footerReference w:type="even" r:id="rIdEvenFooter"/>' : ""}<w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/><w:pgNumType w:start="${overrides.nativeOuterFooter ? "2" : "1"}"/>${overrides.bodyTitlePage ? "<w:titlePg/>" : ""}</w:sectPr></w:body></w:document>`
|
||||
),
|
||||
"word/styles.xml": xml(
|
||||
`<w:styles xmlns:w="${word}">${["Normal", "Heading1", "SourceCode", "Table", "Caption"].map((id) => `<w:style w:type="paragraph" w:styleId="${id}"/>`).join("")}</w:styles>`
|
||||
),
|
||||
"word/settings.xml": xml(`<w:settings xmlns:w="${word}"/>`),
|
||||
"word/settings.xml": xml(
|
||||
`<w:settings xmlns:w="${word}">${overrides.evenAndOddHeaders ? "<w:evenAndOddHeaders/>" : ""}</w:settings>`
|
||||
),
|
||||
"word/fontTable.xml": xml(`<w:fonts xmlns:w="${word}"/>`),
|
||||
"word/numbering.xml": xml(`<w:numbering xmlns:w="${word}"/>`),
|
||||
"word/theme/theme1.xml": xml(
|
||||
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>'
|
||||
),
|
||||
"word/footer1.xml": xml(
|
||||
`<w:ftr xmlns:w="${word}"><w:p><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
|
||||
overrides.logicalOuterFooter
|
||||
? `<w:ftr xmlns:w="${word}"><w:p><w:pPr><w:tabs><w:tab w:val="right" w:pos="9000"/></w:tabs></w:pPr><w:r><w:instrText> IF </w:instrText></w:r><w:r><w:instrText> MOD( </w:instrText></w:r><w:r><w:instrText> PAGE </w:instrText></w:r><w:r><w:instrText> PAGE </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES </w:instrText></w:r></w:p></w:ftr>`
|
||||
: `<w:ftr xmlns:w="${word}"><w:p><w:pPr><w:jc w:val="${overrides.nativeOuterFooter ? "left" : (overrides.footerAlignment ?? "center")}"/></w:pPr><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
|
||||
),
|
||||
...(overrides.nativeOuterFooter
|
||||
? {
|
||||
"word/footer2.xml": xml(
|
||||
`<w:ftr xmlns:w="${word}"><w:p><w:pPr><w:jc w:val="right"/></w:pPr><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
"word/footnotes.xml": xml(
|
||||
`<w:footnotes xmlns:w="${word}"><w:footnote w:id="1"><w:p><w:r><w:t>脚注</w:t></w:r></w:p></w:footnote></w:footnotes>`
|
||||
),
|
||||
"word/media/image1.png": png,
|
||||
"word/_rels/document.xml.rels": xml(
|
||||
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/><Relationship Id="rIdImage" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.invalid/" TargetMode="External"/>${overrides.altChunk ? '<Relationship Id="rIdChunk" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="chunk.html"/>' : ""}</Relationships>`
|
||||
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>${overrides.nativeOuterFooter ? '<Relationship Id="rIdEvenFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer2.xml"/>' : ""}<Relationship Id="rIdImage" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.invalid/" TargetMode="External"/>${overrides.altChunk ? '<Relationship Id="rIdChunk" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="chunk.html"/>' : ""}</Relationships>`
|
||||
),
|
||||
...(overrides.altChunk
|
||||
? { "word/chunk.html": encoder.encode("<p>整体 HTML</p>") }
|
||||
@@ -110,7 +128,38 @@ const expectation = {
|
||||
maximumTextOccurrences: {
|
||||
可编辑正文: 1
|
||||
},
|
||||
forbidInternalMarkers: true
|
||||
forbidInternalMarkers: true,
|
||||
pageDecorations: {
|
||||
exportConfig: {
|
||||
header: defaultExportConfig.header,
|
||||
footer: defaultExportConfig.footer
|
||||
},
|
||||
semanticDocument: {
|
||||
schemaVersion: 1 as const,
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress" as const,
|
||||
firstBodyHeading: "keep" as const
|
||||
},
|
||||
regions: [
|
||||
{
|
||||
kind: "cover" as const,
|
||||
nodes: [
|
||||
{
|
||||
kind: "text" as const,
|
||||
role: "project-report-title" as const,
|
||||
text: "封面"
|
||||
}
|
||||
],
|
||||
section: {
|
||||
headerFooter: "none" as const,
|
||||
pageNumber: "hidden" as const,
|
||||
breakAfter: "next-page" as const,
|
||||
followingPageNumberStart: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe("DOCX 自动验收器", () => {
|
||||
@@ -142,6 +191,15 @@ describe("DOCX 自动验收器", () => {
|
||||
noAltChunk: true
|
||||
})
|
||||
);
|
||||
expect(report.pageDecorations).toEqual({
|
||||
hasCover: true,
|
||||
usesDifferentFirstPage: false,
|
||||
usesEvenAndOddPages: false,
|
||||
bodyPageNumberStart: 1,
|
||||
headerReferenceTypes: [],
|
||||
footerReferenceTypes: ["default"],
|
||||
totalPageField: "SECTIONPAGES"
|
||||
});
|
||||
});
|
||||
|
||||
it("拒绝通过 altChunk 嵌入的正文 HTML", () => {
|
||||
@@ -176,4 +234,67 @@ describe("DOCX 自动验收器", () => {
|
||||
)
|
||||
).toThrow("w:pPr 子节点顺序无效");
|
||||
});
|
||||
|
||||
it("拒绝封面节残留页码和正文节错误首页设置", () => {
|
||||
expect(() =>
|
||||
inspectDocxAcceptance(
|
||||
createAcceptanceDocx({ coverPageNumber: true }),
|
||||
expectation
|
||||
)
|
||||
).toThrow("封面节不得包含 w:pgNumType");
|
||||
expect(() =>
|
||||
inspectDocxAcceptance(
|
||||
createAcceptanceDocx({ bodyTitlePage: true }),
|
||||
expectation
|
||||
)
|
||||
).toThrow("body-title-page");
|
||||
});
|
||||
|
||||
it("拒绝页码位置和奇偶页设置偏离导出配置", () => {
|
||||
expect(() =>
|
||||
inspectDocxAcceptance(
|
||||
createAcceptanceDocx({ footerAlignment: "right" }),
|
||||
expectation
|
||||
)
|
||||
).toThrow("footer-default-alignment");
|
||||
expect(() =>
|
||||
inspectDocxAcceptance(
|
||||
createAcceptanceDocx({ evenAndOddHeaders: true }),
|
||||
expectation
|
||||
)
|
||||
).toThrow("even-and-odd-setting");
|
||||
});
|
||||
|
||||
it("封面重启页码时要求原生奇偶页脚表达外侧页码", () => {
|
||||
const outerExpectation = {
|
||||
...expectation,
|
||||
pageDecorations: {
|
||||
...expectation.pageDecorations,
|
||||
exportConfig: {
|
||||
...expectation.pageDecorations.exportConfig,
|
||||
footer: {
|
||||
...expectation.pageDecorations.exportConfig.footer,
|
||||
alignment: "outer" as const
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const report = inspectDocxAcceptance(
|
||||
createAcceptanceDocx({
|
||||
evenAndOddHeaders: true,
|
||||
nativeOuterFooter: true,
|
||||
footerAlignment: "left"
|
||||
}),
|
||||
outerExpectation
|
||||
);
|
||||
|
||||
expect(report.pageDecorations).toMatchObject({
|
||||
hasCover: true,
|
||||
usesEvenAndOddPages: true,
|
||||
footerReferenceTypes: ["default", "even"]
|
||||
});
|
||||
expect(() =>
|
||||
inspectDocxAcceptance(createAcceptanceDocx(), outerExpectation)
|
||||
).toThrow("body-page-number-start");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
readGeneratedDocxPackage,
|
||||
readReferenceDocxPackage,
|
||||
writeGeneratedDocxPackage,
|
||||
type PandocMediaLayoutItem,
|
||||
type PandocStructurePlan
|
||||
} from "../src/index.js";
|
||||
import { createTestBaselineReference } from "./reference-test-fixture.js";
|
||||
@@ -15,6 +16,12 @@ const word =
|
||||
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const relationships =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
const wordprocessingDrawing =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
|
||||
const drawing =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/main";
|
||||
const picture =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/picture";
|
||||
|
||||
function generatedFixture() {
|
||||
const baseline = readReferenceDocxPackage(
|
||||
@@ -26,11 +33,17 @@ function generatedFixture() {
|
||||
encoder.encode(
|
||||
`<w:document xmlns:w="${word}" xmlns:r="${relationships}"><w:body>` +
|
||||
`<w:p><w:r><w:t>CONTAINER_START</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdOfficialIssueRow"/></w:pPr><w:r><w:t>发文字号</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>签发人</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdTenderTitle"/></w:pPr><w:r><w:t>可编辑封面</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>CONTAINER_END</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>SECTION_BREAK</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>` +
|
||||
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:drawing>` +
|
||||
`<wp:inline xmlns:wp="${wordprocessingDrawing}"><wp:extent cx="100" cy="200"/><wp:docPr id="1" name="Picture" descr="Mermaid 图表 1" title="mdtp-media:docx-media-1"/>` +
|
||||
`<a:graphic xmlns:a="${drawing}"><a:graphicData uri="${picture}"><pic:pic xmlns:pic="${picture}"><pic:nvPicPr><pic:cNvPr id="0" name="image.png"/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm rot="60000" flipH="1"><a:off x="0" y="0"/><a:ext cx="100" cy="200"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` +
|
||||
`</wp:inline></w:drawing></w:r></w:p>` +
|
||||
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:trPr><w:tblHeader/></w:trPr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>D</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdOfficialSignatureDate"/></w:pPr><w:r><w:t>2026年7月29日</w:t></w:r></w:p>` +
|
||||
`<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1000" w:right="1000" w:bottom="1000" w:left="1000"/><w:pgNumType w:start="5"/><w:titlePg/></w:sectPr>` +
|
||||
`</w:body></w:document>`
|
||||
)
|
||||
@@ -46,6 +59,9 @@ function generatedFixture() {
|
||||
|
||||
const plan: PandocStructurePlan = {
|
||||
schemaVersion: 1,
|
||||
metadataPolicy: {
|
||||
author: "suppress"
|
||||
},
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress",
|
||||
firstBodyHeading: "keep"
|
||||
@@ -57,7 +73,15 @@ const plan: PandocStructurePlan = {
|
||||
slot: "tender-cover",
|
||||
startMarker: "CONTAINER_START",
|
||||
endMarker: "CONTAINER_END",
|
||||
blocks: []
|
||||
blocks: [
|
||||
{
|
||||
kind: "paragraph",
|
||||
styleId: "MdOfficialIssueRow",
|
||||
layout: "space-between",
|
||||
segments: ["发文字号", "签发人"],
|
||||
separator: "tab"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
kind: "section-break",
|
||||
@@ -85,6 +109,17 @@ const tokens: DocxThemeTokenSet = {
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
backgroundColor: "#f5f5f5",
|
||||
spacingBeforePt: 7,
|
||||
spacingAfterPt: 51,
|
||||
paddingPt: {
|
||||
top: 3,
|
||||
right: 5,
|
||||
bottom: 11,
|
||||
left: 4
|
||||
},
|
||||
widthPercent: 52,
|
||||
leftIndentPt: 230.4,
|
||||
rightIndentPt: 0,
|
||||
pageBreakAfter: true,
|
||||
keepLines: true,
|
||||
borders: {
|
||||
@@ -121,6 +156,30 @@ const tokens: DocxThemeTokenSet = {
|
||||
keepLines: true
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "table-cell",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
fontSizePt: 10,
|
||||
lineSpacing: 1.8
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "table-header",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
fontSizePt: 10,
|
||||
bold: true,
|
||||
lineSpacing: 1.5,
|
||||
alignment: "center",
|
||||
color: "#ffffff",
|
||||
backgroundColor: "#17324d"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "heading-1",
|
||||
source: "computed-css",
|
||||
@@ -134,8 +193,23 @@ const tokens: DocxThemeTokenSet = {
|
||||
diagnostics: []
|
||||
};
|
||||
|
||||
const mediaLayout: PandocMediaLayoutItem[] = [
|
||||
{
|
||||
id: "docx-media-1",
|
||||
binding: "mdtp-media:docx-media-1",
|
||||
kind: "mermaid",
|
||||
ordinal: 1,
|
||||
altText: "处理流程",
|
||||
alignment: "right",
|
||||
displayWidthPx: 320,
|
||||
displayHeightPx: 180,
|
||||
widthEmu: 3_048_000,
|
||||
heightEmu: 1_714_500
|
||||
}
|
||||
];
|
||||
|
||||
describe("生成 DOCX 结构收口", () => {
|
||||
it("生成真实分节、正文页码、节总页数和固定宽度表格", () => {
|
||||
it("生成真实分节、正文页码、节总页数和全宽自适应表格", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
@@ -170,26 +244,367 @@ describe("生成 DOCX 结构收口", () => {
|
||||
'<w:tblW w:w="5000" w:type="pct"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblLayout w:type="fixed"/>'
|
||||
'<w:tblLayout w:type="autofit"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:gridCol w:w="3302"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="6604"/>');
|
||||
expect(documentXml).toMatch(
|
||||
/<w:pPr><w:pStyle w:val="Figure"\/>[\s\S]*?<w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing[^>]*w:line="240"[^>]*w:lineRule="auto"[^>]*w:before="300"[^>]*w:after="300"\/><w:jc w:val="center"\/>/u
|
||||
);
|
||||
expect(documentXml).toContain('<w:gridCol w:w="1000"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="2000"/>');
|
||||
expect(documentXml).toContain('<w:tcW w:w="0" w:type="auto"/>');
|
||||
expect(
|
||||
documentXml.match(
|
||||
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="300"[^>]*w:lineRule="exact"/gu
|
||||
)
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
documentXml.match(
|
||||
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="exact"/gu
|
||||
)
|
||||
).toHaveLength(2);
|
||||
expect(documentXml.match(/w:fill="17324D"/gu)).toHaveLength(2);
|
||||
expect(documentXml.match(/w:val="FFFFFF"/gu)).toHaveLength(2);
|
||||
expect(
|
||||
documentXml.match(/<w:jc w:val="center"\/>/gu)?.length ?? 0
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
const tableParagraph = documentXml.match(
|
||||
/<w:p>[\s\S]*?<w:t>A<\/w:t>[\s\S]*?<\/w:p>/u
|
||||
)?.[0];
|
||||
expect(tableParagraph).toContain('<w:autoSpaceDE w:val="0"/>');
|
||||
expect(tableParagraph).toContain('<w:autoSpaceDN w:val="0"/>');
|
||||
expect(documentXml).toContain("<w:cantSplit/>");
|
||||
expect(documentXml).toContain(
|
||||
'<w:tab w:val="right" w:pos="9806"/>'
|
||||
);
|
||||
expect(documentXml).toContain('w:fill="F5F5F5"');
|
||||
expect(documentXml).toContain(
|
||||
'<w:spacing w:before="140"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:spacing w:after="1020"/>'
|
||||
);
|
||||
expect(
|
||||
documentXml.match(
|
||||
/<w:ind w:left="4835" w:right="100"\/>/gu
|
||||
)
|
||||
).toHaveLength(2);
|
||||
expect(documentXml).toContain(
|
||||
'<w:top w:val="single" w:sz="8" w:space="3" w:color="111111"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:bottom w:val="single" w:sz="8" w:space="11" w:color="111111"/>'
|
||||
);
|
||||
expect(
|
||||
documentXml.match(/<w:br w:type="page"\/>/gu)
|
||||
).toHaveLength(1);
|
||||
const coverParagraph = documentXml.match(
|
||||
/<w:p(?:\s|>)[\s\S]*?可编辑封面[\s\S]*?<\/w:p>/u
|
||||
)?.[0];
|
||||
expect(coverParagraph).toContain("<w:sectPr>");
|
||||
expect(coverParagraph).not.toContain(
|
||||
'<w:br w:type="page"/>'
|
||||
);
|
||||
expect(footerXml).toContain("SECTIONPAGES");
|
||||
expect(footerXml).not.toContain(" NUMPAGES ");
|
||||
expect(result.report).toEqual({
|
||||
containerCount: 1,
|
||||
editableContainerTableCount: 0,
|
||||
sectionCount: 1,
|
||||
tableCount: 1,
|
||||
pageBreakAfterCount: 1,
|
||||
sectionPageFieldCount: 1
|
||||
sectionPageFieldCount: 1,
|
||||
spaceBetweenParagraphCount: 1,
|
||||
mediaDrawingCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
tokens,
|
||||
mediaLayout
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(result.report.mediaDrawingCount).toBe(1);
|
||||
expect(documentXml).toMatch(
|
||||
/<wp:inline\b[^>]*distT="0"[^>]*distB="0"[^>]*distL="0"[^>]*distR="0"[^>]*>/u
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<wp:extent cx="3048000" cy="1714500"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<a:ext cx="3048000" cy="1714500"/>'
|
||||
);
|
||||
expect(documentXml).toMatch(
|
||||
/<a:graphicFrameLocks\b[^>]*noChangeAspect="1"[^>]*\/>/u
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'descr="处理流程" title=""'
|
||||
);
|
||||
expect(documentXml).toContain('<w:jc w:val="right"/>');
|
||||
expect(documentXml).not.toContain("mdtp-media:");
|
||||
expect(documentXml).not.toContain('rot="60000"');
|
||||
expect(documentXml).not.toContain('flipH="1"');
|
||||
});
|
||||
|
||||
it("拒绝缺失或重复的媒体布局绑定", () => {
|
||||
expect(() =>
|
||||
finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
tokens,
|
||||
[{ ...mediaLayout[0]!, binding: "mdtp-media:missing" }]
|
||||
)
|
||||
).toThrow("稳定绑定");
|
||||
|
||||
expect(() =>
|
||||
finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
tokens,
|
||||
[mediaLayout[0]!, { ...mediaLayout[0]!, id: "duplicate" }]
|
||||
)
|
||||
).toThrow("重复绑定");
|
||||
});
|
||||
|
||||
it("将带最小高度的分节封面映射为可编辑单元格容器", () => {
|
||||
const tableTokens: DocxThemeTokenSet = {
|
||||
...tokens,
|
||||
slots: tokens.slots.map((entry) =>
|
||||
entry.slot === "tender-cover"
|
||||
? {
|
||||
...entry,
|
||||
style: {
|
||||
...entry.style,
|
||||
minimumHeightPt: 620,
|
||||
verticalAlignment: "center" as const,
|
||||
childAlignment: "center" as const
|
||||
}
|
||||
}
|
||||
: entry
|
||||
).concat({
|
||||
slot: "official-issue-row" as const,
|
||||
source: "computed-css" as const,
|
||||
confidence: "exact" as const,
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
spacingAfterPt: 4
|
||||
}
|
||||
}, {
|
||||
slot: "tender-title" as const,
|
||||
source: "computed-css" as const,
|
||||
confidence: "exact" as const,
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
fontSizePt: 20,
|
||||
letterSpacingPt: 1,
|
||||
spacingBeforePt: 6,
|
||||
borders: {
|
||||
bottom: {
|
||||
widthPt: 1,
|
||||
style: "single" as const,
|
||||
color: "#111111"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
tableTokens
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(documentXml).toContain("<w:tbl>");
|
||||
expect(documentXml).toContain(
|
||||
'<w:trHeight w:val="12400" w:hRule="exact"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblInd w:w="4608" w:type="dxa"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:vAlign w:val="top"/>');
|
||||
expect(documentXml).toContain("<w:tblBorders>");
|
||||
expect(documentXml).toContain('<w:insideH w:val="nil"/>');
|
||||
expect(documentXml).toContain('<w:insideV w:val="nil"/>');
|
||||
expect(documentXml).toMatch(
|
||||
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing w:before="0" w:after="0"\/><w:ind w:left="1456" w:right="1475"\/><w:jc w:val="center"\/>/u
|
||||
);
|
||||
expect(documentXml).toMatch(
|
||||
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/>[\s\S]*?<w:rPr><w:spacing w:val="20"\/><\/w:rPr><w:t>可编辑封面<\/w:t>/u
|
||||
);
|
||||
expect(documentXml).toContain('<w:tblLook w:val="0000"');
|
||||
expect(documentXml).toMatch(
|
||||
/<\/w:tbl><w:p><w:pPr><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:sectPr>/u
|
||||
);
|
||||
expect(
|
||||
documentXml.match(/<w:tbl(?:\s|>)/gu)
|
||||
).toHaveLength(2);
|
||||
expect(result.report).toMatchObject({
|
||||
containerCount: 1,
|
||||
editableContainerTableCount: 1,
|
||||
sectionCount: 1,
|
||||
tableCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("将封面最小高度封顶到当前节的页面内容区", () => {
|
||||
const oversizedTokens: DocxThemeTokenSet = {
|
||||
...tokens,
|
||||
slots: tokens.slots.map((entry) =>
|
||||
entry.slot === "tender-cover"
|
||||
? {
|
||||
...entry,
|
||||
style: {
|
||||
...entry.style,
|
||||
minimumHeightPt: 900,
|
||||
verticalAlignment: "center" as const,
|
||||
childAlignment: "left" as const
|
||||
}
|
||||
}
|
||||
: entry
|
||||
).concat(
|
||||
{
|
||||
slot: "official-issue-row" as const,
|
||||
source: "computed-css" as const,
|
||||
confidence: "exact" as const,
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
fontSizePt: 12,
|
||||
lineSpacing: 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "tender-title" as const,
|
||||
source: "computed-css" as const,
|
||||
confidence: "exact" as const,
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
fontSizePt: 20,
|
||||
lineSpacing: 1.2
|
||||
}
|
||||
}
|
||||
)
|
||||
};
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
oversizedTokens
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(documentXml).toContain(
|
||||
'<w:trHeight w:val="14838" w:hRule="exact"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:top w:w="0" w:type="dxa"/>');
|
||||
expect(documentXml).toContain('<w:bottom w:w="0" w:type="dxa"/>');
|
||||
expect(documentXml).toContain('<w:vAlign w:val="top"/>');
|
||||
expect(documentXml.match(/<w:cantSplit\/>/gu)).toHaveLength(3);
|
||||
expect(documentXml.match(/<w:t>\.<\/w:t>/gu)).toHaveLength(2);
|
||||
expect(documentXml.match(/<w:tbl(?:\s|>)/gu)).toHaveLength(2);
|
||||
expect(documentXml.match(/<w:trHeight\b/gu)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("将主题项目符号位置与 Word 悬挂缩进合并", () => {
|
||||
const source = readGeneratedDocxPackage(generatedFixture());
|
||||
const entries = new Map(source.entries);
|
||||
entries.set(
|
||||
"word/numbering.xml",
|
||||
encoder.encode(
|
||||
`<w:numbering xmlns:w="${word}"><w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum></w:numbering>`
|
||||
)
|
||||
);
|
||||
const listTokens: DocxThemeTokenSet = {
|
||||
...tokens,
|
||||
slots: tokens.slots.concat({
|
||||
slot: "unordered-list" as const,
|
||||
source: "computed-css" as const,
|
||||
confidence: "exact" as const,
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
paddingPt: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 30
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(entries),
|
||||
plan,
|
||||
listTokens
|
||||
);
|
||||
const numberingXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/numbering.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(numberingXml).toContain(
|
||||
'<w:ind w:left="960" w:hanging="360"/>'
|
||||
);
|
||||
expect(numberingXml).toContain(
|
||||
'<w:ind w:left="1680" w:hanging="360"/>'
|
||||
);
|
||||
});
|
||||
|
||||
it("按最终页面内容区重算右置百分比容器缩进", () => {
|
||||
const relativeTokens: DocxThemeTokenSet = {
|
||||
...tokens,
|
||||
slots: tokens.slots.map((entry) =>
|
||||
entry.slot === "tender-cover"
|
||||
? {
|
||||
...entry,
|
||||
confidence: "exact" as const,
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
widthPercent: 52,
|
||||
leftIndentPt: 230.4,
|
||||
rightIndentPt: 0,
|
||||
keepLines: true
|
||||
}
|
||||
}
|
||||
: entry
|
||||
)
|
||||
};
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
relativeTokens
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
// A4 内容区为 11906 - 1000 - 1000 = 9906 twips;
|
||||
// 52% 右置容器的左侧空余应为 9906 × 48% = 4755 twips。
|
||||
expect(documentXml).toMatch(
|
||||
/<w:pPr><w:pStyle w:val="MdOfficialIssueRow"\/>[\s\S]*?<w:ind w:left="4755" w:right="0"\/>/u
|
||||
);
|
||||
expect(documentXml).not.toContain('w:left="4608"');
|
||||
expect(documentXml).toMatch(
|
||||
/<w:pPr><w:pStyle w:val="MdOfficialSignatureDate"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝缺失或重复的内部结构标记", () => {
|
||||
expect(() =>
|
||||
finalizeGeneratedDocxStructure(
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inspectDocxMediaAcceptance,
|
||||
readReferenceDocxPackage,
|
||||
writeGeneratedDocxPackage,
|
||||
type DocxMediaAcceptanceExpectation
|
||||
} from "../src/index.js";
|
||||
import { createTestBaselineReference } from "./reference-test-fixture.js";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const word =
|
||||
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const officeRelationships =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
const packageRelationships =
|
||||
"http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
const wordprocessingDrawing =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
|
||||
const drawing =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/main";
|
||||
const picture =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/picture";
|
||||
const imageRelationship =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
|
||||
|
||||
const documentXml =
|
||||
`<w:document xmlns:w="${word}" xmlns:r="${officeRelationships}" xmlns:wp="${wordprocessingDrawing}" xmlns:a="${drawing}" xmlns:pic="${picture}"><w:body>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="Figure"/><w:jc w:val="center"/></w:pPr><w:r><w:drawing>` +
|
||||
`<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="3048000" cy="1524000"/><wp:docPr id="1" name="Picture" descr="验收图片" title=""/><wp:cNvGraphicFramePr><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr>` +
|
||||
`<a:graphic><a:graphicData uri="${picture}"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="image.png"/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="3048000" cy="1524000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` +
|
||||
`</wp:inline></w:drawing></w:r></w:p>` +
|
||||
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1000" w:right="1000" w:bottom="1000" w:left="1000"/></w:sectPr>` +
|
||||
`</w:body></w:document>`;
|
||||
|
||||
const relationshipsXml =
|
||||
`<Relationships xmlns="${packageRelationships}">` +
|
||||
`<Relationship Id="rIdImage" Type="${imageRelationship}" Target="media/image1.png"/>` +
|
||||
`</Relationships>`;
|
||||
|
||||
const png = Uint8Array.of(
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
);
|
||||
|
||||
const expectation: DocxMediaAcceptanceExpectation = {
|
||||
items: [
|
||||
{
|
||||
id: "docx-media-1",
|
||||
binding: "mdtp-media:docx-media-1",
|
||||
kind: "image",
|
||||
ordinal: 1,
|
||||
altText: "验收图片",
|
||||
alignment: "center",
|
||||
displayWidthPx: 320,
|
||||
displayHeightPx: 160,
|
||||
widthEmu: 3_048_000,
|
||||
heightEmu: 1_524_000
|
||||
}
|
||||
],
|
||||
maximumWidthEmu: 6_000_000,
|
||||
maximumHeightEmu: 9_000_000
|
||||
};
|
||||
|
||||
function fixture(options: {
|
||||
document?: (value: string) => string;
|
||||
relationships?: (value: string) => string;
|
||||
image?: Uint8Array;
|
||||
} = {}) {
|
||||
const baseline = readReferenceDocxPackage(
|
||||
createTestBaselineReference()
|
||||
);
|
||||
const entries = new Map(baseline.entries);
|
||||
entries.set(
|
||||
"word/document.xml",
|
||||
encoder.encode(options.document?.(documentXml) ?? documentXml)
|
||||
);
|
||||
entries.set(
|
||||
"word/_rels/document.xml.rels",
|
||||
encoder.encode(
|
||||
options.relationships?.(relationshipsXml) ?? relationshipsXml
|
||||
)
|
||||
);
|
||||
entries.set("word/media/image1.png", options.image ?? png);
|
||||
return writeGeneratedDocxPackage(entries);
|
||||
}
|
||||
|
||||
describe("DOCX 媒体 OOXML 门禁", () => {
|
||||
it("验收内联尺寸、比例、对齐和唯一 PNG 关系", () => {
|
||||
const report = inspectDocxMediaAcceptance(
|
||||
fixture(),
|
||||
expectation,
|
||||
"media-fixture"
|
||||
);
|
||||
|
||||
expect(report).toMatchObject({
|
||||
expectedCount: 1,
|
||||
drawingCount: 1,
|
||||
inlineCount: 1,
|
||||
anchorCount: 0,
|
||||
observations: [
|
||||
{
|
||||
id: "docx-media-1",
|
||||
widthEmu: 3_048_000,
|
||||
heightEmu: 1_524_000,
|
||||
alignment: "center",
|
||||
relationshipId: "rIdImage",
|
||||
partName: "word/media/image1.png"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "浮动锚点",
|
||||
check: "inline-layout",
|
||||
document: (value: string) =>
|
||||
value
|
||||
.replace("<wp:inline ", "<wp:anchor ")
|
||||
.replace("</wp:inline>", "</wp:anchor>")
|
||||
},
|
||||
{
|
||||
name: "双层尺寸不一致",
|
||||
check: "dimensions",
|
||||
document: (value: string) =>
|
||||
value.replace(
|
||||
'<a:ext cx="3048000" cy="1524000"/>',
|
||||
'<a:ext cx="3047000" cy="1524000"/>'
|
||||
)
|
||||
},
|
||||
{
|
||||
name: "比例漂移",
|
||||
check: "aspect-ratio",
|
||||
document: (value: string) =>
|
||||
value.replaceAll('cx="3048000"', 'cx="3048010"')
|
||||
},
|
||||
{
|
||||
name: "错误对齐",
|
||||
check: "alignment",
|
||||
document: (value: string) =>
|
||||
value.replace(
|
||||
'<w:jc w:val="center"/>',
|
||||
'<w:jc w:val="left"/>'
|
||||
)
|
||||
},
|
||||
{
|
||||
name: "缺少比例锁",
|
||||
check: "aspect-lock",
|
||||
document: (value: string) =>
|
||||
value.replace('noChangeAspect="1"', 'noChangeAspect="0"')
|
||||
},
|
||||
{
|
||||
name: "图片裁切",
|
||||
check: "cropping-transform",
|
||||
document: (value: string) =>
|
||||
value.replace("<pic:blipFill>", "<pic:blipFill><a:srcRect/>")
|
||||
},
|
||||
{
|
||||
name: "旋转图片",
|
||||
check: "cropping-transform",
|
||||
document: (value: string) =>
|
||||
value.replace("<a:xfrm>", '<a:xfrm rot="60000">')
|
||||
},
|
||||
{
|
||||
name: "替代文本错位",
|
||||
check: "alternative-text",
|
||||
document: (value: string) =>
|
||||
value.replace('descr="验收图片"', 'descr="错误图片"')
|
||||
},
|
||||
{
|
||||
name: "内部绑定残留",
|
||||
check: "alternative-text",
|
||||
document: (value: string) =>
|
||||
value.replace('title=""', 'title="mdtp-media:docx-media-1"')
|
||||
},
|
||||
{
|
||||
name: "图片关系缺失",
|
||||
check: "relationships",
|
||||
document: (value: string) =>
|
||||
value.replace('r:embed="rIdImage"', 'r:embed="missing"')
|
||||
}
|
||||
])("拒绝$name", ({ check, document }) => {
|
||||
expect(() =>
|
||||
inspectDocxMediaAcceptance(
|
||||
fixture({ document }),
|
||||
expectation,
|
||||
"tampered"
|
||||
)
|
||||
).toThrow(check);
|
||||
});
|
||||
|
||||
it("拒绝外部、非 PNG、伪 PNG 和额外图片关系", () => {
|
||||
expect(() =>
|
||||
inspectDocxMediaAcceptance(
|
||||
fixture({
|
||||
relationships: (value) =>
|
||||
value.replace("/>", ' TargetMode="External"/>')
|
||||
}),
|
||||
expectation,
|
||||
"external"
|
||||
)
|
||||
).toThrow("relationships");
|
||||
|
||||
expect(() =>
|
||||
inspectDocxMediaAcceptance(
|
||||
fixture({
|
||||
relationships: (value) =>
|
||||
value.replace("image1.png", "image1.jpg")
|
||||
}),
|
||||
expectation,
|
||||
"jpeg"
|
||||
)
|
||||
).toThrow("png-media");
|
||||
|
||||
expect(() =>
|
||||
inspectDocxMediaAcceptance(
|
||||
fixture({ image: new Uint8Array(24) }),
|
||||
expectation,
|
||||
"fake-png"
|
||||
)
|
||||
).toThrow("png-media");
|
||||
|
||||
expect(() =>
|
||||
inspectDocxMediaAcceptance(
|
||||
fixture({
|
||||
relationships: (value) =>
|
||||
value.replace(
|
||||
"</Relationships>",
|
||||
`<Relationship Id="extra" Type="${imageRelationship}" Target="media/extra.png"/></Relationships>`
|
||||
)
|
||||
}),
|
||||
expectation,
|
||||
"extra"
|
||||
)
|
||||
).toThrow("额外");
|
||||
});
|
||||
|
||||
it("拒绝超过当前页面内容区的显示尺寸", () => {
|
||||
expect(() =>
|
||||
inspectDocxMediaAcceptance(
|
||||
fixture(),
|
||||
{
|
||||
...expectation,
|
||||
maximumWidthEmu: 3_047_000
|
||||
},
|
||||
"oversized"
|
||||
)
|
||||
).toThrow("dimensions");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ function resource(
|
||||
kindOrdinal,
|
||||
altText: `${kind} ${kindOrdinal}`,
|
||||
caption: `${kind} 图注`,
|
||||
alignment: "center",
|
||||
displayWidthPx: 384,
|
||||
displayHeightPx: 192,
|
||||
captureX: 0,
|
||||
@@ -70,12 +71,20 @@ describe("Pandoc DOCX 媒体映射", () => {
|
||||
"media/media-003.png"
|
||||
]);
|
||||
expect(result.map.image[0]).toMatchObject({
|
||||
binding: "mdtp-media:docx-media-1",
|
||||
path: "media/media-001.png",
|
||||
width: "101.600mm",
|
||||
height: "50.800mm"
|
||||
});
|
||||
expect(result.map.mermaid).toHaveLength(1);
|
||||
expect(result.map.echarts).toHaveLength(1);
|
||||
expect(result.layout[0]).toMatchObject({
|
||||
id: "docx-media-1",
|
||||
binding: "mdtp-media:docx-media-1",
|
||||
alignment: "center",
|
||||
widthEmu: 3_657_600,
|
||||
heightEmu: 1_828_800
|
||||
});
|
||||
});
|
||||
|
||||
it("拒绝顺序错位、无效 PNG 和图表错误", () => {
|
||||
|
||||
@@ -85,6 +85,7 @@ describe("Pandoc 语义结构投影", () => {
|
||||
{
|
||||
kind: "group",
|
||||
role: "official-classification",
|
||||
layout: "space-between",
|
||||
children: [
|
||||
text("official-secrecy", "秘密"),
|
||||
text("official-urgency", "特急")
|
||||
@@ -94,6 +95,7 @@ describe("Pandoc 语义结构投影", () => {
|
||||
{
|
||||
kind: "group",
|
||||
role: "official-issue-row",
|
||||
layout: "space-between",
|
||||
children: [
|
||||
text("official-number", "示例〔2026〕1号"),
|
||||
text(
|
||||
@@ -115,6 +117,7 @@ describe("Pandoc 语义结构投影", () => {
|
||||
markerSeed: "official"
|
||||
});
|
||||
|
||||
expect(plan.metadataPolicy.author).toBe("suppress");
|
||||
expect(plan.titlePolicy.firstBodyHeading).toBe("suppress");
|
||||
expect(plan.prefix[0]).toMatchObject({
|
||||
kind: "container",
|
||||
@@ -127,6 +130,7 @@ describe("Pandoc 语义结构投影", () => {
|
||||
{
|
||||
kind: "paragraph",
|
||||
styleId: "MdOfficialClassification",
|
||||
layout: "space-between",
|
||||
segments: ["秘密", "特急"],
|
||||
separator: "tab"
|
||||
},
|
||||
@@ -138,6 +142,7 @@ describe("Pandoc 语义结构投影", () => {
|
||||
{
|
||||
kind: "paragraph",
|
||||
styleId: "MdOfficialIssueRow",
|
||||
layout: "space-between",
|
||||
segments: ["示例〔2026〕1号", "签发人:张三"],
|
||||
separator: "tab"
|
||||
}
|
||||
@@ -228,6 +233,9 @@ describe("Pandoc 语义结构投影", () => {
|
||||
|
||||
expect(plan).toEqual({
|
||||
schemaVersion: 1,
|
||||
metadataPolicy: {
|
||||
author: "suppress"
|
||||
},
|
||||
titlePolicy: {
|
||||
metadataTitle: "emit",
|
||||
firstBodyHeading: "keep"
|
||||
|
||||
@@ -199,6 +199,10 @@ describe("动态 reference.docx", () => {
|
||||
expect(documentXml).toContain('w:top="907"');
|
||||
expect(stylesXml).toContain('w:styleId="SourceCode"');
|
||||
expect(stylesXml).toContain('w:eastAsia="Microsoft YaHei"');
|
||||
const imageCaptionStyle = stylesXml.match(
|
||||
/<w:style\b[^>]*w:styleId="ImageCaption"[\s\S]*?<\/w:style>/u
|
||||
)?.[0];
|
||||
expect(imageCaptionStyle).toContain('<w:jc w:val="center"/>');
|
||||
expect(footerXml).toContain(" PAGE \\* MERGEFORMAT ");
|
||||
expect(footerXml).toContain(" NUMPAGES \\* MERGEFORMAT ");
|
||||
expect(footerXml).toContain('w:jc w:val="center"');
|
||||
@@ -226,7 +230,7 @@ describe("动态 reference.docx", () => {
|
||||
"Source Han Serif SC",
|
||||
"Times New Roman"
|
||||
],
|
||||
fontSizePt: 14,
|
||||
fontSizePt: 12.75,
|
||||
color: "#112233",
|
||||
lineSpacing: 1.8,
|
||||
firstLineIndentPt: 28,
|
||||
@@ -246,6 +250,28 @@ describe("动态 reference.docx", () => {
|
||||
keepWithNext: true
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-block",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Lucida Console"],
|
||||
fontSizePt: 9,
|
||||
paddingPt: {
|
||||
top: 6,
|
||||
right: 3,
|
||||
bottom: 5,
|
||||
left: 3
|
||||
},
|
||||
contentPaddingLeftPt: 6,
|
||||
borders: {
|
||||
top: { widthPt: 0.75, color: "#e7eaed", style: "single" },
|
||||
right: { widthPt: 0.75, color: "#e7eaed", style: "single" },
|
||||
bottom: { widthPt: 0.75, color: "#e7eaed", style: "single" },
|
||||
left: { widthPt: 0.75, color: "#e7eaed", style: "single" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "table",
|
||||
source: "computed-css",
|
||||
@@ -268,6 +294,36 @@ describe("动态 reference.docx", () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "table-cell",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["SimSun"],
|
||||
borders: {
|
||||
top: {
|
||||
widthPt: 0.85,
|
||||
color: "#556677",
|
||||
style: "single"
|
||||
},
|
||||
right: {
|
||||
widthPt: 0.85,
|
||||
color: "#556677",
|
||||
style: "single"
|
||||
},
|
||||
bottom: {
|
||||
widthPt: 0.85,
|
||||
color: "#556677",
|
||||
style: "single"
|
||||
},
|
||||
left: {
|
||||
widthPt: 0.85,
|
||||
color: "#556677",
|
||||
style: "single"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "official-title",
|
||||
source: "computed-css",
|
||||
@@ -288,6 +344,20 @@ describe("动态 reference.docx", () => {
|
||||
fontCandidates: [],
|
||||
lineSpacing: 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "official-printing-row",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
paddingPt: {
|
||||
top: 3,
|
||||
right: 28,
|
||||
bottom: 3,
|
||||
left: 28
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -302,22 +372,46 @@ describe("动态 reference.docx", () => {
|
||||
);
|
||||
|
||||
expect(stylesXml).toContain('w:styleId="MdOfficialTitle"');
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="MdOfficialTitle"[\s\S]*?<w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/>/u
|
||||
);
|
||||
expect(stylesXml).toContain('w:eastAsia="Source Han Serif SC"');
|
||||
expect(stylesXml).toContain('w:ascii="Times New Roman"');
|
||||
expect(stylesXml).toContain('w:val="112233"');
|
||||
expect(stylesXml).toContain('w:styleId="Heading1"');
|
||||
expect(stylesXml).toContain('w:val="AA0000"');
|
||||
expect(stylesXml).toContain('<w:jc w:val="both"/>');
|
||||
const sourceCodeStyle = stylesXml.match(
|
||||
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
|
||||
)?.[0];
|
||||
expect(sourceCodeStyle).toContain(
|
||||
'<w:left w:val="single" w:sz="6" w:space="7" w:color="E7EAED"/>'
|
||||
);
|
||||
expect(sourceCodeStyle).toMatch(
|
||||
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/u
|
||||
);
|
||||
expect(stylesXml).not.toContain('w:val="justify"');
|
||||
expect(stylesXml).toContain(
|
||||
'w:line="504" w:lineRule="exact"'
|
||||
'w:line="459" w:lineRule="exact"'
|
||||
);
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="Normal"[\s\S]*?<w:rPr>[\s\S]*?<w:sz w:val="26"\/>[\s\S]*?<w:spacing w:val="-5"\/>/u
|
||||
);
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="MdOfficialEdition"[\s\S]*?<w:spacing[^>]*w:line="315"[^>]*w:lineRule="exact"/u
|
||||
);
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="MdOfficialPrintingRow"[\s\S]*?<w:spacing[^>]*w:before="60"[^>]*w:after="60"/u
|
||||
);
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="MdOfficialPrintingRow"[\s\S]*?<w:ind[^>]*w:left="560"[^>]*w:right="560"/u
|
||||
);
|
||||
expect(stylesXml).not.toContain('w:lineRule="auto"');
|
||||
expect(stylesXml).not.toContain("<w:tblW");
|
||||
expect(stylesXml).toContain('w:color="445566"');
|
||||
expect(stylesXml).toMatch(
|
||||
/<w:tblBorders>[\s\S]*?<w:insideH[^>]*w:sz="7"[^>]*w:color="556677"\/>[\s\S]*?<w:insideV[^>]*w:sz="7"[^>]*w:color="556677"\/>/u
|
||||
);
|
||||
expect(fontTableXml).toContain(
|
||||
'w:name="Source Han Serif SC"'
|
||||
);
|
||||
@@ -377,6 +471,8 @@ describe("动态 reference.docx", () => {
|
||||
expect(documentXml).toContain('w:orient="landscape"');
|
||||
expect(documentXml).toContain('w:w="16838"');
|
||||
expect(documentXml).toContain('w:left="1701"');
|
||||
expect(documentXml).toContain('w:header="723"');
|
||||
expect(documentXml).toContain('w:footer="751"');
|
||||
expect(documentXml).toContain('w:start="5"');
|
||||
expect(documentXml).toContain("<w:titlePg");
|
||||
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
|
||||
@@ -390,10 +486,15 @@ describe("动态 reference.docx", () => {
|
||||
expect(headerXml).toContain('w:pos="13720"');
|
||||
expect(headerXml).toContain("<w:pBdr>");
|
||||
expect(headerXml).toContain("<w:bottom");
|
||||
expect(headerXml).toMatch(
|
||||
/<w:spacing[^>]*w:line="213"[^>]*w:lineRule="exact"/u
|
||||
);
|
||||
expect(headerXml).not.toContain("<w:tbl");
|
||||
expect(
|
||||
decoder.decode(entries["word/footer1.xml"])
|
||||
).not.toContain("<w:tbl");
|
||||
const footerXml = decoder.decode(entries["word/footer1.xml"]);
|
||||
expect(footerXml).toMatch(
|
||||
/<w:spacing[^>]*w:line="213"[^>]*w:lineRule="exact"/u
|
||||
);
|
||||
expect(footerXml).not.toContain("<w:tbl");
|
||||
expect(relationships.match(/relationships\/header/gu)).toHaveLength(
|
||||
3
|
||||
);
|
||||
@@ -407,6 +508,133 @@ describe("动态 reference.docx", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("封面页码重启时仍使用原生奇偶页脚保持跨客户端外侧位置", () => {
|
||||
const exportConfig: ExportConfig = {
|
||||
...defaultExportConfig,
|
||||
pageDecorationsMode: "custom",
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
alignment: "outer",
|
||||
format: "page-total",
|
||||
startFrom: 1
|
||||
}
|
||||
};
|
||||
const result = createDynamicReferenceDocx(
|
||||
createBaselineReference(),
|
||||
{
|
||||
...createOptions(exportConfig),
|
||||
semanticDocument: {
|
||||
schemaVersion: 1,
|
||||
profile: "project-report",
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: [
|
||||
{
|
||||
kind: "cover",
|
||||
nodes: [
|
||||
{
|
||||
kind: "text",
|
||||
role: "project-report-title",
|
||||
text: "可研报告"
|
||||
}
|
||||
],
|
||||
section: {
|
||||
headerFooter: "none",
|
||||
pageNumber: "hidden",
|
||||
breakAfter: "next-page",
|
||||
followingPageNumberStart: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
const entries = unzipSync(result.content);
|
||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||
const settingsXml = decoder.decode(entries["word/settings.xml"]);
|
||||
const defaultFooterXml = decoder.decode(entries["word/footer1.xml"]);
|
||||
const evenFooterXml = decoder.decode(entries["word/footer2.xml"]);
|
||||
|
||||
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(2);
|
||||
expect(settingsXml).toContain("<w:evenAndOddHeaders");
|
||||
expect(defaultFooterXml).toContain('w:jc w:val="left"');
|
||||
expect(evenFooterXml).toContain('w:jc w:val="right"');
|
||||
expect(defaultFooterXml).toContain(" = ");
|
||||
expect(defaultFooterXml).toContain(" - 1 ");
|
||||
expect(defaultFooterXml).toContain(" PAGE \\* MERGEFORMAT ");
|
||||
expect(defaultFooterXml).toContain(" NUMPAGES \\* MERGEFORMAT ");
|
||||
expect(defaultFooterXml).not.toContain(" IF ");
|
||||
expect(defaultFooterXml).not.toContain("<w:tbl");
|
||||
expect(validateDynamicReferenceDocx(result.content)).toMatchObject({
|
||||
footerCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it("独立控制正文首页页眉和页码", () => {
|
||||
const exportConfig: ExportConfig = {
|
||||
...defaultExportConfig,
|
||||
pageDecorationsMode: "custom",
|
||||
header: {
|
||||
...defaultExportConfig.header,
|
||||
enabled: true,
|
||||
showOnFirstPage: false,
|
||||
showDivider: true,
|
||||
center: {
|
||||
enabled: true,
|
||||
content: "${title}"
|
||||
}
|
||||
},
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
alignment: "center",
|
||||
showOnFirstPage: true
|
||||
}
|
||||
};
|
||||
const result = createDynamicReferenceDocx(
|
||||
createBaselineReference(),
|
||||
createOptions(exportConfig)
|
||||
);
|
||||
const entries = unzipSync(result.content);
|
||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||
const regularHeader = decoder.decode(entries["word/header1.xml"]);
|
||||
const firstHeader = decoder.decode(entries["word/header2.xml"]);
|
||||
const regularFooter = decoder.decode(entries["word/footer1.xml"]);
|
||||
const firstFooter = decoder.decode(entries["word/footer2.xml"]);
|
||||
|
||||
expect(documentXml).toContain("<w:titlePg");
|
||||
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(2);
|
||||
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(2);
|
||||
expect(regularHeader).toContain("年度 <报告>");
|
||||
expect(regularHeader).toContain("<w:bottom");
|
||||
expect(firstHeader).not.toContain("年度 <报告>");
|
||||
expect(firstHeader).not.toContain("<w:bottom");
|
||||
expect(regularFooter).toContain("PAGE");
|
||||
expect(firstFooter).toContain("PAGE");
|
||||
});
|
||||
|
||||
it("隐藏正文首页页码时生成无内容且无分隔线的首页页脚", () => {
|
||||
const exportConfig: ExportConfig = {
|
||||
...defaultExportConfig,
|
||||
pageDecorationsMode: "custom",
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
showDivider: true,
|
||||
showOnFirstPage: false
|
||||
}
|
||||
};
|
||||
const result = createDynamicReferenceDocx(
|
||||
createBaselineReference(),
|
||||
createOptions(exportConfig)
|
||||
);
|
||||
const entries = unzipSync(result.content);
|
||||
const firstFooter = decoder.decode(entries["word/footer2.xml"]);
|
||||
|
||||
expect(firstFooter).not.toContain("PAGE");
|
||||
expect(firstFooter).not.toContain("<w:top");
|
||||
});
|
||||
|
||||
it("拒绝正文中断裂或类型错误的页眉页脚关系", () => {
|
||||
const result = createDynamicReferenceDocx(
|
||||
createBaselineReference(),
|
||||
|
||||
Reference in New Issue
Block a user