@sliiu/present-themes 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ /** 内置主题 id — 须与 `THEMES` keys 保持一致。 */
2
+ export const BUILTIN_THEME_IDS = [
3
+ "business",
4
+ "academic",
5
+ "creative",
6
+ "minimal",
7
+ "dark",
8
+ "tech",
9
+ "nature",
10
+ "warm",
11
+ "editorial",
12
+ "terminal",
13
+ "pastel",
14
+ "luxe",
15
+ "ink",
16
+ ];
@@ -0,0 +1,300 @@
1
+ // 装饰 shape 构建器 —— 供 compile.ts(AI 生成 slide)和 template.ts(新建模板)共用。
2
+ //
3
+ // 每种 ThemeDecoration 对应一套独立的视觉语言,由多个 Shape 组合实现。
4
+ // Shape 使用最小原语(rect / ellipse / line),任何渲染管线 / 序列化器均可直接处理。
5
+ //
6
+ // 约定:
7
+ // - id 前缀统一用 "dec_",切主题时 ApplyCustomThemeCmd 按前缀批量清除再重建。
8
+ // - zIndex = -1(位于 shapes 数组最底部),不遮挡内容。
9
+ // - locked 由调用方传入:template master 用 true(母版装饰不可选),
10
+ // compile.ts(slide 内装饰)用 false(用户可在画布上微调)。
11
+ // - **不要再用「左上 + 右下两枚出血大圆」这套构图**。它一度被 aurora / neon-burst /
12
+ // organic-blobs / confetti / ink-wash 共用,导致换主题只是换个颜色、feature 页
13
+ // 还每页重复同一对圆。大面积氛围元素一律走「左右出血的横向光带 / 山脊」或贴边
14
+ // 灯管,纵向收在版心之外;角部只留细线、括号、角标这类结构性小元素。
15
+ import { identityTransform } from "@sliiu/present-core";
16
+ /**
17
+ * content 页允许保留的装饰单体最大面积占比(相对 slide 面积)。
18
+ * 实测分布:氛围光晕/大色块类(Glow/Blob/Arc/Mist/Soft)全部 >5%,
19
+ * 结构类(线/栏/括号/散点/印章)全部 <3% —— 4% 是干净的分界线。
20
+ */
21
+ const CONTENT_DECOR_MAX_AREA_RATIO = 0.04;
22
+ export function buildDecorationShapesShared(dec, ctx) {
23
+ if (dec === "none")
24
+ return [];
25
+ const { palette: p, size: { width: W, height: H }, locked, idPrefix } = ctx;
26
+ let n = 0;
27
+ const nextId = () => `dec_${idPrefix}_${++n}`;
28
+ const base = () => ({
29
+ zIndex: -1,
30
+ visible: true,
31
+ locked,
32
+ opacity: 1,
33
+ transform: identityTransform(),
34
+ });
35
+ const mkRect = (bbox, color, alpha, name = "Decor") => ({
36
+ ...base(),
37
+ id: nextId(),
38
+ kind: "rect",
39
+ name,
40
+ bbox,
41
+ fill: alpha !== undefined ? { kind: "solid", color, alpha } : { kind: "solid", color },
42
+ });
43
+ const mkEllipse = (bbox, color, alpha, name = "Decor") => ({
44
+ ...base(),
45
+ id: nextId(),
46
+ kind: "ellipse",
47
+ name,
48
+ bbox,
49
+ fill: alpha !== undefined ? { kind: "solid", color, alpha } : { kind: "solid", color },
50
+ });
51
+ /**
52
+ * 渐变氛围块:从 fromAlpha 淡入到 toAlpha 的线性渐变矩形。
53
+ * 光晕类元素必须用它而不是实心椭圆 —— 实心填充没有羽化,边缘会读成一枚
54
+ * 「透镜 / 山丘」。角度沿用项目约定:0 = L→R,90 = T→B。
55
+ * 逐 stop 的 alpha 会被 serializer 完整写进 <a:gs>,导出无损。
56
+ */
57
+ const mkGlow = (bbox, color, fromAlpha, toAlpha, angle, name = "Glow") => ({
58
+ ...base(),
59
+ id: nextId(),
60
+ kind: "rect",
61
+ name,
62
+ bbox,
63
+ fill: {
64
+ kind: "linear",
65
+ angle,
66
+ stops: [
67
+ { offset: 0, color, alpha: fromAlpha },
68
+ { offset: 1, color, alpha: toAlpha },
69
+ ],
70
+ },
71
+ });
72
+ const out = [];
73
+ switch (dec) {
74
+ // ── 极光光幕(Dark 主题)──────────────────────────────────────────────────
75
+ // 上下两道全宽渐变光幕(顶部 violet 垂落、底部 cyan 升起),中间留出干净版心,
76
+ // 叠在渐变黑背景上形成极光层次。用渐变而非实心椭圆,边缘自然消隐。
77
+ case "corner-gradient-shape": // legacy 别名:渲染为 aurora(与 namedDecoration 的映射一致)
78
+ case "aurora": {
79
+ out.push(mkGlow({ x: 0, y: 0, w: W, h: H * 0.42 }, p.accent2, 0.13, 0, 90, "Aurora Veil"));
80
+ out.push(mkGlow({ x: 0, y: H * 0.48, w: W, h: H * 0.52 }, p.accent1, 0, 0.2, 90, "Aurora Veil 2"));
81
+ // 底部极细分割线
82
+ out.push(mkRect({ x: W * 0.06, y: H * 0.92, w: W * 0.88, h: 1 }, p.divider, 0.4, "Rule"));
83
+ break;
84
+ }
85
+ // ── 科技 L 形 bracket(Tech 主题)────────────────────────────────────────
86
+ // 左侧细竖条(导航轨道感)+ 右下 L 角标 + 右下小亮点(焦点坐标)。
87
+ case "tech-bracket": {
88
+ // 左侧轨道条
89
+ out.push(mkRect({ x: W * 0.028, y: H * 0.08, w: 2, h: H * 0.84 }, p.accent1, 0.55, "Rail"));
90
+ // 左侧轨道顶部亮点
91
+ out.push(mkEllipse({ x: W * 0.028 - 3, y: H * 0.08 - 3, w: 8, h: 8 }, p.accent1, 0.9, "Rail Dot"));
92
+ // 右下角 L 形 bracket:横向
93
+ out.push(mkRect({ x: W * 0.72, y: H * 0.88, w: W * 0.24, h: 2 }, p.accent2, 0.6, "Bracket H"));
94
+ // 右下角 L 形 bracket:纵向
95
+ out.push(mkRect({ x: W * 0.96 - 2, y: H * 0.72, w: 2, h: H * 0.17 }, p.accent2, 0.6, "Bracket V"));
96
+ // 角落高亮节点
97
+ out.push(mkEllipse({ x: W * 0.96 - 5, y: H * 0.88 - 3, w: 8, h: 8 }, p.accent1, 1, "Node"));
98
+ break;
99
+ }
100
+ // ── 霓虹灯管(Creative 主题)─────────────────────────────────────────────
101
+ // 顶部全宽亮条 + 右侧竖灯管 + 左下横灯管(均为「细亮条 + 外扩柔光晕」两件套)
102
+ // + 右上散点 → 霓虹夜场感。灯管贴边走,不侵占版心。
103
+ case "neon-burst": {
104
+ // 顶部亮条
105
+ out.push(mkRect({ x: 0, y: 0, w: W, h: 3 }, p.accent1, 1, "Top Bar"));
106
+ // 右侧竖灯管:柔光晕向版心方向消隐(R→L),亮管压在光晕右端
107
+ out.push(mkGlow({ x: W * 0.88, y: H * 0.16, w: W * 0.078, h: H * 0.62 }, p.accent2, 0, 0.16, 0, "Tube Halo"));
108
+ out.push(mkRect({ x: W * 0.955, y: H * 0.16, w: 3, h: H * 0.62 }, p.accent2, 0.85, "Tube"));
109
+ // 左下横灯管(短,呼应竖管):光晕向上消隐
110
+ out.push(mkGlow({ x: W * 0.06, y: H * 0.79, w: W * 0.3, h: H * 0.07 }, p.accent1, 0, 0.16, 90, "Underline Halo"));
111
+ out.push(mkRect({ x: W * 0.06, y: H * 0.86, w: W * 0.3, h: 3 }, p.accent1, 0.85, "Underline"));
112
+ // 右上散点(3 枚)
113
+ const dotPositions = [
114
+ { x: W * 0.80, y: H * 0.08 },
115
+ { x: W * 0.86, y: H * 0.05 },
116
+ { x: W * 0.92, y: H * 0.10 },
117
+ ];
118
+ for (const dp of dotPositions) {
119
+ out.push(mkEllipse({ x: dp.x, y: dp.y, w: 5, h: 5 }, p.accent3, 0.6, "Dot"));
120
+ }
121
+ break;
122
+ }
123
+ // ── 权威双条(Business 主题)─────────────────────────────────────────────
124
+ // 左侧全高纵条(主色)+ 底部全宽横条双段(主 + 次色)→ 权威、信任感。
125
+ case "authority": {
126
+ // 左侧纵条
127
+ out.push(mkRect({ x: 0, y: 0, w: 5, h: H }, p.accent1, 1, "Left Strip"));
128
+ // 底部主色大段
129
+ out.push(mkRect({ x: 0, y: H - 5, w: W * 0.72, h: 5 }, p.accent1, 1, "Bottom Bar"));
130
+ // 底部次色小段
131
+ out.push(mkRect({ x: W * 0.72, y: H - 5, w: W * 0.28, h: 5 }, p.accent2, 1, "Bottom Bar 2"));
132
+ break;
133
+ }
134
+ // ── 手稿双线(Academic 主题)─────────────────────────────────────────────
135
+ // 顶部横线 + 底部横线(双线如书页边框)+ 居中微型装饰圆点。
136
+ case "manuscript": {
137
+ // 顶部规则线
138
+ out.push(mkRect({ x: W * 0.06, y: H * 0.07, w: W * 0.88, h: 1.5 }, p.text, 0.15, "Top Rule"));
139
+ // 次级顶线(更细,稍低)
140
+ out.push(mkRect({ x: W * 0.06, y: H * 0.072, w: W * 0.88, h: 0.5 }, p.accent1, 0.25, "Top Rule 2"));
141
+ // 底部规则线
142
+ out.push(mkRect({ x: W * 0.06, y: H * 0.915, w: W * 0.88, h: 1.5 }, p.text, 0.12, "Bottom Rule"));
143
+ // 居中装饰点(顶线正中)
144
+ out.push(mkEllipse({ x: W * 0.5 - 3, y: H * 0.068, w: 6, h: 6 }, p.accent1, 0.7, "Ornament"));
145
+ // 左侧页码竖线(文稿页面感)
146
+ out.push(mkRect({ x: W * 0.038, y: H * 0.12, w: 1, h: H * 0.76 }, p.divider, 0.6, "Margin"));
147
+ break;
148
+ }
149
+ // ── 墨线单划(Minimal 主题)──────────────────────────────────────────────
150
+ // 单条精准横线,留白哲学:少即是多。
151
+ case "ink-thread": {
152
+ out.push(mkRect({ x: W * 0.08, y: H * 0.14, w: W * 0.84, h: 1 }, p.accent1, 0.35, "Thread"));
153
+ break;
154
+ }
155
+ // ── 层叠远山(Nature 主题)───────────────────────────────────────────────
156
+ // 两道左右出血的扁平山脊自下而上层叠(近深远淡)+ 顶部地平细线 → 生命感、自然感。
157
+ // 枚举值沿用 organic-blobs(已落库的主题依赖它),几何已不再是角部圆球。
158
+ case "organic-blobs": {
159
+ out.push(mkEllipse({ x: -W * 0.12, y: H * 0.72, w: W * 1.24, h: H * 0.46 }, p.accent1, 0.1, "Ridge A"));
160
+ out.push(mkEllipse({ x: -W * 0.18, y: H * 0.82, w: W * 1.36, h: H * 0.38 }, p.accent2, 0.08, "Ridge B"));
161
+ // 顶部细线(如地平线)
162
+ out.push(mkRect({ x: W * 0.06, y: H * 0.065, w: W * 0.88, h: 1 }, p.divider, 0.7, "Horizon"));
163
+ break;
164
+ }
165
+ // ── 朝阳弧(Warm 主题)───────────────────────────────────────────────────
166
+ // 底部半圆弧(只露出上半部分)+ 底部全宽横条 → 日出能量、暖意迸发。
167
+ case "sunrise-arc": {
168
+ // 朝阳大圆(圆心在 slide 底边以下,只有弧顶露出)
169
+ const r = W * 0.55;
170
+ out.push(mkEllipse({ x: W * 0.5 - r, y: H - r * 0.28, w: r * 2, h: r * 1.1 }, p.accent1, 0.12, "Sun Arc"));
171
+ // 底部实色横条
172
+ out.push(mkRect({ x: 0, y: H - 5, w: W, h: 5 }, p.accent1, 1, "Base Bar"));
173
+ // 底部次色窄条(叠加在横条上,右侧 30%)
174
+ out.push(mkRect({ x: W * 0.70, y: H - 5, w: W * 0.30, h: 5 }, p.accent3, 1, "Base Bar 2"));
175
+ break;
176
+ }
177
+ // ── 杂志报头(Editorial 主题)────────────────────────────────────────────
178
+ // 顶部粗实色报头条 + 紧贴的细分隔线 + 左侧整高页边竖线 + 底部页码块,
179
+ // 模拟杂志/报纸版式的强栏目感。
180
+ case "masthead": {
181
+ // 顶部粗报头条(文字主色)
182
+ out.push(mkRect({ x: 0, y: 0, w: W, h: 10 }, p.text, 1, "Masthead"));
183
+ // 报头条右侧强调段(accent1)
184
+ out.push(mkRect({ x: W * 0.74, y: 0, w: W * 0.26, h: 10 }, p.accent1, 1, "Masthead Accent"));
185
+ // 报头下方细分隔线
186
+ out.push(mkRect({ x: W * 0.06, y: H * 0.12, w: W * 0.88, h: 1.5 }, p.text, 0.22, "Hairline"));
187
+ // 左侧整高页边竖线(栏目分隔)
188
+ out.push(mkRect({ x: W * 0.045, y: H * 0.14, w: 1, h: H * 0.74 }, p.divider, 0.7, "Margin"));
189
+ // 底部页码块(accent1 实色小方)
190
+ out.push(mkRect({ x: W * 0.06, y: H - 14, w: 26, h: 6 }, p.accent1, 1, "Folio"));
191
+ break;
192
+ }
193
+ // ── 终端扫描线(Terminal 主题)───────────────────────────────────────────
194
+ // 左上光标块 + 命令提示符短条 + 横向扫描线(CRT 残影)+ 四角 L 形括号。
195
+ case "terminal": {
196
+ // 左上"光标"实块
197
+ out.push(mkRect({ x: W * 0.035, y: H * 0.085, w: 12, h: 22 }, p.accent1, 0.9, "Cursor"));
198
+ // 命令提示符短条(光标右侧)
199
+ out.push(mkRect({ x: W * 0.035 + 18, y: H * 0.105, w: W * 0.1, h: 3 }, p.accent2, 0.7, "Prompt"));
200
+ // 横向扫描线(CRT 残影,多条极淡)
201
+ for (let i = 0; i < 5; i++) {
202
+ out.push(mkRect({ x: 0, y: H * (0.28 + i * 0.13), w: W, h: 1 }, p.accent1, 0.05, `Scanline ${i}`));
203
+ }
204
+ // 四角 L 形括号(仅左上 + 右下,避免过满)
205
+ out.push(mkRect({ x: W * 0.03, y: H * 0.07, w: 20, h: 2 }, p.accent1, 0.5, "TL H"));
206
+ out.push(mkRect({ x: W * 0.03, y: H * 0.07, w: 2, h: 20 }, p.accent1, 0.5, "TL V"));
207
+ out.push(mkRect({ x: W * 0.97 - 20, y: H * 0.93, w: 20, h: 2 }, p.accent2, 0.5, "BR H"));
208
+ out.push(mkRect({ x: W * 0.97 - 2, y: H * 0.93 - 18, w: 2, h: 20 }, p.accent2, 0.5, "BR V"));
209
+ break;
210
+ }
211
+ // ── 彩纸礼花(Pastel 主题)───────────────────────────────────────────────
212
+ // 底部一道左右出血的柔和色带 + 散落的多彩圆点 → 活泼、亲和。
213
+ // 散点才是这套装饰的识别点,色带只负责托住画面下沿。
214
+ case "confetti": {
215
+ out.push(mkGlow({ x: 0, y: H * 0.6, w: W, h: H * 0.4 }, p.accent1, 0, 0.16, 90, "Soft Band"));
216
+ // 散落彩点(轮流用 4 个 accent)
217
+ const confettiColors = [p.accent1, p.accent2, p.accent3, p.accent4];
218
+ const confettiPos = [
219
+ { x: 0.18, y: 0.16, s: 10 }, { x: 0.34, y: 0.10, s: 7 }, { x: 0.5, y: 0.14, s: 12 },
220
+ { x: 0.66, y: 0.09, s: 8 }, { x: 0.82, y: 0.18, s: 10 }, { x: 0.9, y: 0.4, s: 7 },
221
+ { x: 0.12, y: 0.5, s: 9 }, { x: 0.24, y: 0.82, s: 8 },
222
+ ];
223
+ confettiPos.forEach((c, i) => {
224
+ out.push(mkEllipse({ x: W * c.x, y: H * c.y, w: c.s, h: c.s }, confettiColors[i % 4], 0.55, `Confetti ${i}`));
225
+ });
226
+ break;
227
+ }
228
+ // ── 鎏金细框(Luxe 主题)─────────────────────────────────────────────────
229
+ // 四边内嵌金色细线框 + 四角节点 → 高奢品牌的克制精致感。
230
+ case "gilded": {
231
+ const inset = Math.round(Math.min(W, H) * 0.05);
232
+ const lw = 1.2;
233
+ // 四边细线
234
+ out.push(mkRect({ x: inset, y: inset, w: W - inset * 2, h: lw }, p.accent1, 0.85, "Frame Top"));
235
+ out.push(mkRect({ x: inset, y: H - inset - lw, w: W - inset * 2, h: lw }, p.accent1, 0.85, "Frame Bottom"));
236
+ out.push(mkRect({ x: inset, y: inset, w: lw, h: H - inset * 2 }, p.accent1, 0.85, "Frame Left"));
237
+ out.push(mkRect({ x: W - inset - lw, y: inset, w: lw, h: H - inset * 2 }, p.accent1, 0.85, "Frame Right"));
238
+ // 四角节点小方块
239
+ const corners = [
240
+ { x: inset, y: inset }, { x: W - inset, y: inset },
241
+ { x: inset, y: H - inset }, { x: W - inset, y: H - inset },
242
+ ];
243
+ corners.forEach((c, i) => {
244
+ out.push(mkRect({ x: c.x - 3, y: c.y - 3, w: 6, h: 6 }, p.accent2, 1, `Corner ${i}`));
245
+ });
246
+ break;
247
+ }
248
+ // ── 水墨留白(Ink 主题)──────────────────────────────────────────────────
249
+ // 下半部横向远山淡墨 + 右下朱红印章方块 + 左侧竖向墨线 → 国风文气。
250
+ // 墨晕压在下半部并保持极低透明度:宣纸底 + 深色正文,任何加深都会吃掉对比度。
251
+ case "ink-wash": {
252
+ out.push(mkGlow({ x: 0, y: H * 0.55, w: W, h: H * 0.45 }, p.text, 0, 0.07, 90, "Ink Wash"));
253
+ // 远山剪影:墨韵之外留一道实体山脊线,避免整片只有均匀的灰
254
+ out.push(mkEllipse({ x: W * 0.26, y: H * 0.74, w: W * 0.62, h: H * 0.4 }, p.accent2, 0.06, "Distant Ridge"));
255
+ // 左侧竖向墨线(卷轴边)
256
+ out.push(mkRect({ x: W * 0.05, y: H * 0.14, w: 2, h: H * 0.72 }, p.text, 0.5, "Scroll Line"));
257
+ // 右下朱红印章方块(描边 + 内点)
258
+ out.push(mkRect({ x: W * 0.9, y: H * 0.8, w: 34, h: 34 }, p.accent1, 0.85, "Seal"));
259
+ out.push(mkRect({ x: W * 0.9 + 5, y: H * 0.8 + 5, w: 24, h: 24 }, p.bg, 0.9, "Seal Inner"));
260
+ out.push(mkRect({ x: W * 0.9 + 10, y: H * 0.8 + 10, w: 14, h: 14 }, p.accent1, 0.9, "Seal Core"));
261
+ break;
262
+ }
263
+ // ── 兼容:通用几何线(AI 生成主题)──────────────────────────────────────
264
+ case "geometric-lines": {
265
+ out.push(mkRect({ x: W * 0.03, y: H * 0.1, w: 2, h: H * 0.8 }, p.accent1, 0.6, "VLine"));
266
+ out.push(mkRect({ x: W * 0.5, y: H * 0.04, w: W * 0.18, h: 2 }, p.accent2, 0.5, "HLine"));
267
+ break;
268
+ }
269
+ // ── 兼容:通用底栏(AI 生成主题)────────────────────────────────────────
270
+ case "bottom-accent-bar": {
271
+ out.push(mkRect({ x: 0, y: H - 6, w: W, h: 6 }, p.accent1, undefined, "Bottom Bar"));
272
+ out.push(mkRect({ x: 0, y: H - 6, w: W * 0.18, h: 6 }, p.accent2, undefined, "Bottom Bar 2"));
273
+ break;
274
+ }
275
+ // ── 兼容:通用点阵(AI 生成主题)────────────────────────────────────────
276
+ case "dotted-grid": {
277
+ const cols = 6, rows = 4, gap = 14;
278
+ const startX = W * 0.04;
279
+ const startY = H - rows * gap - 12;
280
+ for (let r = 0; r < rows; r++) {
281
+ for (let c = 0; c < cols; c++) {
282
+ out.push(mkEllipse({ x: startX + c * gap, y: startY + r * gap, w: 3, h: 3 }, p.accent1, 0.35, `Dot ${r}-${c}`));
283
+ }
284
+ }
285
+ break;
286
+ }
287
+ }
288
+ // content 页静默化:滤掉大面积氛围元素(按几何面积判定,对所有装饰风格通用,
289
+ // 不需要逐 case 维护)。feature 页(缺省)保留完整装饰。
290
+ if (ctx.role === "content") {
291
+ const maxArea = W * H * CONTENT_DECOR_MAX_AREA_RATIO;
292
+ return out.filter((sh) => {
293
+ const b = sh.bbox;
294
+ if (!b)
295
+ return true;
296
+ return b.w * b.h <= maxArea;
297
+ });
298
+ }
299
+ return out;
300
+ }
@@ -0,0 +1,154 @@
1
+ import { firstNamedTypeface, canonicalTypeface } from "@sliiu/present-core";
2
+ // 浏览器内字体注入工具:把 Google Fonts CSS 链接以 idempotent 方式塞进 <head>,
3
+ // 让 SVG / Canvas / DOM 渲染时都能正确解析到指定 fontFamily。
4
+ //
5
+ // 用于:
6
+ // - stock-fonts MCP:用户主动选字体(fonts_apply)
7
+ // - theme generator:AI 生成主题时一并把题字 / 正文 family 注入
8
+ //
9
+ // 设计:
10
+ // - 缓存已注入的 family,防止 N 次 apply 同一字体重复添加 <link>。
11
+ // - preconnect 只插入一次(fonts.googleapis.com + fonts.gstatic.com)。
12
+ // - 不依赖任何外部资源;离线 / CDN 不可达时 link 失败不会阻塞渲染,会回退到
13
+ // font 栈中的下一个备选。
14
+ const INJECTED_FAMILIES = new Set();
15
+ export function isFontInjected(family) {
16
+ return INJECTED_FAMILIES.has(family);
17
+ }
18
+ /**
19
+ * 注入一份 Google Fonts CSS(或任意公开 CSS)到当前 document。
20
+ *
21
+ * @param cssUrl Google Fonts CSS v2 url(如 'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap')
22
+ * 也可以是任何指向 @font-face 规则的 CSS url。
23
+ * @param family CSS font-family 名(用于 dedup 缓存 + DOM 标记)。
24
+ */
25
+ export function injectFontCss(cssUrl, family) {
26
+ if (typeof document === "undefined")
27
+ return;
28
+ const existing = document.querySelector(`link[data-mcp-font="${family}"]`);
29
+ if (existing) {
30
+ if (existing.getAttribute("href") !== cssUrl)
31
+ existing.href = cssUrl;
32
+ INJECTED_FAMILIES.add(family);
33
+ return;
34
+ }
35
+ if (INJECTED_FAMILIES.has(family))
36
+ return;
37
+ if (!document.querySelector('link[data-mcp-font-preconnect]')) {
38
+ const pre1 = document.createElement("link");
39
+ pre1.rel = "preconnect";
40
+ pre1.href = "https://fonts.googleapis.com";
41
+ pre1.dataset.mcpFontPreconnect = "1";
42
+ document.head.appendChild(pre1);
43
+ const pre2 = document.createElement("link");
44
+ pre2.rel = "preconnect";
45
+ pre2.href = "https://fonts.gstatic.com";
46
+ pre2.crossOrigin = "anonymous";
47
+ pre2.dataset.mcpFontPreconnect = "1";
48
+ document.head.appendChild(pre2);
49
+ }
50
+ const link = document.createElement("link");
51
+ link.rel = "stylesheet";
52
+ link.href = cssUrl;
53
+ link.dataset.mcpFont = family;
54
+ document.head.appendChild(link);
55
+ INJECTED_FAMILIES.add(family);
56
+ }
57
+ // 字体栈 fallback:CJK 兼容 + 系统兜底。
58
+ const CJK_FALLBACK = ', "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"';
59
+ export const THEME_FONT_POOL = [
60
+ // ---- 拉丁 sans-serif ----
61
+ { family: "Inter", category: "sans-serif", language: "latin",
62
+ cssUrl: "https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap",
63
+ stack: `"Inter", -apple-system, BlinkMacSystemFont${CJK_FALLBACK}, sans-serif`,
64
+ tags: ["modern", "ui", "neutral", "tech", "clean"] },
65
+ { family: "Manrope", category: "sans-serif", language: "latin",
66
+ cssUrl: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap",
67
+ stack: `"Manrope", -apple-system${CJK_FALLBACK}, sans-serif`,
68
+ tags: ["modern", "elegant", "startup"] },
69
+ { family: "Space Grotesk", category: "sans-serif", language: "latin",
70
+ cssUrl: "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap",
71
+ stack: `"Space Grotesk", -apple-system${CJK_FALLBACK}, sans-serif`,
72
+ tags: ["modern", "tech", "geometric", "futuristic"] },
73
+ { family: "DM Sans", category: "sans-serif", language: "latin",
74
+ cssUrl: "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap",
75
+ stack: `"DM Sans"${CJK_FALLBACK}, sans-serif`,
76
+ tags: ["modern", "warm", "friendly"] },
77
+ { family: "Plus Jakarta Sans", category: "sans-serif", language: "latin",
78
+ cssUrl: "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap",
79
+ stack: `"Plus Jakarta Sans"${CJK_FALLBACK}, sans-serif`,
80
+ tags: ["modern", "balanced", "saas"] },
81
+ // ---- 拉丁 display / headline ----
82
+ { family: "Sora", category: "display", language: "latin",
83
+ cssUrl: "https://fonts.googleapis.com/css2?family=Sora:wght@400;600;800&display=swap",
84
+ stack: `"Sora"${CJK_FALLBACK}, sans-serif`,
85
+ tags: ["headline", "tech", "modern", "bold"] },
86
+ { family: "Bricolage Grotesque", category: "display", language: "latin",
87
+ cssUrl: "https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,800&display=swap",
88
+ stack: `"Bricolage Grotesque"${CJK_FALLBACK}, sans-serif`,
89
+ tags: ["editorial", "expressive", "headline"] },
90
+ { family: "Unbounded", category: "display", language: "latin",
91
+ cssUrl: "https://fonts.googleapis.com/css2?family=Unbounded:wght@400;600;800&display=swap",
92
+ stack: `"Unbounded"${CJK_FALLBACK}, sans-serif`,
93
+ tags: ["futuristic", "rounded", "luxury", "headline"] },
94
+ // ---- 拉丁 serif ----
95
+ { family: "Playfair Display", category: "serif", language: "latin",
96
+ cssUrl: "https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,800;0,900;1,400&display=swap",
97
+ stack: `"Playfair Display"${CJK_FALLBACK}, serif`,
98
+ tags: ["elegant", "editorial", "luxury", "headline"] },
99
+ { family: "Fraunces", category: "serif", language: "latin",
100
+ cssUrl: "https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,700;9..144,900&display=swap",
101
+ stack: `"Fraunces"${CJK_FALLBACK}, serif`,
102
+ tags: ["editorial", "warm", "expressive"] },
103
+ { family: "Newsreader", category: "serif", language: "latin",
104
+ cssUrl: "https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,600&display=swap",
105
+ stack: `"Newsreader"${CJK_FALLBACK}, serif`,
106
+ tags: ["reading", "magazine", "warm"] },
107
+ // ---- 等宽 ----
108
+ { family: "JetBrains Mono", category: "monospace", language: "latin",
109
+ cssUrl: "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap",
110
+ stack: `"JetBrains Mono", "Menlo", "Consolas", monospace`,
111
+ tags: ["code", "monospace", "developer"] },
112
+ { family: "IBM Plex Mono", category: "monospace", language: "latin",
113
+ cssUrl: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap",
114
+ stack: `"IBM Plex Mono", "Menlo", "Consolas", monospace`,
115
+ tags: ["code", "monospace", "editorial", "meta"] },
116
+ { family: "Space Mono", category: "monospace", language: "latin",
117
+ cssUrl: "https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap",
118
+ stack: `"Space Mono", "Menlo", monospace`,
119
+ tags: ["code", "retro", "tech"] },
120
+ // ---- 中文 sans ----
121
+ { family: "Noto Sans SC", category: "sans-serif", language: "cjk",
122
+ cssUrl: "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap",
123
+ stack: `"Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif`,
124
+ tags: ["中文", "黑体", "简体", "正式", "现代"] },
125
+ // ---- 中文 serif ----
126
+ { family: "Noto Serif SC", category: "serif", language: "cjk",
127
+ cssUrl: "https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;700;800;900&display=swap",
128
+ stack: `"Noto Serif SC", "Songti SC", "SimSun", serif`,
129
+ tags: ["中文", "宋体", "简体", "正式", "经典"] },
130
+ // ---- 中文显示 / 标题 ----
131
+ { family: "ZCOOL XiaoWei", category: "display", language: "cjk",
132
+ cssUrl: "https://fonts.googleapis.com/css2?family=ZCOOL+XiaoWei&display=swap",
133
+ stack: `"ZCOOL XiaoWei", "Songti SC", serif`,
134
+ tags: ["中文", "宋体", "手写", "标题", "文艺"] },
135
+ { family: "ZCOOL KuaiLe", category: "display", language: "cjk",
136
+ cssUrl: "https://fonts.googleapis.com/css2?family=ZCOOL+KuaiLe&display=swap",
137
+ stack: `"ZCOOL KuaiLe"${CJK_FALLBACK}, sans-serif`,
138
+ tags: ["中文", "可爱", "标题", "活泼"] },
139
+ { family: "Ma Shan Zheng", category: "handwriting", language: "cjk",
140
+ cssUrl: "https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&display=swap",
141
+ stack: `"Ma Shan Zheng", "Songti SC", serif`,
142
+ tags: ["中文", "书法", "毛笔", "手写", "传统"] },
143
+ ];
144
+ /** 按 family 找 preset;找不到返回 null,调用方应回退到默认栈。 */
145
+ export function findFontPreset(family) {
146
+ const exact = THEME_FONT_POOL.find((p) => p.family === family || p.stack === family);
147
+ if (exact)
148
+ return exact;
149
+ const primary = canonicalTypeface(family) ?? firstNamedTypeface(family);
150
+ if (!primary)
151
+ return null;
152
+ const lower = primary.toLowerCase();
153
+ return THEME_FONT_POOL.find((p) => p.family.toLowerCase() === lower) ?? null;
154
+ }
@@ -0,0 +1,16 @@
1
+ /** Design / Console 主题画廊展示顺序(与 template-store 内置模板顺序一致)。 */
2
+ export const THEME_GALLERY_ORDER = [
3
+ "dark",
4
+ "tech",
5
+ "terminal",
6
+ "creative",
7
+ "luxe",
8
+ "business",
9
+ "editorial",
10
+ "warm",
11
+ "pastel",
12
+ "academic",
13
+ "ink",
14
+ "nature",
15
+ "minimal",
16
+ ];
@@ -0,0 +1,7 @@
1
+ export { BUILTIN_THEME_IDS } from "./builtin-ids.js";
2
+ export { THEME_GALLERY_ORDER } from "./gallery-order.js";
3
+ export { THEMES, THEME_IDS, THEME_OUTLINE_HINTS, THEME_STYLE_SEEDS, THEME_FONT_FAMILIES, GeneratedThemeSpecSchema, getTheme, injectThemeFonts, realizeGeneratedTheme, } from "./themes.js";
4
+ export { buildDecorationShapesShared } from "./decorations.js";
5
+ export { THEME_FONT_POOL, findFontPreset, injectFontCss } from "./fonts/inject.js";
6
+ export { buildCoreThemeFromSpec, buildMastersFromThemeDef, findLayoutRefByType, resolveLayoutTypeForSlide, stripSlideThemeDecorations, themeSpecFromThemeDef, } from "./template-masters.js";
7
+ export { ApplyCustomThemeCmd, ApplyThemeCmd, } from "./theme-commands.js";
@@ -0,0 +1,88 @@
1
+ // 共享颜色 / 对比度工具(WCAG 2.x relative luminance + contrast ratio)。
2
+ //
3
+ // 抽出来给两处复用,避免颜色数学各写一份漂移:
4
+ // - qa/validate.ts:生成后 per-slide 文字对比度修复
5
+ // - themes.ts realizeGeneratedTheme:AI 主题 palette 源头对比度兜底(#5)
6
+ //
7
+ // 兜底文字色与 ppt-visual-design.md / ppt-design-rules.mdc §7 保持一致。
8
+ /** 解析 #rgb / #rrggbb(大小写均可)。无法解析返回 null。 */
9
+ export function parseHex(input) {
10
+ if (!input)
11
+ return null;
12
+ let s = input.trim();
13
+ if (s.startsWith("#"))
14
+ s = s.slice(1);
15
+ if (s.length === 3) {
16
+ s = s
17
+ .split("")
18
+ .map((c) => c + c)
19
+ .join("");
20
+ }
21
+ if (s.length !== 6 || /[^0-9a-fA-F]/.test(s))
22
+ return null;
23
+ return {
24
+ r: parseInt(s.slice(0, 2), 16),
25
+ g: parseInt(s.slice(2, 4), 16),
26
+ b: parseInt(s.slice(4, 6), 16),
27
+ };
28
+ }
29
+ /** Rgb → #rrggbb(小写)。 */
30
+ export function toHex(rgb) {
31
+ const h = (n) => Math.max(0, Math.min(255, Math.round(n)))
32
+ .toString(16)
33
+ .padStart(2, "0");
34
+ return `#${h(rgb.r)}${h(rgb.g)}${h(rgb.b)}`;
35
+ }
36
+ function srgbChannel(c) {
37
+ const cs = c / 255;
38
+ return cs <= 0.03928 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4);
39
+ }
40
+ /** WCAG 相对亮度 0..1。 */
41
+ export function relativeLuminance(rgb) {
42
+ return 0.2126 * srgbChannel(rgb.r) + 0.7152 * srgbChannel(rgb.g) + 0.0722 * srgbChannel(rgb.b);
43
+ }
44
+ /** WCAG 对比度 1..21。 */
45
+ export function contrastRatio(a, b) {
46
+ const la = relativeLuminance(a);
47
+ const lb = relativeLuminance(b);
48
+ const hi = Math.max(la, lb);
49
+ const lo = Math.min(la, lb);
50
+ return (hi + 0.05) / (lo + 0.05);
51
+ }
52
+ // 规范里的两个兜底文字色(与 ppt-visual-design.md / ppt-design-rules.mdc 一致)。
53
+ export const SAFE_TEXT_DARK = "#0f172a"; // 用于亮底
54
+ export const SAFE_TEXT_LIGHT = "#f8fafc"; // 用于暗底
55
+ // WCAG AA 对比度阈值(单一事实源):
56
+ // - 大字(标题 / 大数字)≥3.0;正文 ≥4.5。
57
+ // validate.ts 的默认阈值、art-director 的 recolor 门槛、art-director rubric 文案都引用这两个常量,
58
+ // 保证"生成时兜底"和"验收时打分"用同一把尺。
59
+ export const WCAG_LARGE_TEXT_CONTRAST = 3.0;
60
+ export const WCAG_BODY_TEXT_CONTRAST = 4.5;
61
+ /** 根据底色亮度选一个高对比度的兜底文字色。 */
62
+ export function readableTextColor(bg) {
63
+ return relativeLuminance(bg) > 0.5 ? SAFE_TEXT_DARK : SAFE_TEXT_LIGHT;
64
+ }
65
+ /** 线性插值两色,t=0 → a,t=1 → b。 */
66
+ export function mixRgb(a, b, t) {
67
+ const k = Math.max(0, Math.min(1, t));
68
+ return {
69
+ r: a.r + (b.r - a.r) * k,
70
+ g: a.g + (b.g - a.g) * k,
71
+ b: a.b + (b.b - a.b) * k,
72
+ };
73
+ }
74
+ /**
75
+ * 把 `color` 朝 `target` 方向逐步靠拢,直到相对 `bg` 的对比度 ≥ `min`。
76
+ * 用于把「对底色太浅」的次要文字色拉到达标,同时尽量保留原色调(小步逼近)。
77
+ * 若走到 target 仍不达标(几乎不可能,target 应是高对比色),返回 target。
78
+ */
79
+ export function ensureContrast(color, bg, target, min) {
80
+ if (contrastRatio(color, bg) >= min)
81
+ return color;
82
+ for (let t = 0.15; t < 1; t += 0.15) {
83
+ const c = mixRgb(color, target, t);
84
+ if (contrastRatio(c, bg) >= min)
85
+ return c;
86
+ }
87
+ return target;
88
+ }