返回 oh-my-ppt
master.test.ts
根目录 / tests / unit / master / master.test.ts
1 import { describe, expect, it } from 'vitest'
2 import {
3 addMasterGradientStop,
4 buildDefaultMasterConfig,
5 buildMasterCss,
6 buildMasterElementsHtml,
7 normalizeMasterConfig,
8 normalizeMasterElementsConfig,
9 parseMasterCss,
10 parseMasterElementsHtml
11 } from '../../../src/shared/master'
12 import { createDefaultMasterGradient } from '../../../src/shared/master'
13
14 describe('slide master config', () => {
15 it('keeps the default master visually inert except for the existing white canvas', () => {
16 const config = buildDefaultMasterConfig()
17 const css = buildMasterCss(config)
18
19 expect(config).toEqual({
20 backgroundColor: '#ffffff',
21 backgroundMode: 'inherit',
22 backgroundStyle: 'solid',
23 backgroundGradient: createDefaultMasterGradient(),
24 backgroundImage: null,
25 titleFontPreset: 'inherit',
26 bodyFontPreset: 'inherit',
27 titleFontFamily: null,
28 bodyFontFamily: null,
29 titleFontSize: null,
30 bodyFontSize: null,
31 elements: {
32 logoImage: null,
33 footerText: '',
34 watermarkText: '',
35 showLogo: false,
36 showFooter: false,
37 showPageNumber: false,
38 showWatermark: false,
39 footerFontSize: 16,
40 pageNumberFontSize: 16,
41 footerColor: '#334155',
42 pageNumberColor: '#334155',
43 watermarkRotation: -24,
44 watermarkSizeAuto: true,
45 logoPosition: { x: 5, y: 5 },
46 footerPosition: { x: 5, y: 91 },
47 pageNumberPosition: { x: 90, y: 91 },
48 watermarkPosition: { x: 30, y: 42 },
49 logoSize: { width: 16, height: 10 },
50 footerSize: { width: 56, height: 5 },
51 pageNumberSize: { width: 6, height: 5 },
52 watermarkSize: { width: 40, height: 16 }
53 }
54 })
55 expect(css).toContain('--ppt-page-bg: #ffffff;')
56 expect(css).not.toContain('--ppt-master-slide-background')
57 expect(css).not.toContain('--ppt-master-title-font')
58 expect(css).not.toContain('--ppt-master-body-font')
59 })
60
61 it('round trips every supported structured value', () => {
62 const config = {
63 backgroundColor: '#1A2b3C',
64 backgroundMode: 'override' as const,
65 backgroundStyle: 'gradient' as const,
66 backgroundGradient: {
67 type: 'linear' as const,
68 angle: 210,
69 stops: [
70 { color: '#1a2b3c', position: 0 },
71 { color: '#e0f2fe', position: 100 }
72 ]
73 },
74 titleFontPreset: 'serif' as const,
75 bodyFontPreset: 'mono' as const,
76 titleFontFamily: null,
77 bodyFontFamily: null,
78 titleFontSize: null,
79 bodyFontSize: null,
80 elements: {
81 logoImage: null,
82 footerText: '',
83 watermarkText: '',
84 showLogo: false,
85 showFooter: false,
86 showPageNumber: false,
87 showWatermark: false,
88 footerFontSize: 16,
89 pageNumberFontSize: 16,
90 footerColor: '#334155',
91 pageNumberColor: '#334155',
92 watermarkRotation: -24,
93 watermarkSizeAuto: true,
94 logoPosition: { x: 5, y: 5 },
95 footerPosition: { x: 5, y: 91 },
96 pageNumberPosition: { x: 90, y: 91 },
97 watermarkPosition: { x: 30, y: 42 },
98 logoSize: { width: 16, height: 10 },
99 footerSize: { width: 56, height: 5 },
100 pageNumberSize: { width: 6, height: 5 },
101 watermarkSize: { width: 40, height: 16 }
102 }
103 }
104
105 expect(parseMasterCss(buildMasterCss(config))).toEqual({
106 backgroundColor: '#1a2b3c',
107 backgroundMode: 'override',
108 backgroundStyle: 'gradient',
109 backgroundGradient: {
110 type: 'linear',
111 angle: 210,
112 stops: [
113 { color: '#1a2b3c', position: 0 },
114 { color: '#e0f2fe', position: 100 }
115 ]
116 },
117 backgroundImage: null,
118 titleFontPreset: 'serif',
119 bodyFontPreset: 'mono',
120 titleFontFamily: null,
121 bodyFontFamily: null,
122 titleFontSize: null,
123 bodyFontSize: null,
124 elements: {
125 logoImage: null,
126 footerText: '',
127 watermarkText: '',
128 showLogo: false,
129 showFooter: false,
130 showPageNumber: false,
131 showWatermark: false,
132 footerFontSize: 16,
133 pageNumberFontSize: 16,
134 footerColor: '#334155',
135 pageNumberColor: '#334155',
136 watermarkRotation: -24,
137 watermarkSizeAuto: true,
138 logoPosition: { x: 5, y: 5 },
139 footerPosition: { x: 5, y: 91 },
140 pageNumberPosition: { x: 90, y: 91 },
141 watermarkPosition: { x: 30, y: 42 },
142 logoSize: { width: 16, height: 10 },
143 footerSize: { width: 56, height: 5 },
144 pageNumberSize: { width: 6, height: 5 },
145 watermarkSize: { width: 40, height: 16 }
146 }
147 })
148 })
149
150 it('drops unsafe colors, arbitrary font families, and malformed CSS back to safe defaults', () => {
151 expect(
152 normalizeMasterConfig({
153 backgroundColor: 'linear-gradient(red, blue)',
154 backgroundMode: 'always',
155 backgroundStyle: 'conic',
156 backgroundGradient: { type: 'conic', stops: [] },
157 titleFontPreset: 'rounded',
158 bodyFontPreset: 'Comic Sans MS'
159 })
160 ).toEqual(buildDefaultMasterConfig())
161 expect(parseMasterCss('body { color: red; }')).toEqual(buildDefaultMasterConfig())
162 })
163
164 it('migrates legacy global-element anchors and keeps elements inside the canvas', () => {
165 expect(
166 normalizeMasterElementsConfig({
167 logoImage: null,
168 footerText: 'Acme',
169 watermarkText: '',
170 showPageNumber: true,
171 logoPosition: { x: 130.789, y: -3 }
172 })
173 ).toMatchObject({
174 showLogo: false,
175 showFooter: true,
176 showPageNumber: true,
177 showWatermark: false,
178 logoPosition: { x: 84, y: 0 },
179 footerPosition: { x: 5, y: 91 },
180 pageNumberPosition: { x: 90, y: 91 },
181 watermarkPosition: { x: 30, y: 42 },
182 logoSize: { width: 16, height: 10 },
183 footerSize: { width: 56, height: 5 },
184 pageNumberSize: { width: 6, height: 5 },
185 watermarkSize: { width: 40, height: 16 }
186 })
187 })
188
189 it('defaults every global element to hidden and bounds persisted element dimensions', () => {
190 expect(normalizeMasterElementsConfig({})).toMatchObject({
191 showLogo: false,
192 showFooter: false,
193 showPageNumber: false,
194 showWatermark: false
195 })
196 expect(
197 normalizeMasterElementsConfig({
198 showPageNumber: true,
199 logoSize: { width: 130, height: -3 },
200 logoPosition: { x: 99, y: 99 }
201 })
202 ).toMatchObject({
203 logoSize: { width: 100, height: 1 },
204 logoPosition: { x: 0, y: 99 }
205 })
206 })
207
208 it('always builds the fixed global-elements layer after legacy visibility settings are normalized', () => {
209 const html = buildMasterElementsHtml({
210 enabled: false,
211 logoImage: null,
212 footerText: '',
213 watermarkText: '',
214 showPageNumber: true
215 })
216
217 expect(html).toContain('data-ppt-master-elements-layer="1"')
218 expect(html).toContain('z-index:2147483647 !important')
219 expect(parseMasterElementsHtml(html)).not.toHaveProperty('enabled')
220 })
221
222 it('does not generate hidden global-element nodes', () => {
223 const html = buildMasterElementsHtml({
224 logoImage: './images/brand.png',
225 footerText: 'Acme',
226 watermarkText: 'INTERNAL',
227 showLogo: false,
228 showFooter: false,
229 showPageNumber: false,
230 showWatermark: false
231 })
232
233 expect(html).not.toMatch(/<img\b[^>]*data-ppt-master-logo-image/)
234 expect(html).not.toMatch(/<div\b[^>]*data-ppt-master-footer/)
235 expect(html).not.toMatch(/<div\b[^>]*data-ppt-master-page-number/)
236 expect(html).not.toMatch(/<div\b[^>]*data-ppt-master-watermark/)
237 })
238
239 it('overrides the generated page content root and the existing page font variables', () => {
240 const css = buildMasterCss({
241 backgroundColor: '#112233',
242 backgroundMode: 'override',
243 backgroundStyle: 'solid',
244 backgroundGradient: createDefaultMasterGradient(),
245 titleFontPreset: 'serif',
246 bodyFontPreset: 'sans'
247 })
248
249 expect(css).toContain('--ppt-master-slide-background: #112233;')
250 expect(css).toContain('--ppt-title-font: ui-serif, Georgia')
251 expect(css).toContain('--ppt-body-font: system-ui, -apple-system')
252 expect(css).toContain('.ppt-page-content > [data-page-scaffold="1"] > [data-role="content"]')
253 expect(css).toContain('background: var(--ppt-master-slide-background) !important;')
254 })
255
256 it('serializes explicit font families and independent title and body size overrides', () => {
257 const css = buildMasterCss(
258 {
259 backgroundColor: '#ffffff',
260 backgroundMode: 'inherit',
261 backgroundStyle: 'solid',
262 backgroundGradient: createDefaultMasterGradient(),
263 titleFontPreset: 'inherit',
264 bodyFontPreset: 'inherit',
265 titleFontFamily: 'Noto Sans SC',
266 bodyFontFamily: 'Merriweather',
267 titleFontSize: 56,
268 bodyFontSize: 22
269 },
270 '@font-face{font-family:"Noto Sans SC";src:url("./assets/fonts/google-fonts/NotoSansSC/test.woff2")}'
271 )
272
273 expect(css).toContain('@font-face{font-family:"Noto Sans SC";')
274 expect(css).toContain('--ppt-master-title-font: "Noto Sans SC";')
275 expect(css).toContain('--ppt-master-body-font: "Merriweather";')
276 expect(css).toContain('--ppt-master-title-font-size: 56px;')
277 expect(css).toContain('--ppt-master-body-font-size: 22px;')
278 expect(css).toContain('font-size: var(--ppt-master-title-font-size) !important;')
279 expect(css).toContain('font-size: var(--ppt-master-body-font-size) !important;')
280 expect(parseMasterCss(css)).toMatchObject({
281 titleFontFamily: 'Noto Sans SC',
282 bodyFontFamily: 'Merriweather',
283 titleFontSize: 56,
284 bodyFontSize: 22
285 })
286 })
287
288 it('serializes a structured gradient through the SDK and reads it back without accepting raw CSS', () => {
289 const css = buildMasterCss({
290 backgroundColor: '#112233',
291 backgroundMode: 'override',
292 backgroundStyle: 'gradient',
293 backgroundGradient: {
294 type: 'radial',
295 angle: 45,
296 stops: [
297 { color: '#fef3c7', position: 0 },
298 { color: '#d97706', position: 72 },
299 { color: '#7c2d12', position: 100 }
300 ]
301 },
302 titleFontPreset: 'inherit',
303 bodyFontPreset: 'inherit'
304 })
305
306 expect(css).toContain('--ppt-master-background-style: gradient;')
307 expect(css).toContain(
308 '--ppt-master-slide-background: radial-gradient(circle at center, #fef3c7 0%, #d97706 72%, #7c2d12 100%);'
309 )
310 expect(parseMasterCss(css)).toMatchObject({
311 backgroundMode: 'override',
312 backgroundStyle: 'gradient',
313 backgroundGradient: {
314 type: 'radial',
315 stops: [
316 { color: '#fef3c7', position: 0 },
317 { color: '#d97706', position: 72 },
318 { color: '#7c2d12', position: 100 }
319 ]
320 }
321 })
322 })
323
324 it('serializes a session image background with cover positioning and reads it back', () => {
325 const css = buildMasterCss({
326 backgroundColor: '#ffffff',
327 backgroundMode: 'override',
328 backgroundStyle: 'image',
329 backgroundGradient: createDefaultMasterGradient(),
330 backgroundImage: './images/master-background.png',
331 titleFontPreset: 'inherit',
332 bodyFontPreset: 'inherit',
333 titleFontFamily: null,
334 bodyFontFamily: null,
335 titleFontSize: null,
336 bodyFontSize: null
337 })
338
339 expect(css).toContain('--ppt-master-background-style: image;')
340 expect(css).toContain('--ppt-master-background-image: url("./images/master-background.png");')
341 expect(css).toContain(
342 '--ppt-master-slide-background: url("./images/master-background.png") center center / cover no-repeat;'
343 )
344 expect(parseMasterCss(css)).toMatchObject({
345 backgroundMode: 'override',
346 backgroundStyle: 'image',
347 backgroundImage: './images/master-background.png'
348 })
349 })
350
351 it('drops unsafe image references instead of serializing a page-external URL', () => {
352 expect(
353 normalizeMasterConfig({
354 ...buildDefaultMasterConfig(),
355 backgroundMode: 'override',
356 backgroundStyle: 'image',
357 backgroundImage: '../outside.png'
358 })
359 ).toMatchObject({ backgroundStyle: 'solid', backgroundImage: null })
360 expect(
361 normalizeMasterConfig({
362 ...buildDefaultMasterConfig(),
363 backgroundMode: 'override',
364 backgroundStyle: 'image',
365 backgroundImage: 'https://example.com/background.png'
366 })
367 ).toMatchObject({ backgroundStyle: 'solid', backgroundImage: null })
368 })
369
370 it('inserts a stop at the requested position with an interpolated color', () => {
371 expect(
372 addMasterGradientStop(
373 {
374 type: 'linear',
375 angle: 90,
376 stops: [
377 { color: '#ff0000', position: 0 },
378 { color: '#0000ff', position: 100 }
379 ]
380 },
381 25
382 )
383 ).toMatchObject({
384 stops: [
385 { color: '#ff0000', position: 0 },
386 { color: '#bf0040', position: 25 },
387 { color: '#0000ff', position: 100 }
388 ]
389 })
390 })
391
392 it('parses a non-default page background as an overriding master', () => {
393 expect(parseMasterCss(':root { --ppt-page-bg: #f1efea; }')).toMatchObject({
394 backgroundColor: '#f1efea',
395 backgroundMode: 'override'
396 })
397 })
398
399 it('builds an inert, structured global-elements fragment without accepting raw HTML', () => {
400 const html = buildMasterElementsHtml({
401 logoImage: './images/brand.png',
402 footerText: '<b>Acme</b> 2026',
403 watermarkText: 'INTERNAL',
404 showLogo: true,
405 showFooter: true,
406 showPageNumber: true,
407 showWatermark: true,
408 footerFontSize: 18,
409 pageNumberFontSize: 14,
410 footerColor: '#0f766e',
411 pageNumberColor: '#7c2d12',
412 watermarkRotation: 28,
413 watermarkSizeAuto: true,
414 logoPosition: { x: 12.5, y: 9 },
415 footerPosition: { x: 8, y: 89 },
416 pageNumberPosition: { x: 90, y: 89 },
417 watermarkPosition: { x: 30, y: 42 },
418 logoSize: { width: 20, height: 12 },
419 footerSize: { width: 56, height: 5 },
420 pageNumberSize: { width: 6, height: 5 },
421 watermarkSize: { width: 40, height: 16 }
422 })
423
424 expect(html).toContain('data-ppt-master-elements="1"')
425 expect(html).toContain('data-ppt-master-elements-layer="1"')
426 expect(html).toContain('data-ppt-master-page-number="1"')
427 expect(html).toContain('style="left:12.5%;top:9%;width:20%;height:12%;"')
428 expect(html).toContain('font-size:18px;color:#0f766e;')
429 expect(html).toContain('font-size:14px;color:#7c2d12;')
430 expect(html).toContain('data-ppt-master-watermark-height="16"')
431 expect(html).toContain('transform:rotate(28deg);')
432 expect(html).toContain('&lt;b&gt;Acme&lt;/b&gt; 2026')
433 expect(parseMasterElementsHtml(html)).toEqual({
434 logoImage: './images/brand.png',
435 footerText: '<b>Acme</b> 2026',
436 watermarkText: 'INTERNAL',
437 showLogo: true,
438 showFooter: true,
439 showPageNumber: true,
440 showWatermark: true,
441 footerFontSize: 18,
442 pageNumberFontSize: 14,
443 footerColor: '#0f766e',
444 pageNumberColor: '#7c2d12',
445 watermarkRotation: 28,
446 watermarkSizeAuto: true,
447 logoPosition: { x: 12.5, y: 9 },
448 footerPosition: { x: 8, y: 89 },
449 pageNumberPosition: { x: 90, y: 89 },
450 watermarkPosition: { x: 30, y: 42 },
451 logoSize: { width: 20, height: 12 },
452 footerSize: { width: 56, height: 5 },
453 pageNumberSize: { width: 6, height: 5 },
454 watermarkSize: { width: 40, height: 16 }
455 })
456 })
457 })
458
458 lines TYPESCRIPT