import { nanoid } from "nanoid" import type { Gradient, PPTElement, PPTElementShadow, PPTImageElement, PPTLineElement, PPTShapeElement, PPTTextElement, Slide, SlideBackground, SlideTheme, } from "@/types/slides" import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware" interface AiPptOptions { backgroundImage?: string } type PageRole = "cover" | "intro" | "goal" | "story" | "listen" | "lyric" | "knowledge" | "rhythm" | "interaction" | "activity" | "summary" | "homework" | "content" interface Palette { primary: string secondary: string accent: string warm: string background: string surface: string soft: string text: string muted: string dark: string } interface ImageRect { left: number top: number width: number height: number radius?: number } const SLIDE_WIDTH = 1600 const SLIDE_HEIGHT = 900 const FONT_NAME = "Microsoft Yahei" const GENERIC_PAGE_TITLES = new Set(["封面", "课程导入", "故事导入", "导入", "首页", "cover"]) export function buildAiPptSlides(outline: AiCoursewareOutline, options: AiPptOptions = {}): { title: string; theme: Partial; slides: Slide[] } { const normalized = normalizeOutline(outline) const palette = buildPalette(normalized) const slides = normalized.pages.map((page, index) => buildSlide(page, normalized, palette, options, index)) runPreflight(slides) return { title: normalized.title || normalized.chapterName || "音乐课件", theme: { themeColor: palette.primary, backgroundColor: palette.background, fontColor: palette.text, fontName: FONT_NAME, fontSize: "36px", }, slides, } } function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions, index: number): Slide { const role = pageRole(page, index) const elements = templateFor(role)(page, outline, palette, index) return { id: nanoid(10), elements: keepInCanvas(elements), background: backgroundConfig(role, palette, options), remark: slideRemark(page), } } function templateFor(role: PageRole) { switch (role) { case "cover": return coverTemplate case "goal": return goalTemplate case "listen": return listenTemplate case "lyric": return lyricTemplate case "rhythm": return rhythmTemplate case "knowledge": return knowledgeTemplate case "interaction": return interactionTemplate case "activity": return activityTemplate case "summary": return summaryTemplate case "homework": return homeworkTemplate case "intro": case "story": return storyTemplate default: return contentTemplate } } function coverTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const title = chapterTitle(outline, page) const subtitle = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ") const points = studentPoints(page, 2) if (hasImage(page)) { const elements: PPTElement[] = [imageElement(page, { left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT })] elements.push(shapeElement({ left: 0, top: 0, width: 910, height: 900, fill: palette.dark, opacity: 0.78, radius: 0 })) elements.push(textElement({ left: 100, top: 150, width: 700, height: 150, content: title, fontSize: fitFont(title, 700, 150, 66, 48), color: "#FFFFFF", bold: true })) if (subtitle) elements.push(textElement({ left: 104, top: 330, width: 680, height: 48, content: subtitle, fontSize: 24, color: "#EAF2F7" })) elements.push(shapeElement({ left: 104, top: 430, width: 84, height: 10, fill: palette.accent, radius: 4 })) points.forEach((point, index) => elements.push(textElement({ left: 104, top: 500 + index * 64, width: 680, height: 44, content: point, fontSize: fitFont(point, 680, 44, 28, 22), color: "#FFFFFF", bold: index === 0 }))) return elements } const elements: PPTElement[] = [] elements.push(shapeElement({ left: 0, top: 0, width: 560, height: 900, fill: palette.primary, radius: 0 })) elements.push(shapeElement({ left: 560, top: 0, width: 1040, height: 900, fill: palette.background, radius: 0 })) elements.push(textElement({ left: 92, top: 112, width: 380, height: 70, content: "音乐课堂", fontSize: 30, color: "#FFFFFF", bold: true })) elements.push(textElement({ left: 650, top: 170, width: 780, height: 170, content: title, fontSize: fitFont(title, 780, 170, 70, 50), color: palette.primary, bold: true })) if (subtitle) elements.push(textElement({ left: 656, top: 370, width: 740, height: 44, content: subtitle, fontSize: 24, color: palette.muted })) elements.push(...melodyStaff(120, 350, 360, palette)) elements.push(shapeElement({ left: 654, top: 486, width: 120, height: 10, fill: palette.secondary, radius: 4 })) points.forEach((point, index) => elements.push(textElement({ left: 656, top: 548 + index * 66, width: 720, height: 46, content: point, fontSize: fitFont(point, 720, 46, 30, 23), color: palette.text, bold: index === 0 }))) return elements } function storyTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] { const elements = slideHeader(page, outline, palette, "进入情境") const imageLeft = index % 2 === 0 if (hasImage(page)) { const imageRect = imageLeft ? { left: 86, top: 190, width: 860, height: 560, radius: 8 } : { left: 654, top: 190, width: 860, height: 560, radius: 8 } elements.push(imageElement(page, imageRect)) const panelLeft = imageLeft ? 1010 : 86 elements.push(shapeElement({ left: panelLeft, top: 230, width: 500, height: 470, fill: palette.surface, radius: 8, shadow: softShadow() })) elements.push(...studentPanel(page, palette, panelLeft + 42, 278, 416)) } else { elements.push(shapeElement({ left: 86, top: 210, width: 1428, height: 480, fill: palette.surface, radius: 8, shadow: softShadow() })) elements.push(textElement({ left: 156, top: 270, width: 1280, height: 90, content: studentHeadline(page) || page.title, fontSize: fitFont(studentHeadline(page) || page.title, 1280, 90, 42, 30), color: palette.primary, bold: true })) elements.push(...storySequence(studentPoints(page, 4), palette, 156, 420, 1280)) } addClassroomCue(elements, page, palette) return elements } function goalTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "本课目标") const points = studentPoints(page, 3) points.forEach((point, index) => { const left = 92 + index * 500 const colors = [palette.primary, palette.secondary, palette.accent] elements.push(shapeElement({ left, top: 240, width: 430, height: 380, fill: palette.surface, radius: 8, shadow: softShadow() })) elements.push(shapeElement({ left, top: 240, width: 430, height: 18, fill: colors[index], radius: 0 })) elements.push(textElement({ left: left + 34, top: 300, width: 96, height: 74, content: `0${index + 1}`, fontSize: 50, color: colors[index], bold: true })) elements.push(textElement({ left: left + 34, top: 410, width: 350, height: 120, content: point, fontSize: fitFont(point, 350, 120, 32, 25), color: palette.text, bold: true, lineHeight: 1.35 })) }) addClassroomCue(elements, page, palette) return elements } function listenTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "听赏任务") const task = activityTask(page) || studentHeadline(page) || "安静聆听,找一找音乐中的声音" if (hasImage(page)) { elements.push(imageElement(page, { left: 84, top: 185, width: 1432, height: 520, radius: 8 })) elements.push(shapeElement({ left: 84, top: 510, width: 1432, height: 195, fill: palette.dark, opacity: 0.8, radius: 0 })) elements.push(textElement({ left: 130, top: 550, width: 1340, height: 84, content: task, fontSize: fitFont(task, 1340, 84, 42, 30), color: "#FFFFFF", bold: true })) } else { elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 220, fill: palette.dark, radius: 8 })) elements.push(textElement({ left: 150, top: 270, width: 1300, height: 92, content: task, fontSize: fitFont(task, 1300, 92, 44, 30), color: "#FFFFFF", bold: true })) elements.push(...soundWave(250, 520, palette, 1080, 150)) } addClassroomCue(elements, page, palette) return elements } function lyricTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "学唱与歌词") const points = studentPoints(page, 4) elements.push(shapeElement({ left: 90, top: 205, width: 1420, height: 470, fill: palette.surface, radius: 8, shadow: softShadow() })) elements.push(shapeElement({ left: 90, top: 205, width: 26, height: 470, fill: palette.secondary, radius: 0 })) points.forEach((point, index) => { const top = 265 + index * 92 elements.push(textElement({ left: 166, top, width: 1250, height: 58, content: point, fontSize: fitFont(point, 1250, 58, 36, 27), color: index % 2 === 0 ? palette.text : palette.primary, bold: index % 2 === 1 })) }) addClassroomCue(elements, page, palette) return elements } function rhythmTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "节奏练习") const task = activityTask(page) || studentHeadline(page) || "拍一拍,感受节奏" elements.push(textElement({ left: 92, top: 190, width: 1410, height: 72, content: task, fontSize: fitFont(task, 1410, 72, 40, 30), color: palette.text, bold: true })) const beats = activitySteps(page, 4) beats.forEach((beat, index) => { const left = 92 + index * 370 elements.push(shapeElement({ left, top: 330, width: 320, height: 260, fill: index % 2 === 0 ? palette.soft : palette.surface, radius: 8, shadow: lightShadow() })) elements.push(textElement({ left: left + 28, top: 370, width: 70, height: 56, content: `${index + 1}`, fontSize: 42, color: index % 2 === 0 ? palette.primary : palette.secondary, bold: true })) elements.push(textElement({ left: left + 28, top: 460, width: 260, height: 90, content: beat, fontSize: fitFont(beat, 260, 90, 29, 23), color: palette.text, bold: true, lineHeight: 1.3 })) }) addClassroomCue(elements, page, palette) return elements } function knowledgeTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] { const elements = slideHeader(page, outline, palette, "音乐知识") const points = studentPoints(page, 4) if (hasImage(page)) { const imageRight = index % 2 === 0 elements.push(imageElement(page, imageRight ? { left: 900, top: 190, width: 610, height: 500, radius: 8 } : { left: 90, top: 190, width: 610, height: 500, radius: 8 })) elements.push(...knowledgeList(points, palette, imageRight ? 90 : 770, 220, 720)) } else { elements.push(...knowledgeList(points, palette, 90, 220, 1420)) } addClassroomCue(elements, page, palette) return elements } function interactionTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "互动挑战") const question = page.interaction?.question || activityTask(page) || studentHeadline(page) || "你发现了什么?" elements.push(shapeElement({ left: 90, top: 200, width: 1420, height: 190, fill: palette.primary, radius: 8 })) elements.push(textElement({ left: 150, top: 245, width: 1300, height: 100, content: question, fontSize: fitFont(question, 1300, 100, 44, 30), color: "#FFFFFF", bold: true })) const steps = activitySteps(page, 3) steps.forEach((step, index) => { const left = 90 + index * 490 elements.push(textElement({ left, top: 468, width: 88, height: 54, content: `0${index + 1}`, fontSize: 40, color: [palette.primary, palette.secondary, palette.accent][index], bold: true })) elements.push(textElement({ left: left + 96, top: 466, width: 340, height: 100, content: step, fontSize: fitFont(step, 340, 100, 29, 23), color: palette.text, bold: true, lineHeight: 1.3 })) if (index < steps.length - 1) elements.push(lineElement(left + 452, 492, left + 478, 492, palette.muted)) }) addClassroomCue(elements, page, palette) return elements } function activityTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "课堂活动") const task = activityTask(page) || studentHeadline(page) || "一起完成课堂挑战" elements.push(textElement({ left: 92, top: 190, width: 1400, height: 90, content: task, fontSize: fitFont(task, 1400, 90, 42, 30), color: palette.primary, bold: true })) elements.push(...storySequence(activitySteps(page, 4), palette, 92, 360, 1416)) addClassroomCue(elements, page, palette) return elements } function summaryTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "课堂小结") studentPoints(page, 4).forEach((point, index) => { const top = 220 + index * 112 const color = index % 2 === 0 ? palette.secondary : palette.primary elements.push(shapeElement({ left: 100, top, width: 62, height: 62, fill: color, radius: 8 })) elements.push(textElement({ left: 118, top: top + 12, width: 30, height: 30, content: "✓", fontSize: 28, color: "#FFFFFF", bold: true })) elements.push(textElement({ left: 210, top: top + 4, width: 1240, height: 64, content: point, fontSize: fitFont(point, 1240, 64, 34, 26), color: palette.text, bold: true })) }) return elements } function homeworkTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "课后延伸") elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 440, fill: palette.surface, radius: 8, shadow: softShadow() })) activitySteps(page, 4).forEach((step, index) => { const top = 270 + index * 86 elements.push(textElement({ left: 150, top, width: 70, height: 48, content: `${index + 1}.`, fontSize: 34, color: palette.accent, bold: true })) elements.push(textElement({ left: 230, top, width: 1180, height: 58, content: step, fontSize: fitFont(step, 1180, 58, 31, 24), color: palette.text, bold: true })) }) return elements } function contentTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] { return knowledgeTemplate(page, outline, palette, index) } function slideHeader(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, eyebrow: string): PPTElement[] { const title = displayPageTitle(page, outline) return [ textElement({ left: 92, top: 64, width: 300, height: 34, content: eyebrow, fontSize: 20, color: palette.secondary, bold: true }), textElement({ left: 92, top: 102, width: 1320, height: 66, content: title, fontSize: fitFont(title, 1320, 66, 42, 32), color: palette.text, bold: true }), shapeElement({ left: 1450, top: 72, width: 58, height: 8, fill: palette.accent, radius: 4 }), ] } function studentPanel(page: AiCoursewarePage, palette: Palette, left: number, top: number, width: number): PPTElement[] { const elements: PPTElement[] = [] const headline = studentHeadline(page) if (headline) elements.push(textElement({ left, top, width, height: 90, content: headline, fontSize: fitFont(headline, width, 90, 36, 27), color: palette.primary, bold: true })) studentPoints(page, 3).forEach((point, index) => { const itemTop = top + (headline ? 120 : 0) + index * 74 elements.push(shapeElement({ left, top: itemTop + 8, width: 18, height: 18, fill: index % 2 ? palette.secondary : palette.accent, radius: 4 })) elements.push(textElement({ left: left + 38, top: itemTop, width: width - 38, height: 54, content: point, fontSize: fitFont(point, width - 38, 54, 27, 21), color: palette.text, lineHeight: 1.3 })) }) return elements } function storySequence(items: string[], palette: Palette, left: number, top: number, width: number): PPTElement[] { const elements: PPTElement[] = [] const count = Math.max(1, Math.min(items.length, 4)) const gap = 28 const cardWidth = (width - gap * (count - 1)) / count items.slice(0, 4).forEach((item, index) => { const cardLeft = left + index * (cardWidth + gap) elements.push(shapeElement({ left: cardLeft, top, width: cardWidth, height: 190, fill: index % 2 === 0 ? palette.soft : palette.surface, radius: 8, shadow: lightShadow() })) elements.push(textElement({ left: cardLeft + 24, top: top + 24, width: 64, height: 38, content: `0${index + 1}`, fontSize: 26, color: index % 2 === 0 ? palette.primary : palette.secondary, bold: true })) elements.push(textElement({ left: cardLeft + 24, top: top + 82, width: cardWidth - 48, height: 78, content: item, fontSize: fitFont(item, cardWidth - 48, 78, 27, 21), color: palette.text, bold: true, lineHeight: 1.3 })) }) return elements } function knowledgeList(items: string[], palette: Palette, left: number, top: number, width: number): PPTElement[] { const elements: PPTElement[] = [] items.forEach((item, index) => { const itemTop = top + index * 108 elements.push(shapeElement({ left, top: itemTop, width, height: 82, fill: index % 2 === 0 ? palette.surface : palette.soft, radius: 8, shadow: lightShadow() })) elements.push(shapeElement({ left, top: itemTop, width: 14, height: 82, fill: index % 2 === 0 ? palette.primary : palette.secondary, radius: 0 })) elements.push(textElement({ left: left + 42, top: itemTop + 18, width: width - 74, height: 48, content: item, fontSize: fitFont(item, width - 74, 48, 29, 22), color: palette.text, bold: true })) }) return elements } function melodyStaff(left: number, top: number, width: number, palette: Palette): PPTElement[] { const elements: PPTElement[] = [] for (let i = 0; i < 5; i++) elements.push(lineElement(left, top + i * 34, left + width, top + i * 34, "rgba(255,255,255,0.55)")) ;[0.12, 0.36, 0.62, 0.84].forEach((position, index) => { elements.push(shapeElement({ left: left + width * position, top: top + 48 + (index % 2) * 30, width: 34, height: 28, fill: index % 2 ? palette.accent : palette.warm, radius: 8 })) elements.push(lineElement(left + width * position + 30, top + 10 + (index % 2) * 30, left + width * position + 30, top + 60 + (index % 2) * 30, index % 2 ? palette.accent : palette.warm, 5)) }) return elements } function soundWave(left: number, top: number, palette: Palette, width: number, maxHeight: number): PPTElement[] { const heights = [0.28, 0.55, 0.82, 0.45, 1, 0.7, 0.38, 0.64, 0.32] return heights.map((scale, index) => shapeElement({ left: left + index * (width / heights.length), top: top + (maxHeight - maxHeight * scale) / 2, width: Math.max(18, width / 24), height: maxHeight * scale, fill: index % 3 === 0 ? palette.accent : index % 2 === 0 ? palette.secondary : palette.primary, radius: 8, })) } function addClassroomCue(elements: PPTElement[], page: AiCoursewarePage, palette: Palette) { const cue = page.interaction?.question || activityTask(page) if (!cue) return elements.push(shapeElement({ left: 92, top: 754, width: 1416, height: 62, fill: palette.soft, radius: 8 })) elements.push(textElement({ left: 120, top: 772, width: 1360, height: 30, content: cue, fontSize: fitFont(cue, 1360, 30, 22, 18), color: palette.text, bold: true })) } function normalizeOutline(outline: AiCoursewareOutline): AiCoursewareOutline { const chapter = cleanText(outline.chapterName || outline.title || "音乐课件") return { ...outline, title: GENERIC_PAGE_TITLES.has(cleanText(outline.title).toLowerCase()) ? chapter : cleanText(outline.title || chapter), pages: (outline.pages || []).map((page, index) => ({ ...page, pageNo: index + 1, title: index === 0 && GENERIC_PAGE_TITLES.has(cleanText(page.title).toLowerCase()) ? chapter : cleanText(page.title || chapter), bullets: (page.bullets || []).map(cleanText).filter(Boolean), imageKeywords: (page.imageKeywords || []).map(cleanText).filter(Boolean), })), } } function pageRole(page: AiCoursewarePage, index: number): PageRole { if (index === 0) return "cover" const kind = `${page.type || ""} ${page.visualPlan?.layout || ""} ${page.visual?.layout || ""}`.toLowerCase() if (kind.includes("goal")) return "goal" if (kind.includes("listen")) return "listen" if (kind.includes("lyric") || kind.includes("sing")) return "lyric" if (kind.includes("rhythm") || kind.includes("skill")) return "rhythm" if (kind.includes("knowledge") || kind.includes("extension")) return "knowledge" if (kind.includes("interaction")) return "interaction" if (kind.includes("activity")) return "activity" if (kind.includes("summary")) return "summary" if (kind.includes("homework")) return "homework" if (kind.includes("intro")) return "intro" if (kind.includes("background") || kind.includes("story")) return "story" return "content" } function displayPageTitle(page: AiCoursewarePage, outline: AiCoursewareOutline) { const title = cleanText(page.title) return !title || GENERIC_PAGE_TITLES.has(title.toLowerCase()) ? chapterTitle(outline, page) : title } function chapterTitle(outline: AiCoursewareOutline, page: AiCoursewarePage) { return cleanText(outline.chapterName || outline.title || page.title || "音乐课件") } function conciseTextbook(value?: string) { return cleanText(value).replace(/^.*?·/, "") } function hasImage(page: AiCoursewarePage) { return Boolean(page.image?.url) } function studentHeadline(page: AiCoursewarePage) { return cleanText(page.studentContent?.headline || page.subtitle || "") } function studentPoints(page: AiCoursewarePage, limit: number) { const structured = (page.studentContent?.points || []).map(cleanText).filter(Boolean) const legacy = (page.bullets || []).map(cleanText).filter(Boolean) const points = structured.length ? structured : legacy return (points.length ? points : [studentHeadline(page) || cleanText(page.title) || "课堂内容"]).slice(0, limit) } function activityTask(page: AiCoursewarePage) { return cleanText(page.classroomAction?.studentTask || page.activity?.task || "") } function activitySteps(page: AiCoursewarePage, limit: number) { const steps = (page.activity?.steps || []).map(cleanText).filter(Boolean) const task = activityTask(page) const values = steps.length ? steps : task ? [task, ...studentPoints(page, limit)] : studentPoints(page, limit) return values.filter((value, index, all) => all.indexOf(value) === index).slice(0, limit) } function slideRemark(page: AiCoursewarePage) { return [page.speakerNotes, page.teacherTips, page.classroomAction?.teacherInstruction, page.teachingPurpose] .map(cleanText).filter(Boolean).filter((item, index, all) => all.indexOf(item) === index).join("\n") } function buildPalette(outline: AiCoursewareOutline): Palette { const clean = outline.style === "clean" || /[初高]中|七年级|八年级|九年级/.test(outline.gradeName || "") if (clean) return { primary: "#2563EB", secondary: "#0F766E", accent: "#E9553D", warm: "#F5D06F", background: "#F4F7FB", surface: "#FFFFFF", soft: "#EAF1F8", text: "#17222E", muted: "#64748B", dark: "#132A3A", } return { primary: "#E64A78", secondary: "#168F86", accent: "#F28C28", warm: "#FFD66B", background: "#FFF9F5", surface: "#FFFFFF", soft: "#EAF7F4", text: "#263238", muted: "#6B7C8F", dark: "#283B4A", } } function backgroundConfig(_role: PageRole, palette: Palette, options: AiPptOptions): SlideBackground { if (options.backgroundImage) return { type: "image", image: options.backgroundImage, imageSize: "cover" } return { type: "solid", color: palette.background } } function fitFont(content: string, width: number, height: number, preferred: number, minimum: number) { const text = cleanText(content) if (!text) return preferred let size = preferred while (size > minimum) { const charsPerLine = Math.max(1, Math.floor(width / size)) const lines = Math.ceil(text.length / charsPerLine) if (lines * size * 1.35 <= height) break size -= 2 } return size } function textElement(params: { left: number; top: number; width: number; height: number; content: string; fontSize: number; color: string; bold?: boolean; lineHeight?: number }): PPTTextElement { return { id: nanoid(10), type: "text", left: params.left, top: params.top, width: params.width, height: params.height, rotate: 0, content: `
${escapeHtml(params.content)}
`, defaultFontName: FONT_NAME, defaultColor: params.color, lineHeight: params.lineHeight || 1.25, wordSpace: 0, opacity: 1, } } function imageElement(page: AiCoursewarePage, rect: ImageRect): PPTImageElement { const width = page.image?.width || 0 const height = page.image?.height || 0 return { id: nanoid(10), type: "image", left: rect.left, top: rect.top, width: rect.width, height: rect.height, rotate: 0, src: page.image?.url || "", fixedRatio: false, radius: rect.radius || 0, shadow: rect.radius ? softShadow() : undefined, clip: width > 0 && height > 0 ? { shape: "rect", range: coverRange(width, height, rect.width, rect.height) } : undefined, } } function coverRange(sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number): [[number, number], [number, number]] { const sourceRatio = sourceWidth / sourceHeight const targetRatio = targetWidth / targetHeight if (sourceRatio > targetRatio) { const visible = (targetRatio / sourceRatio) * 100 const start = (100 - visible) / 2 return [[start, 0], [100 - start, 100]] } const visible = (sourceRatio / targetRatio) * 100 const start = (100 - visible) / 2 return [[0, start], [100, 100 - start]] } function shapeElement(params: { left: number; top: number; width: number; height: number; fill: string; opacity?: number; radius?: number; shadow?: PPTElementShadow; gradient?: Gradient }): PPTShapeElement { return { id: nanoid(10), type: "shape", left: params.left, top: params.top, width: params.width, height: params.height, rotate: 0, viewBox: [200, 200], path: roundedRectPath(params.radius || 0), fixedRatio: false, fill: params.fill, opacity: params.opacity ?? 1, shadow: params.shadow, gradient: params.gradient, } } function lineElement(x1: number, y1: number, x2: number, y2: number, color: string, width = 2): PPTLineElement { const left = Math.min(x1, x2) const top = Math.min(y1, y2) const lineWidth = Math.abs(x2 - x1) const lineHeight = Math.abs(y2 - y1) return { id: nanoid(10), type: "line", left, top, width, start: [x1 === left ? 0 : lineWidth, y1 === top ? 0 : lineHeight], end: [x2 === left ? 0 : lineWidth, y2 === top ? 0 : lineHeight], style: "solid", color, points: ["", ""], } } function keepInCanvas(elements: PPTElement[]) { return elements.map(element => { const left = Math.max(0, Math.min(element.left, SLIDE_WIDTH - 1)) const top = Math.max(0, Math.min(element.top, SLIDE_HEIGHT - 1)) if (element.type === "line") return { ...element, left, top, width: Math.max(1, Math.min(element.width, 20)) } return { ...element, left, top, width: Math.max(1, Math.min(element.width, SLIDE_WIDTH - left)), height: Math.max(1, Math.min(element.height, SLIDE_HEIGHT - top)) } }) as PPTElement[] } function runPreflight(slides: Slide[]) { slides.forEach((slide, index) => { const invalid = slide.elements.filter(element => element.left < 0 || element.top < 0 || element.left + element.width > SLIDE_WIDTH + 1 || (element.type !== "line" && element.top + element.height > SLIDE_HEIGHT + 1)) const emptyText = slide.elements.filter(element => element.type === "text" && !stripHtml(element.content)) if (invalid.length || emptyText.length) console.warn("AI PPT preflight issue", { pageNo: index + 1, invalid: invalid.length, emptyText: emptyText.length }) }) } function roundedRectPath(radius: number) { const r = Math.max(0, Math.min(radius, 100)) return `M ${r} 0 L ${200 - r} 0 Q 200 0 200 ${r} L 200 ${200 - r} Q 200 200 ${200 - r} 200 L ${r} 200 Q 0 200 0 ${200 - r} L 0 ${r} Q 0 0 ${r} 0 Z` } function softShadow(): PPTElementShadow { return { h: 0, v: 8, blur: 22, color: "rgba(23, 34, 46, 0.14)" } } function lightShadow(): PPTElementShadow { return { h: 0, v: 4, blur: 14, color: "rgba(23, 34, 46, 0.1)" } } function cleanText(value?: string) { return String(value || "").replace(/\s+/g, " ").trim() } function escapeHtml(value: string) { return cleanText(value).replace(/&/g, "&").replace(//g, ">") } function stripHtml(value: string) { return value.replace(/<[^>]+>/g, "").trim() }