返回 oh-my-ppt
master.ts
根目录 / src / shared / master.ts
1 export const MASTER_DIRECTORY = 'master'
2 export const MASTER_CSS_FILENAME = 'master.css'
3 export const MASTER_HTML_FILENAME = 'master.html'
4 export const MASTER_CSS_RELATIVE_PATH = `${MASTER_DIRECTORY}/${MASTER_CSS_FILENAME}`
5 export const MASTER_HTML_RELATIVE_PATH = `${MASTER_DIRECTORY}/${MASTER_HTML_FILENAME}`
6 export const MASTER_CSS_HREF = `./${MASTER_CSS_RELATIVE_PATH}`
7 export const MASTER_HTML_HREF = `./${MASTER_HTML_RELATIVE_PATH}`
8 export const MASTER_LINK_SELECTOR = 'link[data-ppt-master="1"]'
9
10 export const MASTER_FONT_PRESETS = ['inherit', 'sans', 'serif', 'mono'] as const
11 export const MASTER_BACKGROUND_MODES = ['inherit', 'override'] as const
12 export const MASTER_BACKGROUND_STYLES = ['solid', 'gradient', 'image'] as const
13 export const MASTER_GRADIENT_TYPES = ['linear', 'radial'] as const
14 export const MIN_MASTER_GRADIENT_STOPS = 2
15 export const MAX_MASTER_GRADIENT_STOPS = 5
16 export const MIN_MASTER_BODY_FONT_SIZE = 8
17 export const MAX_MASTER_BODY_FONT_SIZE = 96
18 export const MIN_MASTER_TITLE_FONT_SIZE = 12
19 export const MAX_MASTER_TITLE_FONT_SIZE = 160
20
21 export type MasterFontPreset = (typeof MASTER_FONT_PRESETS)[number]
22 export type MasterBackgroundMode = (typeof MASTER_BACKGROUND_MODES)[number]
23 export type MasterBackgroundStyle = (typeof MASTER_BACKGROUND_STYLES)[number]
24 export type MasterGradientType = (typeof MASTER_GRADIENT_TYPES)[number]
25
26 export type MasterGradientStop = {
27 color: string
28 position: number
29 }
30
31 export type MasterGradient = {
32 type: MasterGradientType
33 angle: number
34 stops: MasterGradientStop[]
35 }
36
37 export type MasterElementPosition = {
38 x: number
39 y: number
40 }
41
42 export type MasterElementSize = {
43 width: number
44 height: number
45 }
46
47 export type MasterElementsConfig = {
48 logoImage: string | null
49 footerText: string
50 watermarkText: string
51 showLogo: boolean
52 showFooter: boolean
53 showPageNumber: boolean
54 showWatermark: boolean
55 footerFontSize: number
56 pageNumberFontSize: number
57 footerColor: string
58 pageNumberColor: string
59 watermarkRotation: number
60 watermarkSizeAuto: boolean
61 logoPosition: MasterElementPosition
62 footerPosition: MasterElementPosition
63 pageNumberPosition: MasterElementPosition
64 watermarkPosition: MasterElementPosition
65 logoSize: MasterElementSize
66 footerSize: MasterElementSize
67 pageNumberSize: MasterElementSize
68 watermarkSize: MasterElementSize
69 }
70
71 export type SessionMasterConfig = {
72 backgroundColor: string
73 backgroundMode: MasterBackgroundMode
74 backgroundStyle: MasterBackgroundStyle
75 backgroundGradient: MasterGradient
76 backgroundImage: string | null
77 titleFontPreset: MasterFontPreset
78 bodyFontPreset: MasterFontPreset
79 titleFontFamily: string | null
80 bodyFontFamily: string | null
81 titleFontSize: number | null
82 bodyFontSize: number | null
83 elements: MasterElementsConfig
84 }
85
86 export type SessionMasterStatus = {
87 css: string
88 html: string
89 config: SessionMasterConfig
90 exists: boolean
91 revision: string
92 linkedPageCount: number
93 unlinkedPageCount: number
94 missingPageCount: number
95 totalPageCount: number
96 disabledPageIds: string[]
97 }
98
99 const DEFAULT_MASTER_GRADIENT: MasterGradient = {
100 type: 'linear',
101 angle: 135,
102 stops: [
103 { color: '#c7d2fe', position: 0 },
104 { color: '#4f46e5', position: 100 }
105 ]
106 }
107
108 const DEFAULT_MASTER_ELEMENTS: MasterElementsConfig = {
109 logoImage: null,
110 footerText: '',
111 watermarkText: '',
112 showLogo: false,
113 showFooter: false,
114 showPageNumber: false,
115 showWatermark: false,
116 footerFontSize: 16,
117 pageNumberFontSize: 16,
118 footerColor: '#334155',
119 pageNumberColor: '#334155',
120 watermarkRotation: -24,
121 watermarkSizeAuto: true,
122 logoPosition: { x: 5, y: 5 },
123 footerPosition: { x: 5, y: 91 },
124 pageNumberPosition: { x: 90, y: 91 },
125 watermarkPosition: { x: 30, y: 42 },
126 logoSize: { width: 16, height: 10 },
127 footerSize: { width: 56, height: 5 },
128 pageNumberSize: { width: 6, height: 5 },
129 watermarkSize: { width: 40, height: 16 }
130 }
131
132 const MAX_MASTER_FOOTER_TEXT_LENGTH = 180
133 const MAX_MASTER_WATERMARK_TEXT_LENGTH = 80
134 const MIN_MASTER_ELEMENT_FONT_SIZE = 8
135 const MAX_MASTER_ELEMENT_FONT_SIZE = 160
136
137 const isRecord = (value: unknown): value is Record<string, unknown> =>
138 Boolean(value) && typeof value === 'object' && !Array.isArray(value)
139
140 const clamp = (value: number, min: number, max: number): number =>
141 Math.min(max, Math.max(min, value))
142
143 const cloneGradientStops = (stops: MasterGradientStop[]): MasterGradientStop[] =>
144 stops.map((stop) => ({ ...stop }))
145
146 const normalizeGradientColor = (value: unknown, fallback: string): string => {
147 if (typeof value !== 'string') return fallback
148 const color = value.trim()
149 return /^#[0-9a-fA-F]{6}$/.test(color) ? color.toLowerCase() : fallback
150 }
151
152 const isMasterGradientType = (value: unknown): value is MasterGradientType =>
153 typeof value === 'string' && MASTER_GRADIENT_TYPES.includes(value as MasterGradientType)
154
155 const normalizeGradientAngle = (value: unknown): number => {
156 if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_MASTER_GRADIENT.angle
157 return ((Math.round(value) % 360) + 360) % 360
158 }
159
160 const normalizeGradientStops = (value: unknown): MasterGradientStop[] => {
161 if (!Array.isArray(value) || value.length < MIN_MASTER_GRADIENT_STOPS) {
162 return cloneGradientStops(DEFAULT_MASTER_GRADIENT.stops)
163 }
164 return value.slice(0, MAX_MASTER_GRADIENT_STOPS).map((item, index) => {
165 const fallback =
166 DEFAULT_MASTER_GRADIENT.stops[Math.min(index, DEFAULT_MASTER_GRADIENT.stops.length - 1)]
167 const stop = isRecord(item) ? item : {}
168 return {
169 color: normalizeGradientColor(stop.color, fallback.color),
170 position:
171 typeof stop.position === 'number' && Number.isFinite(stop.position)
172 ? Math.round(clamp(stop.position, 0, 100))
173 : fallback.position
174 }
175 })
176 }
177
178 const interpolateGradientColors = (left: string, right: string, amount: number): string => {
179 const ratio = clamp(amount, 0, 1)
180 const channels = [1, 3, 5].map((start) =>
181 Math.round(
182 Number.parseInt(left.slice(start, start + 2), 16) * (1 - ratio) +
183 Number.parseInt(right.slice(start, start + 2), 16) * ratio
184 )
185 .toString(16)
186 .padStart(2, '0')
187 )
188 return `#${channels.join('')}`
189 }
190
191 export function createDefaultMasterGradient(): MasterGradient {
192 return { ...DEFAULT_MASTER_GRADIENT, stops: cloneGradientStops(DEFAULT_MASTER_GRADIENT.stops) }
193 }
194
195 export function normalizeMasterGradient(value: unknown): MasterGradient {
196 const input = isRecord(value) ? value : {}
197 return {
198 type: isMasterGradientType(input.type) ? input.type : DEFAULT_MASTER_GRADIENT.type,
199 angle: normalizeGradientAngle(input.angle),
200 stops: cloneGradientStops(normalizeGradientStops(input.stops)).sort(
201 (left, right) => left.position - right.position
202 )
203 }
204 }
205
206 export function buildMasterGradientCss(value: unknown): string {
207 const gradient = normalizeMasterGradient(value)
208 const stops = gradient.stops.map((stop) => `${stop.color} ${stop.position}%`).join(', ')
209 return gradient.type === 'radial'
210 ? `radial-gradient(circle at center, ${stops})`
211 : `linear-gradient(${gradient.angle}deg, ${stops})`
212 }
213
214 export function addMasterGradientStop(value: unknown, preferredPosition?: number): MasterGradient {
215 const gradient = normalizeMasterGradient(value)
216 if (gradient.stops.length >= MAX_MASTER_GRADIENT_STOPS) return gradient
217 const pairs = gradient.stops.slice(0, -1).map((stop, index) => ({
218 left: stop,
219 right: gradient.stops[index + 1],
220 gap: gradient.stops[index + 1].position - stop.position
221 }))
222 const requestedPosition =
223 typeof preferredPosition === 'number' && Number.isFinite(preferredPosition)
224 ? Math.round(clamp(preferredPosition, 0, 100))
225 : undefined
226 const target =
227 (requestedPosition === undefined
228 ? undefined
229 : pairs.find(
230 (pair) =>
231 requestedPosition >= pair.left.position && requestedPosition <= pair.right.position
232 )) || pairs.reduce((largest, pair) => (pair.gap > largest.gap ? pair : largest), pairs[0])
233 if (!target) return gradient
234 const position =
235 requestedPosition ?? Math.round((target.left.position + target.right.position) / 2)
236 const ratio =
237 target.gap === 0
238 ? 0.5
239 : (position - target.left.position) / (target.right.position - target.left.position)
240 return normalizeMasterGradient({
241 ...gradient,
242 stops: [
243 ...gradient.stops,
244 {
245 color: interpolateGradientColors(target.left.color, target.right.color, ratio),
246 position
247 }
248 ]
249 })
250 }
251
252 export function updateMasterGradientStop(
253 value: unknown,
254 index: number,
255 patch: Partial<MasterGradientStop>
256 ): MasterGradient {
257 const gradient = normalizeMasterGradient(value)
258 if (!Number.isInteger(index) || index < 0 || index >= gradient.stops.length) return gradient
259 return normalizeMasterGradient({
260 ...gradient,
261 stops: gradient.stops.map((stop, currentIndex) =>
262 currentIndex === index ? { ...stop, ...patch } : stop
263 )
264 })
265 }
266
267 export function removeMasterGradientStop(value: unknown, index: number): MasterGradient {
268 const gradient = normalizeMasterGradient(value)
269 if (
270 gradient.stops.length <= MIN_MASTER_GRADIENT_STOPS ||
271 !Number.isInteger(index) ||
272 index < 0 ||
273 index >= gradient.stops.length
274 ) {
275 return gradient
276 }
277 return normalizeMasterGradient({
278 ...gradient,
279 stops: gradient.stops.filter((_, currentIndex) => currentIndex !== index)
280 })
281 }
282
283 export function parseMasterGradientCss(css: string): MasterGradient | null {
284 if (typeof css !== 'string') return null
285 const parseStops = (value: string): MasterGradientStop[] | null => {
286 const stops = value.split(',').map((part) => {
287 const match = part.trim().match(/^(#[0-9a-fA-F]{6})\s+(-?\d+(?:\.\d+)?)%$/)
288 return match ? { color: match[1], position: Number(match[2]) } : null
289 })
290 return stops.some((stop) => stop === null) || stops.length < MIN_MASTER_GRADIENT_STOPS
291 ? null
292 : (stops as MasterGradientStop[])
293 }
294 const linear = css.trim().match(/^linear-gradient\(\s*(-?\d+(?:\.\d+)?)deg\s*,\s*(.+)\)$/i)
295 if (linear) {
296 const stops = parseStops(linear[2])
297 return stops
298 ? normalizeMasterGradient({ type: 'linear', angle: Number(linear[1]), stops })
299 : null
300 }
301 const radial = css.trim().match(/^radial-gradient\(\s*circle\s+at\s+center\s*,\s*(.+)\)$/i)
302 if (!radial) return null
303 const stops = parseStops(radial[1])
304 return stops
305 ? normalizeMasterGradient({ type: 'radial', angle: DEFAULT_MASTER_GRADIENT.angle, stops })
306 : null
307 }
308
309 const DEFAULT_MASTER_CONFIG: SessionMasterConfig = {
310 backgroundColor: '#ffffff',
311 backgroundMode: 'inherit',
312 backgroundStyle: 'solid',
313 backgroundGradient: createDefaultMasterGradient(),
314 backgroundImage: null,
315 titleFontPreset: 'inherit',
316 bodyFontPreset: 'inherit',
317 titleFontFamily: null,
318 bodyFontFamily: null,
319 titleFontSize: null,
320 bodyFontSize: null,
321 elements: { ...DEFAULT_MASTER_ELEMENTS }
322 }
323
324 const FONT_STACKS: Record<Exclude<MasterFontPreset, 'inherit'>, string> = {
325 sans: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif',
326 serif: 'ui-serif, Georgia, "Noto Serif CJK SC", "Songti SC", SimSun, serif',
327 mono: 'ui-monospace, "SFMono-Regular", "Cascadia Mono", "Microsoft YaHei UI", Consolas, monospace'
328 }
329
330 const normalizeColor = (value: unknown): string => {
331 if (typeof value !== 'string') return DEFAULT_MASTER_CONFIG.backgroundColor
332 const normalized = value.trim()
333 return /^#[0-9a-fA-F]{6}$/.test(normalized)
334 ? normalized.toLowerCase()
335 : DEFAULT_MASTER_CONFIG.backgroundColor
336 }
337
338 const normalizePreset = (value: unknown): MasterFontPreset =>
339 typeof value === 'string' && MASTER_FONT_PRESETS.includes(value as MasterFontPreset)
340 ? (value as MasterFontPreset)
341 : 'inherit'
342
343 const normalizeFontFamily = (value: unknown): string | null => {
344 if (typeof value !== 'string') return null
345 const family = value.replace(/\s+/g, ' ').trim()
346 return family.length > 0 && family.length <= 120 ? family : null
347 }
348
349 const normalizeFontSize = (value: unknown, min: number, max: number): number | null =>
350 typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max
351 ? Math.round(value)
352 : null
353
354 const normalizeBackgroundMode = (value: unknown): MasterBackgroundMode =>
355 typeof value === 'string' && MASTER_BACKGROUND_MODES.includes(value as MasterBackgroundMode)
356 ? (value as MasterBackgroundMode)
357 : 'inherit'
358
359 const normalizeBackgroundStyle = (value: unknown): MasterBackgroundStyle =>
360 typeof value === 'string' && MASTER_BACKGROUND_STYLES.includes(value as MasterBackgroundStyle)
361 ? (value as MasterBackgroundStyle)
362 : 'solid'
363
364 const normalizeBackgroundImage = (value: unknown): string | null => {
365 if (typeof value !== 'string') return null
366 const imagePath = value.trim()
367 const fileName = imagePath.slice('./images/'.length)
368 return imagePath.startsWith('./images/') &&
369 fileName.length > 0 &&
370 fileName !== '.' &&
371 fileName !== '..' &&
372 !fileName.includes('/') &&
373 !fileName.includes('\\') &&
374 !fileName.includes('\0')
375 ? imagePath
376 : null
377 }
378
379 const normalizeMasterElementText = (value: unknown, maxLength: number): string => {
380 if (typeof value !== 'string') return ''
381 return value.replace(/\s+/g, ' ').trim().slice(0, maxLength)
382 }
383
384 const cloneMasterElementPosition = (position: MasterElementPosition): MasterElementPosition => ({
385 ...position
386 })
387
388 const cloneMasterElementSize = (size: MasterElementSize): MasterElementSize => ({ ...size })
389
390 const normalizeMasterElementPosition = (
391 value: unknown,
392 fallback: MasterElementPosition
393 ): MasterElementPosition => {
394 const record = isRecord(value) ? value : {}
395 const normalizeCoordinate = (coordinate: unknown, fallbackCoordinate: number): number =>
396 typeof coordinate === 'number' && Number.isFinite(coordinate)
397 ? Math.round(clamp(coordinate, 0, 100) * 100) / 100
398 : fallbackCoordinate
399 return {
400 x: normalizeCoordinate(record.x, fallback.x),
401 y: normalizeCoordinate(record.y, fallback.y)
402 }
403 }
404
405 const normalizeMasterElementSize = (value: unknown, fallback: MasterElementSize): MasterElementSize => {
406 const record = isRecord(value) ? value : {}
407 const normalizeDimension = (dimension: unknown, fallbackDimension: number): number =>
408 typeof dimension === 'number' && Number.isFinite(dimension)
409 ? Math.round(clamp(dimension, 1, 100) * 100) / 100
410 : fallbackDimension
411 return {
412 width: normalizeDimension(record.width, fallback.width),
413 height: normalizeDimension(record.height, fallback.height)
414 }
415 }
416
417 const normalizeMasterElementFontSize = (value: unknown, fallback: number): number =>
418 typeof value === 'number' && Number.isFinite(value)
419 ? Math.round(clamp(value, MIN_MASTER_ELEMENT_FONT_SIZE, MAX_MASTER_ELEMENT_FONT_SIZE))
420 : fallback
421
422 const normalizeMasterElementRotation = (value: unknown, fallback: number): number =>
423 typeof value === 'number' && Number.isFinite(value)
424 ? Math.round(clamp(value, -180, 180))
425 : fallback
426
427 const normalizeLegacyMasterElementPosition = (
428 value: unknown,
429 fallback: MasterElementPosition,
430 offset: MasterElementPosition
431 ): MasterElementPosition => {
432 const position = normalizeMasterElementPosition(value, fallback)
433 return {
434 x: Math.round(clamp(position.x - offset.x, 0, 100) * 100) / 100,
435 y: Math.round(clamp(position.y - offset.y, 0, 100) * 100) / 100
436 }
437 }
438
439 const keepMasterElementInsideCanvas = (
440 position: MasterElementPosition,
441 size: MasterElementSize
442 ): MasterElementPosition => ({
443 x: clamp(position.x, 0, 100 - size.width),
444 y: clamp(position.y, 0, 100 - size.height)
445 })
446
447 export function buildDefaultMasterElementsConfig(): MasterElementsConfig {
448 return {
449 ...DEFAULT_MASTER_ELEMENTS,
450 logoPosition: cloneMasterElementPosition(DEFAULT_MASTER_ELEMENTS.logoPosition),
451 footerPosition: cloneMasterElementPosition(DEFAULT_MASTER_ELEMENTS.footerPosition),
452 pageNumberPosition: cloneMasterElementPosition(DEFAULT_MASTER_ELEMENTS.pageNumberPosition),
453 watermarkPosition: cloneMasterElementPosition(DEFAULT_MASTER_ELEMENTS.watermarkPosition),
454 logoSize: cloneMasterElementSize(DEFAULT_MASTER_ELEMENTS.logoSize),
455 footerSize: cloneMasterElementSize(DEFAULT_MASTER_ELEMENTS.footerSize),
456 pageNumberSize: cloneMasterElementSize(DEFAULT_MASTER_ELEMENTS.pageNumberSize),
457 watermarkSize: cloneMasterElementSize(DEFAULT_MASTER_ELEMENTS.watermarkSize)
458 }
459 }
460
461 export function normalizeMasterElementsConfig(value: unknown): MasterElementsConfig {
462 const record = isRecord(value) ? value : {}
463 const logoImage = normalizeBackgroundImage(record.logoImage)
464 const footerText = normalizeMasterElementText(record.footerText, MAX_MASTER_FOOTER_TEXT_LENGTH)
465 const watermarkText = normalizeMasterElementText(
466 record.watermarkText,
467 MAX_MASTER_WATERMARK_TEXT_LENGTH
468 )
469 const hasLogoSize = isRecord(record.logoSize)
470 const hasFooterSize = isRecord(record.footerSize)
471 const hasPageNumberSize = isRecord(record.pageNumberSize)
472 const hasWatermarkSize = isRecord(record.watermarkSize)
473 const logoSize = normalizeMasterElementSize(record.logoSize, DEFAULT_MASTER_ELEMENTS.logoSize)
474 const footerSize = normalizeMasterElementSize(record.footerSize, DEFAULT_MASTER_ELEMENTS.footerSize)
475 const pageNumberSize = normalizeMasterElementSize(
476 record.pageNumberSize,
477 DEFAULT_MASTER_ELEMENTS.pageNumberSize
478 )
479 const watermarkSize = normalizeMasterElementSize(
480 record.watermarkSize,
481 DEFAULT_MASTER_ELEMENTS.watermarkSize
482 )
483 const logoPosition = hasLogoSize
484 ? normalizeMasterElementPosition(record.logoPosition, DEFAULT_MASTER_ELEMENTS.logoPosition)
485 : normalizeLegacyMasterElementPosition(record.logoPosition, { x: 5, y: 5 }, { x: 0, y: 0 })
486 const footerPosition = hasFooterSize
487 ? normalizeMasterElementPosition(record.footerPosition, DEFAULT_MASTER_ELEMENTS.footerPosition)
488 : normalizeLegacyMasterElementPosition(
489 record.footerPosition,
490 { x: 5, y: 96 },
491 { x: 0, y: DEFAULT_MASTER_ELEMENTS.footerSize.height }
492 )
493 const pageNumberPosition = hasPageNumberSize
494 ? normalizeMasterElementPosition(record.pageNumberPosition, DEFAULT_MASTER_ELEMENTS.pageNumberPosition)
495 : normalizeLegacyMasterElementPosition(
496 record.pageNumberPosition,
497 { x: 96, y: 96 },
498 {
499 x: DEFAULT_MASTER_ELEMENTS.pageNumberSize.width,
500 y: DEFAULT_MASTER_ELEMENTS.pageNumberSize.height
501 }
502 )
503 const watermarkPosition = hasWatermarkSize
504 ? normalizeMasterElementPosition(record.watermarkPosition, DEFAULT_MASTER_ELEMENTS.watermarkPosition)
505 : normalizeLegacyMasterElementPosition(
506 record.watermarkPosition,
507 { x: 50, y: 50 },
508 {
509 x: DEFAULT_MASTER_ELEMENTS.watermarkSize.width / 2,
510 y: DEFAULT_MASTER_ELEMENTS.watermarkSize.height / 2
511 }
512 )
513 return {
514 logoImage,
515 footerText,
516 watermarkText,
517 showLogo: typeof record.showLogo === 'boolean' ? record.showLogo : Boolean(logoImage),
518 showFooter: typeof record.showFooter === 'boolean' ? record.showFooter : Boolean(footerText),
519 showPageNumber: record.showPageNumber === true,
520 showWatermark:
521 typeof record.showWatermark === 'boolean' ? record.showWatermark : Boolean(watermarkText),
522 footerFontSize: normalizeMasterElementFontSize(
523 record.footerFontSize,
524 DEFAULT_MASTER_ELEMENTS.footerFontSize
525 ),
526 pageNumberFontSize: normalizeMasterElementFontSize(
527 record.pageNumberFontSize,
528 DEFAULT_MASTER_ELEMENTS.pageNumberFontSize
529 ),
530 footerColor: normalizeGradientColor(record.footerColor, DEFAULT_MASTER_ELEMENTS.footerColor),
531 pageNumberColor: normalizeGradientColor(
532 record.pageNumberColor,
533 DEFAULT_MASTER_ELEMENTS.pageNumberColor
534 ),
535 watermarkRotation: normalizeMasterElementRotation(
536 record.watermarkRotation,
537 DEFAULT_MASTER_ELEMENTS.watermarkRotation
538 ),
539 watermarkSizeAuto: record.watermarkSizeAuto !== false,
540 logoPosition: keepMasterElementInsideCanvas(logoPosition, logoSize),
541 footerPosition: keepMasterElementInsideCanvas(footerPosition, footerSize),
542 pageNumberPosition: keepMasterElementInsideCanvas(pageNumberPosition, pageNumberSize),
543 watermarkPosition: keepMasterElementInsideCanvas(watermarkPosition, watermarkSize),
544 logoSize,
545 footerSize,
546 pageNumberSize,
547 watermarkSize
548 }
549 }
550
551 export function buildDefaultMasterConfig(): SessionMasterConfig {
552 return {
553 ...DEFAULT_MASTER_CONFIG,
554 backgroundGradient: createDefaultMasterGradient(),
555 elements: buildDefaultMasterElementsConfig()
556 }
557 }
558
559 export function normalizeMasterConfig(value: unknown): SessionMasterConfig {
560 const record = isRecord(value) ? value : {}
561 const backgroundImage = normalizeBackgroundImage(record.backgroundImage)
562 const backgroundStyle = normalizeBackgroundStyle(record.backgroundStyle)
563 return {
564 backgroundColor: normalizeColor(record.backgroundColor),
565 backgroundMode: normalizeBackgroundMode(record.backgroundMode),
566 backgroundStyle: backgroundStyle === 'image' && !backgroundImage ? 'solid' : backgroundStyle,
567 backgroundGradient: normalizeMasterGradient(record.backgroundGradient),
568 backgroundImage,
569 titleFontPreset: normalizePreset(record.titleFontPreset),
570 bodyFontPreset: normalizePreset(record.bodyFontPreset),
571 titleFontFamily: normalizeFontFamily(record.titleFontFamily),
572 bodyFontFamily: normalizeFontFamily(record.bodyFontFamily),
573 titleFontSize: normalizeFontSize(
574 record.titleFontSize,
575 MIN_MASTER_TITLE_FONT_SIZE,
576 MAX_MASTER_TITLE_FONT_SIZE
577 ),
578 bodyFontSize: normalizeFontSize(record.bodyFontSize, MIN_MASTER_BODY_FONT_SIZE, MAX_MASTER_BODY_FONT_SIZE),
579 elements: normalizeMasterElementsConfig(record.elements)
580 }
581 }
582
583 const normalizeFontStack = (value: string): string => value.replace(/\s+/g, ' ').trim()
584
585 const presetFromStack = (value: string | undefined): MasterFontPreset => {
586 if (!value) return 'inherit'
587 const normalized = normalizeFontStack(value)
588 return (
589 (Object.entries(FONT_STACKS).find(
590 ([, stack]) => normalizeFontStack(stack) === normalized
591 )?.[0] as MasterFontPreset | undefined) || 'inherit'
592 )
593 }
594
595 const readCssVariable = (css: string, name: string): string | undefined => {
596 const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
597 const match = css.match(new RegExp(`${escapedName}\\s*:\\s*([^;}]+)`, 'i'))
598 return match?.[1]?.trim()
599 }
600
601 const escapeCssString = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
602
603 const fontFamilyFromCssValue = (value: string | undefined): string | null => {
604 if (!value) return null
605 const match = value.trim().match(/^"((?:\\.|[^"\\])*)"$/)
606 if (!match) return null
607 return normalizeFontFamily(match[1].replace(/\\(.)/g, '$1'))
608 }
609
610 const fontSizeFromCssValue = (
611 value: string | undefined,
612 min: number,
613 max: number
614 ): number | null => {
615 const match = value?.trim().match(/^(\d+(?:\.\d+)?)px$/i)
616 return normalizeFontSize(match ? Number(match[1]) : null, min, max)
617 }
618
619 const backgroundImageFromCssValue = (value: string | undefined): string | null => {
620 const match = value?.trim().match(/^url\(\s*"((?:\\.|[^"\\])*)"\s*\)$/i)
621 return normalizeBackgroundImage(match?.[1]?.replace(/\\(.)/g, '$1'))
622 }
623
624 const isValidColor = (value: string | undefined): value is string =>
625 typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value.trim())
626
627 const MASTER_PAGE_BACKGROUND_SELECTORS = [
628 '.ppt-page-content > [data-page-scaffold="1"] > [data-role="content"]',
629 '.ppt-page-content > [data-page-scaffold="1"] > [data-role="content"] > :first-child',
630 '.ppt-page-content > [data-page-scaffold="1"] > :first-child'
631 ].join(',\n')
632
633 const MASTER_TITLE_TEXT_SELECTORS = [
634 '.ppt-page-content h1',
635 '.ppt-page-content h2',
636 '.ppt-page-content h3',
637 '.ppt-page-content h4',
638 '.ppt-page-content h5',
639 '.ppt-page-content h6',
640 '.ppt-page-content [data-role="title"]',
641 '.ppt-page-content [data-block-id="title"]'
642 ].join(',\n')
643
644 const MASTER_BODY_TEXT_SELECTORS = [
645 '.ppt-page-content p',
646 '.ppt-page-content li',
647 '.ppt-page-content [data-role="body"]',
648 '.ppt-page-content [data-block-id="body"]'
649 ].join(',\n')
650
651 export function getMasterFontFamilies(value: unknown): string[] {
652 const config = normalizeMasterConfig(value)
653 return Array.from(
654 new Set(
655 [config.titleFontFamily, config.bodyFontFamily].filter((family): family is string =>
656 Boolean(family)
657 )
658 )
659 )
660 }
661
662 export function buildMasterCss(value: unknown, fontFaceCss = ''): string {
663 const config = normalizeMasterConfig(value)
664 const backgroundImage = config.backgroundImage
665 ? `url("${escapeCssString(config.backgroundImage)}")`
666 : null
667 const background =
668 config.backgroundStyle === 'gradient'
669 ? buildMasterGradientCss(config.backgroundGradient)
670 : config.backgroundStyle === 'image' && backgroundImage
671 ? `${backgroundImage} center center / cover no-repeat`
672 : config.backgroundColor
673 const variables = [
674 ` --ppt-page-bg: ${
675 config.backgroundMode === 'override' ? background : DEFAULT_MASTER_CONFIG.backgroundColor
676 };`
677 ]
678 if (config.backgroundMode === 'override') {
679 variables.push(` --ppt-master-background-color: ${config.backgroundColor};`)
680 variables.push(` --ppt-master-background-style: ${config.backgroundStyle};`)
681 if (config.backgroundStyle === 'image' && backgroundImage) {
682 variables.push(` --ppt-master-background-image: ${backgroundImage};`)
683 }
684 variables.push(` --ppt-master-slide-background: ${background};`)
685 }
686 const titleFont = config.titleFontFamily
687 ? `"${escapeCssString(config.titleFontFamily)}"`
688 : config.titleFontPreset !== 'inherit'
689 ? FONT_STACKS[config.titleFontPreset]
690 : null
691 const bodyFont = config.bodyFontFamily
692 ? `"${escapeCssString(config.bodyFontFamily)}"`
693 : config.bodyFontPreset !== 'inherit'
694 ? FONT_STACKS[config.bodyFontPreset]
695 : null
696 if (titleFont) {
697 const fontStack = titleFont
698 variables.push(` --ppt-master-title-font: ${fontStack};`)
699 variables.push(` --ppt-title-font: ${fontStack};`)
700 }
701 if (bodyFont) {
702 const fontStack = bodyFont
703 variables.push(` --ppt-master-body-font: ${fontStack};`)
704 variables.push(` --ppt-body-font: ${fontStack};`)
705 }
706 if (config.titleFontSize !== null) {
707 variables.push(` --ppt-master-title-font-size: ${config.titleFontSize}px;`)
708 }
709 if (config.bodyFontSize !== null) {
710 variables.push(` --ppt-master-body-font-size: ${config.bodyFontSize}px;`)
711 }
712 const backgroundRule =
713 config.backgroundMode === 'override'
714 ? `\n${MASTER_PAGE_BACKGROUND_SELECTORS} {\n background: var(--ppt-master-slide-background) !important;\n}\n`
715 : ''
716 const titleFontRule = titleFont
717 ? `\n${MASTER_TITLE_TEXT_SELECTORS} {\n font-family: var(--ppt-master-title-font) !important;\n}\n`
718 : ''
719 const bodyFontRule = bodyFont
720 ? `\n${MASTER_BODY_TEXT_SELECTORS} {\n font-family: var(--ppt-master-body-font) !important;\n}\n`
721 : ''
722 const titleFontSizeRule =
723 config.titleFontSize !== null
724 ? `\n${MASTER_TITLE_TEXT_SELECTORS} {\n font-size: var(--ppt-master-title-font-size) !important;\n}\n`
725 : ''
726 const bodyFontSizeRule =
727 config.bodyFontSize !== null
728 ? `\n${MASTER_BODY_TEXT_SELECTORS} {\n font-size: var(--ppt-master-body-font-size) !important;\n}\n`
729 : ''
730 const fontFaces = fontFaceCss.trim()
731 return `/* OhMyPPT Slide Master. Managed by the application. */\n${fontFaces ? `${fontFaces}\n` : ''}:root {\n${variables.join(
732 '\n'
733 )}\n}\n${backgroundRule}${bodyFontRule}${titleFontRule}${bodyFontSizeRule}${titleFontSizeRule}`
734 }
735
736 export function parseMasterCss(css: string): SessionMasterConfig {
737 if (typeof css !== 'string' || !/:root\s*\{/i.test(css)) return buildDefaultMasterConfig()
738 const masterSlideBackground = readCssVariable(css, '--ppt-master-slide-background')
739 const masterBackgroundColor = readCssVariable(css, '--ppt-master-background-color')
740 const masterBackgroundImage = backgroundImageFromCssValue(
741 readCssVariable(css, '--ppt-master-background-image')
742 )
743 const pageBackground = readCssVariable(css, '--ppt-page-bg')
744 const parsedGradient = parseMasterGradientCss(masterSlideBackground || '')
745 const hasMasterSlideBackground = isValidColor(masterSlideBackground)
746 const hasMasterBackgroundColor = isValidColor(masterBackgroundColor)
747 const hasLegacyOverride =
748 isValidColor(pageBackground) &&
749 normalizeColor(pageBackground) !== DEFAULT_MASTER_CONFIG.backgroundColor
750 const parsed = normalizeMasterConfig({
751 backgroundColor: hasMasterBackgroundColor
752 ? masterBackgroundColor
753 : parsedGradient
754 ? parsedGradient.stops[0]?.color
755 : hasMasterSlideBackground
756 ? masterSlideBackground
757 : pageBackground,
758 backgroundMode:
759 hasMasterBackgroundColor ||
760 hasMasterSlideBackground ||
761 Boolean(parsedGradient) ||
762 hasLegacyOverride
763 ? 'override'
764 : 'inherit',
765 backgroundStyle: masterBackgroundImage ? 'image' : parsedGradient ? 'gradient' : 'solid',
766 backgroundGradient: parsedGradient || createDefaultMasterGradient(),
767 backgroundImage: masterBackgroundImage,
768 titleFontPreset: presetFromStack(readCssVariable(css, '--ppt-master-title-font')),
769 bodyFontPreset: presetFromStack(readCssVariable(css, '--ppt-master-body-font')),
770 titleFontFamily: fontFamilyFromCssValue(readCssVariable(css, '--ppt-master-title-font')),
771 bodyFontFamily: fontFamilyFromCssValue(readCssVariable(css, '--ppt-master-body-font')),
772 titleFontSize: fontSizeFromCssValue(
773 readCssVariable(css, '--ppt-master-title-font-size'),
774 MIN_MASTER_TITLE_FONT_SIZE,
775 MAX_MASTER_TITLE_FONT_SIZE
776 ),
777 bodyFontSize: fontSizeFromCssValue(
778 readCssVariable(css, '--ppt-master-body-font-size'),
779 MIN_MASTER_BODY_FONT_SIZE,
780 MAX_MASTER_BODY_FONT_SIZE
781 )
782 })
783 return { ...parsed, elements: buildDefaultMasterElementsConfig() }
784 }
785
786 const escapeMasterHtml = (value: string): string =>
787 value
788 .replace(/&/g, '&amp;')
789 .replace(/</g, '&lt;')
790 .replace(/>/g, '&gt;')
791 .replace(/"/g, '&quot;')
792 .replace(/'/g, '&#39;')
793
794 const escapeMasterJson = (value: string): string =>
795 value.replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026')
796
797 /**
798 * The runtime clones this inert template into the guarded page root. Keep all
799 * user-controlled fields as text or validated session image paths; it is not
800 * an arbitrary HTML authoring surface.
801 */
802 export function buildMasterElementsHtml(value: unknown): string {
803 const config = normalizeMasterElementsConfig(value)
804 const json = escapeMasterJson(JSON.stringify(config))
805 const elementStyle = (position: MasterElementPosition, size: MasterElementSize): string =>
806 `left:${position.x}%;top:${position.y}%;width:${size.width}%;height:${size.height}%;`
807 const logo = config.showLogo && config.logoImage
808 ? `<img data-ppt-master-logo-image="1" src="${escapeMasterHtml(config.logoImage)}" alt="" style="${elementStyle(config.logoPosition, config.logoSize)}" />`
809 : ''
810 const footer = config.showFooter && config.footerText
811 ? `<div data-ppt-master-footer="1" style="left:${config.footerPosition.x}%;top:${config.footerPosition.y}%;width:${config.footerSize.width}%;font-size:${config.footerFontSize}px;color:${config.footerColor};">${escapeMasterHtml(config.footerText)}</div>`
812 : ''
813 const watermark = config.showWatermark && config.watermarkText
814 ? `<div data-ppt-master-watermark="1" data-ppt-master-watermark-height="${config.watermarkSize.height}" style="${elementStyle(config.watermarkPosition, config.watermarkSize)}transform:rotate(${config.watermarkRotation}deg);">${escapeMasterHtml(config.watermarkText)}</div>`
815 : ''
816 const pageNumber = config.showPageNumber
817 ? `<div data-ppt-master-page-number="1" aria-hidden="true" style="left:${config.pageNumberPosition.x}%;top:${config.pageNumberPosition.y}%;width:${config.pageNumberSize.width}%;font-size:${config.pageNumberFontSize}px;color:${config.pageNumberColor};"></div>`
818 : ''
819 const layer = `<div data-ppt-master-elements-layer="1" aria-hidden="true">
820 <style data-ppt-master-elements-style="1">
821 [data-ppt-master-elements-layer="1"] { position:absolute !important; inset:0 !important; z-index:2147483647 !important; display:block !important; pointer-events:none !important; color:inherit; font-family:inherit; }
822 [data-ppt-master-logo-image="1"] { position:absolute; display:block; object-fit:contain; object-position:left top; }
823 [data-ppt-master-footer="1"] { position:absolute; display:block; overflow:hidden; line-height:1.25; white-space:nowrap; text-overflow:ellipsis; }
824 [data-ppt-master-page-number="1"] { position:absolute; display:block; overflow:hidden; line-height:1.25; text-align:right; font-variant-numeric:tabular-nums; }
825 [data-ppt-master-watermark="1"] { position:absolute; display:flex; align-items:center; justify-content:center; overflow:hidden; color:rgba(15,23,42,.1); font-weight:700; line-height:1; white-space:nowrap; text-overflow:ellipsis; text-align:center; transform-origin:center; }
826 </style>
827 ${logo}
828 ${footer}
829 ${pageNumber}
830 ${watermark}
831 </div>`
832 return `<!-- OhMyPPT Slide Master elements. Managed by the application. -->
833 <script type="application/json" data-ppt-master-elements-config="1">${json}</script>
834 <template data-ppt-master-elements="1">
835 ${layer}
836 </template>
837 `
838 }
839
840 export function parseMasterElementsHtml(html: string): MasterElementsConfig {
841 if (typeof html !== 'string') return buildDefaultMasterElementsConfig()
842 const match = html.match(
843 /<script\b[^>]*\bdata-ppt-master-elements-config=(?:"1"|'1')[^>]*>([\s\S]*?)<\/script>/i
844 )
845 if (!match?.[1]) return buildDefaultMasterElementsConfig()
846 try {
847 return normalizeMasterElementsConfig(JSON.parse(match[1]))
848 } catch {
849 return buildDefaultMasterElementsConfig()
850 }
851 }
852
852 lines TYPESCRIPT