| 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, '&') |
| 789 | .replace(/</g, '<') |
| 790 | .replace(/>/g, '>') |
| 791 | .replace(/"/g, '"') |
| 792 | .replace(/'/g, ''') |
| 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 |