feat: 实现动态 DOCX 模板与样式映射
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 公共导出入口
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 组成。
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("年度 <报告>");
|
||||
expect(headerXml).toContain("报告 & 计划.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");
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user