feat: 建立 DOCX 主题映射引擎
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./configuration.js";
|
||||
export * from "./slots.js";
|
||||
export * from "./snapshot.js";
|
||||
export * from "./tokens.js";
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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
|
||||
>;
|
||||
Reference in New Issue
Block a user