843 lines
22 KiB
TypeScript
843 lines
22 KiB
TypeScript
import type {
|
|
DocxFontFamily,
|
|
DocxThemeStyleOverrides
|
|
} from "@md-to-pdf/core";
|
|
import {
|
|
cssColorHasPartialAlpha,
|
|
millimetersToPoints,
|
|
parseCssBorder,
|
|
parseCssColor,
|
|
parseCssFontFamilies,
|
|
parseCssLengthToPt,
|
|
roundDocxValue
|
|
} from "./css-values.js";
|
|
import type {
|
|
NormalizedDocxThemeMappingConfig
|
|
} from "./configuration.js";
|
|
import {
|
|
DOCX_STYLE_SLOTS,
|
|
type DocxStyleSlotKind,
|
|
type DocxStyleSlotName
|
|
} from "./slots.js";
|
|
import {
|
|
docxThemeStyleSnapshotSchema,
|
|
type DocxComputedStyle,
|
|
type DocxThemeStyleSnapshot
|
|
} from "./snapshot.js";
|
|
import {
|
|
docxThemeTokenSetSchema,
|
|
type DocxResolvedStyleSlot,
|
|
type DocxSlotStyleToken,
|
|
type DocxStyleConfidence,
|
|
type DocxThemeDiagnostic,
|
|
type DocxThemeTokenSet
|
|
} from "./tokens.js";
|
|
|
|
type MutableStyle = {
|
|
-readonly [Property in keyof DocxSlotStyleToken]:
|
|
DocxSlotStyleToken[Property];
|
|
};
|
|
|
|
interface SlotNormalizationContext {
|
|
slot: DocxStyleSlotName;
|
|
kind: DocxStyleSlotKind;
|
|
documentWidthPx?: number;
|
|
diagnostics: DocxThemeDiagnostic[];
|
|
approximate: boolean;
|
|
}
|
|
|
|
function cssLengthToPx(value: string): number {
|
|
const valuePt = parseCssLengthToPt(value);
|
|
return valuePt === undefined ? 0 : valuePt / 0.75;
|
|
}
|
|
|
|
function resolveDocumentContentWidthPx(
|
|
snapshot: DocxThemeStyleSnapshot
|
|
): number | undefined {
|
|
const computed = snapshot.slots.find(
|
|
(entry) => entry.slot === "document"
|
|
)?.computed;
|
|
if (!computed) {
|
|
return undefined;
|
|
}
|
|
const widthPt = parseCssLengthToPt(computed.width);
|
|
if (widthPt === undefined) {
|
|
return undefined;
|
|
}
|
|
const borderWidthPx = [computed.borderLeft, computed.borderRight]
|
|
.map((value) => parseCssBorder(value)?.widthPt ?? 0)
|
|
.reduce((total, valuePt) => total + valuePt / 0.75, 0);
|
|
const contentWidthPx =
|
|
widthPt / 0.75 -
|
|
cssLengthToPx(computed.paddingLeft) -
|
|
cssLengthToPx(computed.paddingRight) -
|
|
borderWidthPx;
|
|
return contentWidthPx > 0 ? contentWidthPx : widthPt / 0.75;
|
|
}
|
|
|
|
const paragraphKinds = new Set<DocxStyleSlotKind>([
|
|
"document",
|
|
"paragraph",
|
|
"list",
|
|
"structure"
|
|
]);
|
|
const widthSlots = new Set<DocxStyleSlotName>([
|
|
"table",
|
|
"figure"
|
|
]);
|
|
const bodyOverrideSlots = new Set<DocxStyleSlotName>([
|
|
"document",
|
|
"document-author",
|
|
"paragraph",
|
|
"unordered-list",
|
|
"ordered-list",
|
|
"list-item",
|
|
"footnotes"
|
|
]);
|
|
const headingSlots = [
|
|
"heading-1",
|
|
"heading-2",
|
|
"heading-3",
|
|
"heading-4",
|
|
"heading-5",
|
|
"heading-6"
|
|
] as const satisfies readonly DocxStyleSlotName[];
|
|
|
|
function diagnostic(
|
|
context: SlotNormalizationContext,
|
|
input: Omit<DocxThemeDiagnostic, "slot">
|
|
): void {
|
|
context.diagnostics.push({
|
|
...input,
|
|
slot: context.slot
|
|
});
|
|
}
|
|
|
|
function invalidCssValue(
|
|
context: SlotNormalizationContext,
|
|
property: string,
|
|
value: string
|
|
): void {
|
|
diagnostic(context, {
|
|
severity: "warning",
|
|
code: "css-value-invalid",
|
|
property,
|
|
message: `无法将 CSS ${property} 值“${value}”映射到 Word`
|
|
});
|
|
context.approximate = true;
|
|
}
|
|
|
|
function length(
|
|
context: SlotNormalizationContext,
|
|
property: string,
|
|
value: string,
|
|
options: { allowAuto?: boolean; nonNegative?: boolean } = {}
|
|
): number | undefined {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (
|
|
options.allowAuto &&
|
|
["auto", "none", "normal"].includes(normalized)
|
|
) {
|
|
return undefined;
|
|
}
|
|
const result = parseCssLengthToPt(value);
|
|
if (result === undefined) {
|
|
invalidCssValue(context, property, value);
|
|
return undefined;
|
|
}
|
|
if (options.nonNegative && result < 0) {
|
|
diagnostic(context, {
|
|
severity: "warning",
|
|
code: "layout-approximated",
|
|
property,
|
|
message: `Word 不支持负的 ${property},已收敛为 0`
|
|
});
|
|
context.approximate = true;
|
|
return 0;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function color(
|
|
context: SlotNormalizationContext,
|
|
property: string,
|
|
value: string,
|
|
transparentAllowed = false
|
|
): string | undefined {
|
|
const result = parseCssColor(value);
|
|
if (
|
|
result === undefined &&
|
|
!(
|
|
transparentAllowed &&
|
|
/^(?:transparent|rgba?\([^)]*(?:,\s*0|\/\s*0%?)\s*\))$/iu.test(
|
|
value.trim()
|
|
)
|
|
)
|
|
) {
|
|
invalidCssValue(context, property, value);
|
|
}
|
|
if (result !== undefined && cssColorHasPartialAlpha(value)) {
|
|
diagnostic(context, {
|
|
severity: "info",
|
|
code: "layout-approximated",
|
|
property,
|
|
message: `CSS ${property} 半透明色已按白底合成为 Word 不透明色`
|
|
});
|
|
context.approximate = true;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeAlignment(
|
|
context: SlotNormalizationContext,
|
|
computed: DocxComputedStyle
|
|
): DocxSlotStyleToken["alignment"] {
|
|
const alignment = computed.textAlign.toLowerCase();
|
|
if (
|
|
alignment === "left" ||
|
|
alignment === "center" ||
|
|
alignment === "right" ||
|
|
alignment === "justify"
|
|
) {
|
|
return alignment;
|
|
}
|
|
if (alignment === "start") {
|
|
return computed.direction === "rtl" ? "right" : "left";
|
|
}
|
|
if (alignment === "end") {
|
|
return computed.direction === "rtl" ? "left" : "right";
|
|
}
|
|
invalidCssValue(context, "text-align", computed.textAlign);
|
|
return undefined;
|
|
}
|
|
|
|
function normalizeBorder(
|
|
context: SlotNormalizationContext,
|
|
property: string,
|
|
value: string
|
|
) {
|
|
const parsed = parseCssBorder(value);
|
|
if (!parsed) {
|
|
invalidCssValue(context, property, value);
|
|
return undefined;
|
|
}
|
|
if (parsed.approximated) {
|
|
diagnostic(context, {
|
|
severity: "warning",
|
|
code: "css-property-unsupported",
|
|
property,
|
|
message: `CSS ${property} 的边框样式已近似为 Word 单实线`
|
|
});
|
|
context.approximate = true;
|
|
}
|
|
const widthPt = Math.min(20, parsed.widthPt);
|
|
if (widthPt !== parsed.widthPt) {
|
|
diagnostic(context, {
|
|
severity: "warning",
|
|
code: "layout-approximated",
|
|
property,
|
|
message: `CSS ${property} 宽度 ${parsed.widthPt}pt 超过 Word 映射上限,已收敛为 20pt`
|
|
});
|
|
context.approximate = true;
|
|
}
|
|
return parsed.style === "none" ? undefined : {
|
|
widthPt,
|
|
color: parsed.color,
|
|
style: parsed.style
|
|
};
|
|
}
|
|
|
|
function normalizeComputedSlot(
|
|
computed: DocxComputedStyle,
|
|
context: SlotNormalizationContext
|
|
): {
|
|
style: DocxSlotStyleToken;
|
|
confidence: DocxStyleConfidence;
|
|
} {
|
|
const fontSizePt = length(
|
|
context,
|
|
"font-size",
|
|
computed.fontSize
|
|
);
|
|
const style: MutableStyle = {
|
|
fontCandidates: parseCssFontFamilies(computed.fontFamily)
|
|
};
|
|
if (!style.fontCandidates.length) {
|
|
invalidCssValue(context, "font-family", computed.fontFamily);
|
|
}
|
|
if (fontSizePt !== undefined) {
|
|
style.fontSizePt = fontSizePt;
|
|
}
|
|
const weight = Number.parseInt(computed.fontWeight, 10);
|
|
style.bold =
|
|
computed.fontWeight.toLowerCase() === "bold" ||
|
|
(Number.isFinite(weight) && weight >= 600);
|
|
style.italic = ["italic", "oblique"].includes(
|
|
computed.fontStyle.toLowerCase()
|
|
);
|
|
const decorations = computed.textDecorationLine
|
|
.toLowerCase()
|
|
.split(/\s+/u);
|
|
style.underline = decorations.includes("underline");
|
|
style.strikethrough = decorations.includes("line-through");
|
|
const foreground = color(context, "color", computed.color);
|
|
if (foreground) {
|
|
style.color = foreground;
|
|
}
|
|
const background = color(
|
|
context,
|
|
"background-color",
|
|
computed.backgroundColor,
|
|
true
|
|
);
|
|
if (background) {
|
|
style.backgroundColor = background;
|
|
}
|
|
const letterSpacing = computed.letterSpacing.toLowerCase() === "normal"
|
|
? 0
|
|
: length(context, "letter-spacing", computed.letterSpacing);
|
|
if (letterSpacing !== undefined) {
|
|
style.letterSpacingPt = letterSpacing;
|
|
}
|
|
if (fontSizePt !== undefined) {
|
|
if (computed.lineHeight.toLowerCase() === "normal") {
|
|
style.lineSpacing = 1.2;
|
|
diagnostic(context, {
|
|
severity: "info",
|
|
code: "layout-approximated",
|
|
property: "line-height",
|
|
message: "CSS normal 行高按 Word 1.2 倍行距近似"
|
|
});
|
|
context.approximate = true;
|
|
} else {
|
|
const lineHeightPt = length(
|
|
context,
|
|
"line-height",
|
|
computed.lineHeight
|
|
);
|
|
if (lineHeightPt !== undefined) {
|
|
style.lineSpacing = roundDocxValue(
|
|
Math.max(0.5, Math.min(10, lineHeightPt / fontSizePt))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const top = normalizeBorder(context, "border-top", computed.borderTop);
|
|
const right = normalizeBorder(
|
|
context,
|
|
"border-right",
|
|
computed.borderRight
|
|
);
|
|
const bottom = normalizeBorder(
|
|
context,
|
|
"border-bottom",
|
|
computed.borderBottom
|
|
);
|
|
const left = normalizeBorder(
|
|
context,
|
|
"border-left",
|
|
computed.borderLeft
|
|
);
|
|
if (top || right || bottom || left) {
|
|
style.borders = {
|
|
...(top ? { top } : {}),
|
|
...(right ? { right } : {}),
|
|
...(bottom ? { bottom } : {}),
|
|
...(left ? { left } : {})
|
|
};
|
|
}
|
|
const outline = normalizeBorder(
|
|
context,
|
|
"outline",
|
|
computed.outline
|
|
);
|
|
if (outline) {
|
|
if (!style.borders) {
|
|
style.borders = {
|
|
top: outline,
|
|
right: outline,
|
|
bottom: outline,
|
|
left: outline
|
|
};
|
|
diagnostic(context, {
|
|
severity: "warning",
|
|
code: "layout-approximated",
|
|
property: "outline",
|
|
message: "CSS outline 已近似为 Word 四边边框"
|
|
});
|
|
} else {
|
|
diagnostic(context, {
|
|
severity: "warning",
|
|
code: "css-property-unsupported",
|
|
property: "outline",
|
|
message: "CSS border 与 outline 并存,Word 单层边框仅保留 border,结构层需生成双层边框"
|
|
});
|
|
}
|
|
context.approximate = true;
|
|
const outlineOffsetPt = length(
|
|
context,
|
|
"outline-offset",
|
|
computed.outlineOffset
|
|
);
|
|
if (outlineOffsetPt !== undefined && outlineOffsetPt !== 0) {
|
|
diagnostic(context, {
|
|
severity: "warning",
|
|
code: "css-property-unsupported",
|
|
property: "outline-offset",
|
|
message: `Word 边框不支持 CSS outline-offset ${outlineOffsetPt}pt,结构层需近似处理`
|
|
});
|
|
context.approximate = true;
|
|
}
|
|
}
|
|
if (paragraphKinds.has(context.kind) || context.kind === "table") {
|
|
style.alignment = normalizeAlignment(context, computed);
|
|
const firstLineIndentPt = length(
|
|
context,
|
|
"text-indent",
|
|
computed.textIndent
|
|
);
|
|
const leftIndentPt = length(
|
|
context,
|
|
"margin-left",
|
|
computed.marginLeft
|
|
);
|
|
const rightIndentPt = length(
|
|
context,
|
|
"margin-right",
|
|
computed.marginRight
|
|
);
|
|
const spacingBeforePt = length(
|
|
context,
|
|
"margin-top",
|
|
computed.marginTop,
|
|
{ nonNegative: true }
|
|
);
|
|
const spacingAfterPt = length(
|
|
context,
|
|
"margin-bottom",
|
|
computed.marginBottom,
|
|
{ nonNegative: true }
|
|
);
|
|
if (firstLineIndentPt !== undefined) {
|
|
style.firstLineIndentPt = firstLineIndentPt;
|
|
}
|
|
if (leftIndentPt !== undefined) {
|
|
style.leftIndentPt = leftIndentPt;
|
|
}
|
|
if (rightIndentPt !== undefined) {
|
|
style.rightIndentPt = rightIndentPt;
|
|
}
|
|
if (spacingBeforePt !== undefined) {
|
|
style.spacingBeforePt = spacingBeforePt;
|
|
}
|
|
if (spacingAfterPt !== undefined) {
|
|
style.spacingAfterPt = spacingAfterPt;
|
|
}
|
|
}
|
|
if (context.kind !== "inline") {
|
|
const padding = {
|
|
top: length(context, "padding-top", computed.paddingTop, {
|
|
nonNegative: true
|
|
}),
|
|
right: length(context, "padding-right", computed.paddingRight, {
|
|
nonNegative: true
|
|
}),
|
|
bottom: length(context, "padding-bottom", computed.paddingBottom, {
|
|
nonNegative: true
|
|
}),
|
|
left: length(context, "padding-left", computed.paddingLeft, {
|
|
nonNegative: true
|
|
})
|
|
};
|
|
if (
|
|
padding.top !== undefined &&
|
|
padding.right !== undefined &&
|
|
padding.bottom !== undefined &&
|
|
padding.left !== undefined
|
|
) {
|
|
style.paddingPt = {
|
|
top: padding.top,
|
|
right: padding.right,
|
|
bottom: padding.bottom,
|
|
left: padding.left
|
|
};
|
|
}
|
|
}
|
|
if (widthSlots.has(context.slot) && context.documentWidthPx) {
|
|
const widthPt = parseCssLengthToPt(computed.width);
|
|
const widthPx = widthPt === undefined ? undefined : widthPt / 0.75;
|
|
if (widthPx !== undefined) {
|
|
const percent = (widthPx / context.documentWidthPx) * 100;
|
|
style.widthPercent = Math.round(
|
|
Math.max(0, Math.min(100, percent))
|
|
);
|
|
if (percent > 100.01) {
|
|
diagnostic(context, {
|
|
severity: "info",
|
|
code: "layout-approximated",
|
|
property: "width",
|
|
message: "元素宽度超过正文区域,已收敛为 100%"
|
|
});
|
|
context.approximate = true;
|
|
}
|
|
}
|
|
}
|
|
const minimumHeightPt = length(
|
|
context,
|
|
"min-height",
|
|
computed.minHeight,
|
|
{ allowAuto: true, nonNegative: true }
|
|
);
|
|
if (minimumHeightPt !== undefined && minimumHeightPt > 0) {
|
|
style.minimumHeightPt = minimumHeightPt;
|
|
}
|
|
if (computed.display === "none") {
|
|
style.hidden = true;
|
|
}
|
|
if (
|
|
computed.display === "flex" &&
|
|
computed.flexDirection.startsWith("column")
|
|
) {
|
|
const verticalAlignment = {
|
|
"flex-start": "top",
|
|
start: "top",
|
|
center: "center",
|
|
"flex-end": "bottom",
|
|
end: "bottom"
|
|
} as const;
|
|
const resolved =
|
|
verticalAlignment[
|
|
computed.justifyContent as keyof typeof verticalAlignment
|
|
];
|
|
if (resolved) {
|
|
style.verticalAlignment = resolved;
|
|
}
|
|
}
|
|
if (computed.display === "flex" || computed.display === "grid") {
|
|
diagnostic(context, {
|
|
severity: "info",
|
|
code: "layout-approximated",
|
|
property: "display",
|
|
message: `CSS ${computed.display} 布局将由 DOCX 结构层近似`
|
|
});
|
|
context.approximate = true;
|
|
}
|
|
style.pageBreakBefore = ["page", "left", "right"].includes(
|
|
computed.breakBefore
|
|
)
|
|
? true
|
|
: undefined;
|
|
style.pageBreakAfter = ["page", "left", "right"].includes(
|
|
computed.breakAfter
|
|
)
|
|
? true
|
|
: undefined;
|
|
style.keepLines = ["avoid", "avoid-page"].includes(
|
|
computed.breakInside
|
|
)
|
|
? true
|
|
: undefined;
|
|
style.keepWithNext = ["avoid", "avoid-page"].includes(
|
|
computed.breakAfter
|
|
)
|
|
? true
|
|
: undefined;
|
|
return {
|
|
style,
|
|
confidence: context.approximate ? "approximate" : "exact"
|
|
};
|
|
}
|
|
|
|
function overrideFontCandidates(fonts: DocxFontFamily): string[] {
|
|
return [
|
|
fonts.eastAsia,
|
|
fonts.latin,
|
|
...(fonts.complexScript ? [fonts.complexScript] : [])
|
|
].filter((font, index, all) => all.indexOf(font) === index);
|
|
}
|
|
|
|
function applyBorderColor(
|
|
style: MutableStyle,
|
|
borderColor: string,
|
|
sides: readonly ("top" | "right" | "bottom" | "left")[]
|
|
): void {
|
|
const borders = { ...style.borders };
|
|
for (const side of sides) {
|
|
borders[side] = {
|
|
widthPt: borders[side]?.widthPt ?? 0.75,
|
|
style: borders[side]?.style ?? "single",
|
|
color: borderColor
|
|
};
|
|
}
|
|
style.borders = borders;
|
|
}
|
|
|
|
function applyOverrides(
|
|
slot: DocxStyleSlotName,
|
|
styleInput: DocxSlotStyleToken,
|
|
overrides: DocxThemeStyleOverrides
|
|
): {
|
|
style: DocxSlotStyleToken;
|
|
applied: boolean;
|
|
} {
|
|
const style: MutableStyle = { ...styleInput };
|
|
let applied = false;
|
|
const assign = <Key extends keyof MutableStyle>(
|
|
key: Key,
|
|
value: MutableStyle[Key] | undefined
|
|
) => {
|
|
if (value !== undefined) {
|
|
style[key] = value;
|
|
applied = true;
|
|
}
|
|
};
|
|
if (bodyOverrideSlots.has(slot) && overrides.body) {
|
|
const body = overrides.body;
|
|
assign(
|
|
"fontCandidates",
|
|
body.fonts
|
|
? overrideFontCandidates(body.fonts)
|
|
: undefined
|
|
);
|
|
assign("fontSizePt", body.sizePt);
|
|
assign("color", body.color?.toLowerCase());
|
|
assign("lineSpacing", body.lineSpacing);
|
|
assign(
|
|
"firstLineIndentPt",
|
|
body.firstLineIndentChars === undefined
|
|
? undefined
|
|
: body.firstLineIndentChars *
|
|
(body.sizePt ?? style.fontSizePt ?? 12)
|
|
);
|
|
assign("spacingBeforePt", body.spacingBeforePt);
|
|
assign("spacingAfterPt", body.spacingAfterPt);
|
|
}
|
|
const headingIndex = headingSlots.indexOf(
|
|
slot as (typeof headingSlots)[number]
|
|
);
|
|
if (headingIndex >= 0 && overrides.headings) {
|
|
const headings = overrides.headings;
|
|
assign(
|
|
"fontCandidates",
|
|
headings.fonts
|
|
? overrideFontCandidates(headings.fonts)
|
|
: undefined
|
|
);
|
|
assign("fontSizePt", headings.sizesPt?.[headingIndex]);
|
|
assign("color", headings.color?.toLowerCase());
|
|
assign("bold", headings.bold);
|
|
assign("spacingBeforePt", headings.spacingBeforePt);
|
|
assign("spacingAfterPt", headings.spacingAfterPt);
|
|
}
|
|
if (
|
|
(slot === "inline-code" || slot === "code-block") &&
|
|
overrides.code
|
|
) {
|
|
const code = overrides.code;
|
|
assign(
|
|
"fontCandidates",
|
|
code.fonts ? overrideFontCandidates(code.fonts) : undefined
|
|
);
|
|
assign("fontSizePt", code.sizePt);
|
|
assign("color", code.color?.toLowerCase());
|
|
assign("backgroundColor", code.backgroundColor?.toLowerCase());
|
|
assign("lineSpacing", code.lineSpacing);
|
|
if (code.borderColor) {
|
|
applyBorderColor(
|
|
style,
|
|
code.borderColor.toLowerCase(),
|
|
["top", "right", "bottom", "left"]
|
|
);
|
|
applied = true;
|
|
}
|
|
}
|
|
if (slot === "block-quote" && overrides.blockQuote) {
|
|
const quote = overrides.blockQuote;
|
|
assign("color", quote.color?.toLowerCase());
|
|
assign("backgroundColor", quote.backgroundColor?.toLowerCase());
|
|
assign("italic", quote.italic);
|
|
assign(
|
|
"leftIndentPt",
|
|
quote.leftIndentChars === undefined
|
|
? undefined
|
|
: quote.leftIndentChars * (style.fontSizePt ?? 12)
|
|
);
|
|
if (quote.borderColor) {
|
|
applyBorderColor(
|
|
style,
|
|
quote.borderColor.toLowerCase(),
|
|
["left"]
|
|
);
|
|
applied = true;
|
|
}
|
|
}
|
|
if (
|
|
(slot === "table" ||
|
|
slot === "table-header" ||
|
|
slot === "table-cell") &&
|
|
overrides.table
|
|
) {
|
|
const table = overrides.table;
|
|
assign(
|
|
"fontCandidates",
|
|
table.fonts ? overrideFontCandidates(table.fonts) : undefined
|
|
);
|
|
assign("fontSizePt", table.sizePt);
|
|
assign(
|
|
"color",
|
|
(
|
|
slot === "table-header"
|
|
? table.headerColor ?? table.color
|
|
: table.color
|
|
)?.toLowerCase()
|
|
);
|
|
if (slot === "table-header") {
|
|
assign(
|
|
"backgroundColor",
|
|
table.headerBackgroundColor?.toLowerCase()
|
|
);
|
|
}
|
|
if (table.borderColor) {
|
|
applyBorderColor(
|
|
style,
|
|
table.borderColor.toLowerCase(),
|
|
["top", "right", "bottom", "left"]
|
|
);
|
|
applied = true;
|
|
}
|
|
if (
|
|
table.cellMarginMm !== undefined &&
|
|
(slot === "table-header" || slot === "table-cell")
|
|
) {
|
|
const margin = millimetersToPoints(table.cellMarginMm);
|
|
assign("paddingPt", {
|
|
top: margin,
|
|
right: margin,
|
|
bottom: margin,
|
|
left: margin
|
|
});
|
|
}
|
|
}
|
|
if (slot === "caption" && overrides.caption) {
|
|
const caption = overrides.caption;
|
|
assign(
|
|
"fontCandidates",
|
|
caption.fonts
|
|
? overrideFontCandidates(caption.fonts)
|
|
: undefined
|
|
);
|
|
assign("fontSizePt", caption.sizePt);
|
|
assign("color", caption.color?.toLowerCase());
|
|
assign("italic", caption.italic);
|
|
assign("alignment", caption.alignment);
|
|
}
|
|
if (slot === "hyperlink" && overrides.hyperlink) {
|
|
assign("color", overrides.hyperlink.color?.toLowerCase());
|
|
assign("underline", overrides.hyperlink.underline);
|
|
}
|
|
return { style, applied };
|
|
}
|
|
|
|
export interface ResolveDocxThemeTokensOptions {
|
|
snapshot: DocxThemeStyleSnapshot;
|
|
config: NormalizedDocxThemeMappingConfig;
|
|
}
|
|
|
|
export function resolveDocxThemeTokens(
|
|
input: ResolveDocxThemeTokensOptions
|
|
): DocxThemeTokenSet {
|
|
const snapshot = docxThemeStyleSnapshotSchema.parse(input.snapshot);
|
|
const diagnostics: DocxThemeDiagnostic[] = [];
|
|
const documentWidthPx = resolveDocumentContentWidthPx(snapshot);
|
|
const snapshotBySlot = new Map(
|
|
snapshot.slots.map((entry) => [entry.slot, entry])
|
|
);
|
|
const slots: DocxResolvedStyleSlot[] = DOCX_STYLE_SLOTS.map(
|
|
(definition) => {
|
|
const entry = snapshotBySlot.get(definition.name);
|
|
const useComputedCss =
|
|
input.config.mode !== "explicit" &&
|
|
entry?.matched === true &&
|
|
entry.computed !== undefined;
|
|
let resolved: DocxResolvedStyleSlot;
|
|
if (useComputedCss && entry.computed) {
|
|
const context: SlotNormalizationContext = {
|
|
slot: definition.name,
|
|
kind: definition.kind,
|
|
...(documentWidthPx === undefined
|
|
? {}
|
|
: { documentWidthPx }),
|
|
diagnostics,
|
|
approximate: false
|
|
};
|
|
const normalized = normalizeComputedSlot(
|
|
entry.computed,
|
|
context
|
|
);
|
|
resolved = {
|
|
slot: definition.name,
|
|
source: "computed-css",
|
|
confidence: normalized.confidence,
|
|
style: normalized.style
|
|
};
|
|
} else {
|
|
const missing =
|
|
input.config.mode !== "explicit" &&
|
|
entry?.matched !== true;
|
|
diagnostics.push({
|
|
severity: missing ? "warning" : "info",
|
|
code:
|
|
missing
|
|
? "slot-not-found"
|
|
: "preset-fallback-used",
|
|
message:
|
|
missing
|
|
? `样式槽位 ${definition.name} 未命中,使用 ${input.config.basePreset} 预设`
|
|
: `显式模式下样式槽位 ${definition.name} 使用 ${input.config.basePreset} 预设`,
|
|
slot: definition.name
|
|
});
|
|
resolved = {
|
|
slot: definition.name,
|
|
source: "preset-fallback",
|
|
confidence: "fallback",
|
|
style: { fontCandidates: [] }
|
|
};
|
|
}
|
|
if (
|
|
input.config.mode === "auto-with-overrides" ||
|
|
input.config.mode === "explicit"
|
|
) {
|
|
const override = applyOverrides(
|
|
definition.name,
|
|
resolved.style,
|
|
input.config.overrides
|
|
);
|
|
if (override.applied) {
|
|
diagnostics.push({
|
|
severity: "info",
|
|
code: "manifest-override-applied",
|
|
message: `主题清单覆盖已应用到 ${definition.name}`,
|
|
slot: definition.name
|
|
});
|
|
resolved = {
|
|
slot: definition.name,
|
|
source: "manifest-override",
|
|
confidence: "exact",
|
|
style: override.style
|
|
};
|
|
}
|
|
}
|
|
return resolved;
|
|
}
|
|
);
|
|
return docxThemeTokenSetSchema.parse({
|
|
schemaVersion: 1,
|
|
themeId: snapshot.themeId,
|
|
themeFingerprint: snapshot.themeFingerprint,
|
|
mode: input.config.mode,
|
|
basePreset: input.config.basePreset,
|
|
slots,
|
|
diagnostics
|
|
});
|
|
}
|