@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,882 @@
1
+ "use strict";
2
+ // Master + layout 构造(从 ThemeDef 生成母版图,供模板新建与 ApplyCustomThemeCmd 共用)。
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.themeSpecFromThemeDef = themeSpecFromThemeDef;
5
+ exports.buildCoreThemeFromSpec = buildCoreThemeFromSpec;
6
+ exports.buildMastersFromThemeDef = buildMastersFromThemeDef;
7
+ exports.resolveLayoutTypeForSlide = resolveLayoutTypeForSlide;
8
+ exports.findLayoutRefByType = findLayoutRefByType;
9
+ exports.stripSlideThemeDecorations = stripSlideThemeDecorations;
10
+ const core_1 = require("@sliiu/present-core");
11
+ const decorations_1 = require("./decorations");
12
+ function namedDecoration(d) {
13
+ switch (d) {
14
+ case "corner-gradient-shape": return "aurora";
15
+ case "geometric-lines": return "ink-thread";
16
+ case "bottom-accent-bar": return "authority";
17
+ case "dotted-grid": return "tech-bracket";
18
+ case undefined: return "none";
19
+ default: return d;
20
+ }
21
+ }
22
+ function buildMasterFromTheme(spec, theme, size, baseId) {
23
+ // background:spec.background.kind === "solid" → 用 theme.palette.bg;否则取 theme.backgroundPaint。
24
+ const background = theme.backgroundPaint ?? { kind: "solid", color: spec.palette.bg };
25
+ const decoShapes = (0, decorations_1.buildDecorationShapesShared)(spec.decoration ?? "none", {
26
+ palette: {
27
+ bg: spec.palette.bg,
28
+ bgAlt: spec.palette.bgAlt,
29
+ text: spec.palette.text,
30
+ textMuted: spec.palette.textMuted,
31
+ accent1: spec.palette.accent1,
32
+ accent2: spec.palette.accent2,
33
+ accent3: spec.palette.accent3,
34
+ accent4: spec.palette.accent4,
35
+ divider: spec.palette.divider,
36
+ },
37
+ size,
38
+ locked: true,
39
+ idPrefix: `${baseId}_tpl`,
40
+ });
41
+ return {
42
+ id: `${baseId}__master`,
43
+ name: spec.name,
44
+ background,
45
+ shapes: decoShapes,
46
+ layouts: [], // 由 realizeTemplate 填
47
+ txStyles: undefined,
48
+ clrMap: undefined,
49
+ };
50
+ }
51
+ function layoutTuning(theme) {
52
+ let tune;
53
+ switch (theme.layout) {
54
+ case "centered":
55
+ tune = { id: "centered", align: "middle", coverAnchor: "mid", showRule: true, thickTitleRule: false, twoColRatio: 0.5, contentInset: 0, centerBody: true };
56
+ break;
57
+ case "editorial":
58
+ tune = { id: "editorial", align: "start", coverAnchor: "bottom", showRule: false, thickTitleRule: true, twoColRatio: 0.62, contentInset: 0.02, mediaFrame: true };
59
+ break;
60
+ case "terminal":
61
+ tune = { id: "terminal", align: "start", coverAnchor: "mid", showRule: false, thickTitleRule: false, twoColRatio: 0.5, contentInset: 0, prompt: "$ ", bodyPanel: true, panelDots: true };
62
+ break;
63
+ case "classic":
64
+ default:
65
+ tune = { id: "classic", align: "start", coverAnchor: "mid", showRule: true, thickTitleRule: false, twoColRatio: 0.5, contentInset: 0 };
66
+ break;
67
+ }
68
+ // 主题级 contentPanel:在任意版式上叠一层内容卡片(深色主题用),不强制画窗口圆点。
69
+ if (theme.contentPanel)
70
+ tune.bodyPanel = true;
71
+ return tune;
72
+ }
73
+ function buildLayoutsFromTheme(spec, theme, size, masterId) {
74
+ const tune = layoutTuning(theme);
75
+ const layouts = [];
76
+ layouts.push({
77
+ id: `${masterId}__layout_title`,
78
+ name: "Title",
79
+ type: "title",
80
+ shapes: titleLayoutShapes(spec, theme, size, tune),
81
+ });
82
+ layouts.push({
83
+ id: `${masterId}__layout_titleContent`,
84
+ name: "Title + Content",
85
+ type: "obj",
86
+ shapes: titleContentLayoutShapes(spec, theme, size, tune),
87
+ });
88
+ layouts.push({
89
+ id: `${masterId}__layout_twoCol`,
90
+ name: "Two Content",
91
+ type: "twoObj",
92
+ shapes: twoColLayoutShapes(spec, theme, size, tune),
93
+ });
94
+ layouts.push({
95
+ id: `${masterId}__layout_section`,
96
+ name: "Section Header",
97
+ type: "sectionHead",
98
+ shapes: sectionHeaderLayoutShapes(spec, theme, size, tune),
99
+ });
100
+ layouts.push({
101
+ id: `${masterId}__layout_blank`,
102
+ name: "Blank",
103
+ type: "blank",
104
+ shapes: [],
105
+ });
106
+ return layouts;
107
+ }
108
+ // 把 ThemeDef.titleWordArt 烘焙进标题 run + shape,得到"艺术字"标题占位符。
109
+ function titleWordArt(theme) {
110
+ return theme.titleWordArt;
111
+ }
112
+ /**
113
+ * 主题 → 封面母题。显式 `coverMotif` 优先,其次按装饰语言映射,最后按 id 稳定散列
114
+ * 兜底 —— 兜底这一步保证「装饰为 none 的 AI 主题」也不会集体撞脸。
115
+ */
116
+ function coverMotifFor(theme) {
117
+ if (theme.coverMotif)
118
+ return theme.coverMotif;
119
+ switch (namedDecoration(theme.decoration)) {
120
+ case "authority": return "stack";
121
+ case "manuscript": return "rule-box";
122
+ case "ink-thread": return "spine";
123
+ case "tech-bracket": return "bracket";
124
+ case "terminal": return "bracket";
125
+ case "aurora": return "halo";
126
+ case "organic-blobs": return "blob";
127
+ case "sunrise-arc": return "arc";
128
+ case "gilded": return "frame";
129
+ case "neon-burst": return "burst";
130
+ case "confetti": return "dots";
131
+ case "ink-wash": return "wash";
132
+ case "masthead": return "masthead";
133
+ default: {
134
+ // 无装饰语言可依:按 id 散列,稳定但分散。
135
+ const pool = ["slash", "stack", "spine", "bracket", "halo", "burst"];
136
+ let h = 0;
137
+ for (let i = 0; i < theme.id.length; i++)
138
+ h = (h * 31 + theme.id.charCodeAt(i)) >>> 0;
139
+ return pool[h % pool.length];
140
+ }
141
+ }
142
+ }
143
+ function coverMotifShapes(motif, c) {
144
+ const { W, H, mx, palette, titleY, centered } = c;
145
+ const p = (color, alpha) => solidPaint(color, alpha);
146
+ // 左对齐母题的锚点;居中版式统一改用页面中线。
147
+ const ax = centered ? W / 2 : mx;
148
+ switch (motif) {
149
+ case "stack": {
150
+ // 三条递减横线压在标题上方:秩序感、公文/咨询报告气质。
151
+ // 线要**够长够疏**才有版面感 —— 早期版本最长只有 13% 页宽、行距 12px,三条挤在
152
+ // 一起像个小 "≡" 图标,反而显得局促。
153
+ const widths = [W * 0.3, W * 0.19, W * 0.1];
154
+ const colors = [palette.accent1, palette.accent2, palette.accent1];
155
+ const alphas = [1, 1, 0.45];
156
+ const gap = 18;
157
+ return {
158
+ shapes: widths.map((w, i) => decoRect(`dz_stack${i}`, `Rule ${i + 1}`, { x: centered ? W / 2 - w / 2 : ax, y: titleY - 74 + i * gap, w, h: 6 - i * 1.5 }, p(colors[i], alphas[i]))),
159
+ titleDy: H * 0.03,
160
+ };
161
+ }
162
+ case "rule-box": {
163
+ // 通栏细线上下夹住标题块 = 学术论文的版心,配一枚左端方点作起始标记。
164
+ const w = W - mx * 2;
165
+ return {
166
+ shapes: [
167
+ decoRect("dz_rule_top", "Top rule", { x: mx, y: titleY - 30, w, h: 1.5 }, p(palette.divider)),
168
+ decoRect("dz_rule_mark", "Rule mark", { x: mx, y: titleY - 34, w: 10, h: 10 }, p(palette.accent1)),
169
+ decoRect("dz_rule_bottom", "Bottom rule", { x: mx, y: c.subtitleBottom + 20, w, h: 1.5 }, p(palette.divider)),
170
+ ],
171
+ footer: "none",
172
+ };
173
+ }
174
+ case "spine": {
175
+ // 版心左外侧一根贯穿上下的发丝线,标题旁一小段实心 —— 画廊标签牌的语言。
176
+ if (centered) {
177
+ // 居中版式没有"左外侧",改成标题正上方的一根短竖线。
178
+ return {
179
+ shapes: [decoRect("dz_spine", "Spine", { x: W / 2 - 1, y: titleY - 54, w: 2, h: 34 }, p(palette.accent1))],
180
+ };
181
+ }
182
+ const sx = mx - W * 0.028;
183
+ return {
184
+ shapes: [
185
+ decoRect("dz_spine", "Spine", { x: sx, y: H * 0.12, w: 1, h: H * 0.76 }, p(palette.divider)),
186
+ decoRect("dz_spine_mark", "Spine mark", { x: sx - 1, y: titleY + 6, w: 3, h: H * 0.09 }, p(palette.accent1)),
187
+ ],
188
+ footer: "corner",
189
+ };
190
+ }
191
+ case "bracket": {
192
+ // 左上 L 形角标:技术文档 / 终端的取景框语言。
193
+ const arm = W * 0.05;
194
+ const t = 3;
195
+ const bx = centered ? W / 2 - arm / 2 : ax;
196
+ const by = titleY - 44;
197
+ return {
198
+ shapes: [
199
+ decoRect("dz_bracket_h", "Bracket arm", { x: bx, y: by, w: arm, h: t }, p(palette.accent1)),
200
+ decoRect("dz_bracket_v", "Bracket stem", { x: bx, y: by, w: t, h: arm * 0.62 }, p(palette.accent1)),
201
+ ],
202
+ titleDy: H * 0.02,
203
+ footer: "corner",
204
+ };
205
+ }
206
+ case "terminal-bar": {
207
+ // Phosphor 封面:保留终端气质,但不画 ~ $ 文字。两根递减状态条 + 左侧竖标。
208
+ const barW = W * 0.24;
209
+ return {
210
+ shapes: [
211
+ decoRect("dz_term_tick", "Status tick", { x: ax, y: titleY - 52, w: 4, h: 22 }, p(palette.accent1)),
212
+ decoRect("dz_term_bar", "Status bar", { x: ax + 12, y: titleY - 44, w: barW, h: 3 }, p(palette.accent1, 0.85)),
213
+ decoRect("dz_term_bar2", "Status bar 2", { x: ax + 12, y: titleY - 36, w: barW * 0.42, h: 2 }, p(palette.accent2, 0.6)),
214
+ ],
215
+ titleDy: H * 0.015,
216
+ footer: "corner",
217
+ };
218
+ }
219
+ case "halo": {
220
+ // 极光光斑:**出血**到左上角外,只露一段弧。之前是一枚完整圆悬在标题正上方,
221
+ // 底边离标题只有 2px,加上标题的辉光就糊成"圆压字"。出血 + 远离文字带才像光晕
222
+ // 而不是贴纸。
223
+ const d = W * 0.26;
224
+ return {
225
+ shapes: [
226
+ decoEllipse("dz_halo", "Halo", { x: -d * 0.3, y: -d * 0.36, w: d, h: d }, p(palette.accent1, 0.1)),
227
+ decoEllipse("dz_halo_dot", "Halo dot", { x: centered ? W / 2 - 6 : ax, y: titleY - 34, w: 12, h: 12 }, p(palette.accent2)),
228
+ ],
229
+ titleDy: H * 0.025,
230
+ };
231
+ }
232
+ case "blob": {
233
+ // 右上角两枚柔圆,都**出血**到画布外。有机形态一旦画成一枚居中的完整正圆就变成
234
+ // "贴上去的圆形贴纸",很突兀;只露一部分才像自然生长进画面的形状。
235
+ const d1 = W * 0.24, d2 = W * 0.15;
236
+ return {
237
+ shapes: [
238
+ decoEllipse("dz_blob1", "Blob", { x: W - d1 * 0.66, y: -d1 * 0.34, w: d1, h: d1 }, p(palette.accent1, 0.16)),
239
+ decoEllipse("dz_blob2", "Blob 2", { x: W - d1 * 0.62 - d2, y: -d2 * 0.42, w: d2, h: d2 }, p(palette.accent2, 0.2)),
240
+ decoRect("dz_blob_bar", "Kicker", { x: centered ? W / 2 - 24 : ax, y: titleY - 28, w: 48, h: 4 }, p(palette.accent1)),
241
+ ],
242
+ footer: "center",
243
+ };
244
+ }
245
+ case "arc": {
246
+ // 右下角升起的大圆弧 = 地平线上的日出;标题上方配一条细长线而非短粗条。
247
+ const d = W * 0.62;
248
+ return {
249
+ shapes: [
250
+ decoEllipse("dz_arc", "Sun arc", { x: W - d * 0.72, y: H - d * 0.42, w: d, h: d }, p(palette.accent1, 0.12)),
251
+ decoRect("dz_arc_line", "Horizon line", { x: centered ? W / 2 - W * 0.11 : ax, y: titleY - 26, w: W * 0.22, h: 2 }, p(palette.accent2)),
252
+ ],
253
+ titleDy: -H * 0.03,
254
+ footer: "none",
255
+ };
256
+ }
257
+ case "frame": {
258
+ // 四角细线框:高奢/礼盒的烫金边角,只画角不画整框,留白更贵。
259
+ const inset = W * 0.035;
260
+ const arm = W * 0.045;
261
+ const t = 1.5;
262
+ const gold = palette.accent2;
263
+ const corners = [
264
+ ["tl_h", inset, inset, arm, t],
265
+ ["tl_v", inset, inset, t, arm],
266
+ ["tr_h", W - inset - arm, inset, arm, t],
267
+ ["tr_v", W - inset - t, inset, t, arm],
268
+ ["bl_h", inset, H - inset - t, arm, t],
269
+ ["bl_v", inset, H - inset - arm, t, arm],
270
+ ["br_h", W - inset - arm, H - inset - t, arm, t],
271
+ ["br_v", W - inset - t, H - inset - arm, t, arm],
272
+ ];
273
+ return {
274
+ shapes: corners.map(([k, x, y, w, h]) => decoRect(`dz_frame_${k}`, "Frame corner", { x, y, w, h }, p(gold))),
275
+ footer: "none",
276
+ };
277
+ }
278
+ case "burst": {
279
+ // 三道**同角度、等间距**的平行斜条:创意/发布会的速度感。
280
+ // 早期版本三条各用不同角度(-28/-8/+16)又互相错位,读起来像三段随机乱码而不是
281
+ // 一个有意为之的记号 —— 平行 + 等距是让斜线组成立的最低条件。
282
+ const rot = -32;
283
+ const bar = W * 0.07;
284
+ const step = W * 0.028;
285
+ const colors = [palette.accent1, palette.accent2, palette.accent3 ?? palette.accent1];
286
+ const spanW = bar + step * (colors.length - 1);
287
+ const x0 = centered ? W / 2 - spanW / 2 : ax;
288
+ return {
289
+ shapes: colors.map((color, i) => decoRect(`dz_burst${i}`, `Burst ${i + 1}`, { x: x0 + i * step, y: titleY - 58, w: bar, h: 6 }, p(color), { rotation: rot })),
290
+ titleDy: H * 0.035,
291
+ footer: "center",
292
+ };
293
+ }
294
+ case "dots": {
295
+ // 3×3 点阵:粉彩/轻盈主题,颗粒感但不抢戏。
296
+ const r = 7;
297
+ const gap = W * 0.026;
298
+ const gx = centered ? W / 2 - gap : ax;
299
+ const gy = titleY - 62;
300
+ const shapes = [];
301
+ for (let row = 0; row < 3; row++) {
302
+ for (let col = 0; col < 3; col++) {
303
+ const fade = 1 - (row + col) * 0.12;
304
+ shapes.push(decoEllipse(`dz_dot_${row}${col}`, "Dot", { x: gx + col * gap, y: gy + row * gap, w: r, h: r }, p(col % 2 === 0 ? palette.accent1 : palette.accent2, fade)));
305
+ }
306
+ }
307
+ return { shapes, titleDy: H * 0.035, footer: "center" };
308
+ }
309
+ case "wash": {
310
+ // 右侧一整条低饱和色块(留白 : 墨色 ≈ 2:1)+ 版心内一条竖线,水墨/国风的分割。
311
+ const bw = W * 0.24;
312
+ if (centered) {
313
+ // 居中标题会横穿整个版心,右侧竖条会压到字上;改成顶部横幅。
314
+ const bh = H * 0.16;
315
+ return {
316
+ shapes: [
317
+ decoRect("dz_wash", "Wash panel", { x: 0, y: 0, w: W, h: bh }, p(palette.accent1, 0.08)),
318
+ decoRect("dz_wash_edge", "Wash edge", { x: 0, y: bh, w: W, h: 2 }, p(palette.accent1, 0.35)),
319
+ decoRect("dz_wash_mark", "Seal mark", { x: W / 2 - 9, y: bh / 2 - 9, w: 18, h: 18 }, p(palette.accent2, 0.9)),
320
+ ],
321
+ footer: "none",
322
+ };
323
+ }
324
+ return {
325
+ shapes: [
326
+ decoRect("dz_wash", "Wash panel", { x: W - bw, y: 0, w: bw, h: H }, p(palette.accent1, 0.08)),
327
+ decoRect("dz_wash_edge", "Wash edge", { x: W - bw, y: 0, w: 2, h: H }, p(palette.accent1, 0.35)),
328
+ decoRect("dz_wash_mark", "Seal mark", { x: W - bw + bw / 2 - 9, y: H * 0.12, w: 18, h: 18 }, p(palette.accent2, 0.9)),
329
+ ],
330
+ textInsetRight: bw + W * 0.03,
331
+ footer: "none",
332
+ };
333
+ }
334
+ case "masthead": {
335
+ // 杂志报头:通栏粗顶线 + 压在其上的次色短段。
336
+ return {
337
+ shapes: [
338
+ decoRect("dz_topline", "Overline", { x: mx, y: titleY - 26, w: W - mx * 2, h: 4 }, p(palette.accent1)),
339
+ decoRect("dz_kicker", "Kicker", { x: mx, y: titleY - 26, w: W * 0.16, h: 4 }, p(palette.accent2)),
340
+ ],
341
+ };
342
+ }
343
+ case "slash":
344
+ default: {
345
+ // 历史默认:左上斜向 ribbon + 短粗 kicker。现在只有"无装饰语言"的主题才会命中。
346
+ if (centered) {
347
+ return {
348
+ shapes: [decoRect("dz_kicker", "Kicker", { x: W / 2 - 28, y: titleY - 26, w: 56, h: 8 }, p(palette.accent1))],
349
+ };
350
+ }
351
+ return {
352
+ shapes: [
353
+ decoRect("dz_ribbon", "Accent ribbon", { x: mx, y: H * 0.2, w: W * 0.06, h: 6 }, p(palette.accent2), { rotation: -18 }),
354
+ decoRect("dz_kicker", "Kicker", { x: mx, y: H * 0.3, w: 56, h: 8 }, p(palette.accent1)),
355
+ ],
356
+ };
357
+ }
358
+ }
359
+ }
360
+ function coverFooterShapes(style, c) {
361
+ const { W, H, mx, palette } = c;
362
+ switch (style) {
363
+ case "none":
364
+ return [];
365
+ case "center":
366
+ return [decoRect("dz_foot", "Footer bar", { x: W / 2 - W * 0.09, y: H - 6, w: W * 0.18, h: 6 }, solidPaint(palette.accent1))];
367
+ case "corner":
368
+ return [
369
+ decoRect("dz_foot", "Footer bar", { x: mx, y: H - 5, w: W * 0.1, h: 5 }, solidPaint(palette.accent1)),
370
+ decoRect("dz_foot2", "Footer bar 2", { x: W - mx - W * 0.04, y: H - 5, w: W * 0.04, h: 5 }, solidPaint(palette.accent2)),
371
+ ];
372
+ case "split":
373
+ default:
374
+ return [
375
+ decoRect("dz_foot", "Footer bar", { x: 0, y: H - 6, w: W * 0.55, h: 6 }, solidPaint(palette.accent1)),
376
+ decoRect("dz_foot2", "Footer bar 2", { x: W * 0.55, y: H - 6, w: W * 0.18, h: 6 }, solidPaint(palette.accent2)),
377
+ ];
378
+ }
379
+ }
380
+ function titleLayoutShapes(spec, theme, size, tune) {
381
+ const W = size.width, H = size.height;
382
+ const mx = W * 0.08;
383
+ const wa = titleWordArt(theme);
384
+ const centered = tune.align === "middle";
385
+ const baseTitleY = tune.coverAnchor === "bottom" ? H * 0.52 : H * 0.36;
386
+ const out = [];
387
+ // 终端封面的命令提示符:**独立成一行**放在标题上方,而不是塞在标题左边。
388
+ // 贴在标题同一基线上时它会和标题糊成一句("~ $ 未来已来…"),而且只要字体 / 字号 /
389
+ // 字距一变,预留的那点宽度就不够,提示符直接压到标题头两个字上 —— 这是靠"多留点宽度"
390
+ // 修不干净的一类 bug。真实终端本来也是提示符独占一行,换行反而更像。
391
+ // 它按行**预留纵向空间**(整个标题块下移),这样上方母题的装饰也不会被它撞到。
392
+ const showCoverPrompt = !!tune.prompt && theme.coverPrompt !== false;
393
+ const promptFs = showCoverPrompt ? Math.round(theme.fontSizes.coverTitle * 0.34) : 0;
394
+ const promptGap = showCoverPrompt ? promptFs * 1.9 : 0;
395
+ const motif = coverMotifFor(theme);
396
+ const ctx = {
397
+ W,
398
+ H,
399
+ mx,
400
+ palette: spec.palette,
401
+ titleY: baseTitleY,
402
+ subtitleBottom: baseTitleY + promptGap + theme.fontSizes.coverTitle * 1.5 + theme.fontSizes.coverSubtitle * 1.6,
403
+ centered,
404
+ };
405
+ const m = coverMotifShapes(motif, ctx);
406
+ out.push(...m.shapes);
407
+ const titleY = baseTitleY + (m.titleDy ?? 0) + promptGap;
408
+ const textX = centered ? mx : mx + (m.titleIndent ?? 0);
409
+ const textW = W - mx * 2 - (centered ? 0 : m.titleIndent ?? 0) - (m.textInsetRight ?? 0);
410
+ if (showCoverPrompt) {
411
+ out.push(decoText("dz_prompt", "Prompt", { x: textX, y: titleY - promptGap, w: textW, h: promptFs * 1.5 }, "~ $", {
412
+ color: spec.palette.accent1,
413
+ fontFamily: extractFamilyName(theme.fonts.mono ?? theme.fonts.title),
414
+ fontSize: promptFs,
415
+ weight: wa?.weight ?? 700,
416
+ alpha: 0.75,
417
+ align: tune.align,
418
+ }));
419
+ }
420
+ // 封面大标题(艺术字)。
421
+ out.push(placeholderText({
422
+ id: "ph_title",
423
+ name: "Title 1",
424
+ bbox: { x: textX, y: titleY, w: textW, h: theme.fontSizes.coverTitle * 1.4 },
425
+ placeholder: { type: "ctrTitle", idx: 0 },
426
+ defaultText: "Click to add title",
427
+ color: wa?.color ?? spec.palette.text,
428
+ fontFamily: extractFamilyName(theme.fonts.title),
429
+ fontSize: theme.fontSizes.coverTitle,
430
+ wordArt: wa,
431
+ isTitle: true,
432
+ align: tune.align,
433
+ autoFit: true,
434
+ }));
435
+ // 副标题。
436
+ out.push(placeholderText({
437
+ id: "ph_subtitle",
438
+ name: "Subtitle 2",
439
+ bbox: { x: textX, y: titleY + theme.fontSizes.coverTitle * 1.5, w: textW, h: theme.fontSizes.coverSubtitle * 1.6 },
440
+ placeholder: { type: "subTitle", idx: 1 },
441
+ defaultText: "Subtitle",
442
+ color: spec.palette.textMuted,
443
+ fontFamily: extractFamilyName(theme.fonts.body),
444
+ fontSize: theme.fontSizes.coverSubtitle,
445
+ align: tune.align,
446
+ autoFit: true,
447
+ }));
448
+ out.push(...coverFooterShapes(m.footer ?? (centered ? "center" : "split"), ctx));
449
+ return out;
450
+ }
451
+ function titleContentLayoutShapes(spec, theme, size, tune) {
452
+ const W = size.width, H = size.height;
453
+ const mx = W * 0.06;
454
+ const inset = W * tune.contentInset;
455
+ const cx = mx + inset;
456
+ const cw = W - cx * 2;
457
+ const wa = titleWordArt(theme);
458
+ const centered = tune.align === "middle";
459
+ const out = [];
460
+ if (tune.thickTitleRule) {
461
+ // 杂志:标题上方粗顶线 + 无左侧 kicker 竖条,编辑栏目感。
462
+ out.push(decoRect("dz_topline", "Overline", { x: cx, y: H * 0.075, w: cw, h: 4 }, solidPaint(spec.palette.accent1)));
463
+ }
464
+ else if (!centered) {
465
+ // 经典/终端:标题左侧 kicker 竖条。
466
+ out.push(decoRect("dz_kicker", "Kicker", { x: cx, y: H * 0.085, w: 6, h: theme.fontSizes.pageTitle * 1.1 }, solidPaint(spec.palette.accent1)));
467
+ }
468
+ else {
469
+ // 居中:标题正上方居中短条。
470
+ out.push(decoRect("dz_kicker", "Kicker", { x: W / 2 - 22, y: H * 0.06, w: 44, h: 6 }, solidPaint(spec.palette.accent1)));
471
+ }
472
+ // 终端:标题前缀提示符。
473
+ if (tune.prompt) {
474
+ out.push(decoText("dz_prompt", "Prompt", { x: cx, y: H * 0.085, w: W * 0.06, h: theme.fontSizes.pageTitle * 1.4 }, tune.prompt, {
475
+ color: spec.palette.accent1,
476
+ fontFamily: extractFamilyName(theme.fonts.title),
477
+ fontSize: theme.fontSizes.pageTitle,
478
+ weight: wa?.weight ?? 700,
479
+ alpha: 0.8,
480
+ }));
481
+ }
482
+ const titleX = centered ? cx : tune.thickTitleRule || tune.prompt ? cx + (tune.prompt ? W * 0.06 : 0) : cx + 18;
483
+ out.push(placeholderText({
484
+ id: "ph_title",
485
+ name: "Title 1",
486
+ bbox: { x: titleX, y: H * 0.08, w: cx + cw - titleX, h: theme.fontSizes.pageTitle * 1.4 },
487
+ placeholder: { type: "title", idx: 0 },
488
+ defaultText: "Click to add title",
489
+ color: wa?.color ?? spec.palette.text,
490
+ fontFamily: extractFamilyName(theme.fonts.title),
491
+ fontSize: theme.fontSizes.pageTitle,
492
+ wordArt: wa,
493
+ isTitle: true,
494
+ align: tune.align,
495
+ }));
496
+ // 标题下方细分割线(仅 showRule);centered 用居中短线,其余贯穿版心。
497
+ if (tune.showRule) {
498
+ if (tune.centerBody) {
499
+ out.push(decoRect("dz_rule", "Title rule", { x: W / 2 - W * 0.12, y: H * 0.2, w: W * 0.24, h: 1.5 }, solidPaint(spec.palette.divider)));
500
+ }
501
+ else {
502
+ out.push(decoRect("dz_rule", "Title rule", { x: cx, y: H * 0.21, w: cw, h: 1 }, solidPaint(spec.palette.divider)));
503
+ }
504
+ }
505
+ // 正文区底板:terminal 代码块(含窗口圆点)/ 深色主题内容卡片(无圆点)。
506
+ const dotsExtra = tune.panelDots ? 0.018 : 0; // 有窗口圆点时顶部多留一条 title bar 空间
507
+ if (tune.bodyPanel) {
508
+ const px = cx - W * 0.012, py = H * (0.225 - dotsExtra), pw = cw + W * 0.024, ph = H * (0.685 + dotsExtra);
509
+ out.push(decoPanel("dz_panel", "Content panel", { x: px, y: py, w: pw, h: ph }, spec.palette.bgAlt, 0.6, spec.palette.divider, 8));
510
+ out.push(decoRect("dz_panel_edge", "Panel edge", { x: px, y: py, w: 3, h: ph }, solidPaint(spec.palette.accent1, 0.8)));
511
+ if (tune.panelDots) {
512
+ const dotColors = [spec.palette.accent4, spec.palette.accent3, spec.palette.accent1];
513
+ dotColors.forEach((c, i) => out.push(decoEllipse(`dz_dot${i}`, "Window dot", { x: px + 16 + i * 16, y: py + 14, w: 8, h: 8 }, solidPaint(c, 0.85))));
514
+ }
515
+ }
516
+ // centered 版式:正文收成居中窄栏(measure ≈ 72%),与居中标题呼应。
517
+ const bodyW = tune.centerBody ? cw * 0.72 : tune.bodyPanel ? cw - W * 0.02 : cw;
518
+ const bodyX = tune.centerBody ? cx + (cw - bodyW) / 2 : tune.bodyPanel ? cx + W * 0.01 : cx;
519
+ out.push(placeholderText({
520
+ id: "ph_body",
521
+ name: "Content 2",
522
+ bbox: { x: bodyX, y: tune.panelDots ? H * 0.285 : tune.bodyPanel ? H * 0.255 : H * 0.24, w: bodyW, h: tune.bodyPanel ? H * 0.6 : H * 0.64 },
523
+ placeholder: { type: "body", idx: 1 },
524
+ defaultText: "Click to add content",
525
+ color: spec.palette.text,
526
+ fontFamily: extractFamilyName(theme.fonts.body),
527
+ fontSize: theme.fontSizes.body,
528
+ }));
529
+ // 页脚强调点。
530
+ out.push(decoRect("dz_footdot", "Footer dot", { x: cx, y: H - 30, w: 18, h: 4 }, solidPaint(spec.palette.accent1)));
531
+ return out;
532
+ }
533
+ function twoColLayoutShapes(spec, theme, size, tune) {
534
+ const W = size.width, H = size.height;
535
+ const mx = W * 0.06;
536
+ const inset = W * tune.contentInset;
537
+ const cx = mx + inset;
538
+ const cw = W - cx * 2;
539
+ const wa = titleWordArt(theme);
540
+ const gutter = W * 0.03;
541
+ // 按 tuning 比例切分左右栏(editorial 用 0.62 不对称宽左栏)。
542
+ const leftW = (cw - gutter) * tune.twoColRatio;
543
+ const rightW = cw - gutter - leftW;
544
+ const colY = H * 0.24, colH = H * 0.64;
545
+ const centered = tune.align === "middle";
546
+ const out = [];
547
+ if (tune.thickTitleRule) {
548
+ out.push(decoRect("dz_topline", "Overline", { x: cx, y: H * 0.075, w: cw, h: 4 }, solidPaint(spec.palette.accent1)));
549
+ }
550
+ else if (!centered) {
551
+ out.push(decoRect("dz_kicker", "Kicker", { x: cx, y: H * 0.085, w: 6, h: theme.fontSizes.pageTitle * 1.1 }, solidPaint(spec.palette.accent1)));
552
+ }
553
+ else {
554
+ out.push(decoRect("dz_kicker", "Kicker", { x: W / 2 - 22, y: H * 0.06, w: 44, h: 6 }, solidPaint(spec.palette.accent1)));
555
+ }
556
+ const titleX = centered ? cx : tune.thickTitleRule ? cx : cx + 18;
557
+ out.push(placeholderText({
558
+ id: "ph_title",
559
+ name: "Title 1",
560
+ bbox: { x: titleX, y: H * 0.08, w: cx + cw - titleX, h: theme.fontSizes.pageTitle * 1.4 },
561
+ placeholder: { type: "title", idx: 0 },
562
+ defaultText: "Click to add title",
563
+ color: wa?.color ?? spec.palette.text,
564
+ fontFamily: extractFamilyName(theme.fonts.title),
565
+ fontSize: theme.fontSizes.pageTitle,
566
+ wordArt: wa,
567
+ isTitle: true,
568
+ align: tune.align,
569
+ }));
570
+ if (tune.showRule) {
571
+ out.push(decoRect("dz_rule", "Title rule", { x: cx, y: H * 0.21, w: cw, h: 1 }, solidPaint(spec.palette.divider)));
572
+ }
573
+ out.push(placeholderText({
574
+ id: "ph_left",
575
+ name: "Content Left",
576
+ bbox: { x: cx, y: colY, w: leftW, h: colH },
577
+ placeholder: { type: "body", idx: 1 },
578
+ defaultText: "Left content",
579
+ color: spec.palette.text,
580
+ fontFamily: extractFamilyName(theme.fonts.body),
581
+ fontSize: theme.fontSizes.body,
582
+ }));
583
+ if (tune.mediaFrame) {
584
+ // 图文左右版:右栏改为媒体占位框(底板 + 细边 + 居中提示 + 对角斜线暗示"待插图")。
585
+ const fx = cx + leftW + gutter, fw = rightW;
586
+ out.push(decoPanel("dz_media", "Media frame", { x: fx, y: colY, w: fw, h: colH }, spec.palette.bgAlt, 0.7, spec.palette.accent1, 6));
587
+ out.push(decoRect("dz_media_bar", "Media caption bar", { x: fx, y: colY + colH - 10, w: fw, h: 4 }, solidPaint(spec.palette.accent1, 0.85)));
588
+ out.push(decoText("dz_media_hint", "Media hint", { x: fx, y: colY + colH / 2 - 22, w: fw, h: 44 }, "▦", {
589
+ color: spec.palette.textMuted,
590
+ fontFamily: extractFamilyName(theme.fonts.body),
591
+ fontSize: 40,
592
+ weight: 400,
593
+ alpha: 0.5,
594
+ align: "middle",
595
+ }));
596
+ }
597
+ else {
598
+ // 中缝分割线。
599
+ out.push(decoRect("dz_divider", "Divider", { x: cx + leftW + gutter / 2, y: colY, w: 1.5, h: colH }, solidPaint(spec.palette.divider)));
600
+ out.push(placeholderText({
601
+ id: "ph_right",
602
+ name: "Content Right",
603
+ bbox: { x: cx + leftW + gutter, y: colY, w: rightW, h: colH },
604
+ placeholder: { type: "body", idx: 2 },
605
+ defaultText: "Right content",
606
+ color: spec.palette.text,
607
+ fontFamily: extractFamilyName(theme.fonts.body),
608
+ fontSize: theme.fontSizes.body,
609
+ }));
610
+ }
611
+ return out;
612
+ }
613
+ function sectionHeaderLayoutShapes(spec, theme, size, tune) {
614
+ const W = size.width, H = size.height;
615
+ const mx = W * 0.1;
616
+ const wa = titleWordArt(theme);
617
+ const centered = tune.align === "middle";
618
+ const out = [];
619
+ // 变形点缀:右侧一枚旋转的大色块(半透明),作为章节页的主视觉变形元素。
620
+ out.push(decoRect("dz_shard", "Section shard", { x: W * 0.78, y: H * 0.18, w: W * 0.22, h: W * 0.22 }, solidPaint(spec.palette.accent1, 0.1), { rotation: 18 }));
621
+ // 巨大且低饱和的章节序号(装饰,不可编辑);居中版放标题正上方居中。
622
+ out.push(decoText("dz_num", "Section number", { x: centered ? 0 : mx, y: H * 0.18, w: centered ? W : W * 0.5, h: 120 }, "01", {
623
+ color: spec.palette.accent1,
624
+ fontFamily: extractFamilyName(theme.fonts.title),
625
+ fontSize: 96,
626
+ weight: wa?.weight ?? 800,
627
+ alpha: 0.85,
628
+ align: tune.align,
629
+ }));
630
+ // kicker 短条。
631
+ out.push(decoRect("dz_kicker", "Kicker", { x: centered ? W / 2 - 28 : mx, y: H * 0.46, w: 56, h: 8 }, solidPaint(spec.palette.accent2)));
632
+ out.push(placeholderText({
633
+ id: "ph_title",
634
+ name: "Section Title",
635
+ bbox: { x: mx, y: H * 0.5, w: W - mx * 2, h: theme.fontSizes.sectionTitle * 1.4 },
636
+ placeholder: { type: "title", idx: 0 },
637
+ defaultText: "Section title",
638
+ color: wa?.color ?? spec.palette.text,
639
+ fontFamily: extractFamilyName(theme.fonts.title),
640
+ fontSize: theme.fontSizes.sectionTitle,
641
+ wordArt: wa,
642
+ isTitle: true,
643
+ align: tune.align,
644
+ }));
645
+ out.push(placeholderText({
646
+ id: "ph_caption",
647
+ name: "Section Caption",
648
+ bbox: { x: mx, y: H * 0.5 + theme.fontSizes.sectionTitle * 1.5, w: W - mx * 2, h: theme.fontSizes.body * 1.6 },
649
+ placeholder: { type: "body", idx: 1 },
650
+ defaultText: "Section caption",
651
+ color: spec.palette.textMuted,
652
+ fontFamily: extractFamilyName(theme.fonts.body),
653
+ fontSize: theme.fontSizes.body,
654
+ align: tune.align,
655
+ }));
656
+ return out;
657
+ }
658
+ // ---------------------------------------------------------------------------
659
+ // shape helper
660
+ // ---------------------------------------------------------------------------
661
+ function solidPaint(color, alpha) {
662
+ return (alpha === undefined ? { kind: "solid", color } : { kind: "solid", color, alpha });
663
+ }
664
+ // 装饰矩形(非占位符,locked,可选变形旋转)。
665
+ function decoRect(id, name, bbox, fill, opts = {}) {
666
+ const transform = (0, core_1.identityTransform)();
667
+ if (opts.rotation)
668
+ transform.rotation = opts.rotation;
669
+ return {
670
+ id,
671
+ kind: "rect",
672
+ name,
673
+ bbox,
674
+ zIndex: 0,
675
+ visible: true,
676
+ locked: true,
677
+ opacity: 1,
678
+ transform,
679
+ fill,
680
+ };
681
+ }
682
+ // 装饰椭圆(非占位符,locked)——终端代码窗口的"红黄绿"圆点等。
683
+ function decoEllipse(id, name, bbox, fill) {
684
+ return {
685
+ id,
686
+ kind: "ellipse",
687
+ name,
688
+ bbox,
689
+ zIndex: 0,
690
+ visible: true,
691
+ locked: true,
692
+ opacity: 1,
693
+ transform: (0, core_1.identityTransform)(),
694
+ fill,
695
+ };
696
+ }
697
+ // 装饰面板(非占位符,locked)——带圆角 + 描边的底板,用于代码块 / 媒体占位框。
698
+ function decoPanel(id, name, bbox, fillColor, fillAlpha, strokeColor, rx) {
699
+ return {
700
+ id,
701
+ kind: "rect",
702
+ name,
703
+ bbox,
704
+ rx,
705
+ zIndex: 0,
706
+ visible: true,
707
+ locked: true,
708
+ opacity: 1,
709
+ transform: (0, core_1.identityTransform)(),
710
+ fill: { kind: "solid", color: fillColor, alpha: fillAlpha },
711
+ stroke: { paint: { kind: "solid", color: strokeColor, alpha: 0.5 }, width: 1 },
712
+ };
713
+ }
714
+ // 装饰文字(非占位符,locked)——用于章节大序号等不可编辑的主题视觉元素。
715
+ function decoText(id, name, bbox, text, style) {
716
+ return {
717
+ id,
718
+ kind: "text",
719
+ name,
720
+ bbox,
721
+ zIndex: 0,
722
+ visible: true,
723
+ locked: true,
724
+ opacity: style.alpha ?? 1,
725
+ transform: (0, core_1.identityTransform)(),
726
+ paragraphs: [
727
+ {
728
+ ...(style.align ? { align: style.align } : {}),
729
+ runs: [
730
+ {
731
+ text,
732
+ fontFamily: style.fontFamily,
733
+ fontSize: style.fontSize,
734
+ fontWeight: style.weight ?? 800,
735
+ color: style.color,
736
+ },
737
+ ],
738
+ },
739
+ ],
740
+ };
741
+ }
742
+ // ---------------------------------------------------------------------------
743
+ // Placeholder text helper(含艺术字烘焙)
744
+ // ---------------------------------------------------------------------------
745
+ function placeholderText(args) {
746
+ const isTitle = !!args.isTitle;
747
+ const wa = isTitle ? args.wordArt : undefined;
748
+ const run = {
749
+ text: args.defaultText,
750
+ fontFamily: args.fontFamily,
751
+ fontSize: args.fontSize,
752
+ color: args.color,
753
+ fontWeight: wa?.weight ?? (isTitle ? 700 : 400),
754
+ };
755
+ if (wa?.letterSpacing !== undefined)
756
+ run.letterSpacing = wa.letterSpacing;
757
+ if (wa?.caps)
758
+ run.caps = "all";
759
+ if (wa?.outlineColor && wa.outlineWidth) {
760
+ run.textOutline = { paint: { kind: "solid", color: wa.outlineColor }, width: wa.outlineWidth };
761
+ }
762
+ const paragraph = { runs: [run] };
763
+ if (args.align && args.align !== "start")
764
+ paragraph.align = args.align;
765
+ const shape = {
766
+ id: args.id,
767
+ kind: "text",
768
+ name: args.name,
769
+ bbox: args.bbox,
770
+ visible: true,
771
+ locked: false,
772
+ opacity: 1,
773
+ transform: (0, core_1.identityTransform)(),
774
+ paragraphs: [paragraph],
775
+ placeholder: { type: args.placeholder.type, idx: args.placeholder.idx },
776
+ };
777
+ if (args.autoFit)
778
+ shape.autoFit = { kind: "normAutofit" };
779
+ // 标题阴影 / 变形:烘焙到 shape 级,使用户输入标题即继承。
780
+ if (wa?.shadow) {
781
+ shape.shadow = {
782
+ kind: "outer",
783
+ blur: wa.shadow.blur,
784
+ dx: wa.shadow.dx,
785
+ dy: wa.shadow.dy,
786
+ color: wa.shadow.color,
787
+ alpha: wa.shadow.alpha,
788
+ };
789
+ }
790
+ return shape;
791
+ }
792
+ function extractFamilyName(stack) {
793
+ // ThemeDef.fonts 是 CSS font-stack(多字体 fallback 链)。模板需要单一字体名。
794
+ // 取第一个引号或第一个逗号前的部分。
795
+ const m = stack.trim().match(/^"([^"]+)"|^'([^']+)'|^([^,]+)/);
796
+ return (m?.[1] ?? m?.[2] ?? m?.[3] ?? stack).trim();
797
+ }
798
+ /** ThemeDef → GeneratedThemeSpec(无 LLM,供母版构建与模板合成共用)。 */
799
+ function themeSpecFromThemeDef(theme, spec) {
800
+ return (spec ??
801
+ {
802
+ name: theme.name,
803
+ description: theme.description,
804
+ mode: "light",
805
+ palette: {
806
+ bg: theme.palette.bg,
807
+ bgAlt: theme.palette.bgAlt,
808
+ text: theme.palette.text,
809
+ textMuted: theme.palette.textMuted,
810
+ accent1: theme.palette.accent1,
811
+ accent2: theme.palette.accent2,
812
+ accent3: theme.palette.accent3,
813
+ accent4: theme.palette.accent4,
814
+ divider: theme.palette.divider,
815
+ },
816
+ background: { kind: "solid" },
817
+ fonts: {
818
+ title: extractFamilyName(theme.fonts.title),
819
+ body: extractFamilyName(theme.fonts.body),
820
+ mono: extractFamilyName(theme.fonts.mono),
821
+ },
822
+ decoration: namedDecoration(theme.decoration),
823
+ });
824
+ }
825
+ function buildCoreThemeFromSpec(spec, theme, themeId) {
826
+ return {
827
+ id: themeId,
828
+ name: spec.name,
829
+ colors: {
830
+ bg: spec.palette.bg,
831
+ bgAlt: spec.palette.bgAlt,
832
+ text: spec.palette.text,
833
+ textMuted: spec.palette.textMuted,
834
+ accent1: spec.palette.accent1,
835
+ accent2: spec.palette.accent2,
836
+ accent3: spec.palette.accent3,
837
+ accent4: spec.palette.accent4,
838
+ divider: spec.palette.divider,
839
+ bg1: spec.palette.bg,
840
+ bg2: spec.palette.bgAlt,
841
+ tx1: spec.palette.text,
842
+ tx2: spec.palette.textMuted,
843
+ },
844
+ fonts: {
845
+ major: extractFamilyName(theme.fonts.title),
846
+ minor: extractFamilyName(theme.fonts.body),
847
+ },
848
+ };
849
+ }
850
+ /** 从 ThemeDef 构建完整母版 + 版式(与 template picker / 新建 deck 同源)。 */
851
+ function buildMastersFromThemeDef(theme, size, baseId, spec) {
852
+ const resolvedSpec = themeSpecFromThemeDef(theme, spec);
853
+ const master = buildMasterFromTheme(resolvedSpec, theme, size, baseId);
854
+ master.layouts = buildLayoutsFromTheme(resolvedSpec, theme, size, master.id);
855
+ const coreTheme = buildCoreThemeFromSpec(resolvedSpec, theme, theme.id);
856
+ return { masters: [master], coreTheme };
857
+ }
858
+ /** 套用主题后按版式 type 为 slide 重绑 layoutRef。 */
859
+ function resolveLayoutTypeForSlide(slide, index, prevMasters) {
860
+ if (slide.layoutRef && prevMasters) {
861
+ const master = prevMasters.find((m) => m.id === slide.layoutRef.masterId);
862
+ const layout = master?.layouts?.find((l) => l.id === slide.layoutRef.layoutId);
863
+ if (layout?.type)
864
+ return layout.type;
865
+ }
866
+ return index === 0 ? "title" : "obj";
867
+ }
868
+ function findLayoutRefByType(masters, layoutType) {
869
+ const master = masters[0];
870
+ if (!master?.layouts)
871
+ return undefined;
872
+ const layout = master.layouts.find((l) => l.type === layoutType);
873
+ if (!layout)
874
+ return undefined;
875
+ return { masterId: master.id, layoutId: layout.id };
876
+ }
877
+ /** 清除 slide 层遗留的 dec_*(母版路径下装饰改由 compose 合成)。 */
878
+ function stripSlideThemeDecorations(slide) {
879
+ for (const layer of slide.layers) {
880
+ layer.shapes = layer.shapes.filter((sh) => !sh.id.startsWith("dec_"));
881
+ }
882
+ }