feat: 实现动态 DOCX 模板与样式映射

This commit is contained in:
SkyJourney
2026-07-30 14:10:43 +08:00
parent fe5d67fb6d
commit fa159d916f
41 changed files with 3610 additions and 7 deletions
@@ -1,6 +1,7 @@
import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { docxThemeStyleSchema } from "@md-to-pdf/core";
const bundledThemeNames = {
"typora-github": "Typora Github",
@@ -83,6 +84,22 @@ describe("内置主题清单", () => {
}
});
it("每个内置主题声明可校验的 DOCX 样式预设", async () => {
const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url)
);
for (const id of Object.keys(bundledThemeNames)) {
const manifest = JSON.parse(
await readFile(`${themesRoot}${id}/theme.json`, "utf8")
) as { docxStyle?: unknown };
expect(
docxThemeStyleSchema.safeParse(manifest.docxStyle).success
).toBe(true);
}
});
it("为红头主题声明分类与兼容的结构化文档", async () => {
const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url)
+1
View File
@@ -10,6 +10,7 @@ src/
document.ts Markdown 文档、分页载荷、结果与耗时模型
document-link.ts 跨端链接分类与 PDF 本地链接编码协议
docx.ts DOCX 请求、能力、错误、结果与文件名协议
docx-style.ts 主题 DOCX 样式预设和安全覆盖协议
export-config.ts 纸张、边距、页眉页脚、页码与图表配置
theme.ts 主题清单、主题能力与 CSS 载荷模型
index.ts 公共导出入口
+121
View File
@@ -0,0 +1,121 @@
import { z } from "zod";
const colorSchema = z
.string()
.regex(/^#[0-9a-f]{6}$/iu, "DOCX 使");
const fontNameSchema = z
.string()
.trim()
.min(1)
.max(100)
.regex(
/^[\p{L}\p{N}\s._-]+$/u,
"DOCX 字体名称包含不支持的字符"
);
const pointSizeSchema = z.number().min(6).max(72);
const spacingSchema = z.number().min(0).max(144);
export const docxStylePresetSchema = z.enum([
"general",
"technical",
"official",
"formal",
"tender"
]);
export type DocxStylePreset = z.infer<
typeof docxStylePresetSchema
>;
export const docxFontFamilySchema = z.object({
latin: fontNameSchema,
eastAsia: fontNameSchema,
complexScript: fontNameSchema.optional()
});
export type DocxFontFamily = z.infer<
typeof docxFontFamilySchema
>;
const docxBodyStyleSchema = z.object({
fonts: docxFontFamilySchema.optional(),
sizePt: pointSizeSchema.optional(),
color: colorSchema.optional(),
lineSpacing: z.number().min(1).max(3).optional(),
firstLineIndentChars: z.number().min(0).max(10).optional(),
spacingBeforePt: spacingSchema.optional(),
spacingAfterPt: spacingSchema.optional()
});
const docxHeadingStyleSchema = z.object({
fonts: docxFontFamilySchema.optional(),
sizesPt: z
.tuple([
pointSizeSchema,
pointSizeSchema,
pointSizeSchema,
pointSizeSchema,
pointSizeSchema,
pointSizeSchema
])
.optional(),
color: colorSchema.optional(),
bold: z.boolean().optional(),
spacingBeforePt: spacingSchema.optional(),
spacingAfterPt: spacingSchema.optional()
});
const docxCodeStyleSchema = z.object({
fonts: docxFontFamilySchema.optional(),
sizePt: pointSizeSchema.optional(),
color: colorSchema.optional(),
backgroundColor: colorSchema.optional(),
borderColor: colorSchema.optional(),
lineSpacing: z.number().min(1).max(3).optional()
});
const docxBlockStyleSchema = z.object({
color: colorSchema.optional(),
backgroundColor: colorSchema.optional(),
borderColor: colorSchema.optional(),
leftIndentChars: z.number().min(0).max(20).optional(),
italic: z.boolean().optional()
});
const docxTableStyleSchema = z.object({
fonts: docxFontFamilySchema.optional(),
sizePt: pointSizeSchema.optional(),
color: colorSchema.optional(),
headerColor: colorSchema.optional(),
headerBackgroundColor: colorSchema.optional(),
borderColor: colorSchema.optional(),
cellMarginMm: z.number().min(0).max(20).optional()
});
const docxCaptionStyleSchema = z.object({
fonts: docxFontFamilySchema.optional(),
sizePt: pointSizeSchema.optional(),
color: colorSchema.optional(),
italic: z.boolean().optional(),
alignment: z.enum(["left", "center", "right"]).optional()
});
export const docxThemeStyleSchema = z.object({
preset: docxStylePresetSchema,
body: docxBodyStyleSchema.optional(),
headings: docxHeadingStyleSchema.optional(),
code: docxCodeStyleSchema.optional(),
blockQuote: docxBlockStyleSchema.optional(),
table: docxTableStyleSchema.optional(),
caption: docxCaptionStyleSchema.optional(),
hyperlink: z
.object({
color: colorSchema.optional(),
underline: z.boolean().optional()
})
.optional()
});
export type DocxThemeStyle = z.infer<
typeof docxThemeStyleSchema
>;
+1
View File
@@ -1,5 +1,6 @@
export * from "./document.js";
export * from "./docx.js";
export * from "./docx-style.js";
export * from "./document-profile.js";
export * from "./document-link.js";
export * from "./export-config.js";
+2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { documentProfileNameSchema } from "./document-profile.js";
import { docxThemeStyleSchema } from "./docx-style.js";
import {
footerSchema,
headerSchema,
@@ -50,6 +51,7 @@ export const themeManifestSchema = z.object({
footer: footerSchema.optional()
})
.optional(),
docxStyle: docxThemeStyleSchema.optional(),
bundled: z.boolean()
});
+46 -1
View File
@@ -10,7 +10,8 @@ import {
docxCapabilitySchema,
docxMediaCapturePlanSchema,
docxExportErrorResponseSchema,
docxExportRequestSchema
docxExportRequestSchema,
docxThemeStyleSchema
} from "../src/index.js";
describe("DOCX 共享协议", () => {
@@ -165,3 +166,47 @@ describe("DOCX 共享协议", () => {
).toBe(false);
});
});
describe("DOCX 主题样式协议", () => {
it("接受预设和受限样式覆盖", () => {
expect(
docxThemeStyleSchema.parse({
preset: "formal",
body: {
fonts: {
latin: "Times New Roman",
eastAsia: "SimSun"
},
sizePt: 12,
lineSpacing: 1.5
},
headings: {
sizesPt: [22, 18, 16, 14, 12, 10.5],
color: "#1f2937"
},
table: {
borderColor: "#d1d5db",
cellMarginMm: 1.5
}
})
).toMatchObject({
preset: "formal",
body: { sizePt: 12, lineSpacing: 1.5 }
});
});
it("拒绝危险字体名称和越界样式值", () => {
expect(() =>
docxThemeStyleSchema.parse({
preset: "general",
body: {
fonts: {
latin: "Arial; DROP",
eastAsia: "宋体"
},
sizePt: 200
}
})
).toThrow();
});
});
+37
View File
@@ -0,0 +1,37 @@
# @md-to-pdf/docx-engine
纯 Node.js 的 DOCX 模板和 Pandoc 编排核心。当前阶段负责读取固定 Pandoc
版本的默认 `reference.docx`、验证 ZIP 安全边界,将主题 DOCX 样式声明
解析为完整样式预设,并生成纸张、页边距、字体、段落、代码、表格、
页眉、页脚和页码均已映射的动态模板。
## 设计边界
- 不依赖 Fastify、Electron 或浏览器 UI
- ZIP 使用纯 JavaScript `fflate`
- OOXML 使用 `@xmldom/xmldom`,不拼接未经转义的用户 XML
- 不解析任意主题 CSS,主题通过 `docxStyle` 提供受限结构化覆盖;
- 生成结果会复验全部 XML、包内关系、内容类型、最终节和关键样式;
- Pandoc 进程定位、超时和临时目录属于后续转换服务阶段。
## 安全限制
- 输入模板压缩包不超过 2 MiB
- ZIP 条目不超过 256 个;
- 解压后总大小不超过 64 MiB
- 拒绝加密、ZIP64、未知压缩方法、重复路径和路径穿越;
- 必须包含文档、样式、字体、编号、设置、关系和内容类型等关键部件。
## 验证
```powershell
npm run test -w @md-to-pdf/docx-engine
npm run typecheck -w @md-to-pdf/docx-engine
npm run build -w @md-to-pdf/docx-engine
npm run verify:docx-reference
```
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
`MD_TO_PDF_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板,
分别验证公文 A4 与自定义横向模板,并在系统临时目录中完成转换和清理,
不会保留用户文档或验收产物。
@@ -0,0 +1,26 @@
---
title: 阶段六动态模板验收
author: Markdown PDF 导出器研发组
---
# 一级标题
这是一段可编辑的中文正文,包含 **加粗**、*斜体*、`inline code`
[内部链接](https://example.invalid/)。
## 二级标题
> 引用段落用于检查字体、颜色、缩进和段落间距。
| 验收项 | 预期结果 |
| --- | --- |
| 纸张 | 继承动态 reference.docx |
| 样式 | 保留标题、正文和表格样式 |
```typescript
export function editableDocument(value: string) {
return `DOCX 内容仍可编辑:${value}`;
}
```
最后一段用于确认 Pandoc 输出仍由标准 Word 段落和文本 Run 组成。
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@md-to-pdf/docx-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"verify:pandoc": "node scripts/verify-pandoc-reference.mjs"
},
"dependencies": {
"@md-to-pdf/core": "0.1.0",
"@xmldom/xmldom": "0.9.10",
"fflate": "0.8.3"
},
"devDependencies": {
"@types/node": "^24.10.1",
"vitest": "^4.1.10"
}
}
@@ -0,0 +1,299 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DOCX_PANDOC_VERSION,
defaultExportConfig,
themeManifestSchema
} from "@md-to-pdf/core";
import {
createDynamicReferenceDocx,
validateDynamicReferenceDocx
} from "@md-to-pdf/docx-engine";
import { unzipSync } from "fflate";
const directory = path.dirname(fileURLToPath(import.meta.url));
const packageDirectory = path.resolve(directory, "..");
const repositoryDirectory = path.resolve(packageDirectory, "../..");
const fixturePath = path.join(
packageDirectory,
"fixtures",
"pandoc-reference.md"
);
const themePath = path.join(
repositoryDirectory,
"themes",
"gov-red-standard",
"theme.json"
);
const pandocExecutable =
process.env.MD_TO_PDF_PANDOC_PATH?.trim() || "pandoc";
const maximumBuffer = 128 * 1024 * 1024;
const decoder = new TextDecoder();
function runPandoc(arguments_, options = {}) {
const result = spawnSync(pandocExecutable, arguments_, {
encoding: options.binary ? null : "utf8",
maxBuffer: maximumBuffer,
...options
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const stderr = Buffer.isBuffer(result.stderr)
? result.stderr.toString("utf8")
: result.stderr;
throw new Error(
stderr?.trim() || `Pandoc 退出码 ${String(result.status)}`
);
}
return result;
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function countMatches(value, pattern) {
return value.match(pattern)?.length ?? 0;
}
function inspectResult(content, expected) {
const validation = validateDynamicReferenceDocx(content);
const entries = unzipSync(content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
const settingsXml = decoder.decode(entries["word/settings.xml"]);
const relationshipsXml = decoder.decode(
entries["word/_rels/document.xml.rels"]
);
const bodyIsEditable =
documentXml.includes("可编辑的中文正文") &&
documentXml.includes("<w:tbl") &&
documentXml.includes("editableDocument");
const checks = {
pageWidth: documentXml.includes(`w:w="${expected.pageWidth}"`),
pageHeight: documentXml.includes(`w:h="${expected.pageHeight}"`),
orientation:
expected.orientation === "portrait"
? !documentXml.includes('w:orient="landscape"')
: documentXml.includes('w:orient="landscape"'),
leftMargin: documentXml.includes(`w:left="${expected.leftMargin}"`),
sourceCodeStyle: stylesXml.includes('w:styleId="SourceCode"'),
chineseBodyFont: stylesXml.includes('w:eastAsia="SimSun"'),
editableBody: bodyIsEditable,
headerReferences:
countMatches(documentXml, /w:headerReference/gu) ===
expected.headerCount,
footerReferences:
countMatches(documentXml, /w:footerReference/gu) ===
expected.footerCount,
pageField: Object.entries(entries)
.filter(([name]) => /^word\/footer\d+\.xml$/u.test(name))
.some(([, value]) =>
decoder.decode(value).includes(" PAGE \\* MERGEFORMAT ")
),
headerRelationship:
expected.headerCount === 0 ||
relationshipsXml.includes("relationships/header"),
footerRelationship:
expected.footerCount === 0 ||
relationshipsXml.includes("relationships/footer"),
evenOddSetting:
expected.usesEvenAndOddPages ===
settingsXml.includes("<w:evenAndOddHeaders")
};
for (const [name, passed] of Object.entries(checks)) {
assert(passed, `${expected.id} 的语义检查失败:${name}`);
}
assert(
validation.headerCount === expected.headerCount,
`${expected.id} 的页眉部件数量不正确`
);
assert(
validation.footerCount === expected.footerCount,
`${expected.id} 的页脚部件数量不正确`
);
return { validation, checks };
}
function createVariant(
temporaryDirectory,
baseline,
theme,
markdown,
variant
) {
const reference = createDynamicReferenceDocx(baseline, {
exportConfig: variant.exportConfig,
theme,
fileName: `${variant.id}.md`,
metadata: {
title: "阶段六动态模板验收",
author: "Markdown PDF 导出器研发组"
}
});
const referencePath = path.join(
temporaryDirectory,
`${variant.id}-reference.docx`
);
const markdownPath = path.join(
temporaryDirectory,
`${variant.id}.md`
);
const resultPath = path.join(
temporaryDirectory,
`${variant.id}-result.docx`
);
fs.writeFileSync(referencePath, reference.content);
fs.writeFileSync(markdownPath, markdown);
runPandoc([
markdownPath,
"--from",
"markdown",
"--to",
"docx",
"--reference-doc",
referencePath,
"--output",
resultPath
]);
const result = fs.readFileSync(resultPath);
return {
id: variant.id,
referenceBytes: reference.content.byteLength,
resultBytes: result.byteLength,
templateFingerprint: reference.templateFingerprint,
cacheKey: reference.cacheKey,
...inspectResult(result, variant.expected)
};
}
const versionResult = runPandoc(["--version"]);
const detectedVersion =
versionResult.stdout
.split(/\r?\n/u)[0]
?.replace(/^pandoc\s+/u, "")
.trim() ?? "";
assert(
detectedVersion === DOCX_PANDOC_VERSION,
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${detectedVersion || "未知"}`
);
const baselineResult = runPandoc(
["--print-default-data-file", "reference.docx"],
{ binary: true }
);
const baseline = new Uint8Array(baselineResult.stdout);
const theme = themeManifestSchema.parse(
JSON.parse(fs.readFileSync(themePath, "utf8"))
);
const markdown = fs.readFileSync(fixturePath, "utf8");
const temporaryDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), "md-to-pdf-docx-reference-")
);
const customConfig = {
...defaultExportConfig,
name: "DOCX 横向验收",
pageDecorationsMode: "custom",
paper: {
...defaultExportConfig.paper,
orientation: "landscape",
marginMode: "custom",
margins: {
top: "20mm",
right: "25mm",
bottom: "22mm",
left: "30mm"
}
},
header: {
...defaultExportConfig.header,
enabled: true,
showDivider: true,
left: {
enabled: true,
content: "${title}"
},
center: {
enabled: true,
content: "${author}"
},
right: {
enabled: true,
content: "${filename}"
}
},
footer: {
...defaultExportConfig.footer,
alignment: "outer",
format: "chinese-page-total",
startFrom: 5,
showOnFirstPage: false
}
};
const variants = [
{
id: "official-a4",
exportConfig: defaultExportConfig,
expected: {
id: "official-a4",
pageWidth: 11906,
pageHeight: 16838,
orientation: "portrait",
leftMargin: 1587,
headerCount: 0,
footerCount: 2,
usesEvenAndOddPages: true
}
},
{
id: "custom-landscape",
exportConfig: customConfig,
expected: {
id: "custom-landscape",
pageWidth: 16838,
pageHeight: 11906,
orientation: "landscape",
leftMargin: 1701,
headerCount: 3,
footerCount: 3,
usesEvenAndOddPages: true
}
}
];
try {
const results = variants.map((variant) =>
createVariant(
temporaryDirectory,
baseline,
theme,
markdown,
variant
)
);
console.log(
JSON.stringify(
{
pandocVersion: detectedVersion,
baselineBytes: baseline.byteLength,
variants: results
},
null,
2
)
);
} finally {
fs.rmSync(temporaryDirectory, {
recursive: true,
force: true
});
}
@@ -0,0 +1,519 @@
import {
lengthToMillimeters,
type FooterConfig,
type HeaderConfig
} from "@md-to-pdf/core";
import {
CONTENT_TYPES_NAMESPACE,
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
appendElement,
colorValue,
parseXmlPart,
pointsToHalfPoints,
serializeXmlPart,
type XmlElement
} from "./ooxml.js";
import {
resolveHeaderTemplate,
type DynamicReferenceDocxOptions
} from "./reference-options.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";
const HEADER_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
const FOOTER_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml";
const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
type HeaderFooterReferenceType = "default" | "even" | "first";
export interface HeaderFooterReference {
kind: "header" | "footer";
type: HeaderFooterReferenceType;
relationshipId: string;
}
export interface HeaderFooterTransformResult {
entries: Map<string, Uint8Array>;
references: HeaderFooterReference[];
usesEvenAndOddPages: boolean;
usesDifferentFirstPage: boolean;
}
interface RunStyle {
font: string;
sizePt: number;
color: string;
}
function firstFontName(fontFamily: string, fallback: string) {
const first = fontFamily.split(",")[0]?.trim() ?? "";
return first.replace(/^["']|["']$/gu, "") || fallback;
}
function createWordPart(rootName: "hdr" | "ftr") {
return parseXmlPart(
new TextEncoder().encode(
`<w:${rootName} xmlns:w="${WORD_NAMESPACE}" xmlns:r="${OFFICE_RELATIONSHIP_NAMESPACE}"/>`
),
`word/${rootName}.xml`
);
}
function appendRunProperties(run: XmlElement, style: RunStyle) {
const rPr = appendElement(run, WORD_NAMESPACE, "w:rPr");
appendElement(rPr, WORD_NAMESPACE, "w:rFonts", {
"w:ascii": style.font,
"w:hAnsi": style.font,
"w:eastAsia": style.font,
"w:cs": style.font
});
appendElement(rPr, WORD_NAMESPACE, "w:color", {
"w:val": colorValue(style.color)
});
const size = pointsToHalfPoints(style.sizePt);
appendElement(rPr, WORD_NAMESPACE, "w:sz", {
"w:val": size
});
appendElement(rPr, WORD_NAMESPACE, "w:szCs", {
"w:val": size
});
}
function appendText(
paragraph: XmlElement,
value: string,
style: RunStyle
) {
if (!value) {
return;
}
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(run, style);
const text = appendElement(run, WORD_NAMESPACE, "w:t");
text.setAttributeNS(XML_NAMESPACE, "xml:space", "preserve");
text.appendChild(text.ownerDocument!.createTextNode(value));
}
function appendField(
paragraph: XmlElement,
instruction: "PAGE" | "NUMPAGES",
style: RunStyle
) {
const begin = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(begin, style);
appendElement(begin, WORD_NAMESPACE, "w:fldChar", {
"w:fldCharType": "begin",
"w:dirty": "true"
});
const command = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(command, style);
const instructionText = appendElement(
command,
WORD_NAMESPACE,
"w:instrText"
);
instructionText.setAttributeNS(
XML_NAMESPACE,
"xml:space",
"preserve"
);
instructionText.appendChild(
instructionText.ownerDocument!.createTextNode(
` ${instruction} \\* MERGEFORMAT `
)
);
const separate = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(separate, style);
appendElement(separate, WORD_NAMESPACE, "w:fldChar", {
"w:fldCharType": "separate"
});
appendText(paragraph, "1", style);
const end = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(end, style);
appendElement(end, WORD_NAMESPACE, "w:fldChar", {
"w:fldCharType": "end"
});
}
function appendFooterContent(
paragraph: XmlElement,
footer: FooterConfig,
style: RunStyle
) {
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);
for (const token of tokens) {
if (token === "${page}") {
appendField(paragraph, "PAGE", style);
} else if (token === "${pages}") {
appendField(paragraph, "NUMPAGES", style);
} else {
appendText(paragraph, token, style);
}
}
}
function appendThreeColumnTable(
root: XmlElement,
options: {
position: "header" | "footer";
divider: boolean;
dividerColor: string;
alignments: readonly ["left", "center", "right"];
render: (
paragraph: XmlElement,
alignment: "left" | "center" | "right"
) => void;
}
) {
const table = appendElement(root, WORD_NAMESPACE, "w:tbl");
const tableProperties = appendElement(
table,
WORD_NAMESPACE,
"w:tblPr"
);
appendElement(tableProperties, WORD_NAMESPACE, "w:tblW", {
"w:w": "5000",
"w:type": "pct"
});
appendElement(tableProperties, WORD_NAMESPACE, "w:tblLayout", {
"w:type": "fixed"
});
if (options.divider) {
const borders = appendElement(
tableProperties,
WORD_NAMESPACE,
"w:tblBorders"
);
appendElement(
borders,
WORD_NAMESPACE,
options.position === "header" ? "w:bottom" : "w:top",
{
"w:val": "single",
"w:sz": "4",
"w:space": "2",
"w:color": colorValue(options.dividerColor)
}
);
}
const grid = appendElement(table, WORD_NAMESPACE, "w:tblGrid");
for (let index = 0; index < 3; index += 1) {
appendElement(grid, WORD_NAMESPACE, "w:gridCol", {
"w:w": "2400"
});
}
const row = appendElement(table, WORD_NAMESPACE, "w:tr");
for (const alignment of options.alignments) {
const cell = appendElement(row, WORD_NAMESPACE, "w:tc");
const cellProperties = appendElement(
cell,
WORD_NAMESPACE,
"w:tcPr"
);
appendElement(cellProperties, WORD_NAMESPACE, "w:tcW", {
"w:w": "1667",
"w:type": "pct"
});
const paragraph = appendElement(cell, WORD_NAMESPACE, "w:p");
const paragraphProperties = appendElement(
paragraph,
WORD_NAMESPACE,
"w:pPr"
);
appendElement(paragraphProperties, WORD_NAMESPACE, "w:jc", {
"w:val": alignment
});
options.render(paragraph, alignment);
}
}
function createHeaderPart(
header: HeaderConfig,
options: DynamicReferenceDocxOptions,
fallbackFont: string
) {
const document = createWordPart("hdr");
const style: RunStyle = {
font: firstFontName(header.fontFamily, fallbackFont),
sizePt: (lengthToMillimeters(header.fontSize) * 72) / 25.4,
color: header.color
};
appendThreeColumnTable(document.documentElement!, {
position: "header",
divider: header.showDivider,
dividerColor: header.color,
alignments: ["left", "center", "right"],
render: (paragraph, alignment) => {
const slot = header[alignment];
if (slot.enabled) {
appendText(
paragraph,
resolveHeaderTemplate(slot.content, options),
style
);
}
}
});
return serializeXmlPart(document);
}
function createFooterPart(
footer: FooterConfig,
alignment: "left" | "center" | "right",
fallbackFont: string,
empty = false
) {
const document = createWordPart("ftr");
const style: RunStyle = {
font: firstFontName(footer.fontFamily, fallbackFont),
sizePt: (lengthToMillimeters(footer.fontSize) * 72) / 25.4,
color: footer.color
};
appendThreeColumnTable(document.documentElement!, {
position: "footer",
divider: footer.showDivider,
dividerColor: footer.color,
alignments: ["left", "center", "right"],
render: (paragraph, cellAlignment) => {
if (!empty && cellAlignment === alignment) {
appendFooterContent(paragraph, footer, style);
}
}
});
return serializeXmlPart(document);
}
function removeExistingHeaderFooterParts(
entries: Map<string, Uint8Array>
) {
for (const name of [...entries.keys()]) {
if (/^word\/(?:header|footer)\d+\.xml$/u.test(name)) {
entries.delete(name);
}
}
}
function nextRelationshipId(relationships: XmlElement) {
const maximum = Array.from(
relationships.getElementsByTagNameNS(
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)
).reduce((current, relationship) => {
const match = /^rId(\d+)$/u.exec(
relationship.getAttribute("Id") ?? ""
);
return Math.max(current, Number(match?.[1] ?? 0));
}, 0);
let next = maximum + 1;
return () => `rId${next++}`;
}
function updateRelationships(
entries: Map<string, Uint8Array>,
parts: Array<{
kind: "header" | "footer";
type: HeaderFooterReferenceType;
partName: string;
}>
) {
const document = parseXmlPart(
entries.get("word/_rels/document.xml.rels")!,
"word/_rels/document.xml.rels"
);
const root = document.documentElement!;
for (const relationship of Array.from(
root.getElementsByTagNameNS(
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)
)) {
const type = relationship.getAttribute("Type");
if (
type === HEADER_RELATIONSHIP_TYPE ||
type === FOOTER_RELATIONSHIP_TYPE
) {
root.removeChild(relationship);
}
}
const allocateId = nextRelationshipId(root);
const references: HeaderFooterReference[] = [];
for (const part of parts) {
const relationshipId = allocateId();
appendElement(
root,
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship",
{
Id: relationshipId,
Type:
part.kind === "header"
? HEADER_RELATIONSHIP_TYPE
: FOOTER_RELATIONSHIP_TYPE,
Target: part.partName.replace(/^word\//u, "")
}
);
references.push({
kind: part.kind,
type: part.type,
relationshipId
});
}
entries.set(
"word/_rels/document.xml.rels",
serializeXmlPart(document)
);
return references;
}
function updateContentTypes(
entries: Map<string, Uint8Array>,
parts: Array<{
kind: "header" | "footer";
partName: string;
}>
) {
const document = parseXmlPart(
entries.get("[Content_Types].xml")!,
"[Content_Types].xml"
);
const root = document.documentElement!;
for (const override of Array.from(
root.getElementsByTagNameNS(CONTENT_TYPES_NAMESPACE, "Override")
)) {
if (
/^\/word\/(?:header|footer)\d+\.xml$/u.test(
override.getAttribute("PartName") ?? ""
)
) {
root.removeChild(override);
}
}
for (const part of parts) {
appendElement(root, CONTENT_TYPES_NAMESPACE, "Override", {
PartName: `/${part.partName}`,
ContentType:
part.kind === "header"
? HEADER_CONTENT_TYPE
: FOOTER_CONTENT_TYPE
});
}
entries.set("[Content_Types].xml", serializeXmlPart(document));
}
export function createHeaderFooterParts(
sourceEntries: ReadonlyMap<string, Uint8Array>,
options: DynamicReferenceDocxOptions,
header: HeaderConfig,
footer: FooterConfig,
fallbackFont: string
): HeaderFooterTransformResult {
const entries = new Map(sourceEntries);
removeExistingHeaderFooterParts(entries);
const parts: Array<{
kind: "header" | "footer";
type: HeaderFooterReferenceType;
partName: string;
}> = [];
let headerIndex = 1;
let footerIndex = 1;
const usesEvenAndOddPages =
footer.enabled && footer.alignment === "outer";
const usesDifferentFirstPage =
footer.enabled && !footer.showOnFirstPage;
if (header.enabled) {
const headerContent = createHeaderPart(
header,
options,
fallbackFont
);
for (const type of [
"default",
...(usesEvenAndOddPages ? ["even"] : []),
...(usesDifferentFirstPage ? ["first"] : [])
] as HeaderFooterReferenceType[]) {
const partName = `word/header${headerIndex++}.xml`;
entries.set(partName, headerContent);
parts.push({ kind: "header", type, partName });
}
}
if (footer.enabled) {
const defaultAlignment =
footer.alignment === "outer" ? "right" : footer.alignment;
const defaultPartName = `word/footer${footerIndex++}.xml`;
entries.set(
defaultPartName,
createFooterPart(
footer,
defaultAlignment,
fallbackFont
)
);
parts.push({
kind: "footer",
type: "default",
partName: defaultPartName
});
if (usesEvenAndOddPages) {
const evenPartName = `word/footer${footerIndex++}.xml`;
entries.set(
evenPartName,
createFooterPart(footer, "left", fallbackFont)
);
parts.push({
kind: "footer",
type: "even",
partName: evenPartName
});
}
if (usesDifferentFirstPage) {
const firstPartName = `word/footer${footerIndex++}.xml`;
entries.set(
firstPartName,
createFooterPart(
footer,
defaultAlignment,
fallbackFont,
true
)
);
parts.push({
kind: "footer",
type: "first",
partName: firstPartName
});
}
}
const references = updateRelationships(entries, parts);
updateContentTypes(entries, parts);
return {
entries,
references,
usesEvenAndOddPages,
usesDifferentFirstPage
};
}
+9
View File
@@ -0,0 +1,9 @@
export * from "./header-footer-transform.js";
export * from "./ooxml.js";
export * from "./reference-builder.js";
export * from "./reference-options.js";
export * from "./reference-package.js";
export * from "./section-transform.js";
export * from "./style-presets.js";
export * from "./styles-transform.js";
export * from "./validator.js";
+149
View File
@@ -0,0 +1,149 @@
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
import type {
Document as XmlDocument,
Element as XmlElement
} from "@xmldom/xmldom";
export type { XmlDocument, XmlElement };
export const WORD_NAMESPACE =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
export const OFFICE_RELATIONSHIP_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
export const PACKAGE_RELATIONSHIP_NAMESPACE =
"http://schemas.openxmlformats.org/package/2006/relationships";
export const CONTENT_TYPES_NAMESPACE =
"http://schemas.openxmlformats.org/package/2006/content-types";
export const DRAWING_NAMESPACE =
"http://schemas.openxmlformats.org/drawingml/2006/main";
const decoder = new TextDecoder("utf-8", { fatal: true });
const encoder = new TextEncoder();
export function parseXmlPart(
content: Uint8Array,
partName: string
) {
try {
return new DOMParser({
onError: (level, message) => {
if (level !== "warning") {
throw new Error(message);
}
}
}).parseFromString(decoder.decode(content), "application/xml");
} catch (error) {
throw new Error(
`${partName} 不是有效 OOXML${
error instanceof Error ? error.message : "XML 解析失败"
}`
);
}
}
export function serializeXmlPart(document: XmlDocument) {
return encoder.encode(
`<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(
document.documentElement!
)}`
);
}
export function directChildren(
parent: XmlElement,
namespace: string,
localName: string
) {
const children: XmlElement[] = [];
for (const node of Array.from(parent.childNodes)) {
const element = node as XmlElement;
if (
node.nodeType === 1 &&
element.namespaceURI === namespace &&
element.localName === localName
) {
children.push(element);
}
}
return children;
}
export function firstDirectChild(
parent: XmlElement,
namespace: string,
localName: string
) {
return directChildren(parent, namespace, localName)[0];
}
export function removeDirectChildren(
parent: XmlElement,
namespace: string,
...localNames: string[]
) {
for (const node of Array.from(parent.childNodes)) {
const element = node as XmlElement;
if (
node.nodeType === 1 &&
element.namespaceURI === namespace &&
localNames.includes(element.localName ?? "")
) {
parent.removeChild(node);
}
}
}
export function appendElement(
parent: XmlElement,
namespace: string,
qualifiedName: string,
attributes: Record<string, string> = {}
) {
const element = parent.ownerDocument!.createElementNS(
namespace,
qualifiedName
);
for (const [name, value] of Object.entries(attributes)) {
const prefix = name.includes(":") ? name.split(":")[0] : "";
const attributeNamespace =
prefix === "w"
? WORD_NAMESPACE
: prefix === "r"
? OFFICE_RELATIONSHIP_NAMESPACE
: null;
element.setAttributeNS(attributeNamespace, name, value);
}
parent.appendChild(element);
return element;
}
export function ensureDirectElement(
parent: XmlElement,
namespace: string,
qualifiedName: string
) {
const localName = qualifiedName.split(":").at(-1)!;
return (
firstDirectChild(parent, namespace, localName) ??
appendElement(parent, namespace, qualifiedName)
);
}
export function colorValue(value: string) {
if (!/^#[0-9a-f]{6}$/iu.test(value)) {
throw new Error(`DOCX 颜色值无效:${value}`);
}
return value.replace(/^#/u, "").toUpperCase();
}
export function pointsToHalfPoints(value: number) {
return String(Math.round(value * 2));
}
export function pointsToTwips(value: number) {
return String(Math.round(value * 20));
}
export function millimetersToTwips(value: number) {
return Math.round((value * 1440) / 25.4);
}
@@ -0,0 +1,155 @@
import { createHash } from "node:crypto";
import {
DOCX_PANDOC_VERSION,
lengthToMillimeters
} from "@md-to-pdf/core";
import {
createHeaderFooterParts
} from "./header-footer-transform.js";
import {
readReferenceDocxPackage,
writeReferenceDocxPackage
} from "./reference-package.js";
import {
resolveReferencePageOptions,
type DynamicReferenceDocxOptions
} from "./reference-options.js";
import {
transformDocumentSectionXml,
transformSettingsXml
} from "./section-transform.js";
import {
transformFontTableXml,
transformStylesXml,
transformThemeFontsXml
} from "./styles-transform.js";
import { resolveDocxThemeStyle } from "./style-presets.js";
import { validateDynamicReferenceDocx } from "./validator.js";
export interface DynamicReferenceDocxResult {
content: Uint8Array;
baselineFingerprint: string;
templateFingerprint: string;
cacheKey: string;
stylePreset: string;
partCount: number;
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(
([key, item]) =>
`${JSON.stringify(key)}:${stableJson(item)}`
)
.join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}
export function createDynamicReferenceCacheKey(
baselineFingerprint: string,
options: DynamicReferenceDocxOptions
) {
return createHash("sha256")
.update(
stableJson({
pandocVersion: DOCX_PANDOC_VERSION,
baselineFingerprint,
theme: {
id: options.theme.id,
version: options.theme.version,
category: options.theme.category,
docxStyle: options.theme.docxStyle,
pageDefaults: options.theme.pageDefaults
},
paper: options.exportConfig.paper,
pageDecorationsMode:
options.exportConfig.pageDecorationsMode,
header: options.exportConfig.header,
footer: options.exportConfig.footer,
fileName: options.fileName,
metadata: options.metadata
})
)
.digest("hex");
}
export function createDynamicReferenceDocx(
baseline: Uint8Array,
options: DynamicReferenceDocxOptions
): DynamicReferenceDocxResult {
const reference = readReferenceDocxPackage(baseline);
const style = resolveDocxThemeStyle(options.theme);
const page = resolveReferencePageOptions(options);
const withDecorations = createHeaderFooterParts(
reference.entries,
options,
page.header,
page.footer,
style.body.fonts.eastAsia
);
const entries = withDecorations.entries;
entries.set(
"word/styles.xml",
transformStylesXml(entries.get("word/styles.xml")!, style)
);
entries.set(
"word/fontTable.xml",
transformFontTableXml(entries.get("word/fontTable.xml")!, style)
);
entries.set(
"word/theme/theme1.xml",
transformThemeFontsXml(
entries.get("word/theme/theme1.xml")!,
style
)
);
entries.set(
"word/document.xml",
transformDocumentSectionXml(
entries.get("word/document.xml")!,
{
dimensions: page.dimensions,
margins: page.margins,
orientation: page.orientation,
headerHeightMm: page.header.enabled
? lengthToMillimeters(page.header.height)
: 0,
footerHeightMm: page.footer.enabled
? lengthToMillimeters(page.footer.height)
: 0,
pageNumberStart: page.footer.startFrom,
references: withDecorations.references,
usesDifferentFirstPage:
withDecorations.usesDifferentFirstPage
}
)
);
entries.set(
"word/settings.xml",
transformSettingsXml(
entries.get("word/settings.xml")!,
withDecorations.usesEvenAndOddPages
)
);
const content = writeReferenceDocxPackage(entries);
const validation = validateDynamicReferenceDocx(content);
return {
content,
baselineFingerprint: reference.fingerprint,
templateFingerprint: createHash("sha256")
.update(content)
.digest("hex"),
cacheKey: createDynamicReferenceCacheKey(
reference.fingerprint,
options
),
stylePreset: style.preset,
partCount: validation.partCount
};
}
@@ -0,0 +1,64 @@
import {
defaultExportConfig,
getPaperDimensionsMm,
lengthToMillimeters,
resolvePageMargins,
type ExportConfig,
type MarkdownDocumentMetadata,
type ThemeManifest
} from "@md-to-pdf/core";
export interface DynamicReferenceDocxOptions {
exportConfig: ExportConfig;
theme: ThemeManifest;
fileName: string;
metadata: Pick<MarkdownDocumentMetadata, "title" | "author">;
}
export function resolveReferencePageOptions(
options: DynamicReferenceDocxOptions
) {
const { exportConfig, theme } = options;
const themeDefaults = theme.pageDefaults;
const dimensions = getPaperDimensionsMm(
exportConfig.paper.format,
exportConfig.paper.orientation
);
const margins = resolvePageMargins(
exportConfig.paper,
themeDefaults?.margins
);
const useThemeDecorations =
exportConfig.pageDecorationsMode === "theme";
return {
dimensions,
margins: {
top: lengthToMillimeters(margins.top),
right: lengthToMillimeters(margins.right),
bottom: lengthToMillimeters(margins.bottom),
left: lengthToMillimeters(margins.left)
},
orientation: exportConfig.paper.orientation,
header: useThemeDecorations
? themeDefaults?.header ?? defaultExportConfig.header
: exportConfig.header,
footer: useThemeDecorations
? themeDefaults?.footer ?? defaultExportConfig.footer
: exportConfig.footer
};
}
export function resolveHeaderTemplate(
value: string,
options: DynamicReferenceDocxOptions
) {
const replacements: Record<string, string> = {
title: options.metadata.title,
author: options.metadata.author,
filename: options.fileName
};
return value.replace(
/\$\{(title|author|filename)\}/gu,
(_match, name: string) => replacements[name] ?? ""
);
}
@@ -0,0 +1,196 @@
import { createHash } from "node:crypto";
import { unzipSync, zipSync, type Zippable } from "fflate";
export const MAXIMUM_REFERENCE_DOCX_BYTES = 2 * 1024 * 1024;
export const MAXIMUM_REFERENCE_ENTRY_COUNT = 256;
export const MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES =
64 * 1024 * 1024;
export const requiredReferenceDocxParts = [
"[Content_Types].xml",
"_rels/.rels",
"word/document.xml",
"word/fontTable.xml",
"word/numbering.xml",
"word/settings.xml",
"word/styles.xml",
"word/_rels/document.xml.rels",
"word/theme/theme1.xml"
] as const;
export interface ReferenceDocxPackage {
fingerprint: string;
entries: ReadonlyMap<string, Uint8Array>;
}
interface ZipDirectoryEntry {
name: string;
uncompressedSize: number;
}
function findEndOfCentralDirectory(content: Uint8Array) {
const view = new DataView(
content.buffer,
content.byteOffset,
content.byteLength
);
const minimumOffset = Math.max(0, content.byteLength - 65_557);
for (
let offset = content.byteLength - 22;
offset >= minimumOffset;
offset -= 1
) {
if (view.getUint32(offset, true) === 0x06054b50) {
return offset;
}
}
throw new Error("reference.docx 缺少 ZIP 中央目录");
}
function validateEntryName(name: string) {
if (
!name ||
name.includes("\\") ||
name.startsWith("/") ||
name.includes(":") ||
name.split("/").some((segment) => segment === "..")
) {
throw new Error(`reference.docx 包含不安全路径:${name}`);
}
}
function inspectZipDirectory(content: Uint8Array) {
if (
content.byteLength < 22 ||
content.byteLength > MAXIMUM_REFERENCE_DOCX_BYTES
) {
throw new Error("reference.docx 压缩包大小超过限制或内容不完整");
}
const view = new DataView(
content.buffer,
content.byteOffset,
content.byteLength
);
const endOffset = findEndOfCentralDirectory(content);
const diskNumber = view.getUint16(endOffset + 4, true);
const directoryDisk = view.getUint16(endOffset + 6, true);
const entryCount = view.getUint16(endOffset + 10, true);
const directorySize = view.getUint32(endOffset + 12, true);
const directoryOffset = view.getUint32(endOffset + 16, true);
if (
diskNumber !== 0 ||
directoryDisk !== 0 ||
entryCount > MAXIMUM_REFERENCE_ENTRY_COUNT ||
entryCount === 0 ||
directoryOffset + directorySize > endOffset
) {
throw new Error("reference.docx ZIP 中央目录不受支持");
}
const decoder = new TextDecoder("utf-8", { fatal: true });
const entries: ZipDirectoryEntry[] = [];
const names = new Set<string>();
let totalUncompressedBytes = 0;
let offset = directoryOffset;
for (let index = 0; index < entryCount; index += 1) {
if (
offset + 46 > endOffset ||
view.getUint32(offset, true) !== 0x02014b50
) {
throw new Error("reference.docx ZIP 中央目录损坏");
}
const flags = view.getUint16(offset + 8, true);
const compressionMethod = view.getUint16(offset + 10, true);
const compressedSize = view.getUint32(offset + 20, true);
const uncompressedSize = view.getUint32(offset + 24, true);
const nameLength = view.getUint16(offset + 28, true);
const extraLength = view.getUint16(offset + 30, true);
const commentLength = view.getUint16(offset + 32, true);
const nextOffset =
offset + 46 + nameLength + extraLength + commentLength;
if (
nextOffset > endOffset ||
(flags & 0x1) !== 0 ||
![0, 8].includes(compressionMethod) ||
compressedSize === 0xffffffff ||
uncompressedSize === 0xffffffff
) {
throw new Error("reference.docx 包含不支持的 ZIP 条目");
}
const name = decoder.decode(
content.subarray(offset + 46, offset + 46 + nameLength)
);
validateEntryName(name);
if (names.has(name)) {
throw new Error(`reference.docx 包含重复条目:${name}`);
}
names.add(name);
totalUncompressedBytes += uncompressedSize;
if (
totalUncompressedBytes >
MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES
) {
throw new Error("reference.docx 解压后总大小超过限制");
}
entries.push({ name, uncompressedSize });
offset = nextOffset;
}
if (offset !== directoryOffset + directorySize) {
throw new Error("reference.docx ZIP 中央目录长度不一致");
}
return entries;
}
export function readReferenceDocxPackage(
content: Uint8Array
): ReferenceDocxPackage {
const directory = inspectZipDirectory(content);
const unzipped = unzipSync(content);
const entries = new Map<string, Uint8Array>();
for (const descriptor of directory) {
if (descriptor.name.endsWith("/")) {
continue;
}
const value = unzipped[descriptor.name];
if (
!value ||
value.byteLength !== descriptor.uncompressedSize
) {
throw new Error(
`reference.docx 条目解压尺寸不一致:${descriptor.name}`
);
}
entries.set(descriptor.name, value);
}
for (const required of requiredReferenceDocxParts) {
if (!entries.has(required)) {
throw new Error(`reference.docx 缺少必要部件:${required}`);
}
}
return {
fingerprint: createHash("sha256")
.update(content)
.digest("hex"),
entries
};
}
export function writeReferenceDocxPackage(
entries: ReadonlyMap<string, Uint8Array>
) {
const zippable: Zippable = {};
const mtime = new Date("1980-01-01T00:00:00.000Z");
for (const [name, content] of [...entries].sort(([left], [right]) =>
left.localeCompare(right)
)) {
validateEntryName(name);
zippable[name] = [content, { mtime }];
}
const result = zipSync(zippable, {
level: 6,
mtime
});
readReferenceDocxPackage(result);
return result;
}
@@ -0,0 +1,149 @@
import {
WORD_NAMESPACE,
appendElement,
firstDirectChild,
millimetersToTwips,
parseXmlPart,
removeDirectChildren,
serializeXmlPart,
type XmlElement
} from "./ooxml.js";
import type { HeaderFooterReference } from "./header-footer-transform.js";
export interface ReferenceSectionOptions {
dimensions: {
width: number;
height: number;
};
margins: {
top: number;
right: number;
bottom: number;
left: number;
};
orientation: "portrait" | "landscape";
headerHeightMm: number;
footerHeightMm: number;
pageNumberStart: number;
references: HeaderFooterReference[];
usesDifferentFirstPage: boolean;
}
function insertReference(
section: XmlElement,
reference: HeaderFooterReference
) {
const element = section.ownerDocument!.createElementNS(
WORD_NAMESPACE,
`w:${reference.kind}Reference`
);
element.setAttributeNS(
WORD_NAMESPACE,
"w:type",
reference.type
);
element.setAttributeNS(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"r:id",
reference.relationshipId
);
section.insertBefore(element, section.firstChild);
}
export function transformDocumentSectionXml(
content: Uint8Array,
options: ReferenceSectionOptions
) {
const document = parseXmlPart(content, "word/document.xml");
const body = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"body"
)[0];
const section = body
? firstDirectChild(body, WORD_NAMESPACE, "sectPr")
: undefined;
if (!section) {
throw new Error("word/document.xml 缺少 w:body/w:sectPr");
}
removeDirectChildren(
section,
WORD_NAMESPACE,
"headerReference",
"footerReference",
"pgSz",
"pgMar",
"pgNumType",
"titlePg"
);
for (const reference of [...options.references].reverse()) {
insertReference(section, reference);
}
const pageSize = appendElement(
section,
WORD_NAMESPACE,
"w:pgSz",
{
"w:w": String(millimetersToTwips(options.dimensions.width)),
"w:h": String(millimetersToTwips(options.dimensions.height))
}
);
if (options.orientation === "landscape") {
pageSize.setAttributeNS(
WORD_NAMESPACE,
"w:orient",
"landscape"
);
}
appendElement(section, WORD_NAMESPACE, "w:pgMar", {
"w:top": String(millimetersToTwips(options.margins.top)),
"w:right": String(millimetersToTwips(options.margins.right)),
"w:bottom": String(millimetersToTwips(options.margins.bottom)),
"w:left": String(millimetersToTwips(options.margins.left)),
"w:header": String(
millimetersToTwips(
Math.max(
0,
options.margins.top - options.headerHeightMm
)
)
),
"w:footer": String(
millimetersToTwips(
Math.max(
0,
options.margins.bottom - options.footerHeightMm
)
)
),
"w:gutter": "0"
});
appendElement(section, WORD_NAMESPACE, "w:pgNumType", {
"w:start": String(options.pageNumberStart)
});
if (options.usesDifferentFirstPage) {
appendElement(section, WORD_NAMESPACE, "w:titlePg");
}
return serializeXmlPart(document);
}
export function transformSettingsXml(
content: Uint8Array,
usesEvenAndOddPages: boolean
) {
const document = parseXmlPart(content, "word/settings.xml");
const settings = document.documentElement!;
removeDirectChildren(
settings,
WORD_NAMESPACE,
"evenAndOddHeaders"
);
if (usesEvenAndOddPages) {
appendElement(
settings,
WORD_NAMESPACE,
"w:evenAndOddHeaders"
);
}
return serializeXmlPart(document);
}
+258
View File
@@ -0,0 +1,258 @@
import {
docxThemeStyleSchema,
type DocxFontFamily,
type DocxStylePreset,
type DocxThemeStyle,
type ThemeManifest
} from "@md-to-pdf/core";
export interface ResolvedDocxThemeStyle {
preset: DocxStylePreset;
body: {
fonts: DocxFontFamily;
sizePt: number;
color: string;
lineSpacing: number;
firstLineIndentChars: number;
spacingBeforePt: number;
spacingAfterPt: number;
};
headings: {
fonts: DocxFontFamily;
sizesPt: [number, number, number, number, number, number];
color: string;
bold: boolean;
spacingBeforePt: number;
spacingAfterPt: number;
};
code: {
fonts: DocxFontFamily;
sizePt: number;
color: string;
backgroundColor: string;
borderColor: string;
lineSpacing: number;
};
blockQuote: {
color: string;
backgroundColor: string;
borderColor: string;
leftIndentChars: number;
italic: boolean;
};
table: {
fonts: DocxFontFamily;
sizePt: number;
color: string;
headerColor: string;
headerBackgroundColor: string;
borderColor: string;
cellMarginMm: number;
};
caption: {
fonts: DocxFontFamily;
sizePt: number;
color: string;
italic: boolean;
alignment: "left" | "center" | "right";
};
hyperlink: {
color: string;
underline: boolean;
};
}
const songFonts: DocxFontFamily = {
latin: "Times New Roman",
eastAsia: "SimSun"
};
const sansFonts: DocxFontFamily = {
latin: "Arial",
eastAsia: "SimHei"
};
const technicalFonts: DocxFontFamily = {
latin: "Arial",
eastAsia: "Microsoft YaHei"
};
const codeFonts: DocxFontFamily = {
latin: "Consolas",
eastAsia: "Microsoft YaHei"
};
const baseStyle: Omit<ResolvedDocxThemeStyle, "preset"> = {
body: {
fonts: songFonts,
sizePt: 10.5,
color: "#1f2328",
lineSpacing: 1.5,
firstLineIndentChars: 0,
spacingBeforePt: 0,
spacingAfterPt: 6
},
headings: {
fonts: sansFonts,
sizesPt: [22, 18, 16, 14, 12, 10.5],
color: "#1f2328",
bold: true,
spacingBeforePt: 12,
spacingAfterPt: 6
},
code: {
fonts: codeFonts,
sizePt: 9,
color: "#24292f",
backgroundColor: "#f6f8fa",
borderColor: "#d0d7de",
lineSpacing: 1.25
},
blockQuote: {
color: "#57606a",
backgroundColor: "#ffffff",
borderColor: "#d0d7de",
leftIndentChars: 2,
italic: false
},
table: {
fonts: songFonts,
sizePt: 10.5,
color: "#1f2328",
headerColor: "#1f2328",
headerBackgroundColor: "#f6f8fa",
borderColor: "#d0d7de",
cellMarginMm: 1.5
},
caption: {
fonts: songFonts,
sizePt: 9,
color: "#57606a",
italic: false,
alignment: "center"
},
hyperlink: {
color: "#0969da",
underline: true
}
};
const presetOverrides: Record<
DocxStylePreset,
Partial<Omit<ResolvedDocxThemeStyle, "preset">>
> = {
general: {},
technical: {
body: {
...baseStyle.body,
fonts: technicalFonts
},
table: {
...baseStyle.table,
fonts: technicalFonts
}
},
official: {
body: {
...baseStyle.body,
sizePt: 16,
firstLineIndentChars: 2,
spacingAfterPt: 0
},
headings: {
...baseStyle.headings,
color: "#000000",
sizesPt: [22, 18, 16, 16, 14, 12]
}
},
formal: {
body: {
...baseStyle.body,
sizePt: 13.5,
firstLineIndentChars: 2,
spacingAfterPt: 0
},
headings: {
...baseStyle.headings,
color: "#1f2937"
}
},
tender: {
body: {
...baseStyle.body,
sizePt: 12,
lineSpacing: 1.5
},
headings: {
...baseStyle.headings,
color: "#1f4e79"
},
table: {
...baseStyle.table,
headerBackgroundColor: "#dbeafe",
borderColor: "#94a3b8"
}
}
};
function mergeSection<T extends object>(
base: T,
override:
| { [Key in keyof T]?: T[Key] | undefined }
| undefined
): T {
if (!override) {
return { ...base };
}
const result = { ...base };
for (const key of Object.keys(override) as Array<keyof T>) {
const value = override[key];
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
export function inferDocxStylePreset(
manifest: Pick<ThemeManifest, "category">
): DocxStylePreset {
if (manifest.category === "red-letter") {
return "official";
}
if (manifest.category === "formal") {
return "formal";
}
if (manifest.category === "tender") {
return "tender";
}
return "general";
}
export function resolveDocxThemeStyle(
manifest: ThemeManifest
): ResolvedDocxThemeStyle {
const declared: DocxThemeStyle = docxThemeStyleSchema.parse(
manifest.docxStyle ?? {
preset: inferDocxStylePreset(manifest)
}
);
const preset = presetOverrides[declared.preset];
const base = {
...baseStyle,
...preset
};
return {
preset: declared.preset,
body: mergeSection(base.body, declared.body),
headings: mergeSection(base.headings, declared.headings),
code: mergeSection(base.code, declared.code),
blockQuote: mergeSection(
base.blockQuote,
declared.blockQuote
),
table: mergeSection(base.table, declared.table),
caption: mergeSection(base.caption, declared.caption),
hyperlink: mergeSection(
base.hyperlink,
declared.hyperlink
)
};
}
@@ -0,0 +1,684 @@
import type { DocxFontFamily } from "@md-to-pdf/core";
import type { ResolvedDocxThemeStyle } from "./style-presets.js";
import {
DRAWING_NAMESPACE,
WORD_NAMESPACE,
appendElement,
colorValue,
directChildren,
ensureDirectElement,
firstDirectChild,
millimetersToTwips,
parseXmlPart,
pointsToHalfPoints,
pointsToTwips,
removeDirectChildren,
serializeXmlPart,
type XmlElement
} from "./ooxml.js";
function setFonts(parent: XmlElement, fonts: DocxFontFamily) {
removeDirectChildren(parent, WORD_NAMESPACE, "rFonts");
appendElement(parent, WORD_NAMESPACE, "w:rFonts", {
"w:ascii": fonts.latin,
"w:hAnsi": fonts.latin,
"w:eastAsia": fonts.eastAsia,
"w:cs": fonts.complexScript ?? fonts.latin
});
}
function setRunStyle(
parent: XmlElement,
options: {
fonts?: DocxFontFamily;
sizePt?: number;
color?: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
backgroundColor?: string;
}
) {
removeDirectChildren(
parent,
WORD_NAMESPACE,
"rFonts",
"sz",
"szCs",
"color",
"b",
"bCs",
"i",
"iCs",
"u",
"shd"
);
if (options.fonts) {
setFonts(parent, options.fonts);
}
if (options.bold) {
appendElement(parent, WORD_NAMESPACE, "w:b");
appendElement(parent, WORD_NAMESPACE, "w:bCs");
}
if (options.italic) {
appendElement(parent, WORD_NAMESPACE, "w:i");
appendElement(parent, WORD_NAMESPACE, "w:iCs");
}
if (options.color) {
appendElement(parent, WORD_NAMESPACE, "w:color", {
"w:val": colorValue(options.color)
});
}
if (options.underline) {
appendElement(parent, WORD_NAMESPACE, "w:u", {
"w:val": "single"
});
}
if (options.backgroundColor) {
appendElement(parent, WORD_NAMESPACE, "w:shd", {
"w:val": "clear",
"w:color": "auto",
"w:fill": colorValue(options.backgroundColor)
});
}
if (options.sizePt !== undefined) {
const size = pointsToHalfPoints(options.sizePt);
appendElement(parent, WORD_NAMESPACE, "w:sz", {
"w:val": size
});
appendElement(parent, WORD_NAMESPACE, "w:szCs", {
"w:val": size
});
}
}
function setParagraphSpacing(
parent: XmlElement,
options: {
beforePt: number;
afterPt: number;
lineSpacing: number;
firstLineIndentChars?: number;
}
) {
removeDirectChildren(parent, WORD_NAMESPACE, "spacing", "ind");
appendElement(parent, WORD_NAMESPACE, "w:spacing", {
"w:before": pointsToTwips(options.beforePt),
"w:after": pointsToTwips(options.afterPt),
"w:line": String(Math.round(options.lineSpacing * 240)),
"w:lineRule": "auto"
});
if (options.firstLineIndentChars !== undefined) {
appendElement(parent, WORD_NAMESPACE, "w:ind", {
"w:firstLineChars": String(
Math.round(options.firstLineIndentChars * 100)
)
});
}
}
function findStyle(styles: XmlElement, styleId: string) {
return Array.from(
styles.getElementsByTagNameNS(WORD_NAMESPACE, "style")
).find(
(element) =>
element.getAttributeNS(WORD_NAMESPACE, "styleId") === styleId
);
}
function ensureStyle(
styles: XmlElement,
styleId: string,
type: "paragraph" | "character" | "table",
basedOn?: string
) {
const existing = findStyle(styles, styleId);
if (existing) {
return existing;
}
const style = appendElement(styles, WORD_NAMESPACE, "w:style", {
"w:type": type,
"w:customStyle": "1",
"w:styleId": styleId
});
appendElement(style, WORD_NAMESPACE, "w:name", {
"w:val": styleId.replace(/([a-z])([A-Z])/gu, "$1 $2")
});
if (basedOn) {
appendElement(style, WORD_NAMESPACE, "w:basedOn", {
"w:val": basedOn
});
}
return style;
}
function applyBodyStyle(
styles: XmlElement,
style: ResolvedDocxThemeStyle
) {
const defaults = firstDirectChild(
styles,
WORD_NAMESPACE,
"docDefaults"
);
if (!defaults) {
throw new Error("word/styles.xml 缺少 w:docDefaults");
}
const defaultRun = ensureDirectElement(
ensureDirectElement(
defaults,
WORD_NAMESPACE,
"w:rPrDefault"
),
WORD_NAMESPACE,
"w:rPr"
);
setRunStyle(defaultRun, {
fonts: style.body.fonts,
sizePt: style.body.sizePt,
color: style.body.color
});
const defaultParagraph = ensureDirectElement(
ensureDirectElement(
defaults,
WORD_NAMESPACE,
"w:pPrDefault"
),
WORD_NAMESPACE,
"w:pPr"
);
setParagraphSpacing(defaultParagraph, {
beforePt: style.body.spacingBeforePt,
afterPt: style.body.spacingAfterPt,
lineSpacing: style.body.lineSpacing,
firstLineIndentChars: style.body.firstLineIndentChars
});
for (const styleId of [
"Normal",
"BodyText",
"FirstParagraph",
"FootnoteText",
"Definition",
"Figure"
]) {
const target = ensureStyle(
styles,
styleId,
"paragraph",
styleId === "Normal" ? undefined : "Normal"
);
const pPr = ensureDirectElement(
target,
WORD_NAMESPACE,
"w:pPr"
);
setParagraphSpacing(pPr, {
beforePt: style.body.spacingBeforePt,
afterPt: style.body.spacingAfterPt,
lineSpacing: style.body.lineSpacing,
firstLineIndentChars: style.body.firstLineIndentChars
});
const rPr = ensureDirectElement(
target,
WORD_NAMESPACE,
"w:rPr"
);
setRunStyle(rPr, {
fonts: style.body.fonts,
sizePt: style.body.sizePt,
color: style.body.color
});
}
const compact = ensureStyle(
styles,
"Compact",
"paragraph",
"BodyText"
);
setParagraphSpacing(
ensureDirectElement(compact, WORD_NAMESPACE, "w:pPr"),
{
beforePt: 0,
afterPt: 0,
lineSpacing: style.body.lineSpacing,
firstLineIndentChars: 0
}
);
}
function applyHeadings(
styles: XmlElement,
style: ResolvedDocxThemeStyle
) {
for (let level = 1; level <= 6; level += 1) {
const sizePt = style.headings.sizesPt[level - 1]!;
for (const styleId of [
`Heading${level}`,
`Heading${level}Char`
]) {
const target = ensureStyle(
styles,
styleId,
styleId.endsWith("Char") ? "character" : "paragraph",
styleId.endsWith("Char")
? "DefaultParagraphFont"
: "Normal"
);
if (!styleId.endsWith("Char")) {
const pPr = ensureDirectElement(
target,
WORD_NAMESPACE,
"w:pPr"
);
setParagraphSpacing(pPr, {
beforePt: style.headings.spacingBeforePt,
afterPt: style.headings.spacingAfterPt,
lineSpacing: 1.25
});
if (!firstDirectChild(pPr, WORD_NAMESPACE, "keepNext")) {
appendElement(pPr, WORD_NAMESPACE, "w:keepNext");
}
if (!firstDirectChild(pPr, WORD_NAMESPACE, "keepLines")) {
appendElement(pPr, WORD_NAMESPACE, "w:keepLines");
}
}
setRunStyle(
ensureDirectElement(target, WORD_NAMESPACE, "w:rPr"),
{
fonts: style.headings.fonts,
sizePt,
color: style.headings.color,
bold: style.headings.bold
}
);
}
}
for (const [styleId, sizePt] of [
["Title", style.headings.sizesPt[0] + 4],
["TitleChar", style.headings.sizesPt[0] + 4],
["Subtitle", style.headings.sizesPt[1]],
["SubtitleChar", style.headings.sizesPt[1]]
] as const) {
const target = ensureStyle(
styles,
styleId,
styleId.endsWith("Char") ? "character" : "paragraph",
styleId.endsWith("Char")
? "DefaultParagraphFont"
: "Normal"
);
if (!styleId.endsWith("Char")) {
const pPr = ensureDirectElement(
target,
WORD_NAMESPACE,
"w:pPr"
);
setParagraphSpacing(pPr, {
beforePt: 0,
afterPt: style.headings.spacingAfterPt,
lineSpacing: 1.25
});
removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
appendElement(pPr, WORD_NAMESPACE, "w:jc", {
"w:val": "center"
});
}
setRunStyle(
ensureDirectElement(target, WORD_NAMESPACE, "w:rPr"),
{
fonts: style.headings.fonts,
sizePt,
color: style.headings.color,
bold: styleId.startsWith("Title")
}
);
}
}
function applyCodeAndQuote(
styles: XmlElement,
style: ResolvedDocxThemeStyle
) {
const inlineCode = ensureStyle(
styles,
"VerbatimChar",
"character",
"BodyTextChar"
);
setRunStyle(
ensureDirectElement(inlineCode, WORD_NAMESPACE, "w:rPr"),
{
fonts: style.code.fonts,
sizePt: style.code.sizePt,
color: style.code.color,
backgroundColor: style.code.backgroundColor
}
);
const sourceCode = ensureStyle(
styles,
"SourceCode",
"paragraph",
"Normal"
);
const codePPr = ensureDirectElement(
sourceCode,
WORD_NAMESPACE,
"w:pPr"
);
setParagraphSpacing(codePPr, {
beforePt: 3,
afterPt: 3,
lineSpacing: style.code.lineSpacing,
firstLineIndentChars: 0
});
removeDirectChildren(codePPr, WORD_NAMESPACE, "shd", "pBdr");
appendElement(codePPr, WORD_NAMESPACE, "w:shd", {
"w:val": "clear",
"w:color": "auto",
"w:fill": colorValue(style.code.backgroundColor)
});
const codeBorders = appendElement(
codePPr,
WORD_NAMESPACE,
"w:pBdr"
);
for (const side of ["top", "left", "bottom", "right"]) {
appendElement(codeBorders, WORD_NAMESPACE, `w:${side}`, {
"w:val": "single",
"w:sz": "4",
"w:space": "2",
"w:color": colorValue(style.code.borderColor)
});
}
setRunStyle(
ensureDirectElement(sourceCode, WORD_NAMESPACE, "w:rPr"),
{
fonts: style.code.fonts,
sizePt: style.code.sizePt,
color: style.code.color,
backgroundColor: style.code.backgroundColor
}
);
const quote = ensureStyle(
styles,
"BlockText",
"paragraph",
"BodyText"
);
const quotePPr = ensureDirectElement(
quote,
WORD_NAMESPACE,
"w:pPr"
);
setParagraphSpacing(quotePPr, {
beforePt: 6,
afterPt: 6,
lineSpacing: style.body.lineSpacing,
firstLineIndentChars: 0
});
removeDirectChildren(quotePPr, WORD_NAMESPACE, "ind", "shd", "pBdr");
appendElement(quotePPr, WORD_NAMESPACE, "w:ind", {
"w:leftChars": String(style.blockQuote.leftIndentChars * 100)
});
appendElement(quotePPr, WORD_NAMESPACE, "w:shd", {
"w:val": "clear",
"w:color": "auto",
"w:fill": colorValue(style.blockQuote.backgroundColor)
});
const quoteBorders = appendElement(
quotePPr,
WORD_NAMESPACE,
"w:pBdr"
);
appendElement(quoteBorders, WORD_NAMESPACE, "w:left", {
"w:val": "single",
"w:sz": "18",
"w:space": "6",
"w:color": colorValue(style.blockQuote.borderColor)
});
setRunStyle(
ensureDirectElement(quote, WORD_NAMESPACE, "w:rPr"),
{
fonts: style.body.fonts,
sizePt: style.body.sizePt,
color: style.blockQuote.color,
italic: style.blockQuote.italic
}
);
}
function applyTableAndCaption(
styles: XmlElement,
style: ResolvedDocxThemeStyle
) {
const table = ensureStyle(styles, "Table", "table", "TableNormal");
const tblPr = ensureDirectElement(
table,
WORD_NAMESPACE,
"w:tblPr"
);
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblCellMar", "tblBorders");
const margins = appendElement(
tblPr,
WORD_NAMESPACE,
"w:tblCellMar"
);
const cellMargin = String(
millimetersToTwips(style.table.cellMarginMm)
);
for (const side of ["top", "left", "bottom", "right"]) {
appendElement(margins, WORD_NAMESPACE, `w:${side}`, {
"w:w": cellMargin,
"w:type": "dxa"
});
}
const borders = appendElement(
tblPr,
WORD_NAMESPACE,
"w:tblBorders"
);
for (const side of [
"top",
"left",
"bottom",
"right",
"insideH",
"insideV"
]) {
appendElement(borders, WORD_NAMESPACE, `w:${side}`, {
"w:val": "single",
"w:sz": "4",
"w:space": "0",
"w:color": colorValue(style.table.borderColor)
});
}
setRunStyle(
ensureDirectElement(table, WORD_NAMESPACE, "w:rPr"),
{
fonts: style.table.fonts,
sizePt: style.table.sizePt,
color: style.table.color
}
);
let firstRow = directChildren(
table,
WORD_NAMESPACE,
"tblStylePr"
).find(
(element) =>
element.getAttributeNS(WORD_NAMESPACE, "type") === "firstRow"
);
if (!firstRow) {
firstRow = appendElement(
table,
WORD_NAMESPACE,
"w:tblStylePr",
{ "w:type": "firstRow" }
);
}
setRunStyle(
ensureDirectElement(firstRow, WORD_NAMESPACE, "w:rPr"),
{
fonts: style.table.fonts,
sizePt: style.table.sizePt,
color: style.table.headerColor,
bold: true
}
);
const cellProperties = ensureDirectElement(
firstRow,
WORD_NAMESPACE,
"w:tcPr"
);
removeDirectChildren(cellProperties, WORD_NAMESPACE, "shd");
appendElement(cellProperties, WORD_NAMESPACE, "w:shd", {
"w:val": "clear",
"w:color": "auto",
"w:fill": colorValue(style.table.headerBackgroundColor)
});
for (const styleId of ["Caption", "TableCaption", "ImageCaption"]) {
const caption = ensureStyle(
styles,
styleId,
"paragraph",
styleId === "Caption" ? "Normal" : "Caption"
);
const pPr = ensureDirectElement(
caption,
WORD_NAMESPACE,
"w:pPr"
);
setParagraphSpacing(pPr, {
beforePt: 3,
afterPt: 6,
lineSpacing: 1.25
});
removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
appendElement(pPr, WORD_NAMESPACE, "w:jc", {
"w:val": style.caption.alignment
});
setRunStyle(
ensureDirectElement(caption, WORD_NAMESPACE, "w:rPr"),
{
fonts: style.caption.fonts,
sizePt: style.caption.sizePt,
color: style.caption.color,
italic: style.caption.italic
}
);
}
const hyperlink = ensureStyle(
styles,
"Hyperlink",
"character",
"BodyTextChar"
);
setRunStyle(
ensureDirectElement(hyperlink, WORD_NAMESPACE, "w:rPr"),
{
color: style.hyperlink.color,
underline: style.hyperlink.underline
}
);
}
export function transformStylesXml(
content: Uint8Array,
style: ResolvedDocxThemeStyle
) {
const document = parseXmlPart(content, "word/styles.xml");
const styles = document.documentElement!;
if (
styles.namespaceURI !== WORD_NAMESPACE ||
styles.localName !== "styles"
) {
throw new Error("word/styles.xml 根元素无效");
}
applyBodyStyle(styles, style);
applyHeadings(styles, style);
applyCodeAndQuote(styles, style);
applyTableAndCaption(styles, style);
return serializeXmlPart(document);
}
export function transformFontTableXml(
content: Uint8Array,
style: ResolvedDocxThemeStyle
) {
const document = parseXmlPart(content, "word/fontTable.xml");
const root = document.documentElement!;
const requiredFonts = new Set<string>();
for (const fonts of [
style.body.fonts,
style.headings.fonts,
style.code.fonts,
style.table.fonts,
style.caption.fonts
]) {
requiredFonts.add(fonts.latin);
requiredFonts.add(fonts.eastAsia);
if (fonts.complexScript) {
requiredFonts.add(fonts.complexScript);
}
}
const existing = new Set(
Array.from(
root.getElementsByTagNameNS(WORD_NAMESPACE, "font")
).map((element) => element.getAttributeNS(WORD_NAMESPACE, "name"))
);
for (const name of requiredFonts) {
if (!existing.has(name)) {
const font = appendElement(root, WORD_NAMESPACE, "w:font", {
"w:name": name
});
appendElement(font, WORD_NAMESPACE, "w:family", {
"w:val": name === style.code.fonts.latin ? "modern" : "auto"
});
}
}
return serializeXmlPart(document);
}
export function transformThemeFontsXml(
content: Uint8Array,
style: ResolvedDocxThemeStyle
) {
const document = parseXmlPart(content, "word/theme/theme1.xml");
for (const schemeName of ["majorFont", "minorFont"]) {
const scheme = document.getElementsByTagNameNS(
DRAWING_NAMESPACE,
schemeName
)[0];
if (!scheme) {
continue;
}
const latin = scheme.getElementsByTagNameNS(
DRAWING_NAMESPACE,
"latin"
)[0];
const eastAsia = scheme.getElementsByTagNameNS(
DRAWING_NAMESPACE,
"ea"
)[0];
latin?.setAttribute(
"typeface",
schemeName === "majorFont"
? style.headings.fonts.latin
: style.body.fonts.latin
);
eastAsia?.setAttribute(
"typeface",
schemeName === "majorFont"
? style.headings.fonts.eastAsia
: style.body.fonts.eastAsia
);
}
return serializeXmlPart(document);
}
+347
View File
@@ -0,0 +1,347 @@
import path from "node:path";
import {
CONTENT_TYPES_NAMESPACE,
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
directChildren,
parseXmlPart,
type XmlElement
} from "./ooxml.js";
import { readReferenceDocxPackage } from "./reference-package.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";
const HEADER_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
const FOOTER_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml";
const requiredStyleIds = [
"Normal",
"Heading1",
"SourceCode",
"Table",
"Caption"
] as const;
interface Relationship {
id: string;
type: string;
targetPart: string | undefined;
}
export interface DynamicReferenceValidation {
partCount: number;
xmlPartCount: number;
relationshipCount: number;
headerCount: number;
footerCount: number;
}
function relationshipSourcePart(relationshipsPart: string) {
if (relationshipsPart === "_rels/.rels") {
return "";
}
const match = /^(.*)\/_rels\/([^/]+)\.rels$/u.exec(
relationshipsPart
);
if (!match) {
throw new Error(`OOXML 关系部件路径无效:${relationshipsPart}`);
}
return path.posix.join(match[1]!, match[2]!);
}
function resolveRelationshipTarget(
relationshipsPart: string,
target: string
) {
if (
!target ||
target.includes("\\") ||
target.includes("\0") ||
/^[a-z][a-z0-9+.-]*:/iu.test(target)
) {
throw new Error(
`${relationshipsPart} 包含不安全的内部关系目标:${target}`
);
}
const sourcePart = relationshipSourcePart(relationshipsPart);
const targetPart = target.startsWith("/")
? path.posix.normalize(target.slice(1))
: path.posix.normalize(
path.posix.join(path.posix.dirname(sourcePart), target)
);
if (
!targetPart ||
targetPart === "." ||
targetPart === ".." ||
targetPart.startsWith("../") ||
targetPart.startsWith("/")
) {
throw new Error(
`${relationshipsPart} 的内部关系目标越出 DOCX 包:${target}`
);
}
return targetPart;
}
function parseRelationships(
partName: string,
content: Uint8Array,
entries: ReadonlyMap<string, Uint8Array>
) {
const document = parseXmlPart(content, partName);
const root = document.documentElement;
if (
!root ||
root.namespaceURI !== PACKAGE_RELATIONSHIP_NAMESPACE ||
root.localName !== "Relationships"
) {
throw new Error(`${partName} 的关系根元素无效`);
}
const relationships = new Map<string, Relationship>();
for (const element of directChildren(
root,
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)) {
const id = element.getAttribute("Id");
const type = element.getAttribute("Type");
const target = element.getAttribute("Target");
if (!id || !type || !target) {
throw new Error(`${partName} 包含不完整的关系声明`);
}
if (relationships.has(id)) {
throw new Error(`${partName} 包含重复关系 ID${id}`);
}
const external =
(element.getAttribute("TargetMode") ?? "").toLowerCase() ===
"external";
const targetPart = external
? undefined
: resolveRelationshipTarget(partName, target);
if (targetPart && !entries.has(targetPart)) {
throw new Error(
`${partName} 的关系 ${id} 指向缺失部件:${targetPart}`
);
}
relationships.set(id, { id, type, targetPart });
}
return relationships;
}
function validateReferencedParts(
section: XmlElement,
localName: "headerReference" | "footerReference",
expectedType: string,
expectedRootName: "hdr" | "ftr",
relationships: ReadonlyMap<string, Relationship>,
entries: ReadonlyMap<string, Uint8Array>
) {
const references = directChildren(
section,
WORD_NAMESPACE,
localName
);
const pageTypes = new Set<string>();
for (const reference of references) {
const pageType =
reference.getAttributeNS(WORD_NAMESPACE, "type") ?? "";
const relationshipId =
reference.getAttributeNS(
OFFICE_RELATIONSHIP_NAMESPACE,
"id"
) ?? "";
if (!["default", "even", "first"].includes(pageType)) {
throw new Error(`${localName} 包含无效页面类型:${pageType}`);
}
if (pageTypes.has(pageType)) {
throw new Error(`${localName} 重复声明页面类型:${pageType}`);
}
pageTypes.add(pageType);
const relationship = relationships.get(relationshipId);
if (
!relationship ||
relationship.type !== expectedType ||
!relationship.targetPart
) {
throw new Error(
`${localName} 引用了无效关系:${relationshipId || "(空)"}`
);
}
const part = entries.get(relationship.targetPart)!;
const document = parseXmlPart(part, relationship.targetPart);
const root = document.documentElement;
if (
!root ||
root.namespaceURI !== WORD_NAMESPACE ||
root.localName !== expectedRootName
) {
throw new Error(
`${relationship.targetPart} 的页眉页脚根元素无效`
);
}
}
return references.length;
}
function validateContentTypes(
entries: ReadonlyMap<string, Uint8Array>,
relationships: ReadonlyMap<string, Relationship>
) {
const partName = "[Content_Types].xml";
const document = parseXmlPart(entries.get(partName)!, partName);
const root = document.documentElement;
if (
!root ||
root.namespaceURI !== CONTENT_TYPES_NAMESPACE ||
root.localName !== "Types"
) {
throw new Error("[Content_Types].xml 的根元素无效");
}
const overrides = new Map<string, string>();
for (const element of directChildren(
root,
CONTENT_TYPES_NAMESPACE,
"Override"
)) {
const part = element.getAttribute("PartName");
const contentType = element.getAttribute("ContentType");
if (!part || !contentType || overrides.has(part)) {
throw new Error("[Content_Types].xml 包含无效或重复的 Override");
}
overrides.set(part, contentType);
}
for (const relationship of relationships.values()) {
if (
relationship.type !== HEADER_RELATIONSHIP_TYPE &&
relationship.type !== FOOTER_RELATIONSHIP_TYPE
) {
continue;
}
const expected =
relationship.type === HEADER_RELATIONSHIP_TYPE
? HEADER_CONTENT_TYPE
: FOOTER_CONTENT_TYPE;
if (
!relationship.targetPart ||
overrides.get(`/${relationship.targetPart}`) !== expected
) {
throw new Error(
`${relationship.targetPart ?? "(空)"} 缺少正确的 Content Type`
);
}
}
}
function validateStyles(entries: ReadonlyMap<string, Uint8Array>) {
const partName = "word/styles.xml";
const document = parseXmlPart(entries.get(partName)!, partName);
const root = document.documentElement;
if (
!root ||
root.namespaceURI !== WORD_NAMESPACE ||
root.localName !== "styles"
) {
throw new Error("word/styles.xml 的根元素无效");
}
const styleIds = new Set(
Array.from(
root.getElementsByTagNameNS(WORD_NAMESPACE, "style")
).map((element) =>
element.getAttributeNS(WORD_NAMESPACE, "styleId")
)
);
for (const styleId of requiredStyleIds) {
if (!styleIds.has(styleId)) {
throw new Error(`word/styles.xml 缺少关键样式:${styleId}`);
}
}
}
export function validateDynamicReferenceDocx(
content: Uint8Array
): DynamicReferenceValidation {
const reference = readReferenceDocxPackage(content);
let xmlPartCount = 0;
let relationshipCount = 0;
const relationshipParts = new Map<
string,
ReadonlyMap<string, Relationship>
>();
for (const [partName, part] of reference.entries) {
if (partName.endsWith(".xml") || partName.endsWith(".rels")) {
parseXmlPart(part, partName);
xmlPartCount += 1;
}
if (partName.endsWith(".rels")) {
const relationships = parseRelationships(
partName,
part,
reference.entries
);
relationshipParts.set(partName, relationships);
relationshipCount += relationships.size;
}
}
const documentPart = "word/document.xml";
const document = parseXmlPart(
reference.entries.get(documentPart)!,
documentPart
);
const root = document.documentElement;
if (
!root ||
root.namespaceURI !== WORD_NAMESPACE ||
root.localName !== "document"
) {
throw new Error("word/document.xml 的根元素无效");
}
const sections = Array.from(
root.getElementsByTagNameNS(WORD_NAMESPACE, "sectPr")
);
const finalSection = sections.at(-1);
if (
!finalSection ||
directChildren(finalSection, WORD_NAMESPACE, "pgSz").length !== 1 ||
directChildren(finalSection, WORD_NAMESPACE, "pgMar").length !== 1
) {
throw new Error("word/document.xml 缺少完整的最终节页面设置");
}
const documentRelationships = relationshipParts.get(
"word/_rels/document.xml.rels"
)!;
const headerCount = validateReferencedParts(
finalSection,
"headerReference",
HEADER_RELATIONSHIP_TYPE,
"hdr",
documentRelationships,
reference.entries
);
const footerCount = validateReferencedParts(
finalSection,
"footerReference",
FOOTER_RELATIONSHIP_TYPE,
"ftr",
documentRelationships,
reference.entries
);
validateContentTypes(reference.entries, documentRelationships);
validateStyles(reference.entries);
return {
partCount: reference.entries.size,
xmlPartCount,
relationshipCount,
headerCount,
footerCount
};
}
@@ -0,0 +1,275 @@
import { describe, expect, it } from "vitest";
import { unzipSync, zipSync } from "fflate";
import {
defaultExportConfig,
type ExportConfig,
type ThemeManifest
} from "@md-to-pdf/core";
import {
createDynamicReferenceDocx,
validateDynamicReferenceDocx,
writeReferenceDocxPackage
} from "../src/index.js";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const wordNamespace =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
function xml(value: string) {
return encoder.encode(value);
}
function createBaselineReference() {
const styles = [
"Normal",
"BodyText",
"FirstParagraph",
"Compact",
"Title",
"TitleChar",
"Subtitle",
"SubtitleChar",
"Heading1",
"Heading1Char",
"Heading2",
"Heading2Char",
"Heading3",
"Heading3Char",
"Heading4",
"Heading4Char",
"Heading5",
"Heading5Char",
"Heading6",
"Heading6Char",
"BlockText",
"FootnoteText",
"DefaultParagraphFont",
"Table",
"Definition",
"Caption",
"TableCaption",
"ImageCaption",
"Figure",
"BodyTextChar",
"VerbatimChar",
"Hyperlink"
]
.map((styleId) => {
const type =
styleId === "Table"
? "table"
: styleId.endsWith("Char") ||
styleId === "DefaultParagraphFont" ||
styleId === "VerbatimChar" ||
styleId === "Hyperlink"
? "character"
: "paragraph";
return `<w:style w:type="${type}" w:styleId="${styleId}"><w:name w:val="${styleId}"/></w:style>`;
})
.join("");
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"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'
),
"_rels/.rels": xml(
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>'
),
"word/document.xml": xml(
`<w:document xmlns:w="${wordNamespace}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p/><w:sectPr><w:footnotePr/></w:sectPr></w:body></w:document>`
),
"word/fontTable.xml": xml(
`<w:fonts xmlns:w="${wordNamespace}"><w:font w:name="Arial"/></w:fonts>`
),
"word/numbering.xml": xml(
`<w:numbering xmlns:w="${wordNamespace}"/>`
),
"word/settings.xml": xml(
`<w:settings xmlns:w="${wordNamespace}"/>`
),
"word/styles.xml": xml(
`<w:styles xmlns:w="${wordNamespace}"><w:docDefaults><w:rPrDefault><w:rPr/></w:rPrDefault><w:pPrDefault><w:pPr/></w:pPrDefault></w:docDefaults>${styles}</w:styles>`
),
"word/_rels/document.xml.rels": xml(
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>'
),
"word/theme/theme1.xml": xml(
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:themeElements><a:fontScheme><a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/></a:minorFont></a:fontScheme></a:themeElements></a:theme>'
)
},
{ mtime: new Date("2020-01-01T00:00:00.000Z") }
);
}
function theme(
overrides: Partial<ThemeManifest> = {}
): ThemeManifest {
return {
manifestVersion: 1,
id: "test-theme",
name: "测试主题",
version: "1.0.0",
description: "测试",
author: "测试",
license: "内部许可",
entry: "theme.css",
domPreset: "generic",
defaultFontSize: "16px",
supportedFeatures: [],
category: "general",
compatibleProfiles: [],
docxStyle: { preset: "technical" },
bundled: true,
...overrides
};
}
function createOptions(exportConfig: ExportConfig) {
return {
exportConfig,
theme: theme(),
fileName: "报告 & 计划.md",
metadata: {
title: "年度 <报告>",
author: "测试人"
}
};
}
describe("动态 reference.docx", () => {
it("生成稳定的纸张、正文样式和页码模板", () => {
const baseline = createBaselineReference();
const first = createDynamicReferenceDocx(
baseline,
createOptions(defaultExportConfig)
);
const second = createDynamicReferenceDocx(
baseline,
createOptions(defaultExportConfig)
);
const changedMetadata = createDynamicReferenceDocx(baseline, {
...createOptions(defaultExportConfig),
metadata: {
title: "另一份报告",
author: "测试人"
}
});
const entries = unzipSync(first.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
const footerXml = decoder.decode(entries["word/footer1.xml"]);
expect(first.templateFingerprint).toBe(
second.templateFingerprint
);
expect(first.cacheKey).toBe(second.cacheKey);
expect(first.cacheKey).not.toBe(changedMetadata.cacheKey);
expect(documentXml).toContain('w:w="11906"');
expect(documentXml).toContain('w:h="16838"');
expect(documentXml).toContain('w:top="907"');
expect(stylesXml).toContain('w:styleId="SourceCode"');
expect(stylesXml).toContain('w:eastAsia="Microsoft YaHei"');
expect(footerXml).toContain(" PAGE \\* MERGEFORMAT ");
expect(footerXml).toContain(" NUMPAGES \\* MERGEFORMAT ");
expect(validateDynamicReferenceDocx(first.content)).toMatchObject({
partCount: first.partCount,
headerCount: 0,
footerCount: 1
});
});
it("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
const exportConfig: ExportConfig = {
...defaultExportConfig,
pageDecorationsMode: "custom",
paper: {
...defaultExportConfig.paper,
orientation: "landscape",
marginMode: "custom",
margins: {
top: "20mm",
right: "25mm",
bottom: "22mm",
left: "30mm"
}
},
header: {
...defaultExportConfig.header,
enabled: true,
showDivider: true,
left: {
enabled: true,
content: "${title} / ${filename}"
}
},
footer: {
...defaultExportConfig.footer,
alignment: "outer",
format: "official-page",
startFrom: 5,
showOnFirstPage: false
}
};
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(exportConfig)
);
const entries = unzipSync(result.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const settingsXml = decoder.decode(entries["word/settings.xml"]);
const headerXml = decoder.decode(entries["word/header1.xml"]);
const relationships = decoder.decode(
entries["word/_rels/document.xml.rels"]
);
expect(documentXml).toContain('w:orient="landscape"');
expect(documentXml).toContain('w:w="16838"');
expect(documentXml).toContain('w:left="1701"');
expect(documentXml).toContain('w:start="5"');
expect(documentXml).toContain("<w:titlePg");
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
expect(settingsXml).toContain("<w:evenAndOddHeaders");
expect(headerXml).toContain("年度 &lt;报告&gt;");
expect(headerXml).toContain("报告 &amp; 计划.md");
expect(relationships.match(/relationships\/header/gu)).toHaveLength(
3
);
expect(relationships.match(/relationships\/footer/gu)).toHaveLength(
3
);
expect(entries["word/footer3.xml"]).toBeDefined();
expect(validateDynamicReferenceDocx(result.content)).toMatchObject({
headerCount: 3,
footerCount: 3
});
});
it("拒绝正文中断裂或类型错误的页眉页脚关系", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(defaultExportConfig)
);
const entries = new Map(
Object.entries(unzipSync(result.content))
);
const relationships = decoder
.decode(entries.get("word/_rels/document.xml.rels")!)
.replace(
"relationships/footer",
"relationships/header"
);
entries.set(
"word/_rels/document.xml.rels",
encoder.encode(relationships)
);
const damaged = writeReferenceDocxPackage(entries);
expect(() => validateDynamicReferenceDocx(damaged)).toThrow(
/footerReference /u
);
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { zipSync } from "fflate";
import {
readReferenceDocxPackage,
requiredReferenceDocxParts
} from "../src/index.js";
function createReferencePackage(
overrides: Record<string, Uint8Array> = {}
) {
const entries = Object.fromEntries(
requiredReferenceDocxParts.map((name) => [
name,
new TextEncoder().encode(`<part name="${name}"/>`)
])
);
return zipSync({ ...entries, ...overrides });
}
describe("reference.docx 包", () => {
it("预检中央目录并返回稳定指纹", () => {
const content = createReferencePackage();
const first = readReferenceDocxPackage(content);
const second = readReferenceDocxPackage(content);
expect(first.entries.size).toBe(requiredReferenceDocxParts.length);
expect(first.entries.has("word/styles.xml")).toBe(true);
expect(first.fingerprint).toMatch(/^[0-9a-f]{64}$/u);
expect(second.fingerprint).toBe(first.fingerprint);
});
it("拒绝路径穿越和缺少必要部件", () => {
expect(() =>
readReferenceDocxPackage(
createReferencePackage({
"../outside.xml": new Uint8Array([1])
})
)
).toThrow("不安全路径");
const incomplete = zipSync({
"[Content_Types].xml": new Uint8Array([1])
});
expect(() => readReferenceDocxPackage(incomplete)).toThrow(
"缺少必要部件"
);
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { ThemeManifest } from "@md-to-pdf/core";
import {
inferDocxStylePreset,
resolveDocxThemeStyle
} from "../src/index.js";
function manifest(
overrides: Partial<ThemeManifest> = {}
): ThemeManifest {
return {
manifestVersion: 1,
id: "test-theme",
name: "测试主题",
version: "1.0.0",
description: "测试",
author: "测试",
license: "内部许可",
entry: "theme.css",
domPreset: "generic",
defaultFontSize: "16px",
supportedFeatures: [],
category: "general",
compatibleProfiles: [],
bundled: true,
...overrides
};
}
describe("DOCX 样式预设", () => {
it("按主题分类提供兼容回退", () => {
expect(inferDocxStylePreset(manifest())).toBe("general");
expect(
inferDocxStylePreset(manifest({ category: "red-letter" }))
).toBe("official");
expect(
resolveDocxThemeStyle(
manifest({ category: "formal" })
).body.firstLineIndentChars
).toBe(2);
});
it("将主题覆盖合并到完整预设", () => {
const resolved = resolveDocxThemeStyle(
manifest({
docxStyle: {
preset: "tender",
body: {
sizePt: 13,
color: "#112233"
},
headings: {
color: "#334455"
}
}
})
);
expect(resolved.body).toMatchObject({
sizePt: 13,
color: "#112233",
lineSpacing: 1.5
});
expect(resolved.headings.color).toBe("#334455");
expect(resolved.code.fonts.latin).toBe("Consolas");
});
});
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": [
"src"
]
}