feat: 将主题令牌接入 DOCX 动态模板
This commit is contained in:
@@ -3,6 +3,7 @@ import http from "node:http";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createApplicationService } from "@md-to-pdf/application";
|
import { createApplicationService } from "@md-to-pdf/application";
|
||||||
|
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
DOCX_STYLE_SLOT_NAMES,
|
DOCX_STYLE_SLOT_NAMES,
|
||||||
createDocxThemeStyleCaptureScript,
|
createDocxThemeStyleCaptureScript,
|
||||||
@@ -120,8 +121,17 @@ try {
|
|||||||
const tokenSets = [];
|
const tokenSets = [];
|
||||||
for (const theme of bundledThemes) {
|
for (const theme of bundledThemes) {
|
||||||
console.error(`[Electron DOCX theme styles] capturing ${theme.id}`);
|
console.error(`[Electron DOCX theme styles] capturing ${theme.id}`);
|
||||||
const themeCss = await application.getThemeCss(theme.id);
|
const prepared = await application.prepareDocxExport({
|
||||||
assert(themeCss !== undefined, `主题 ${theme.id} 缺少 CSS`);
|
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 =
|
const themeFingerprint =
|
||||||
await createDocxThemeStyleFingerprint(theme.id, themeCss);
|
await createDocxThemeStyleFingerprint(theme.id, themeCss);
|
||||||
const request = {
|
const request = {
|
||||||
@@ -150,7 +160,7 @@ try {
|
|||||||
tokenSets.push(
|
tokenSets.push(
|
||||||
resolveDocxThemeTokens({
|
resolveDocxThemeTokens({
|
||||||
snapshot,
|
snapshot,
|
||||||
config: normalizeDocxThemeMappingConfig(theme)
|
config: normalizeDocxThemeMappingConfig(manifest)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
DocxExportService,
|
DocxExportService,
|
||||||
|
DocxThemeTokenService,
|
||||||
MAXIMUM_MARKDOWN_LENGTH,
|
MAXIMUM_MARKDOWN_LENGTH,
|
||||||
readDocxExportRuntimeLimits,
|
readDocxExportRuntimeLimits,
|
||||||
type ApplicationService
|
type ApplicationService
|
||||||
@@ -159,6 +160,9 @@ export class DesktopApplicationController {
|
|||||||
const pandocRuntime = new PandocRuntime({
|
const pandocRuntime = new PandocRuntime({
|
||||||
desktopResourcesPath: options.desktopResourcesPath
|
desktopResourcesPath: options.desktopResourcesPath
|
||||||
});
|
});
|
||||||
|
this.#docxMediaEngine = new ElectronDocxMediaEngine({
|
||||||
|
renderUrl: options.docxRenderUrl
|
||||||
|
});
|
||||||
this.#docxExportService = new DocxExportService({
|
this.#docxExportService = new DocxExportService({
|
||||||
application: this.#applicationService,
|
application: this.#applicationService,
|
||||||
runtime: pandocRuntime,
|
runtime: pandocRuntime,
|
||||||
@@ -169,11 +173,11 @@ export class DesktopApplicationController {
|
|||||||
import.meta.url
|
import.meta.url
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
themeTokens: new DocxThemeTokenService(
|
||||||
|
this.#docxMediaEngine
|
||||||
|
),
|
||||||
limits: readDocxExportRuntimeLimits()
|
limits: readDocxExportRuntimeLimits()
|
||||||
});
|
});
|
||||||
this.#docxMediaEngine = new ElectronDocxMediaEngine({
|
|
||||||
renderUrl: options.docxRenderUrl
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface ElectronDocxMediaEngineOptions {
|
|||||||
export class ElectronDocxMediaEngine
|
export class ElectronDocxMediaEngine
|
||||||
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
|
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
|
||||||
{
|
{
|
||||||
|
readonly themeStyleBaseUrl: string;
|
||||||
private readonly renderUrl: string;
|
private readonly renderUrl: string;
|
||||||
private windowPromise: Promise<BrowserWindow> | undefined;
|
private windowPromise: Promise<BrowserWindow> | undefined;
|
||||||
private queue = Promise.resolve();
|
private queue = Promise.resolve();
|
||||||
@@ -28,6 +29,7 @@ export class ElectronDocxMediaEngine
|
|||||||
|
|
||||||
constructor(options: ElectronDocxMediaEngineOptions) {
|
constructor(options: ElectronDocxMediaEngineOptions) {
|
||||||
this.renderUrl = options.renderUrl;
|
this.renderUrl = options.renderUrl;
|
||||||
|
this.themeStyleBaseUrl = this.renderUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
capture(
|
capture(
|
||||||
|
|||||||
@@ -3,12 +3,22 @@ import http from "node:http";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createApplicationService } from "@md-to-pdf/application";
|
import { createApplicationService } from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
DOCX_PANDOC_VERSION,
|
||||||
|
defaultExportConfig
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
DOCX_STYLE_SLOT_NAMES,
|
DOCX_STYLE_SLOT_NAMES,
|
||||||
createDocxThemeStyleFingerprint,
|
createDocxThemeStyleFingerprint,
|
||||||
normalizeDocxThemeMappingConfig,
|
normalizeDocxThemeMappingConfig,
|
||||||
resolveDocxThemeTokens
|
resolveDocxThemeTokens
|
||||||
} from "@md-to-pdf/docx-theme-engine";
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import {
|
||||||
|
PandocRuntime,
|
||||||
|
createDynamicReferenceDocx,
|
||||||
|
readReferenceDocxPackage,
|
||||||
|
resolveTokenFonts
|
||||||
|
} from "@md-to-pdf/docx-engine";
|
||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js";
|
import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js";
|
||||||
|
|
||||||
@@ -19,6 +29,10 @@ const outputDirectory = path.join(
|
|||||||
"output",
|
"output",
|
||||||
"docx-theme-styles"
|
"docx-theme-styles"
|
||||||
);
|
);
|
||||||
|
const referenceDirectory = path.join(
|
||||||
|
outputDirectory,
|
||||||
|
"references"
|
||||||
|
);
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (!condition) {
|
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({
|
const application = createApplicationService({
|
||||||
bundledRoot: path.join(repositoryDirectory, "themes"),
|
bundledRoot: path.join(repositoryDirectory, "themes"),
|
||||||
localRoot: path.join(repositoryDirectory, ".local", "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 baseUrl = `http://127.0.0.1:${address.port}/`;
|
||||||
const browser = await chromium.launch({ headless: true });
|
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 {
|
try {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
@@ -104,10 +207,20 @@ try {
|
|||||||
);
|
);
|
||||||
const snapshots = [];
|
const snapshots = [];
|
||||||
const tokenSets = [];
|
const tokenSets = [];
|
||||||
|
const references = [];
|
||||||
for (const theme of bundledThemes) {
|
for (const theme of bundledThemes) {
|
||||||
console.error(`[DOCX theme styles] capturing ${theme.id}`);
|
console.error(`[DOCX theme styles] capturing ${theme.id}`);
|
||||||
const themeCss = await application.getThemeCss(theme.id);
|
const prepared = await application.prepareDocxExport({
|
||||||
assert(themeCss !== undefined, `主题 ${theme.id} 缺少 CSS`);
|
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 =
|
const themeFingerprint =
|
||||||
await createDocxThemeStyleFingerprint(theme.id, themeCss);
|
await createDocxThemeStyleFingerprint(theme.id, themeCss);
|
||||||
const snapshot =
|
const snapshot =
|
||||||
@@ -129,12 +242,27 @@ try {
|
|||||||
.join("、")}`
|
.join("、")}`
|
||||||
);
|
);
|
||||||
snapshots.push(snapshot);
|
snapshots.push(snapshot);
|
||||||
tokenSets.push(
|
const tokens = resolveDocxThemeTokens({
|
||||||
resolveDocxThemeTokens({
|
snapshot,
|
||||||
snapshot,
|
config: normalizeDocxThemeMappingConfig(manifest)
|
||||||
config: normalizeDocxThemeMappingConfig(theme)
|
});
|
||||||
})
|
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(
|
assert(
|
||||||
snapshots.length === 14,
|
snapshots.length === 14,
|
||||||
@@ -172,7 +300,9 @@ try {
|
|||||||
themeCount: snapshots.length,
|
themeCount: snapshots.length,
|
||||||
slotCount: DOCX_STYLE_SLOT_NAMES.length,
|
slotCount: DOCX_STYLE_SLOT_NAMES.length,
|
||||||
snapshots,
|
snapshots,
|
||||||
tokenSets
|
tokenSets,
|
||||||
|
pandocVersion: capability.capability.detectedVersion,
|
||||||
|
references
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
@@ -190,7 +320,9 @@ try {
|
|||||||
diagnostics: tokenSets.reduce(
|
diagnostics: tokenSets.reduce(
|
||||||
(count, tokens) => count + tokens.diagnostics.length,
|
(count, tokens) => count + tokens.diagnostics.length,
|
||||||
0
|
0
|
||||||
)
|
),
|
||||||
|
pandocVersion: capability.capability.detectedVersion,
|
||||||
|
references: references.length
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await context.close();
|
await context.close();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ApplicationRequestError,
|
ApplicationRequestError,
|
||||||
DocxExportService,
|
DocxExportService,
|
||||||
DocxExportServiceError,
|
DocxExportServiceError,
|
||||||
|
DocxThemeTokenService,
|
||||||
createApplicationService,
|
createApplicationService,
|
||||||
readDocxExportRuntimeLimits,
|
readDocxExportRuntimeLimits,
|
||||||
type ApplicationService
|
type ApplicationService
|
||||||
@@ -133,6 +134,7 @@ function createDocxServerTiming(
|
|||||||
`queue;dur=${milliseconds(timings.queueMs)}`,
|
`queue;dur=${milliseconds(timings.queueMs)}`,
|
||||||
`runtime-probe;dur=${milliseconds(timings.probeMs)}`,
|
`runtime-probe;dur=${milliseconds(timings.probeMs)}`,
|
||||||
`prepare;dur=${milliseconds(timings.prepareMs)}`,
|
`prepare;dur=${milliseconds(timings.prepareMs)}`,
|
||||||
|
`theme-style;dur=${milliseconds(timings.themeStyleMs)}`,
|
||||||
`media;dur=${milliseconds(timings.mediaMs)}`,
|
`media;dur=${milliseconds(timings.mediaMs)}`,
|
||||||
`reference;dur=${milliseconds(timings.referenceMs)}`,
|
`reference;dur=${milliseconds(timings.referenceMs)}`,
|
||||||
`pandoc;dur=${milliseconds(timings.pandocMs)}`,
|
`pandoc;dur=${milliseconds(timings.pandocMs)}`,
|
||||||
@@ -166,6 +168,8 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
const pandocRuntime = options.docxExportService
|
const pandocRuntime = options.docxExportService
|
||||||
? undefined
|
? undefined
|
||||||
: new PandocRuntime();
|
: new PandocRuntime();
|
||||||
|
const docxMediaAdapter =
|
||||||
|
options.docxMediaAdapter ?? createDocxMediaEngine();
|
||||||
const docxExportService =
|
const docxExportService =
|
||||||
options.docxExportService ??
|
options.docxExportService ??
|
||||||
new DocxExportService({
|
new DocxExportService({
|
||||||
@@ -174,10 +178,11 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
converter: new PandocDocxConverter({
|
converter: new PandocDocxConverter({
|
||||||
runtime: pandocRuntime!
|
runtime: pandocRuntime!
|
||||||
}),
|
}),
|
||||||
|
themeTokens: new DocxThemeTokenService(
|
||||||
|
docxMediaAdapter
|
||||||
|
),
|
||||||
limits: readDocxExportRuntimeLimits()
|
limits: readDocxExportRuntimeLimits()
|
||||||
});
|
});
|
||||||
const docxMediaAdapter =
|
|
||||||
options.docxMediaAdapter ?? createDocxMediaEngine();
|
|
||||||
|
|
||||||
if (options.prewarmPdfBrowser) {
|
if (options.prewarmPdfBrowser) {
|
||||||
app.addHook("onReady", async () => {
|
app.addHook("onReady", async () => {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ async function closeContext(context: BrowserContext) {
|
|||||||
export class PlaywrightDocxMediaEngine
|
export class PlaywrightDocxMediaEngine
|
||||||
implements ServerDocxMediaCaptureAdapter
|
implements ServerDocxMediaCaptureAdapter
|
||||||
{
|
{
|
||||||
|
readonly themeStyleBaseUrl: string;
|
||||||
private readonly renderOrigin: string;
|
private readonly renderOrigin: string;
|
||||||
private readonly renderUrl: string;
|
private readonly renderUrl: string;
|
||||||
private readonly launchBrowser: () => Promise<Browser>;
|
private readonly launchBrowser: () => Promise<Browser>;
|
||||||
@@ -54,6 +55,7 @@ export class PlaywrightDocxMediaEngine
|
|||||||
"/preview-frame.html?target=continuous",
|
"/preview-frame.html?target=continuous",
|
||||||
this.renderOrigin
|
this.renderOrigin
|
||||||
).href;
|
).href;
|
||||||
|
this.themeStyleBaseUrl = this.renderUrl;
|
||||||
this.launchBrowser =
|
this.launchBrowser =
|
||||||
options.launchBrowser ??
|
options.launchBrowser ??
|
||||||
(() =>
|
(() =>
|
||||||
|
|||||||
@@ -42,11 +42,12 @@ function generatedDocx(): DocxExportResult {
|
|||||||
queueMs: 1,
|
queueMs: 1,
|
||||||
probeMs: 2,
|
probeMs: 2,
|
||||||
prepareMs: 3,
|
prepareMs: 3,
|
||||||
mediaMs: 4,
|
themeStyleMs: 4,
|
||||||
referenceMs: 5,
|
mediaMs: 5,
|
||||||
pandocMs: 6,
|
referenceMs: 6,
|
||||||
validationMs: 7,
|
pandocMs: 7,
|
||||||
totalMs: 28
|
validationMs: 8,
|
||||||
|
totalMs: 36
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -72,6 +73,8 @@ function createDocxService(
|
|||||||
|
|
||||||
function createMediaAdapter() {
|
function createMediaAdapter() {
|
||||||
return {
|
return {
|
||||||
|
themeStyleBaseUrl:
|
||||||
|
"http://localhost:5173/preview-frame.html",
|
||||||
capture: vi.fn<
|
capture: vi.fn<
|
||||||
DocxMediaCaptureAdapter["capture"]
|
DocxMediaCaptureAdapter["capture"]
|
||||||
>(async () => ({
|
>(async () => ({
|
||||||
@@ -82,6 +85,9 @@ function createMediaAdapter() {
|
|||||||
},
|
},
|
||||||
captures: []
|
captures: []
|
||||||
})),
|
})),
|
||||||
|
captureThemeStyle: vi.fn(async () => {
|
||||||
|
throw new Error("此测试使用注入的 DOCX 导出服务");
|
||||||
|
}),
|
||||||
close: vi.fn(async () => undefined)
|
close: vi.fn(async () => undefined)
|
||||||
} satisfies ServerDocxMediaCaptureAdapter;
|
} satisfies ServerDocxMediaCaptureAdapter;
|
||||||
}
|
}
|
||||||
@@ -173,7 +179,7 @@ describe("DOCX HTTP API", () => {
|
|||||||
"runtime-probe;dur=2.0"
|
"runtime-probe;dur=2.0"
|
||||||
);
|
);
|
||||||
expect(response.headers["server-timing"]).toContain(
|
expect(response.headers["server-timing"]).toContain(
|
||||||
"pandoc;dur=6.0"
|
"pandoc;dur=7.0"
|
||||||
);
|
);
|
||||||
expect(response.rawPayload).toEqual(
|
expect(response.rawPayload).toEqual(
|
||||||
Buffer.from([80, 75, 3, 4])
|
Buffer.from([80, 75, 3, 4])
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ describe("DOCX 导出客户端", () => {
|
|||||||
queueMs: 0,
|
queueMs: 0,
|
||||||
probeMs: 0,
|
probeMs: 0,
|
||||||
prepareMs: 0,
|
prepareMs: 0,
|
||||||
|
themeStyleMs: 0,
|
||||||
mediaMs: 0,
|
mediaMs: 0,
|
||||||
referenceMs: 0,
|
referenceMs: 0,
|
||||||
pandocMs: 0,
|
pandocMs: 0,
|
||||||
|
|||||||
+16
-1
@@ -193,6 +193,20 @@ Front Matter 和正文首个 H1 构建主题无关的语义树,再由 Renderer
|
|||||||
`reference.docx` 或最终 DOCX;下一步阶段 12B 将把已归一化的主题令牌
|
`reference.docx` 或最终 DOCX;下一步阶段 12B 将把已归一化的主题令牌
|
||||||
接入动态 Word 模板。
|
接入动态 Word 模板。
|
||||||
|
|
||||||
|
`v0.6.0` 阶段 12B 已将主题令牌接入真实 DOCX 生产链。应用层现在复用
|
||||||
|
Server Playwright 与 Desktop Electron 适配器,按主题 CSS SHA-256
|
||||||
|
指纹缓存计算样式快照,并与媒体捕获并行生成 56 槽位令牌;`explicit`
|
||||||
|
模式直接使用清单覆盖与预设降级,无需启动浏览器。完整令牌进入 Pandoc
|
||||||
|
转换输入和动态模板缓存键,主题变化不会复用旧模板;诊断进入导出警告,
|
||||||
|
并新增 `themeStyleMs` 耗时。DOCX 引擎通过固定槽位映射写入标准 Markdown
|
||||||
|
样式和 31 个 `Md*` 结构样式,同时覆盖字体、字号、颜色、段落、代码、
|
||||||
|
引用、表格、图注、链接、字体表与 Office 主题字体,全程不按主题 ID
|
||||||
|
分支。Playwright Chromium 151 与 Electron Chromium 150 对 14 套主题
|
||||||
|
各采集 784 个槽位,完整令牌差异为 0;固定 Pandoc 3.9.0.2 的默认模板
|
||||||
|
成功生成并验证 14 份动态 `reference.docx`,每份均含 31 个结构样式。
|
||||||
|
当前结构样式尚未绑定到 Pandoc AST,封面、版头、分节与标题去重留给
|
||||||
|
阶段 12C。
|
||||||
|
|
||||||
## 2. 已完成
|
## 2. 已完成
|
||||||
|
|
||||||
### 2.1 项目骨架
|
### 2.1 项目骨架
|
||||||
@@ -1124,7 +1138,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
近似诊断已通过 14 主题双引擎矩阵;下一步由统一语义文档模型消费令牌;
|
近似诊断已通过 14 主题双引擎矩阵;下一步由统一语义文档模型消费令牌;
|
||||||
- 阶段 12A:已建立独立统一语义文档模型,Renderer 与 DOCX 准备结果
|
- 阶段 12A:已建立独立统一语义文档模型,Renderer 与 DOCX 准备结果
|
||||||
共用同一语义树,现有 HTML DOM 保持兼容;
|
共用同一语义树,现有 HTML DOM 保持兼容;
|
||||||
- 阶段 12B:将主题令牌接入动态 `reference.docx`;
|
- 阶段 12B:已将跨端主题令牌接入动态 `reference.docx`,14 主题双引擎
|
||||||
|
令牌和真实 Pandoc 模板矩阵通过;
|
||||||
- 阶段 12C:完成 Pandoc 结构映射、封面分节、标题去重、表格宽度和
|
- 阶段 12C:完成 Pandoc 结构映射、封面分节、标题去重、表格宽度和
|
||||||
分页控制;
|
分页控制;
|
||||||
- 阶段 12D:完成 14 套主题的真实 Word/WPS 视觉与结构回归;
|
- 阶段 12D:完成 14 套主题的真实 Word/WPS 视觉与结构回归;
|
||||||
|
|||||||
Generated
+2
@@ -9916,6 +9916,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
"@md-to-pdf/docx-engine": "0.1.0",
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||||
"@md-to-pdf/renderer": "0.1.0"
|
"@md-to-pdf/renderer": "0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -9938,6 +9939,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||||
"@xmldom/xmldom": "0.9.10",
|
"@xmldom/xmldom": "0.9.10",
|
||||||
"fflate": "0.8.3"
|
"fflate": "0.8.3"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@
|
|||||||
"verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine",
|
"verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine",
|
||||||
"verify:docx-matrix": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine",
|
"verify:docx-matrix": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine",
|
||||||
"verify:docx-themes": "npm run build:web-runtime && npm run verify:themes -w @md-to-pdf/docx-engine",
|
"verify:docx-themes": "npm run build:web-runtime && npm run verify:themes -w @md-to-pdf/docx-engine",
|
||||||
"verify:docx-theme-styles": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop",
|
"verify:docx-theme-styles": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop",
|
||||||
"verify:docx-acceptance": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop && npm run verify:pandoc -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine && npm run verify:docx-http -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop -- desktop-docx-save.test.ts",
|
"verify:docx-acceptance": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop && npm run verify:pandoc -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine && npm run verify:docx-http -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop -- desktop-docx-save.test.ts",
|
||||||
"verify:docx-http": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-http -w @md-to-pdf/server",
|
"verify:docx-http": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-http -w @md-to-pdf/server",
|
||||||
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ src/
|
|||||||
application-service.ts 渲染、主题和资源用例入口
|
application-service.ts 渲染、主题和资源用例入口
|
||||||
docx-export-service.ts DOCX 并发、超时、错误和完整导出编排
|
docx-export-service.ts DOCX 并发、超时、错误和完整导出编排
|
||||||
docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装
|
docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装
|
||||||
|
docx-theme-token-service.ts 主题指纹、快照缓存和 Word 令牌准备
|
||||||
image-resources.ts 本地、Base64 与受限远程图片处理
|
image-resources.ts 本地、Base64 与受限远程图片处理
|
||||||
theme-registry.ts 内置及自定义主题扫描、校验与缓存
|
theme-registry.ts 内置及自定义主题扫描、校验与缓存
|
||||||
index.ts 公共导出入口
|
index.ts 公共导出入口
|
||||||
@@ -21,6 +22,7 @@ tests/
|
|||||||
bundled-themes.test.ts
|
bundled-themes.test.ts
|
||||||
docx-export-service.test.ts
|
docx-export-service.test.ts
|
||||||
docx-media-service.test.ts
|
docx-media-service.test.ts
|
||||||
|
docx-theme-token-service.test.ts
|
||||||
image-resources.test.ts
|
image-resources.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,10 +58,14 @@ Desktop 才会以 Markdown 所在目录为边界解析相对资源。
|
|||||||
大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png`
|
大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png`
|
||||||
清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。
|
清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。
|
||||||
|
|
||||||
`DocxExportService` 串联 capability 探测、请求准备、平台媒体捕获、
|
`DocxThemeTokenService` 复用 Server Playwright 或 Desktop Electron
|
||||||
Pandoc 转换和最终 OOXML 校验。默认并发为 1、队列为 4、总超时为 90 秒;
|
适配器采集主题计算样式,按主题 CSS 指纹缓存快照并生成 56 槽位令牌。
|
||||||
|
`explicit` 模式不启动浏览器,直接使用清单覆盖和预设降级。
|
||||||
|
|
||||||
|
`DocxExportService` 串联 capability 探测、请求准备、平台媒体捕获、主题
|
||||||
|
令牌准备、Pandoc 转换和最终 OOXML 校验。媒体与主题令牌并行准备。默认并发为 1、队列为 4、总超时为 90 秒;
|
||||||
可以通过 `DOCX_CONCURRENCY`、`DOCX_MAX_QUEUE` 和 `DOCX_TIMEOUT_MS`
|
可以通过 `DOCX_CONCURRENCY`、`DOCX_MAX_QUEUE` 和 `DOCX_TIMEOUT_MS`
|
||||||
配置。排队、探测、准备、媒体、模板、Pandoc、校验和总耗时使用共享协议
|
配置。排队、探测、准备、主题样式、媒体、模板、Pandoc、校验和总耗时使用共享协议
|
||||||
返回;关闭服务时会取消活动任务、拒绝排队任务并等待清理完成。
|
返回;关闭服务时会取消活动任务、拒绝排队任务并等待清理完成。
|
||||||
|
|
||||||
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
|
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
"@md-to-pdf/docx-engine": "0.1.0",
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||||
"@md-to-pdf/renderer": "0.1.0"
|
"@md-to-pdf/renderer": "0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import {
|
|||||||
prepareDocxMedia,
|
prepareDocxMedia,
|
||||||
type DocxMediaCaptureAdapter
|
type DocxMediaCaptureAdapter
|
||||||
} from "./docx-media-service.js";
|
} from "./docx-media-service.js";
|
||||||
|
import type {
|
||||||
|
DocxThemeTokenProvider,
|
||||||
|
PreparedDocxThemeTokens
|
||||||
|
} from "./docx-theme-token-service.js";
|
||||||
import type { ImageResolutionContext } from "./image-resources.js";
|
import type { ImageResolutionContext } from "./image-resources.js";
|
||||||
|
|
||||||
const DEFAULT_DOCX_CONCURRENCY = 1;
|
const DEFAULT_DOCX_CONCURRENCY = 1;
|
||||||
@@ -47,6 +51,7 @@ export interface DocxExportServiceOptions {
|
|||||||
application: Pick<ApplicationService, "prepareDocxExport">;
|
application: Pick<ApplicationService, "prepareDocxExport">;
|
||||||
runtime: DocxCapabilityProvider;
|
runtime: DocxCapabilityProvider;
|
||||||
converter: DocxConversionPort;
|
converter: DocxConversionPort;
|
||||||
|
themeTokens: DocxThemeTokenProvider;
|
||||||
limits?: Partial<DocxExportRuntimeLimits>;
|
limits?: Partial<DocxExportRuntimeLimits>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,24 +364,51 @@ export class DocxExportService {
|
|||||||
const prepareMs = performance.now() - prepareStarted;
|
const prepareMs = performance.now() - prepareStarted;
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
|
|
||||||
const mediaStarted = performance.now();
|
let themeStyleMs = 0;
|
||||||
const media = await prepareDocxMedia(
|
let mediaMs = 0;
|
||||||
prepared,
|
const [themeTokens, media] = await Promise.all([
|
||||||
options.mediaAdapter,
|
(async () => {
|
||||||
controller.signal
|
const started = performance.now();
|
||||||
);
|
try {
|
||||||
const mediaMs = performance.now() - mediaStarted;
|
return await this.options.themeTokens.prepare(
|
||||||
|
prepared,
|
||||||
|
controller.signal
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
themeStyleMs = performance.now() - started;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
(async () => {
|
||||||
|
const started = performance.now();
|
||||||
|
try {
|
||||||
|
return await prepareDocxMedia(
|
||||||
|
prepared,
|
||||||
|
options.mediaAdapter,
|
||||||
|
controller.signal
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
mediaMs = performance.now() - started;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
]);
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
|
|
||||||
const conversion = await this.options.converter.convert(
|
const conversion = await this.options.converter.convert(
|
||||||
this.createConversionInput(prepared, media),
|
this.createConversionInput(
|
||||||
|
prepared,
|
||||||
|
themeTokens,
|
||||||
|
media
|
||||||
|
),
|
||||||
controller.signal
|
controller.signal
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
docx: conversion.docx,
|
docx: conversion.docx,
|
||||||
fileName: createDocxFileName(prepared.request.fileName),
|
fileName: createDocxFileName(prepared.request.fileName),
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
warnings: media.warnings,
|
warnings: [
|
||||||
|
...media.warnings,
|
||||||
|
...themeTokens.warnings
|
||||||
|
],
|
||||||
echartsErrors: media.echartsErrors,
|
echartsErrors: media.echartsErrors,
|
||||||
mermaidErrors: media.mermaidErrors
|
mermaidErrors: media.mermaidErrors
|
||||||
},
|
},
|
||||||
@@ -384,6 +416,7 @@ export class DocxExportService {
|
|||||||
queueMs,
|
queueMs,
|
||||||
probeMs,
|
probeMs,
|
||||||
prepareMs,
|
prepareMs,
|
||||||
|
themeStyleMs,
|
||||||
mediaMs,
|
mediaMs,
|
||||||
referenceMs: conversion.timings.referenceMs,
|
referenceMs: conversion.timings.referenceMs,
|
||||||
pandocMs: conversion.timings.pandocMs,
|
pandocMs: conversion.timings.pandocMs,
|
||||||
@@ -446,6 +479,7 @@ export class DocxExportService {
|
|||||||
|
|
||||||
private createConversionInput(
|
private createConversionInput(
|
||||||
prepared: PreparedDocxExport,
|
prepared: PreparedDocxExport,
|
||||||
|
themeTokens: PreparedDocxThemeTokens,
|
||||||
media: Awaited<ReturnType<typeof prepareDocxMedia>>
|
media: Awaited<ReturnType<typeof prepareDocxMedia>>
|
||||||
): PandocDocxConversionInput {
|
): PandocDocxConversionInput {
|
||||||
return {
|
return {
|
||||||
@@ -454,6 +488,7 @@ export class DocxExportService {
|
|||||||
language: prepared.request.language,
|
language: prepared.request.language,
|
||||||
exportConfig: prepared.request.exportConfig,
|
exportConfig: prepared.request.exportConfig,
|
||||||
theme: prepared.theme.manifest,
|
theme: prepared.theme.manifest,
|
||||||
|
themeTokens: themeTokens.tokens,
|
||||||
metadata: prepared.document.metadata,
|
metadata: prepared.document.metadata,
|
||||||
media
|
media
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type {
|
||||||
|
DocxThemeTokenSet,
|
||||||
|
DocxThemeStyleCaptureAdapter
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import {
|
||||||
|
DOCX_STYLE_SLOTS,
|
||||||
|
DocxThemeStyleSnapshotCache,
|
||||||
|
createDocxThemeStyleFingerprint,
|
||||||
|
normalizeDocxThemeMappingConfig,
|
||||||
|
resolveDocxThemeTokens,
|
||||||
|
type DocxThemeStyleSnapshot
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import type { PreparedDocxExport } from "./application-service.js";
|
||||||
|
|
||||||
|
export interface PreparedDocxThemeTokens {
|
||||||
|
tokens: DocxThemeTokenSet;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocxThemeTokenProvider {
|
||||||
|
prepare(
|
||||||
|
prepared: PreparedDocxExport,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PreparedDocxThemeTokens>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExplicitSnapshot(
|
||||||
|
themeId: string,
|
||||||
|
themeFingerprint: string
|
||||||
|
): DocxThemeStyleSnapshot {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
themeId,
|
||||||
|
themeFingerprint,
|
||||||
|
viewport: {
|
||||||
|
widthPx: 794,
|
||||||
|
heightPx: 1123,
|
||||||
|
deviceScaleFactor: 1
|
||||||
|
},
|
||||||
|
rootFontSizePx: 16,
|
||||||
|
slots: DOCX_STYLE_SLOTS.map(({ name }) => ({
|
||||||
|
slot: name,
|
||||||
|
matched: false
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocxThemeTokenService
|
||||||
|
implements DocxThemeTokenProvider
|
||||||
|
{
|
||||||
|
private readonly snapshots: DocxThemeStyleSnapshotCache;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly adapter: DocxThemeStyleCaptureAdapter
|
||||||
|
) {
|
||||||
|
this.snapshots = new DocxThemeStyleSnapshotCache(adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepare(
|
||||||
|
prepared: PreparedDocxExport,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PreparedDocxThemeTokens> {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const config = normalizeDocxThemeMappingConfig(
|
||||||
|
prepared.theme.manifest
|
||||||
|
);
|
||||||
|
const themeFingerprint =
|
||||||
|
await createDocxThemeStyleFingerprint(
|
||||||
|
prepared.theme.manifest.id,
|
||||||
|
prepared.theme.css
|
||||||
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const snapshot =
|
||||||
|
config.mode === "explicit"
|
||||||
|
? createExplicitSnapshot(
|
||||||
|
prepared.theme.manifest.id,
|
||||||
|
themeFingerprint
|
||||||
|
)
|
||||||
|
: await this.snapshots.capture(
|
||||||
|
{
|
||||||
|
themeId: prepared.theme.manifest.id,
|
||||||
|
themeFingerprint,
|
||||||
|
themeCss: prepared.theme.css,
|
||||||
|
baseUrl: this.adapter.themeStyleBaseUrl
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
const tokens = resolveDocxThemeTokens({
|
||||||
|
snapshot,
|
||||||
|
config
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
tokens,
|
||||||
|
warnings: tokens.diagnostics
|
||||||
|
.filter(
|
||||||
|
(diagnostic) => diagnostic.severity !== "info"
|
||||||
|
)
|
||||||
|
.map((diagnostic) => diagnostic.message)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(themeId?: string) {
|
||||||
|
this.snapshots.invalidate(themeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,11 @@ export {
|
|||||||
type DocxMediaCaptureRequest,
|
type DocxMediaCaptureRequest,
|
||||||
type DocxMediaRenderDimensions
|
type DocxMediaRenderDimensions
|
||||||
} from "./docx-media-service.js";
|
} from "./docx-media-service.js";
|
||||||
|
export {
|
||||||
|
DocxThemeTokenService,
|
||||||
|
type DocxThemeTokenProvider,
|
||||||
|
type PreparedDocxThemeTokens
|
||||||
|
} from "./docx-theme-token-service.js";
|
||||||
export {
|
export {
|
||||||
DocxExportService,
|
DocxExportService,
|
||||||
DocxExportServiceError,
|
DocxExportServiceError,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
PandocDocxConversionResult,
|
PandocDocxConversionResult,
|
||||||
PandocRuntimeResolution
|
PandocRuntimeResolution
|
||||||
} from "@md-to-pdf/docx-engine";
|
} from "@md-to-pdf/docx-engine";
|
||||||
|
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
|
||||||
import {
|
import {
|
||||||
DocxExportService,
|
DocxExportService,
|
||||||
readDocxExportRuntimeLimits,
|
readDocxExportRuntimeLimits,
|
||||||
@@ -84,6 +85,16 @@ const availableCapability: DocxCapability = {
|
|||||||
detectedVersion: "3.9.0.2"
|
detectedVersion: "3.9.0.2"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const themeTokens: DocxThemeTokenSet = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
themeId: "test-theme",
|
||||||
|
themeFingerprint: "c".repeat(64),
|
||||||
|
mode: "auto-with-overrides",
|
||||||
|
basePreset: "technical",
|
||||||
|
slots: [],
|
||||||
|
diagnostics: []
|
||||||
|
};
|
||||||
|
|
||||||
function resolution(
|
function resolution(
|
||||||
capability: DocxCapability = availableCapability
|
capability: DocxCapability = availableCapability
|
||||||
): PandocRuntimeResolution {
|
): PandocRuntimeResolution {
|
||||||
@@ -151,13 +162,22 @@ function createService(options: {
|
|||||||
options.convert ??
|
options.convert ??
|
||||||
vi.fn().mockResolvedValue(conversionResult())
|
vi.fn().mockResolvedValue(conversionResult())
|
||||||
},
|
},
|
||||||
|
themeTokens: {
|
||||||
|
prepare: vi.fn().mockResolvedValue({
|
||||||
|
tokens: themeTokens,
|
||||||
|
warnings: []
|
||||||
|
})
|
||||||
|
},
|
||||||
limits: options.limits
|
limits: options.limits
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("DOCX 共享导出服务", () => {
|
describe("DOCX 共享导出服务", () => {
|
||||||
it("串联准备、媒体和转换并返回完整耗时", async () => {
|
it("串联准备、媒体和转换并返回完整耗时", async () => {
|
||||||
const service = createService({});
|
const convert = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(conversionResult());
|
||||||
|
const service = createService({ convert });
|
||||||
const result = await service.generate(
|
const result = await service.generate(
|
||||||
{ markdown: "# 测试" },
|
{ markdown: "# 测试" },
|
||||||
{ mediaAdapter: emptyAdapter }
|
{ mediaAdapter: emptyAdapter }
|
||||||
@@ -170,7 +190,14 @@ describe("DOCX 共享导出服务", () => {
|
|||||||
echartsErrors: [],
|
echartsErrors: [],
|
||||||
mermaidErrors: []
|
mermaidErrors: []
|
||||||
});
|
});
|
||||||
|
expect(convert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
themeTokens
|
||||||
|
}),
|
||||||
|
expect.any(AbortSignal)
|
||||||
|
);
|
||||||
expect(result.timings).toMatchObject({
|
expect(result.timings).toMatchObject({
|
||||||
|
themeStyleMs: expect.any(Number),
|
||||||
referenceMs: 3,
|
referenceMs: 3,
|
||||||
pandocMs: 4,
|
pandocMs: 4,
|
||||||
validationMs: 5
|
validationMs: 5
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
defaultExportConfig,
|
||||||
|
type ThemeManifest
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
DOCX_STYLE_SLOTS,
|
||||||
|
type DocxThemeStyleCaptureAdapter
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import {
|
||||||
|
DocxThemeTokenService,
|
||||||
|
type PreparedDocxExport
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
function manifest(
|
||||||
|
docxStyle: ThemeManifest["docxStyle"] = {
|
||||||
|
mode: "auto",
|
||||||
|
basePreset: "technical"
|
||||||
|
}
|
||||||
|
): 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,
|
||||||
|
bundled: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepared(
|
||||||
|
docxStyle?: ThemeManifest["docxStyle"]
|
||||||
|
): PreparedDocxExport {
|
||||||
|
return {
|
||||||
|
request: {
|
||||||
|
markdown: "# 测试",
|
||||||
|
fileName: "测试.md",
|
||||||
|
language: "zh-CN",
|
||||||
|
resources: [],
|
||||||
|
exportConfig: {
|
||||||
|
...defaultExportConfig,
|
||||||
|
themeId: "test-theme"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
rendererVersion: 1,
|
||||||
|
articleHtml:
|
||||||
|
'<article id="write"><h1>测试</h1></article>',
|
||||||
|
bodyHtml: "<h1>测试</h1>",
|
||||||
|
metadata: {
|
||||||
|
title: "测试",
|
||||||
|
author: "",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
semanticDocument: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
titlePolicy: {
|
||||||
|
metadataTitle: "suppress",
|
||||||
|
firstBodyHeading: "keep"
|
||||||
|
},
|
||||||
|
regions: []
|
||||||
|
},
|
||||||
|
features: [],
|
||||||
|
warnings: []
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
manifest: manifest(docxStyle),
|
||||||
|
source: "bundled",
|
||||||
|
css: "#write { font-family: Test; }"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapter(): DocxThemeStyleCaptureAdapter {
|
||||||
|
return {
|
||||||
|
themeStyleBaseUrl:
|
||||||
|
"http://localhost:5173/preview-frame.html",
|
||||||
|
captureThemeStyle: vi.fn(async (request) => ({
|
||||||
|
schemaVersion: 1,
|
||||||
|
themeId: request.themeId,
|
||||||
|
themeFingerprint: request.themeFingerprint,
|
||||||
|
viewport: {
|
||||||
|
widthPx: 794,
|
||||||
|
heightPx: 1123,
|
||||||
|
deviceScaleFactor: 1
|
||||||
|
},
|
||||||
|
rootFontSizePx: 16,
|
||||||
|
slots: DOCX_STYLE_SLOTS.map(({ name }) => ({
|
||||||
|
slot: name,
|
||||||
|
matched: false
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DOCX 主题令牌准备服务", () => {
|
||||||
|
it("按主题指纹缓存跨端样式快照", async () => {
|
||||||
|
const captureAdapter = adapter();
|
||||||
|
const service = new DocxThemeTokenService(captureAdapter);
|
||||||
|
|
||||||
|
const first = await service.prepare(prepared());
|
||||||
|
const second = await service.prepare(prepared());
|
||||||
|
|
||||||
|
expect(captureAdapter.captureThemeStyle).toHaveBeenCalledTimes(1);
|
||||||
|
expect(first.tokens).toEqual(second.tokens);
|
||||||
|
expect(first.tokens.themeId).toBe("test-theme");
|
||||||
|
expect(first.tokens.themeFingerprint).toMatch(/^[a-f0-9]{64}$/u);
|
||||||
|
expect(first.tokens.slots).toHaveLength(DOCX_STYLE_SLOTS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("显式模式跳过浏览器采集并只产生信息级降级诊断", async () => {
|
||||||
|
const captureAdapter = adapter();
|
||||||
|
const service = new DocxThemeTokenService(captureAdapter);
|
||||||
|
|
||||||
|
const result = await service.prepare(
|
||||||
|
prepared({
|
||||||
|
mode: "explicit",
|
||||||
|
basePreset: "technical"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(captureAdapter.captureThemeStyle).not.toHaveBeenCalled();
|
||||||
|
expect(result.tokens.mode).toBe("explicit");
|
||||||
|
expect(result.warnings).toEqual([]);
|
||||||
|
expect(
|
||||||
|
result.tokens.diagnostics.every(
|
||||||
|
(diagnostic) => diagnostic.severity === "info"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -187,6 +187,7 @@ export interface DocxGenerationTimings {
|
|||||||
queueMs: number;
|
queueMs: number;
|
||||||
probeMs: number;
|
probeMs: number;
|
||||||
prepareMs: number;
|
prepareMs: number;
|
||||||
|
themeStyleMs: number;
|
||||||
mediaMs: number;
|
mediaMs: number;
|
||||||
referenceMs: number;
|
referenceMs: number;
|
||||||
pandocMs: number;
|
pandocMs: number;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# @md-to-pdf/docx-engine
|
# @md-to-pdf/docx-engine
|
||||||
|
|
||||||
纯 Node.js 的 DOCX 模板和 Pandoc 编排核心。当前阶段负责读取固定 Pandoc
|
纯 Node.js 的 DOCX 模板和 Pandoc 编排核心。当前阶段负责读取固定 Pandoc
|
||||||
版本的默认 `reference.docx`、验证 ZIP 安全边界,将主题 DOCX 样式声明
|
版本的默认 `reference.docx`、验证 ZIP 安全边界,将归一化主题令牌和
|
||||||
解析为完整样式预设,并生成纸张、页边距、字体、段落、代码、表格、
|
兼容样式预设映射为 Word 样式,并生成纸张、页边距、字体、段落、代码、表格、
|
||||||
页眉、页脚和页码均已映射的动态模板。Pandoc 转换器通过静态 Lua Filter
|
页眉、页脚和页码均已映射的动态模板。Pandoc 转换器通过静态 Lua Filter
|
||||||
将普通图片、Mermaid 和 ECharts 节点替换为受控 PNG,同时保留 Markdown
|
将普通图片、Mermaid 和 ECharts 节点替换为受控 PNG,同时保留 Markdown
|
||||||
正文、标题、列表、表格、代码和公式的可编辑文档结构。
|
正文、标题、列表、表格、代码和公式的可编辑文档结构。
|
||||||
@@ -12,7 +12,9 @@
|
|||||||
- 不依赖 Fastify、Electron 或浏览器 UI;
|
- 不依赖 Fastify、Electron 或浏览器 UI;
|
||||||
- ZIP 使用纯 JavaScript `fflate`;
|
- ZIP 使用纯 JavaScript `fflate`;
|
||||||
- OOXML 使用 `@xmldom/xmldom`,不拼接未经转义的用户 XML;
|
- OOXML 使用 `@xmldom/xmldom`,不拼接未经转义的用户 XML;
|
||||||
- 不解析任意主题 CSS,主题通过 `docxStyle` 提供受限结构化覆盖;
|
- 不解析任意主题 CSS,只消费 `@md-to-pdf/docx-theme-engine` 的受限
|
||||||
|
语义令牌;`docxStyle` 预设在槽位缺失时提供兼容降级;
|
||||||
|
- 槽位只映射到稳定的标准或 `Md*` Word 样式,不包含主题 ID 分支;
|
||||||
- 生成结果会复验全部 XML、包内关系、内容类型、最终节和关键样式;
|
- 生成结果会复验全部 XML、包内关系、内容类型、最终节和关键样式;
|
||||||
- 每次转换使用独立临时目录、隔离 Pandoc data 目录并在 `finally` 清理;
|
- 每次转换使用独立临时目录、隔离 Pandoc data 目录并在 `finally` 清理;
|
||||||
- 媒体映射检查顺序、PNG、像素、单图/总大小和物理显示尺寸;
|
- 媒体映射检查顺序、PNG、像素、单图/总大小和物理显示尺寸;
|
||||||
@@ -37,6 +39,7 @@ npm run build -w @md-to-pdf/docx-engine
|
|||||||
npm run verify:docx-reference
|
npm run verify:docx-reference
|
||||||
npm run verify:docx-conversion
|
npm run verify:docx-conversion
|
||||||
npm run verify:docx-acceptance
|
npm run verify:docx-acceptance
|
||||||
|
npm run verify:docx-theme-styles
|
||||||
```
|
```
|
||||||
|
|
||||||
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
|
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
|
||||||
@@ -51,3 +54,8 @@ npm run verify:docx-acceptance
|
|||||||
`output/docx-acceptance/`,供 Word/WPS 互操作验收使用。根级命令还会
|
`output/docx-acceptance/`,供 Word/WPS 互操作验收使用。根级命令还会
|
||||||
依次执行动态模板、媒体转换、Server HTTP 和 Desktop 原生保存验收;
|
依次执行动态模板、媒体转换、Server HTTP 和 Desktop 原生保存验收;
|
||||||
仅需重跑三配置矩阵时可使用 `npm run verify:docx-matrix`。
|
仅需重跑三配置矩阵时可使用 `npm run verify:docx-matrix`。
|
||||||
|
|
||||||
|
`verify:docx-theme-styles` 使用 Playwright 与 Electron 分别采集 14 套
|
||||||
|
内置主题的 56 个槽位,检查跨引擎令牌一致性,并使用固定 Pandoc 默认
|
||||||
|
模板生成 14 份动态 `reference.docx`。模板矩阵检查标准 Markdown 样式、
|
||||||
|
结构化 `Md*` 样式、正文字体、字号、字体表和缓存指纹。
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||||
"@xmldom/xmldom": "0.9.10",
|
"@xmldom/xmldom": "0.9.10",
|
||||||
"fflate": "0.8.3"
|
"fflate": "0.8.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ export * from "./reference-package.js";
|
|||||||
export * from "./section-transform.js";
|
export * from "./section-transform.js";
|
||||||
export * from "./style-presets.js";
|
export * from "./style-presets.js";
|
||||||
export * from "./styles-transform.js";
|
export * from "./styles-transform.js";
|
||||||
|
export * from "./token-style-map.js";
|
||||||
export * from "./validator.js";
|
export * from "./validator.js";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type PreparedDocxMedia,
|
type PreparedDocxMedia,
|
||||||
type ThemeManifest
|
type ThemeManifest
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
|
||||||
import {
|
import {
|
||||||
createDynamicReferenceCacheKey,
|
createDynamicReferenceCacheKey,
|
||||||
createDynamicReferenceDocx,
|
createDynamicReferenceDocx,
|
||||||
@@ -57,6 +58,7 @@ export interface PandocDocxConversionInput {
|
|||||||
language: string;
|
language: string;
|
||||||
exportConfig: ExportConfig;
|
exportConfig: ExportConfig;
|
||||||
theme: ThemeManifest;
|
theme: ThemeManifest;
|
||||||
|
themeTokens: DocxThemeTokenSet;
|
||||||
metadata: MarkdownDocumentMetadata;
|
metadata: MarkdownDocumentMetadata;
|
||||||
media: PreparedDocxMedia;
|
media: PreparedDocxMedia;
|
||||||
}
|
}
|
||||||
@@ -133,6 +135,7 @@ function referenceOptions(
|
|||||||
return {
|
return {
|
||||||
exportConfig: input.exportConfig,
|
exportConfig: input.exportConfig,
|
||||||
theme: input.theme,
|
theme: input.theme,
|
||||||
|
themeTokens: input.themeTokens,
|
||||||
fileName: input.fileName,
|
fileName: input.fileName,
|
||||||
metadata: {
|
metadata: {
|
||||||
title: input.metadata.title,
|
title: input.metadata.title,
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
transformStylesXml,
|
transformStylesXml,
|
||||||
transformThemeFontsXml
|
transformThemeFontsXml
|
||||||
} from "./styles-transform.js";
|
} from "./styles-transform.js";
|
||||||
|
import {
|
||||||
|
createDocxTokenSlotMap,
|
||||||
|
resolveTokenFonts
|
||||||
|
} from "./token-style-map.js";
|
||||||
import { resolveDocxThemeStyle } from "./style-presets.js";
|
import { resolveDocxThemeStyle } from "./style-presets.js";
|
||||||
import { validateDynamicReferenceDocx } from "./validator.js";
|
import { validateDynamicReferenceDocx } from "./validator.js";
|
||||||
|
|
||||||
@@ -67,6 +71,7 @@ export function createDynamicReferenceCacheKey(
|
|||||||
docxStyle: options.theme.docxStyle,
|
docxStyle: options.theme.docxStyle,
|
||||||
pageDefaults: options.theme.pageDefaults
|
pageDefaults: options.theme.pageDefaults
|
||||||
},
|
},
|
||||||
|
themeTokens: options.themeTokens,
|
||||||
paper: options.exportConfig.paper,
|
paper: options.exportConfig.paper,
|
||||||
pageDecorationsMode:
|
pageDecorationsMode:
|
||||||
options.exportConfig.pageDecorationsMode,
|
options.exportConfig.pageDecorationsMode,
|
||||||
@@ -85,29 +90,46 @@ export function createDynamicReferenceDocx(
|
|||||||
): DynamicReferenceDocxResult {
|
): DynamicReferenceDocxResult {
|
||||||
const reference = readReferenceDocxPackage(baseline);
|
const reference = readReferenceDocxPackage(baseline);
|
||||||
const style = resolveDocxThemeStyle(options.theme);
|
const style = resolveDocxThemeStyle(options.theme);
|
||||||
|
const tokenSlots = options.themeTokens
|
||||||
|
? createDocxTokenSlotMap(options.themeTokens)
|
||||||
|
: undefined;
|
||||||
|
const bodyFonts = resolveTokenFonts(
|
||||||
|
tokenSlots?.get("paragraph")?.style ??
|
||||||
|
tokenSlots?.get("document")?.style,
|
||||||
|
style.body.fonts
|
||||||
|
);
|
||||||
const page = resolveReferencePageOptions(options);
|
const page = resolveReferencePageOptions(options);
|
||||||
const withDecorations = createHeaderFooterParts(
|
const withDecorations = createHeaderFooterParts(
|
||||||
reference.entries,
|
reference.entries,
|
||||||
options,
|
options,
|
||||||
page.header,
|
page.header,
|
||||||
page.footer,
|
page.footer,
|
||||||
style.body.fonts.eastAsia,
|
bodyFonts.eastAsia,
|
||||||
page.dimensions.width - page.margins.left - page.margins.right
|
page.dimensions.width - page.margins.left - page.margins.right
|
||||||
);
|
);
|
||||||
const entries = withDecorations.entries;
|
const entries = withDecorations.entries;
|
||||||
entries.set(
|
entries.set(
|
||||||
"word/styles.xml",
|
"word/styles.xml",
|
||||||
transformStylesXml(entries.get("word/styles.xml")!, style)
|
transformStylesXml(
|
||||||
|
entries.get("word/styles.xml")!,
|
||||||
|
style,
|
||||||
|
options.themeTokens
|
||||||
|
)
|
||||||
);
|
);
|
||||||
entries.set(
|
entries.set(
|
||||||
"word/fontTable.xml",
|
"word/fontTable.xml",
|
||||||
transformFontTableXml(entries.get("word/fontTable.xml")!, style)
|
transformFontTableXml(
|
||||||
|
entries.get("word/fontTable.xml")!,
|
||||||
|
style,
|
||||||
|
options.themeTokens
|
||||||
|
)
|
||||||
);
|
);
|
||||||
entries.set(
|
entries.set(
|
||||||
"word/theme/theme1.xml",
|
"word/theme/theme1.xml",
|
||||||
transformThemeFontsXml(
|
transformThemeFontsXml(
|
||||||
entries.get("word/theme/theme1.xml")!,
|
entries.get("word/theme/theme1.xml")!,
|
||||||
style
|
style,
|
||||||
|
options.themeTokens
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
entries.set(
|
entries.set(
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import {
|
|||||||
type MarkdownDocumentMetadata,
|
type MarkdownDocumentMetadata,
|
||||||
type ThemeManifest
|
type ThemeManifest
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
|
||||||
|
|
||||||
export interface DynamicReferenceDocxOptions {
|
export interface DynamicReferenceDocxOptions {
|
||||||
exportConfig: ExportConfig;
|
exportConfig: ExportConfig;
|
||||||
theme: ThemeManifest;
|
theme: ThemeManifest;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
metadata: Pick<MarkdownDocumentMetadata, "title" | "author">;
|
metadata: Pick<MarkdownDocumentMetadata, "title" | "author">;
|
||||||
|
themeTokens?: DocxThemeTokenSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveReferencePageOptions(
|
export function resolveReferencePageOptions(
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import type { DocxFontFamily } from "@md-to-pdf/core";
|
import type { DocxFontFamily } from "@md-to-pdf/core";
|
||||||
|
import type {
|
||||||
|
DocxSlotStyleToken,
|
||||||
|
DocxStyleSlotName,
|
||||||
|
DocxThemeTokenSet
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
import type { ResolvedDocxThemeStyle } from "./style-presets.js";
|
import type { ResolvedDocxThemeStyle } from "./style-presets.js";
|
||||||
|
import {
|
||||||
|
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||||
|
collectDocxTokenFonts,
|
||||||
|
createDocxTokenSlotMap,
|
||||||
|
resolveTokenFonts
|
||||||
|
} from "./token-style-map.js";
|
||||||
import {
|
import {
|
||||||
DRAWING_NAMESPACE,
|
DRAWING_NAMESPACE,
|
||||||
WORD_NAMESPACE,
|
WORD_NAMESPACE,
|
||||||
@@ -152,6 +163,242 @@ function ensureStyle(
|
|||||||
return style;
|
return style;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWordAttribute(
|
||||||
|
element: XmlElement,
|
||||||
|
name: string,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
element.setAttributeNS(WORD_NAMESPACE, `w:${name}`, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProperty(
|
||||||
|
parent: XmlElement,
|
||||||
|
localName: string,
|
||||||
|
enabled: boolean | undefined
|
||||||
|
) {
|
||||||
|
if (enabled === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeDirectChildren(parent, WORD_NAMESPACE, localName);
|
||||||
|
if (enabled) {
|
||||||
|
appendElement(parent, WORD_NAMESPACE, `w:${localName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackFontsForSlot(
|
||||||
|
slot: DocxStyleSlotName,
|
||||||
|
fallback: ResolvedDocxThemeStyle
|
||||||
|
) {
|
||||||
|
if (slot.startsWith("heading-") || slot.endsWith("-title")) {
|
||||||
|
return fallback.headings.fonts;
|
||||||
|
}
|
||||||
|
if (slot === "inline-code" || slot === "code-block") {
|
||||||
|
return fallback.code.fonts;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
slot === "table" ||
|
||||||
|
slot === "table-header" ||
|
||||||
|
slot === "table-cell"
|
||||||
|
) {
|
||||||
|
return fallback.table.fonts;
|
||||||
|
}
|
||||||
|
if (slot === "caption") {
|
||||||
|
return fallback.caption.fonts;
|
||||||
|
}
|
||||||
|
return fallback.body.fonts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTokenRunStyle(
|
||||||
|
parent: XmlElement,
|
||||||
|
token: DocxSlotStyleToken,
|
||||||
|
fallbackFonts: DocxFontFamily
|
||||||
|
) {
|
||||||
|
if (token.fontCandidates.length) {
|
||||||
|
setFonts(parent, resolveTokenFonts(token, fallbackFonts));
|
||||||
|
}
|
||||||
|
if (token.fontSizePt !== undefined) {
|
||||||
|
removeDirectChildren(parent, WORD_NAMESPACE, "sz", "szCs");
|
||||||
|
const size = pointsToHalfPoints(token.fontSizePt);
|
||||||
|
appendElement(parent, WORD_NAMESPACE, "w:sz", {
|
||||||
|
"w:val": size
|
||||||
|
});
|
||||||
|
appendElement(parent, WORD_NAMESPACE, "w:szCs", {
|
||||||
|
"w:val": size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (token.color !== undefined) {
|
||||||
|
removeDirectChildren(parent, WORD_NAMESPACE, "color");
|
||||||
|
appendElement(parent, WORD_NAMESPACE, "w:color", {
|
||||||
|
"w:val": colorValue(token.color)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (token.backgroundColor !== undefined) {
|
||||||
|
removeDirectChildren(parent, WORD_NAMESPACE, "shd");
|
||||||
|
appendElement(parent, WORD_NAMESPACE, "w:shd", {
|
||||||
|
"w:val": "clear",
|
||||||
|
"w:color": "auto",
|
||||||
|
"w:fill": colorValue(token.backgroundColor)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (token.underline !== undefined) {
|
||||||
|
removeDirectChildren(parent, WORD_NAMESPACE, "u");
|
||||||
|
if (token.underline) {
|
||||||
|
appendElement(parent, WORD_NAMESPACE, "w:u", {
|
||||||
|
"w:val": "single"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (token.letterSpacingPt !== undefined) {
|
||||||
|
removeDirectChildren(parent, WORD_NAMESPACE, "spacing");
|
||||||
|
appendElement(parent, WORD_NAMESPACE, "w:spacing", {
|
||||||
|
"w:val": pointsToTwips(token.letterSpacingPt)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toggleProperty(parent, "b", token.bold);
|
||||||
|
toggleProperty(parent, "bCs", token.bold);
|
||||||
|
toggleProperty(parent, "i", token.italic);
|
||||||
|
toggleProperty(parent, "iCs", token.italic);
|
||||||
|
toggleProperty(parent, "strike", token.strikethrough);
|
||||||
|
toggleProperty(parent, "vanish", token.hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTokenBorders(
|
||||||
|
parent: XmlElement,
|
||||||
|
token: DocxSlotStyleToken
|
||||||
|
) {
|
||||||
|
if (!token.borders) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeDirectChildren(parent, WORD_NAMESPACE, "pBdr");
|
||||||
|
const borders = appendElement(
|
||||||
|
parent,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:pBdr"
|
||||||
|
);
|
||||||
|
for (const side of [
|
||||||
|
"top",
|
||||||
|
"right",
|
||||||
|
"bottom",
|
||||||
|
"left"
|
||||||
|
] as const) {
|
||||||
|
const border = token.borders[side];
|
||||||
|
if (!border) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appendElement(borders, WORD_NAMESPACE, `w:${side}`, {
|
||||||
|
"w:val": border.style,
|
||||||
|
"w:sz": String(
|
||||||
|
Math.max(0, Math.min(160, Math.round(border.widthPt * 8)))
|
||||||
|
),
|
||||||
|
"w:space": String(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
31,
|
||||||
|
Math.round(token.paddingPt?.[side] ?? 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"w:color": colorValue(border.color)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTokenParagraphStyle(
|
||||||
|
parent: XmlElement,
|
||||||
|
token: DocxSlotStyleToken
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
token.spacingBeforePt !== undefined ||
|
||||||
|
token.spacingAfterPt !== undefined ||
|
||||||
|
token.lineSpacing !== undefined
|
||||||
|
) {
|
||||||
|
const spacing = ensureDirectElement(
|
||||||
|
parent,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:spacing"
|
||||||
|
);
|
||||||
|
if (token.spacingBeforePt !== undefined) {
|
||||||
|
setWordAttribute(
|
||||||
|
spacing,
|
||||||
|
"before",
|
||||||
|
pointsToTwips(token.spacingBeforePt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (token.spacingAfterPt !== undefined) {
|
||||||
|
setWordAttribute(
|
||||||
|
spacing,
|
||||||
|
"after",
|
||||||
|
pointsToTwips(token.spacingAfterPt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (token.lineSpacing !== undefined) {
|
||||||
|
setWordAttribute(
|
||||||
|
spacing,
|
||||||
|
"line",
|
||||||
|
String(Math.round(token.lineSpacing * 240))
|
||||||
|
);
|
||||||
|
setWordAttribute(spacing, "lineRule", "auto");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
token.firstLineIndentPt !== undefined ||
|
||||||
|
token.leftIndentPt !== undefined ||
|
||||||
|
token.rightIndentPt !== undefined
|
||||||
|
) {
|
||||||
|
const indent = ensureDirectElement(
|
||||||
|
parent,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:ind"
|
||||||
|
);
|
||||||
|
if (token.firstLineIndentPt !== undefined) {
|
||||||
|
setWordAttribute(
|
||||||
|
indent,
|
||||||
|
"firstLine",
|
||||||
|
pointsToTwips(token.firstLineIndentPt)
|
||||||
|
);
|
||||||
|
indent.removeAttributeNS(WORD_NAMESPACE, "firstLineChars");
|
||||||
|
}
|
||||||
|
if (token.leftIndentPt !== undefined) {
|
||||||
|
setWordAttribute(
|
||||||
|
indent,
|
||||||
|
"left",
|
||||||
|
pointsToTwips(token.leftIndentPt)
|
||||||
|
);
|
||||||
|
indent.removeAttributeNS(WORD_NAMESPACE, "leftChars");
|
||||||
|
}
|
||||||
|
if (token.rightIndentPt !== undefined) {
|
||||||
|
setWordAttribute(
|
||||||
|
indent,
|
||||||
|
"right",
|
||||||
|
pointsToTwips(token.rightIndentPt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (token.alignment !== undefined) {
|
||||||
|
removeDirectChildren(parent, WORD_NAMESPACE, "jc");
|
||||||
|
appendElement(parent, WORD_NAMESPACE, "w:jc", {
|
||||||
|
"w:val": token.alignment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (token.backgroundColor !== undefined) {
|
||||||
|
removeDirectChildren(parent, WORD_NAMESPACE, "shd");
|
||||||
|
appendElement(parent, WORD_NAMESPACE, "w:shd", {
|
||||||
|
"w:val": "clear",
|
||||||
|
"w:color": "auto",
|
||||||
|
"w:fill": colorValue(token.backgroundColor)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
applyTokenBorders(parent, token);
|
||||||
|
toggleProperty(parent, "keepLines", token.keepLines);
|
||||||
|
toggleProperty(parent, "keepNext", token.keepWithNext);
|
||||||
|
toggleProperty(
|
||||||
|
parent,
|
||||||
|
"pageBreakBefore",
|
||||||
|
token.pageBreakBefore
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function applyBodyStyle(
|
function applyBodyStyle(
|
||||||
styles: XmlElement,
|
styles: XmlElement,
|
||||||
style: ResolvedDocxThemeStyle
|
style: ResolvedDocxThemeStyle
|
||||||
@@ -589,9 +836,215 @@ function applyTableAndCaption(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTableTokenStyles(
|
||||||
|
styles: XmlElement,
|
||||||
|
slots: ReadonlyMap<
|
||||||
|
DocxStyleSlotName,
|
||||||
|
{ style: DocxSlotStyleToken }
|
||||||
|
>,
|
||||||
|
fallback: ResolvedDocxThemeStyle
|
||||||
|
) {
|
||||||
|
const tableToken = slots.get("table")?.style;
|
||||||
|
const cellToken =
|
||||||
|
slots.get("table-cell")?.style ?? tableToken;
|
||||||
|
const headerToken =
|
||||||
|
slots.get("table-header")?.style ?? cellToken;
|
||||||
|
if (!tableToken && !cellToken && !headerToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const table = ensureStyle(
|
||||||
|
styles,
|
||||||
|
"Table",
|
||||||
|
"table",
|
||||||
|
"TableNormal"
|
||||||
|
);
|
||||||
|
const tblPr = ensureDirectElement(
|
||||||
|
table,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:tblPr"
|
||||||
|
);
|
||||||
|
if (tableToken?.widthPercent !== undefined) {
|
||||||
|
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblW");
|
||||||
|
appendElement(tblPr, WORD_NAMESPACE, "w:tblW", {
|
||||||
|
"w:w": String(
|
||||||
|
Math.round(tableToken.widthPercent * 50)
|
||||||
|
),
|
||||||
|
"w:type": "pct"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const padding = cellToken?.paddingPt ?? tableToken?.paddingPt;
|
||||||
|
if (padding) {
|
||||||
|
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblCellMar");
|
||||||
|
const margins = appendElement(
|
||||||
|
tblPr,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:tblCellMar"
|
||||||
|
);
|
||||||
|
for (const side of [
|
||||||
|
"top",
|
||||||
|
"right",
|
||||||
|
"bottom",
|
||||||
|
"left"
|
||||||
|
] as const) {
|
||||||
|
appendElement(margins, WORD_NAMESPACE, `w:${side}`, {
|
||||||
|
"w:w": pointsToTwips(padding[side]),
|
||||||
|
"w:type": "dxa"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tableToken?.borders) {
|
||||||
|
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblBorders");
|
||||||
|
const borders = appendElement(
|
||||||
|
tblPr,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:tblBorders"
|
||||||
|
);
|
||||||
|
for (const side of [
|
||||||
|
"top",
|
||||||
|
"right",
|
||||||
|
"bottom",
|
||||||
|
"left"
|
||||||
|
] as const) {
|
||||||
|
const border = tableToken.borders[side];
|
||||||
|
if (!border) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appendElement(borders, WORD_NAMESPACE, `w:${side}`, {
|
||||||
|
"w:val": border.style,
|
||||||
|
"w:sz": String(Math.round(border.widthPt * 8)),
|
||||||
|
"w:space": "0",
|
||||||
|
"w:color": colorValue(border.color)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cellToken) {
|
||||||
|
applyTokenRunStyle(
|
||||||
|
ensureDirectElement(table, WORD_NAMESPACE, "w:rPr"),
|
||||||
|
cellToken,
|
||||||
|
fallback.table.fonts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (headerToken) {
|
||||||
|
applyTokenRunStyle(
|
||||||
|
ensureDirectElement(firstRow, WORD_NAMESPACE, "w:rPr"),
|
||||||
|
headerToken,
|
||||||
|
fallback.table.fonts
|
||||||
|
);
|
||||||
|
if (headerToken.backgroundColor !== undefined) {
|
||||||
|
const tcPr = ensureDirectElement(
|
||||||
|
firstRow,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:tcPr"
|
||||||
|
);
|
||||||
|
removeDirectChildren(tcPr, WORD_NAMESPACE, "shd");
|
||||||
|
appendElement(tcPr, WORD_NAMESPACE, "w:shd", {
|
||||||
|
"w:val": "clear",
|
||||||
|
"w:color": "auto",
|
||||||
|
"w:fill": colorValue(
|
||||||
|
headerToken.backgroundColor
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTokenStyles(
|
||||||
|
styles: XmlElement,
|
||||||
|
tokens: DocxThemeTokenSet,
|
||||||
|
fallback: ResolvedDocxThemeStyle
|
||||||
|
) {
|
||||||
|
const slots = createDocxTokenSlotMap(tokens);
|
||||||
|
const documentToken = slots.get("document")?.style;
|
||||||
|
if (documentToken) {
|
||||||
|
const defaults = firstDirectChild(
|
||||||
|
styles,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"docDefaults"
|
||||||
|
);
|
||||||
|
if (defaults) {
|
||||||
|
applyTokenRunStyle(
|
||||||
|
ensureDirectElement(
|
||||||
|
ensureDirectElement(
|
||||||
|
defaults,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:rPrDefault"
|
||||||
|
),
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:rPr"
|
||||||
|
),
|
||||||
|
documentToken,
|
||||||
|
fallback.body.fonts
|
||||||
|
);
|
||||||
|
applyTokenParagraphStyle(
|
||||||
|
ensureDirectElement(
|
||||||
|
ensureDirectElement(
|
||||||
|
defaults,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:pPrDefault"
|
||||||
|
),
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:pPr"
|
||||||
|
),
|
||||||
|
documentToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [slot, entry] of slots) {
|
||||||
|
const bindings = DOCX_SLOT_WORD_STYLE_BINDINGS[slot];
|
||||||
|
if (!bindings) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const binding of bindings) {
|
||||||
|
const target = ensureStyle(
|
||||||
|
styles,
|
||||||
|
binding.styleId,
|
||||||
|
binding.type,
|
||||||
|
binding.basedOn
|
||||||
|
);
|
||||||
|
if (binding.type === "paragraph") {
|
||||||
|
applyTokenParagraphStyle(
|
||||||
|
ensureDirectElement(
|
||||||
|
target,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:pPr"
|
||||||
|
),
|
||||||
|
entry.style
|
||||||
|
);
|
||||||
|
}
|
||||||
|
applyTokenRunStyle(
|
||||||
|
ensureDirectElement(
|
||||||
|
target,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:rPr"
|
||||||
|
),
|
||||||
|
entry.style,
|
||||||
|
fallbackFontsForSlot(slot, fallback)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyTableTokenStyles(styles, slots, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
export function transformStylesXml(
|
export function transformStylesXml(
|
||||||
content: Uint8Array,
|
content: Uint8Array,
|
||||||
style: ResolvedDocxThemeStyle
|
style: ResolvedDocxThemeStyle,
|
||||||
|
tokens?: DocxThemeTokenSet
|
||||||
) {
|
) {
|
||||||
const document = parseXmlPart(content, "word/styles.xml");
|
const document = parseXmlPart(content, "word/styles.xml");
|
||||||
const styles = document.documentElement!;
|
const styles = document.documentElement!;
|
||||||
@@ -605,12 +1058,16 @@ export function transformStylesXml(
|
|||||||
applyHeadings(styles, style);
|
applyHeadings(styles, style);
|
||||||
applyCodeAndQuote(styles, style);
|
applyCodeAndQuote(styles, style);
|
||||||
applyTableAndCaption(styles, style);
|
applyTableAndCaption(styles, style);
|
||||||
|
if (tokens) {
|
||||||
|
applyTokenStyles(styles, tokens, style);
|
||||||
|
}
|
||||||
return serializeXmlPart(document);
|
return serializeXmlPart(document);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformFontTableXml(
|
export function transformFontTableXml(
|
||||||
content: Uint8Array,
|
content: Uint8Array,
|
||||||
style: ResolvedDocxThemeStyle
|
style: ResolvedDocxThemeStyle,
|
||||||
|
tokens?: DocxThemeTokenSet
|
||||||
) {
|
) {
|
||||||
const document = parseXmlPart(content, "word/fontTable.xml");
|
const document = parseXmlPart(content, "word/fontTable.xml");
|
||||||
const root = document.documentElement!;
|
const root = document.documentElement!;
|
||||||
@@ -628,6 +1085,11 @@ export function transformFontTableXml(
|
|||||||
requiredFonts.add(fonts.complexScript);
|
requiredFonts.add(fonts.complexScript);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (tokens) {
|
||||||
|
for (const font of collectDocxTokenFonts(tokens)) {
|
||||||
|
requiredFonts.add(font);
|
||||||
|
}
|
||||||
|
}
|
||||||
const existing = new Set(
|
const existing = new Set(
|
||||||
Array.from(
|
Array.from(
|
||||||
root.getElementsByTagNameNS(WORD_NAMESPACE, "font")
|
root.getElementsByTagNameNS(WORD_NAMESPACE, "font")
|
||||||
@@ -648,9 +1110,22 @@ export function transformFontTableXml(
|
|||||||
|
|
||||||
export function transformThemeFontsXml(
|
export function transformThemeFontsXml(
|
||||||
content: Uint8Array,
|
content: Uint8Array,
|
||||||
style: ResolvedDocxThemeStyle
|
style: ResolvedDocxThemeStyle,
|
||||||
|
tokens?: DocxThemeTokenSet
|
||||||
) {
|
) {
|
||||||
const document = parseXmlPart(content, "word/theme/theme1.xml");
|
const document = parseXmlPart(content, "word/theme/theme1.xml");
|
||||||
|
const slots = tokens
|
||||||
|
? createDocxTokenSlotMap(tokens)
|
||||||
|
: undefined;
|
||||||
|
const headingFonts = resolveTokenFonts(
|
||||||
|
slots?.get("heading-1")?.style,
|
||||||
|
style.headings.fonts
|
||||||
|
);
|
||||||
|
const bodyFonts = resolveTokenFonts(
|
||||||
|
slots?.get("paragraph")?.style ??
|
||||||
|
slots?.get("document")?.style,
|
||||||
|
style.body.fonts
|
||||||
|
);
|
||||||
for (const schemeName of ["majorFont", "minorFont"]) {
|
for (const schemeName of ["majorFont", "minorFont"]) {
|
||||||
const scheme = document.getElementsByTagNameNS(
|
const scheme = document.getElementsByTagNameNS(
|
||||||
DRAWING_NAMESPACE,
|
DRAWING_NAMESPACE,
|
||||||
@@ -670,14 +1145,14 @@ export function transformThemeFontsXml(
|
|||||||
latin?.setAttribute(
|
latin?.setAttribute(
|
||||||
"typeface",
|
"typeface",
|
||||||
schemeName === "majorFont"
|
schemeName === "majorFont"
|
||||||
? style.headings.fonts.latin
|
? headingFonts.latin
|
||||||
: style.body.fonts.latin
|
: bodyFonts.latin
|
||||||
);
|
);
|
||||||
eastAsia?.setAttribute(
|
eastAsia?.setAttribute(
|
||||||
"typeface",
|
"typeface",
|
||||||
schemeName === "majorFont"
|
schemeName === "majorFont"
|
||||||
? style.headings.fonts.eastAsia
|
? headingFonts.eastAsia
|
||||||
: style.body.fonts.eastAsia
|
: bodyFonts.eastAsia
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return serializeXmlPart(document);
|
return serializeXmlPart(document);
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { DocxFontFamily } from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
docxThemeTokenSetSchema,
|
||||||
|
type DocxResolvedStyleSlot,
|
||||||
|
type DocxSlotStyleToken,
|
||||||
|
type DocxStyleSlotName,
|
||||||
|
type DocxThemeTokenSet
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
|
||||||
|
export interface DocxWordStyleBinding {
|
||||||
|
styleId: string;
|
||||||
|
type: "paragraph" | "character";
|
||||||
|
basedOn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraph(
|
||||||
|
styleId: string,
|
||||||
|
basedOn = "Normal"
|
||||||
|
): DocxWordStyleBinding {
|
||||||
|
return { styleId, type: "paragraph", basedOn };
|
||||||
|
}
|
||||||
|
|
||||||
|
function character(
|
||||||
|
styleId: string,
|
||||||
|
basedOn = "DefaultParagraphFont"
|
||||||
|
): DocxWordStyleBinding {
|
||||||
|
return { styleId, type: "character", basedOn };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
|
||||||
|
Partial<
|
||||||
|
Record<
|
||||||
|
DocxStyleSlotName,
|
||||||
|
readonly DocxWordStyleBinding[]
|
||||||
|
>
|
||||||
|
>
|
||||||
|
> = {
|
||||||
|
"document-title": [paragraph("Title")],
|
||||||
|
"document-author": [paragraph("Author")],
|
||||||
|
paragraph: [
|
||||||
|
paragraph("Normal", ""),
|
||||||
|
paragraph("BodyText"),
|
||||||
|
paragraph("FirstParagraph"),
|
||||||
|
paragraph("Definition"),
|
||||||
|
paragraph("Figure")
|
||||||
|
],
|
||||||
|
strong: [character("Strong")],
|
||||||
|
emphasis: [character("Emphasis")],
|
||||||
|
strikethrough: [character("Strikeout")],
|
||||||
|
"inline-code": [character("VerbatimChar", "BodyTextChar")],
|
||||||
|
"heading-1": [
|
||||||
|
paragraph("Heading1"),
|
||||||
|
character("Heading1Char")
|
||||||
|
],
|
||||||
|
"heading-2": [
|
||||||
|
paragraph("Heading2"),
|
||||||
|
character("Heading2Char")
|
||||||
|
],
|
||||||
|
"heading-3": [
|
||||||
|
paragraph("Heading3"),
|
||||||
|
character("Heading3Char")
|
||||||
|
],
|
||||||
|
"heading-4": [
|
||||||
|
paragraph("Heading4"),
|
||||||
|
character("Heading4Char")
|
||||||
|
],
|
||||||
|
"heading-5": [
|
||||||
|
paragraph("Heading5"),
|
||||||
|
character("Heading5Char")
|
||||||
|
],
|
||||||
|
"heading-6": [
|
||||||
|
paragraph("Heading6"),
|
||||||
|
character("Heading6Char")
|
||||||
|
],
|
||||||
|
"unordered-list": [paragraph("MdUnorderedList", "BodyText")],
|
||||||
|
"ordered-list": [paragraph("MdOrderedList", "BodyText")],
|
||||||
|
"list-item": [
|
||||||
|
paragraph("Compact", "BodyText"),
|
||||||
|
paragraph("ListParagraph", "BodyText")
|
||||||
|
],
|
||||||
|
"block-quote": [paragraph("BlockText", "BodyText")],
|
||||||
|
"code-block": [paragraph("SourceCode")],
|
||||||
|
hyperlink: [character("Hyperlink", "BodyTextChar")],
|
||||||
|
figure: [paragraph("Figure")],
|
||||||
|
caption: [
|
||||||
|
paragraph("Caption"),
|
||||||
|
paragraph("TableCaption", "Caption"),
|
||||||
|
paragraph("ImageCaption", "Caption")
|
||||||
|
],
|
||||||
|
footnotes: [paragraph("FootnoteText")],
|
||||||
|
"official-masthead": [paragraph("MdOfficialMasthead")],
|
||||||
|
"official-classification": [
|
||||||
|
paragraph("MdOfficialClassification")
|
||||||
|
],
|
||||||
|
"official-issuer": [paragraph("MdOfficialIssuer")],
|
||||||
|
"official-issue-row": [paragraph("MdOfficialIssueRow")],
|
||||||
|
"official-number": [paragraph("MdOfficialNumber")],
|
||||||
|
"official-signatory": [paragraph("MdOfficialSignatory")],
|
||||||
|
"official-title": [paragraph("MdOfficialTitle")],
|
||||||
|
"official-signature": [paragraph("MdOfficialSignature")],
|
||||||
|
"official-edition": [paragraph("MdOfficialEdition")],
|
||||||
|
"briefing-masthead": [paragraph("MdBriefingMasthead")],
|
||||||
|
"briefing-meta": [paragraph("MdBriefingMeta")],
|
||||||
|
"briefing-title": [paragraph("MdBriefingTitle")],
|
||||||
|
"briefing-contact": [paragraph("MdBriefingContact")],
|
||||||
|
"project-report-cover": [paragraph("MdProjectReportCover")],
|
||||||
|
"project-report-project-name": [
|
||||||
|
paragraph("MdProjectReportProjectName")
|
||||||
|
],
|
||||||
|
"project-report-title": [paragraph("MdProjectReportTitle")],
|
||||||
|
"project-report-version": [
|
||||||
|
paragraph("MdProjectReportVersion")
|
||||||
|
],
|
||||||
|
"project-report-owner": [paragraph("MdProjectReportOwner")],
|
||||||
|
"project-report-prepared-by": [
|
||||||
|
paragraph("MdProjectReportPreparedBy")
|
||||||
|
],
|
||||||
|
"project-report-date": [paragraph("MdProjectReportDate")],
|
||||||
|
"tender-cover": [paragraph("MdTenderCover")],
|
||||||
|
"tender-copy-mark": [paragraph("MdTenderCopyMark")],
|
||||||
|
"tender-project-name": [paragraph("MdTenderProjectName")],
|
||||||
|
"tender-project-number": [
|
||||||
|
paragraph("MdTenderProjectNumber")
|
||||||
|
],
|
||||||
|
"tender-title": [paragraph("MdTenderTitle")],
|
||||||
|
"tender-volume": [paragraph("MdTenderVolume")],
|
||||||
|
"tender-bidder": [paragraph("MdTenderBidder")],
|
||||||
|
"tender-representative": [
|
||||||
|
paragraph("MdTenderRepresentative")
|
||||||
|
],
|
||||||
|
"tender-date": [paragraph("MdTenderDate")]
|
||||||
|
};
|
||||||
|
|
||||||
|
const eastAsiaFontPattern =
|
||||||
|
/(?:yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|宋|黑|仿宋|楷|等线)/iu;
|
||||||
|
const genericFontNames = new Set([
|
||||||
|
"serif",
|
||||||
|
"sans-serif",
|
||||||
|
"monospace",
|
||||||
|
"cursive",
|
||||||
|
"fantasy",
|
||||||
|
"system-ui"
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function resolveTokenFonts(
|
||||||
|
token: DocxSlotStyleToken | undefined,
|
||||||
|
fallback: DocxFontFamily
|
||||||
|
): DocxFontFamily {
|
||||||
|
const candidates = (token?.fontCandidates ?? []).filter(
|
||||||
|
(candidate) =>
|
||||||
|
!genericFontNames.has(candidate.trim().toLowerCase())
|
||||||
|
);
|
||||||
|
if (!candidates.length) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const eastAsia =
|
||||||
|
candidates.find((candidate) =>
|
||||||
|
eastAsiaFontPattern.test(candidate)
|
||||||
|
) ?? candidates[0]!;
|
||||||
|
const latin =
|
||||||
|
candidates.find(
|
||||||
|
(candidate) => !eastAsiaFontPattern.test(candidate)
|
||||||
|
) ?? candidates[0]!;
|
||||||
|
return {
|
||||||
|
latin,
|
||||||
|
eastAsia,
|
||||||
|
complexScript: latin
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocxTokenSlotMap(
|
||||||
|
input: DocxThemeTokenSet
|
||||||
|
): ReadonlyMap<DocxStyleSlotName, DocxResolvedStyleSlot> {
|
||||||
|
const tokens = docxThemeTokenSetSchema.parse(input);
|
||||||
|
return new Map(
|
||||||
|
tokens.slots.map((entry) => [entry.slot, entry])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectDocxTokenFonts(
|
||||||
|
input: DocxThemeTokenSet
|
||||||
|
): Set<string> {
|
||||||
|
const fonts = new Set<string>();
|
||||||
|
for (const { style } of input.slots) {
|
||||||
|
for (const candidate of style.fontCandidates) {
|
||||||
|
if (!genericFontNames.has(candidate.trim().toLowerCase())) {
|
||||||
|
fonts.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fonts;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type PreparedDocxMedia,
|
type PreparedDocxMedia,
|
||||||
type ThemeManifest
|
type ThemeManifest
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
|
||||||
import {
|
import {
|
||||||
PandocDocxConversionError,
|
PandocDocxConversionError,
|
||||||
PandocDocxConverter,
|
PandocDocxConverter,
|
||||||
@@ -49,6 +50,16 @@ const emptyMedia: PreparedDocxMedia = {
|
|||||||
totalBytes: 0
|
totalBytes: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const themeTokens: DocxThemeTokenSet = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
themeId: "test-theme",
|
||||||
|
themeFingerprint: "c".repeat(64),
|
||||||
|
mode: "auto-with-overrides",
|
||||||
|
basePreset: "technical",
|
||||||
|
slots: [],
|
||||||
|
diagnostics: []
|
||||||
|
};
|
||||||
|
|
||||||
function input() {
|
function input() {
|
||||||
return {
|
return {
|
||||||
markdown: "# 测试",
|
markdown: "# 测试",
|
||||||
@@ -56,6 +67,7 @@ function input() {
|
|||||||
language: "zh-CN",
|
language: "zh-CN",
|
||||||
exportConfig: defaultExportConfig,
|
exportConfig: defaultExportConfig,
|
||||||
theme: theme(),
|
theme: theme(),
|
||||||
|
themeTokens,
|
||||||
metadata: {
|
metadata: {
|
||||||
title: "测试",
|
title: "测试",
|
||||||
author: "测试人",
|
author: "测试人",
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
type ExportConfig,
|
type ExportConfig,
|
||||||
type ThemeManifest
|
type ThemeManifest
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
import type {
|
||||||
|
DocxResolvedStyleSlot,
|
||||||
|
DocxThemeTokenSet
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
import {
|
import {
|
||||||
createDynamicReferenceDocx,
|
createDynamicReferenceDocx,
|
||||||
validateGeneratedDocx,
|
validateGeneratedDocx,
|
||||||
@@ -129,6 +133,21 @@ function theme(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function themeTokens(
|
||||||
|
fingerprint = "c".repeat(64),
|
||||||
|
slots: DocxResolvedStyleSlot[] = []
|
||||||
|
): DocxThemeTokenSet {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
themeId: "test-theme",
|
||||||
|
themeFingerprint: fingerprint,
|
||||||
|
mode: "auto-with-overrides",
|
||||||
|
basePreset: "technical",
|
||||||
|
slots,
|
||||||
|
diagnostics: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createOptions(exportConfig: ExportConfig) {
|
function createOptions(exportConfig: ExportConfig) {
|
||||||
return {
|
return {
|
||||||
exportConfig,
|
exportConfig,
|
||||||
@@ -137,7 +156,8 @@ function createOptions(exportConfig: ExportConfig) {
|
|||||||
metadata: {
|
metadata: {
|
||||||
title: "年度 <报告>",
|
title: "年度 <报告>",
|
||||||
author: "测试人"
|
author: "测试人"
|
||||||
}
|
},
|
||||||
|
themeTokens: themeTokens()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +179,10 @@ describe("动态 reference.docx", () => {
|
|||||||
author: "测试人"
|
author: "测试人"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const changedTokens = createDynamicReferenceDocx(baseline, {
|
||||||
|
...createOptions(defaultExportConfig),
|
||||||
|
themeTokens: themeTokens("d".repeat(64))
|
||||||
|
});
|
||||||
const entries = unzipSync(first.content);
|
const entries = unzipSync(first.content);
|
||||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||||
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
||||||
@@ -169,6 +193,7 @@ describe("动态 reference.docx", () => {
|
|||||||
);
|
);
|
||||||
expect(first.cacheKey).toBe(second.cacheKey);
|
expect(first.cacheKey).toBe(second.cacheKey);
|
||||||
expect(first.cacheKey).not.toBe(changedMetadata.cacheKey);
|
expect(first.cacheKey).not.toBe(changedMetadata.cacheKey);
|
||||||
|
expect(first.cacheKey).not.toBe(changedTokens.cacheKey);
|
||||||
expect(documentXml).toContain('w:w="11906"');
|
expect(documentXml).toContain('w:w="11906"');
|
||||||
expect(documentXml).toContain('w:h="16838"');
|
expect(documentXml).toContain('w:h="16838"');
|
||||||
expect(documentXml).toContain('w:top="907"');
|
expect(documentXml).toContain('w:top="907"');
|
||||||
@@ -186,6 +211,106 @@ describe("动态 reference.docx", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("将通用主题令牌写入标准样式、结构样式和字体部件", () => {
|
||||||
|
const result = createDynamicReferenceDocx(
|
||||||
|
createBaselineReference(),
|
||||||
|
{
|
||||||
|
...createOptions(defaultExportConfig),
|
||||||
|
themeTokens: themeTokens("e".repeat(64), [
|
||||||
|
{
|
||||||
|
slot: "paragraph",
|
||||||
|
source: "computed-css",
|
||||||
|
confidence: "exact",
|
||||||
|
style: {
|
||||||
|
fontCandidates: [
|
||||||
|
"Source Han Serif SC",
|
||||||
|
"Times New Roman"
|
||||||
|
],
|
||||||
|
fontSizePt: 14,
|
||||||
|
color: "#112233",
|
||||||
|
lineSpacing: 1.8,
|
||||||
|
firstLineIndentPt: 28
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "heading-1",
|
||||||
|
source: "computed-css",
|
||||||
|
confidence: "exact",
|
||||||
|
style: {
|
||||||
|
fontCandidates: ["SimHei", "Arial"],
|
||||||
|
fontSizePt: 22,
|
||||||
|
bold: true,
|
||||||
|
color: "#aa0000",
|
||||||
|
alignment: "center",
|
||||||
|
keepWithNext: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "table",
|
||||||
|
source: "computed-css",
|
||||||
|
confidence: "exact",
|
||||||
|
style: {
|
||||||
|
fontCandidates: [],
|
||||||
|
widthPercent: 100,
|
||||||
|
paddingPt: {
|
||||||
|
top: 3,
|
||||||
|
right: 4,
|
||||||
|
bottom: 3,
|
||||||
|
left: 4
|
||||||
|
},
|
||||||
|
borders: {
|
||||||
|
top: {
|
||||||
|
widthPt: 1,
|
||||||
|
color: "#445566",
|
||||||
|
style: "single"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "official-title",
|
||||||
|
source: "computed-css",
|
||||||
|
confidence: "exact",
|
||||||
|
style: {
|
||||||
|
fontCandidates: ["SimSun"],
|
||||||
|
fontSizePt: 22,
|
||||||
|
color: "#ff0000",
|
||||||
|
alignment: "center",
|
||||||
|
keepWithNext: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const entries = unzipSync(result.content);
|
||||||
|
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
||||||
|
const fontTableXml = decoder.decode(
|
||||||
|
entries["word/fontTable.xml"]
|
||||||
|
);
|
||||||
|
const themeXml = decoder.decode(
|
||||||
|
entries["word/theme/theme1.xml"]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stylesXml).toContain('w:styleId="MdOfficialTitle"');
|
||||||
|
expect(stylesXml).toContain('w:eastAsia="Source Han Serif SC"');
|
||||||
|
expect(stylesXml).toContain('w:ascii="Times New Roman"');
|
||||||
|
expect(stylesXml).toContain('w:val="112233"');
|
||||||
|
expect(stylesXml).toContain('w:styleId="Heading1"');
|
||||||
|
expect(stylesXml).toContain('w:val="AA0000"');
|
||||||
|
expect(stylesXml).toContain('w:w="5000" w:type="pct"');
|
||||||
|
expect(stylesXml).toContain('w:color="445566"');
|
||||||
|
expect(fontTableXml).toContain(
|
||||||
|
'w:name="Source Han Serif SC"'
|
||||||
|
);
|
||||||
|
expect(fontTableXml).toContain('w:name="SimHei"');
|
||||||
|
expect(themeXml).toContain(
|
||||||
|
'<a:latin typeface="Arial"'
|
||||||
|
);
|
||||||
|
expect(themeXml).toContain(
|
||||||
|
'<a:ea typeface="SimHei"'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
|
it("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
|
||||||
const exportConfig: ExportConfig = {
|
const exportConfig: ExportConfig = {
|
||||||
...defaultExportConfig,
|
...defaultExportConfig,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import {
|
||||||
|
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||||
|
collectDocxTokenFonts,
|
||||||
|
createDocxTokenSlotMap,
|
||||||
|
resolveTokenFonts
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
const tokens: DocxThemeTokenSet = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
themeId: "test-theme",
|
||||||
|
themeFingerprint: "a".repeat(64),
|
||||||
|
mode: "auto",
|
||||||
|
basePreset: "general",
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
slot: "paragraph",
|
||||||
|
source: "computed-css",
|
||||||
|
confidence: "exact",
|
||||||
|
style: {
|
||||||
|
fontCandidates: [
|
||||||
|
"Source Han Serif SC",
|
||||||
|
"Times New Roman",
|
||||||
|
"serif"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
diagnostics: []
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("DOCX 令牌样式映射", () => {
|
||||||
|
it("以稳定样式 ID 覆盖标准 Markdown 和结构槽位", () => {
|
||||||
|
expect(
|
||||||
|
DOCX_SLOT_WORD_STYLE_BINDINGS["heading-1"]
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
styleId: "Heading1",
|
||||||
|
type: "paragraph",
|
||||||
|
basedOn: "Normal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
styleId: "Heading1Char",
|
||||||
|
type: "character",
|
||||||
|
basedOn: "DefaultParagraphFont"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
DOCX_SLOT_WORD_STYLE_BINDINGS["official-title"]?.[0]
|
||||||
|
?.styleId
|
||||||
|
).toBe("MdOfficialTitle");
|
||||||
|
expect(
|
||||||
|
DOCX_SLOT_WORD_STYLE_BINDINGS["tender-representative"]?.[0]
|
||||||
|
?.styleId
|
||||||
|
).toBe("MdTenderRepresentative");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("按文字系统选择字体并过滤 CSS 通用族名", () => {
|
||||||
|
const slot = createDocxTokenSlotMap(tokens).get("paragraph");
|
||||||
|
expect(
|
||||||
|
resolveTokenFonts(slot?.style, {
|
||||||
|
latin: "Arial",
|
||||||
|
eastAsia: "SimSun"
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
latin: "Times New Roman",
|
||||||
|
eastAsia: "Source Han Serif SC",
|
||||||
|
complexScript: "Times New Roman"
|
||||||
|
});
|
||||||
|
expect([...collectDocxTokenFonts(tokens)]).toEqual([
|
||||||
|
"Source Han Serif SC",
|
||||||
|
"Times New Roman"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立
|
|||||||
|
|
||||||
Server Playwright 与 Desktop Electron 适配器负责在现有受限浏览器生命周期
|
Server Playwright 与 Desktop Electron 适配器负责在现有受限浏览器生命周期
|
||||||
中执行本包脚本;主题 CSS 和字体资源继续使用各平台已有的同源资源协议。
|
中执行本包脚本;主题 CSS 和字体资源继续使用各平台已有的同源资源协议。
|
||||||
`@md-to-pdf/docx-engine` 后续消费本包令牌生成 `reference.docx` 和最终
|
`@md-to-pdf/application` 已负责指纹、快照缓存和令牌准备,
|
||||||
OOXML。Flex、Grid 和超出 Word 上限的装饰边不会静默丢弃,而是保留近似
|
`@md-to-pdf/docx-engine` 已消费本包令牌生成动态 `reference.docx`。
|
||||||
|
Flex、Grid 和超出 Word 上限的装饰边不会静默丢弃,而是保留近似
|
||||||
令牌与诊断,供语义文档结构层选择 Word 表格、分节或段落布局实现。
|
令牌与诊断,供语义文档结构层选择 Word 表格、分节或段落布局实现。
|
||||||
|
|||||||
@@ -759,7 +759,9 @@ export function resolveDocxThemeTokens(
|
|||||||
style: normalized.style
|
style: normalized.style
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const missing = entry?.matched !== true;
|
const missing =
|
||||||
|
input.config.mode !== "explicit" &&
|
||||||
|
entry?.matched !== true;
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
severity: missing ? "warning" : "info",
|
severity: missing ? "warning" : "info",
|
||||||
code:
|
code:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type DocxThemeStyleCaptureRequest = z.infer<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export interface DocxThemeStyleCaptureAdapter {
|
export interface DocxThemeStyleCaptureAdapter {
|
||||||
|
readonly themeStyleBaseUrl: string;
|
||||||
captureThemeStyle(
|
captureThemeStyle(
|
||||||
request: DocxThemeStyleCaptureRequest,
|
request: DocxThemeStyleCaptureRequest,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
|
|||||||
Reference in New Issue
Block a user