feat: 建立 DOCX 主题映射引擎

This commit is contained in:
SkyJourney
2026-07-30 20:28:47 +08:00
parent e7c688df6e
commit 6fed666f44
15 changed files with 851 additions and 25 deletions
@@ -0,0 +1,104 @@
import {
docxThemeStyleOverridesSchema,
type DocxStylePreset,
type DocxThemeStyleMode,
type DocxThemeStyleOverrides,
type ThemeManifest
} from "@md-to-pdf/core";
export interface NormalizedDocxThemeMappingConfig {
mode: DocxThemeStyleMode;
basePreset: DocxStylePreset;
overrides: DocxThemeStyleOverrides;
legacyPreset: boolean;
}
function inferBasePreset(
manifest: Pick<ThemeManifest, "category">
): DocxStylePreset {
if (manifest.category === "red-letter") {
return "official";
}
if (manifest.category === "formal") {
return "formal";
}
if (manifest.category === "tender") {
return "tender";
}
return "general";
}
function mergeSection<T extends object>(
legacy: T | undefined,
current: T | undefined
): T | undefined {
if (!legacy && !current) {
return undefined;
}
return {
...legacy,
...current
} as T;
}
export function normalizeDocxThemeMappingConfig(
manifest: ThemeManifest
): NormalizedDocxThemeMappingConfig {
const style = manifest.docxStyle;
const legacyPreset =
style?.preset !== undefined &&
style.mode === undefined &&
style.basePreset === undefined;
const mode =
style?.mode ??
(legacyPreset ? "explicit" : "auto");
const basePreset =
style?.basePreset ??
style?.preset ??
inferBasePreset(manifest);
const legacyOverrides = docxThemeStyleOverridesSchema.parse({
body: style?.body,
headings: style?.headings,
code: style?.code,
blockQuote: style?.blockQuote,
table: style?.table,
caption: style?.caption,
hyperlink: style?.hyperlink
});
const overrides = docxThemeStyleOverridesSchema.parse({
body: mergeSection(
legacyOverrides.body,
style?.overrides?.body
),
headings: mergeSection(
legacyOverrides.headings,
style?.overrides?.headings
),
code: mergeSection(
legacyOverrides.code,
style?.overrides?.code
),
blockQuote: mergeSection(
legacyOverrides.blockQuote,
style?.overrides?.blockQuote
),
table: mergeSection(
legacyOverrides.table,
style?.overrides?.table
),
caption: mergeSection(
legacyOverrides.caption,
style?.overrides?.caption
),
hyperlink: mergeSection(
legacyOverrides.hyperlink,
style?.overrides?.hyperlink
)
});
return {
mode,
basePreset,
overrides,
legacyPreset
};
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./configuration.js";
export * from "./slots.js";
export * from "./snapshot.js";
export * from "./tokens.js";
+149
View File
@@ -0,0 +1,149 @@
import { z } from "zod";
export const DOCX_STYLE_SLOT_NAMES = [
"document",
"document-title",
"document-author",
"paragraph",
"strong",
"emphasis",
"strikethrough",
"inline-code",
"heading-1",
"heading-2",
"heading-3",
"heading-4",
"heading-5",
"heading-6",
"unordered-list",
"ordered-list",
"list-item",
"block-quote",
"code-block",
"table",
"table-header",
"table-cell",
"hyperlink",
"figure",
"image",
"caption",
"footnotes",
"official-masthead",
"official-classification",
"official-issuer",
"official-issue-row",
"official-number",
"official-signatory",
"official-signature",
"official-edition",
"briefing-masthead",
"briefing-meta",
"briefing-contact",
"cover",
"cover-copy-mark",
"cover-project-name",
"cover-project-number",
"cover-title",
"cover-volume",
"cover-owner",
"cover-prepared-by",
"cover-bidder",
"cover-representative",
"cover-version",
"cover-date"
] as const;
export const docxStyleSlotNameSchema = z.enum(
DOCX_STYLE_SLOT_NAMES
);
export type DocxStyleSlotName = z.infer<
typeof docxStyleSlotNameSchema
>;
export const docxStyleSlotKindSchema = z.enum([
"document",
"paragraph",
"inline",
"list",
"table",
"media",
"structure"
]);
export type DocxStyleSlotKind = z.infer<
typeof docxStyleSlotKindSchema
>;
export interface DocxStyleSlotDefinition {
name: DocxStyleSlotName;
kind: DocxStyleSlotKind;
selector: string;
}
function slot(
name: DocxStyleSlotName,
kind: DocxStyleSlotKind
): DocxStyleSlotDefinition {
return {
name,
kind,
selector: `[data-docx-slot="${name}"]`
};
}
const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
document: "document",
"document-title": "paragraph",
"document-author": "paragraph",
paragraph: "paragraph",
strong: "inline",
emphasis: "inline",
strikethrough: "inline",
"inline-code": "inline",
"heading-1": "paragraph",
"heading-2": "paragraph",
"heading-3": "paragraph",
"heading-4": "paragraph",
"heading-5": "paragraph",
"heading-6": "paragraph",
"unordered-list": "list",
"ordered-list": "list",
"list-item": "list",
"block-quote": "paragraph",
"code-block": "paragraph",
table: "table",
"table-header": "table",
"table-cell": "table",
hyperlink: "inline",
figure: "media",
image: "media",
caption: "paragraph",
footnotes: "paragraph",
"official-masthead": "structure",
"official-classification": "structure",
"official-issuer": "structure",
"official-issue-row": "structure",
"official-number": "structure",
"official-signatory": "structure",
"official-signature": "structure",
"official-edition": "structure",
"briefing-masthead": "structure",
"briefing-meta": "structure",
"briefing-contact": "structure",
cover: "structure",
"cover-copy-mark": "structure",
"cover-project-name": "structure",
"cover-project-number": "structure",
"cover-title": "structure",
"cover-volume": "structure",
"cover-owner": "structure",
"cover-prepared-by": "structure",
"cover-bidder": "structure",
"cover-representative": "structure",
"cover-version": "structure",
"cover-date": "structure"
};
export const DOCX_STYLE_SLOTS: readonly DocxStyleSlotDefinition[] =
DOCX_STYLE_SLOT_NAMES.map((name) => slot(name, kinds[name]));
@@ -0,0 +1,90 @@
import { z } from "zod";
import { docxStyleSlotNameSchema } from "./slots.js";
const cssValueSchema = z.string().trim().min(1).max(500);
export const docxComputedStyleSchema = z.object({
fontFamily: cssValueSchema,
fontSize: cssValueSchema,
fontWeight: cssValueSchema,
fontStyle: cssValueSchema,
color: cssValueSchema,
backgroundColor: cssValueSchema,
lineHeight: cssValueSchema,
letterSpacing: cssValueSchema,
textAlign: cssValueSchema,
textIndent: cssValueSchema,
textDecorationLine: cssValueSchema,
marginTop: cssValueSchema,
marginRight: cssValueSchema,
marginBottom: cssValueSchema,
marginLeft: cssValueSchema,
paddingTop: cssValueSchema,
paddingRight: cssValueSchema,
paddingBottom: cssValueSchema,
paddingLeft: cssValueSchema,
borderTop: cssValueSchema,
borderRight: cssValueSchema,
borderBottom: cssValueSchema,
borderLeft: cssValueSchema,
width: cssValueSchema,
maxWidth: cssValueSchema,
breakBefore: cssValueSchema,
breakAfter: cssValueSchema,
breakInside: cssValueSchema,
display: cssValueSchema
});
export type DocxComputedStyle = z.infer<
typeof docxComputedStyleSchema
>;
export const docxStyleSlotSnapshotSchema = z.object({
slot: docxStyleSlotNameSchema,
matched: z.boolean(),
computed: docxComputedStyleSchema.optional()
});
export type DocxStyleSlotSnapshot = z.infer<
typeof docxStyleSlotSnapshotSchema
>;
export const docxThemeStyleSnapshotSchema = z
.object({
schemaVersion: z.literal(1),
themeId: z
.string()
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
themeFingerprint: z.string().regex(/^[a-f0-9]{64}$/u),
viewport: z.object({
widthPx: z.number().int().positive().max(10000),
heightPx: z.number().int().positive().max(10000),
deviceScaleFactor: z.number().positive().max(10)
}),
rootFontSizePx: z.number().positive().max(200),
slots: z.array(docxStyleSlotSnapshotSchema).min(1)
})
.superRefine((snapshot, context) => {
const names = new Set<string>();
for (const entry of snapshot.slots) {
if (names.has(entry.slot)) {
context.addIssue({
code: "custom",
message: `样式快照包含重复槽位:${entry.slot}`,
path: ["slots"]
});
}
names.add(entry.slot);
if (entry.matched !== Boolean(entry.computed)) {
context.addIssue({
code: "custom",
message: `槽位 ${entry.slot} 的匹配状态与计算样式不一致`,
path: ["slots"]
});
}
}
});
export type DocxThemeStyleSnapshot = z.infer<
typeof docxThemeStyleSnapshotSchema
>;
+158
View File
@@ -0,0 +1,158 @@
import {
docxStylePresetSchema,
docxThemeStyleModeSchema
} from "@md-to-pdf/core";
import { z } from "zod";
import { docxStyleSlotNameSchema } from "./slots.js";
const colorSchema = z
.string()
.regex(/^#[0-9a-f]{6}$/iu, "使");
export const docxStyleValueSourceSchema = z.enum([
"computed-css",
"manifest-override",
"preset-fallback",
"engine-default"
]);
export type DocxStyleValueSource = z.infer<
typeof docxStyleValueSourceSchema
>;
export const docxStyleConfidenceSchema = z.enum([
"exact",
"approximate",
"fallback"
]);
export type DocxStyleConfidence = z.infer<
typeof docxStyleConfidenceSchema
>;
export const docxThemeDiagnosticSeveritySchema = z.enum([
"info",
"warning",
"error"
]);
export const docxThemeDiagnosticCodeSchema = z.enum([
"slot-not-found",
"css-value-invalid",
"css-property-unsupported",
"font-fallback-required",
"layout-approximated",
"preset-fallback-used",
"manifest-override-applied"
]);
export const docxThemeDiagnosticSchema = z.object({
severity: docxThemeDiagnosticSeveritySchema,
code: docxThemeDiagnosticCodeSchema,
message: z.string().min(1).max(500),
slot: docxStyleSlotNameSchema.optional(),
property: z.string().min(1).max(100).optional()
});
export type DocxThemeDiagnostic = z.infer<
typeof docxThemeDiagnosticSchema
>;
const docxBorderTokenSchema = z.object({
widthPt: z.number().min(0).max(20),
color: colorSchema,
style: z.enum([
"none",
"single",
"double",
"dotted",
"dashed"
])
});
export const docxSlotStyleTokenSchema = z.object({
fontCandidates: z.array(z.string().trim().min(1).max(100)).max(20),
fontSizePt: z.number().min(1).max(200).optional(),
bold: z.boolean().optional(),
italic: z.boolean().optional(),
underline: z.boolean().optional(),
strikethrough: z.boolean().optional(),
color: colorSchema.optional(),
backgroundColor: colorSchema.optional(),
lineSpacing: z.number().min(0.5).max(10).optional(),
letterSpacingPt: z.number().min(-20).max(100).optional(),
alignment: z
.enum(["left", "center", "right", "justify"])
.optional(),
firstLineIndentPt: z.number().min(-1000).max(1000).optional(),
leftIndentPt: z.number().min(-1000).max(1000).optional(),
rightIndentPt: z.number().min(-1000).max(1000).optional(),
spacingBeforePt: z.number().min(0).max(2000).optional(),
spacingAfterPt: z.number().min(0).max(2000).optional(),
paddingPt: z
.object({
top: z.number().min(0).max(1000),
right: z.number().min(0).max(1000),
bottom: z.number().min(0).max(1000),
left: z.number().min(0).max(1000)
})
.optional(),
borders: z
.object({
top: docxBorderTokenSchema.optional(),
right: docxBorderTokenSchema.optional(),
bottom: docxBorderTokenSchema.optional(),
left: docxBorderTokenSchema.optional()
})
.optional(),
widthPercent: z.number().min(0).max(100).optional(),
pageBreakBefore: z.boolean().optional(),
pageBreakAfter: z.boolean().optional(),
keepLines: z.boolean().optional(),
keepWithNext: z.boolean().optional()
});
export type DocxSlotStyleToken = z.infer<
typeof docxSlotStyleTokenSchema
>;
export const docxResolvedStyleSlotSchema = z.object({
slot: docxStyleSlotNameSchema,
source: docxStyleValueSourceSchema,
confidence: docxStyleConfidenceSchema,
style: docxSlotStyleTokenSchema
});
export type DocxResolvedStyleSlot = z.infer<
typeof docxResolvedStyleSlotSchema
>;
export const docxThemeTokenSetSchema = z
.object({
schemaVersion: z.literal(1),
themeId: z
.string()
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
themeFingerprint: z.string().regex(/^[a-f0-9]{64}$/u),
mode: docxThemeStyleModeSchema,
basePreset: docxStylePresetSchema,
slots: z.array(docxResolvedStyleSlotSchema),
diagnostics: z.array(docxThemeDiagnosticSchema)
})
.superRefine((tokens, context) => {
const names = new Set<string>();
for (const entry of tokens.slots) {
if (names.has(entry.slot)) {
context.addIssue({
code: "custom",
message: `DOCX 样式令牌包含重复槽位:${entry.slot}`,
path: ["slots"]
});
}
names.add(entry.slot);
}
});
export type DocxThemeTokenSet = z.infer<
typeof docxThemeTokenSetSchema
>;