feat: 将主题令牌接入 DOCX 动态模板
This commit is contained in:
@@ -3,12 +3,22 @@ import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createApplicationService } from "@md-to-pdf/application";
|
||||
import {
|
||||
DOCX_PANDOC_VERSION,
|
||||
defaultExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
DOCX_STYLE_SLOT_NAMES,
|
||||
createDocxThemeStyleFingerprint,
|
||||
normalizeDocxThemeMappingConfig,
|
||||
resolveDocxThemeTokens
|
||||
} from "@md-to-pdf/docx-theme-engine";
|
||||
import {
|
||||
PandocRuntime,
|
||||
createDynamicReferenceDocx,
|
||||
readReferenceDocxPackage,
|
||||
resolveTokenFonts
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import { chromium } from "playwright";
|
||||
import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js";
|
||||
|
||||
@@ -19,6 +29,10 @@ const outputDirectory = path.join(
|
||||
"output",
|
||||
"docx-theme-styles"
|
||||
);
|
||||
const referenceDirectory = path.join(
|
||||
outputDirectory,
|
||||
"references"
|
||||
);
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
@@ -26,6 +40,81 @@ function assert(condition, message) {
|
||||
}
|
||||
}
|
||||
|
||||
function styleBlock(stylesXml, styleId) {
|
||||
const escaped = styleId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
return stylesXml.match(
|
||||
new RegExp(
|
||||
`<w:style[^>]*w:styleId="${escaped}"[^>]*>[\\s\\S]*?<\\/w:style>`,
|
||||
"u"
|
||||
)
|
||||
)?.[0];
|
||||
}
|
||||
|
||||
function inspectReference(reference, tokens) {
|
||||
const entries = readReferenceDocxPackage(reference.content).entries;
|
||||
const stylesXml = new TextDecoder().decode(
|
||||
entries.get("word/styles.xml")
|
||||
);
|
||||
const fontTableXml = new TextDecoder().decode(
|
||||
entries.get("word/fontTable.xml")
|
||||
);
|
||||
const normal = styleBlock(stylesXml, "Normal");
|
||||
const paragraph = tokens.slots.find(
|
||||
(slot) => slot.slot === "paragraph"
|
||||
)?.style;
|
||||
assert(normal, `主题 ${tokens.themeId} 缺少 Normal 样式`);
|
||||
const fonts = resolveTokenFonts(paragraph, {
|
||||
latin: "Arial",
|
||||
eastAsia: "SimSun"
|
||||
});
|
||||
if (paragraph?.fontCandidates.length) {
|
||||
assert(
|
||||
normal.includes(`w:ascii="${fonts.latin}"`),
|
||||
`主题 ${tokens.themeId} 的 Latin 正文字体未写入模板`
|
||||
);
|
||||
assert(
|
||||
normal.includes(`w:eastAsia="${fonts.eastAsia}"`),
|
||||
`主题 ${tokens.themeId} 的东亚正文字体未写入模板`
|
||||
);
|
||||
assert(
|
||||
fontTableXml.includes(`w:name="${fonts.eastAsia}"`),
|
||||
`主题 ${tokens.themeId} 的东亚字体未写入字体表`
|
||||
);
|
||||
}
|
||||
if (paragraph?.fontSizePt !== undefined) {
|
||||
assert(
|
||||
normal.includes(
|
||||
`w:sz w:val="${Math.round(paragraph.fontSizePt * 2)}"`
|
||||
),
|
||||
`主题 ${tokens.themeId} 的正文字号未写入模板`
|
||||
);
|
||||
}
|
||||
for (const styleId of [
|
||||
"Heading1",
|
||||
"SourceCode",
|
||||
"Table",
|
||||
"MdOfficialTitle",
|
||||
"MdBriefingTitle",
|
||||
"MdProjectReportTitle",
|
||||
"MdTenderTitle"
|
||||
]) {
|
||||
assert(
|
||||
stylesXml.includes(`w:styleId="${styleId}"`),
|
||||
`主题 ${tokens.themeId} 缺少样式 ${styleId}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
bytes: reference.content.byteLength,
|
||||
templateFingerprint: reference.templateFingerprint,
|
||||
cacheKey: reference.cacheKey,
|
||||
stylePreset: reference.stylePreset,
|
||||
partCount: reference.partCount,
|
||||
paragraphFonts: fonts,
|
||||
customStyleCount:
|
||||
stylesXml.match(/w:styleId="Md[A-Za-z]+"/gu)?.length ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const application = createApplicationService({
|
||||
bundledRoot: path.join(repositoryDirectory, "themes"),
|
||||
localRoot: path.join(repositoryDirectory, ".local", "themes")
|
||||
@@ -88,6 +177,20 @@ if (!address || typeof address === "string") {
|
||||
}
|
||||
const baseUrl = `http://127.0.0.1:${address.port}/`;
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const runtime = new PandocRuntime(
|
||||
process.env.DOCX_PANDOC_PATH?.trim()
|
||||
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
|
||||
: {}
|
||||
);
|
||||
const capability = await runtime.probe();
|
||||
if (capability.capability.status !== "available") {
|
||||
throw new Error(capability.capability.message);
|
||||
}
|
||||
assert(
|
||||
capability.capability.detectedVersion === DOCX_PANDOC_VERSION,
|
||||
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${capability.capability.detectedVersion}`
|
||||
);
|
||||
const baseline = await runtime.getDefaultReferenceDocx();
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
@@ -104,10 +207,20 @@ try {
|
||||
);
|
||||
const snapshots = [];
|
||||
const tokenSets = [];
|
||||
const references = [];
|
||||
for (const theme of bundledThemes) {
|
||||
console.error(`[DOCX theme styles] capturing ${theme.id}`);
|
||||
const themeCss = await application.getThemeCss(theme.id);
|
||||
assert(themeCss !== undefined, `主题 ${theme.id} 缺少 CSS`);
|
||||
const prepared = await application.prepareDocxExport({
|
||||
markdown: `# ${theme.name}`,
|
||||
fileName: `${theme.id}.md`,
|
||||
language: "zh-CN",
|
||||
exportConfig: {
|
||||
...defaultExportConfig,
|
||||
themeId: theme.id
|
||||
}
|
||||
});
|
||||
const themeCss = prepared.theme.css;
|
||||
const manifest = prepared.theme.manifest;
|
||||
const themeFingerprint =
|
||||
await createDocxThemeStyleFingerprint(theme.id, themeCss);
|
||||
const snapshot =
|
||||
@@ -129,12 +242,27 @@ try {
|
||||
.join("、")}`
|
||||
);
|
||||
snapshots.push(snapshot);
|
||||
tokenSets.push(
|
||||
resolveDocxThemeTokens({
|
||||
snapshot,
|
||||
config: normalizeDocxThemeMappingConfig(theme)
|
||||
})
|
||||
const tokens = resolveDocxThemeTokens({
|
||||
snapshot,
|
||||
config: normalizeDocxThemeMappingConfig(manifest)
|
||||
});
|
||||
tokenSets.push(tokens);
|
||||
const reference = createDynamicReferenceDocx(baseline, {
|
||||
exportConfig: prepared.request.exportConfig,
|
||||
theme: manifest,
|
||||
fileName: prepared.request.fileName,
|
||||
metadata: prepared.document.metadata,
|
||||
themeTokens: tokens
|
||||
});
|
||||
fs.mkdirSync(referenceDirectory, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(referenceDirectory, `${theme.id}.docx`),
|
||||
reference.content
|
||||
);
|
||||
references.push({
|
||||
themeId: theme.id,
|
||||
...inspectReference(reference, tokens)
|
||||
});
|
||||
}
|
||||
assert(
|
||||
snapshots.length === 14,
|
||||
@@ -172,7 +300,9 @@ try {
|
||||
themeCount: snapshots.length,
|
||||
slotCount: DOCX_STYLE_SLOT_NAMES.length,
|
||||
snapshots,
|
||||
tokenSets
|
||||
tokenSets,
|
||||
pandocVersion: capability.capability.detectedVersion,
|
||||
references
|
||||
},
|
||||
null,
|
||||
2
|
||||
@@ -190,7 +320,9 @@ try {
|
||||
diagnostics: tokenSets.reduce(
|
||||
(count, tokens) => count + tokens.diagnostics.length,
|
||||
0
|
||||
)
|
||||
),
|
||||
pandocVersion: capability.capability.detectedVersion,
|
||||
references: references.length
|
||||
})
|
||||
);
|
||||
await context.close();
|
||||
|
||||
Reference in New Issue
Block a user