import { nanoid } from "nanoid" import type { Gradient, PPTElement, PPTElementShadow, PPTImageElement, PPTVideoElement, PPTEnjoyElement, PPTCloudCoachElement, PPTLineElement, PPTAnimation, PPTShapeElement, PPTTextElement, Slide, SlideBackground, SlideTheme } from "@/types/slides" import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware" import { getAiCoursewarePalette, type AiCoursewareTemplateId, type AiCoursewareThemePalette } from "@/config/aiCoursewareTemplates" interface AiPptOptions { backgroundImage?: string enableAnimations?: boolean paletteVariant?: number templateId?: AiCoursewareTemplateId } type PageRole = | "cover" | "intro" | "goal" | "story" | "listen" | "lyric" | "notation" | "instrument" | "compare" | "knowledge" | "rhythm" | "interaction" | "performance" | "practice" | "activity" | "summary" | "homework" | "content" type Palette = AiCoursewareThemePalette interface ImageRect { left: number top: number width: number height: number radius?: number } const SLIDE_WIDTH = 1600 const SLIDE_HEIGHT = 900 const VIEWPORT_WIDTH = 1920 const VIEWPORT_HEIGHT = 1080 const VIEWPORT_SCALE = VIEWPORT_WIDTH / SLIDE_WIDTH 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, options.paletteVariant || 0, options.templateId) 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 dedicatedResource = dedicatedResourceElement(page) const contentElements = dedicatedResource ? [dedicatedResource] : videoBinding(page) ? videoResourceTemplate(page, outline, palette) : [...templateForPage(page, role)(page, outline, palette, index), ...inlineResourceElements(page)] const elements = dedicatedResource ? contentElements : [...templateDecorationElements(options.templateId, palette, role), ...contentElements] return { id: nanoid(10), elements: scaleElementsToViewport(keepInCanvas(elements)), background: backgroundConfig(role, palette, options), remark: slideRemark(page), animations: options.enableAnimations ? entranceAnimations(contentElements) : undefined, turningMode: options.enableAnimations ? "fade" : "no" } } function templateDecorationElements(templateId: AiCoursewareTemplateId | undefined, palette: Palette, role: PageRole): PPTElement[] { if (!templateId || role === "cover") return [] switch (templateId) { case "playful-handdrawn": return [ shapeElement({ left: 1450, top: 36, width: 74, height: 74, fill: palette.warm, opacity: 0.5, radius: 37 }), shapeElement({ left: 1518, top: 92, width: 42, height: 42, fill: palette.accent, opacity: 0.45, radius: 21 }), shapeElement({ left: 26, top: 792, width: 58, height: 58, fill: palette.secondary, opacity: 0.26, radius: 29 }) ] case "fresh-education": return [ shapeElement({ left: 0, top: 0, width: 1600, height: 12, fill: palette.secondary, opacity: 0.85, radius: 0 }), shapeElement({ left: 1530, top: 742, width: 70, height: 158, fill: palette.soft, opacity: 0.8, radius: 0 }) ] case "academic-blue": return [ shapeElement({ left: 0, top: 0, width: 1600, height: 18, fill: palette.primary, radius: 0 }), shapeElement({ left: 1548, top: 38, width: 18, height: 132, fill: palette.accent, radius: 0 }), shapeElement({ left: 1574, top: 38, width: 8, height: 84, fill: palette.secondary, radius: 0 }) ] case "oriental-culture": return [ shapeElement({ left: 1460, top: 42, width: 88, height: 88, fill: palette.primary, opacity: 0.14, radius: 44 }), shapeElement({ left: 1499, top: 52, width: 10, height: 68, fill: palette.primary, opacity: 0.62, radius: 2 }), shapeElement({ left: 1434, top: 148, width: 114, height: 4, fill: palette.accent, opacity: 0.55, radius: 2 }) ] case "digital-tech": return [ shapeElement({ left: 80, top: 120, width: 1440, height: 2, fill: palette.secondary, opacity: 0.16, radius: 0 }), shapeElement({ left: 80, top: 780, width: 1440, height: 2, fill: palette.primary, opacity: 0.16, radius: 0 }), shapeElement({ left: 118, top: 80, width: 2, height: 740, fill: palette.secondary, opacity: 0.12, radius: 0 }), shapeElement({ left: 1480, top: 80, width: 2, height: 740, fill: palette.primary, opacity: 0.12, radius: 0 }), shapeElement({ left: 1468, top: 108, width: 26, height: 26, fill: palette.secondary, opacity: 0.75, radius: 4 }) ] case "active-classroom": return [ shapeElement({ left: 1390, top: 30, width: 58, height: 18, fill: palette.accent, radius: 9 }), shapeElement({ left: 1458, top: 30, width: 92, height: 18, fill: palette.secondary, radius: 9 }), shapeElement({ left: 44, top: 816, width: 116, height: 20, fill: palette.warm, opacity: 0.8, radius: 10 }) ] } } function entranceAnimations(elements: PPTElement[]): PPTAnimation[] { return elements .filter( element => (element.type === "text" || element.type === "image" || element.type === "shape") && element.width >= 120 && element.height >= 45 ) .slice(0, 6) .map((element, index) => ({ id: nanoid(10), elId: element.id, effect: index === 0 ? "fadeIn" : "fadeInUp", type: "in", duration: 500, trigger: index === 0 ? "auto" : "click" })) } function templateForPage(page: AiCoursewarePage, role: PageRole) { const layout = cleanText(page.visualPlan?.layout || page.visual?.layout).toLowerCase() switch (layout) { case "cover_scene": return coverTemplate case "full_image_question": return immersiveImageTemplate case "split_image": return storyTemplate case "goal_cards": return goalTemplate case "listen_wave": return listenTemplate case "lyric_focus": return lyricTemplate case "score_focus": return notationTemplate case "rhythm_grid": return rhythmTemplate case "instrument_focus": return instrumentTemplate case "compare_listen": return compareTemplate case "knowledge_cards": return knowledgeTemplate case "activity_steps": return activityTemplate case "practice_check": return practiceTemplate case "summary_checklist": return summaryTemplate case "homework_list": return homeworkTemplate case "story_stage": return storyStageTemplate case "observe_question": return observeQuestionTemplate case "listen_compare_visual": return listenCompareVisualTemplate case "sound_timeline": return soundTimelineTemplate case "action_demo": return actionDemoTemplate case "group_mission": return groupMissionTemplate case "performance_stage": return performanceStageTemplate case "visual_summary": return visualSummaryTemplate default: return templateForRole(role) } } function templateForRole(role: PageRole) { switch (role) { case "cover": return coverTemplate case "goal": return goalTemplate case "listen": return listenTemplate case "lyric": return lyricTemplate case "notation": return notationTemplate case "instrument": return instrumentTemplate case "compare": return compareTemplate case "rhythm": return rhythmTemplate case "knowledge": return knowledgeTemplate case "interaction": return interactionTemplate case "performance": return performanceTemplate case "practice": return practiceTemplate case "activity": return activityTemplate case "summary": return summaryTemplate case "homework": return homeworkTemplate case "intro": case "story": return storyTemplate default: return contentTemplate } } function immersiveImageTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] { if (!hasImage(page)) return storyTemplate(page, outline, palette, index) const title = displayPageTitle(page, outline) const question = page.interaction?.question || activityTask(page) || studentHeadline(page) || studentPoints(page, 1)[0] const panelOnLeft = index % 2 === 0 const panelLeft = panelOnLeft ? 0 : 930 const textLeft = panelOnLeft ? 104 : 1010 const elements: PPTElement[] = [imageElement(page, { left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT })] elements.push(shapeElement({ left: panelLeft, top: 0, width: 670, height: 900, fill: palette.dark, opacity: 0.82, radius: 0 })) elements.push( textElement({ left: textLeft, top: 110, width: 480, height: 48, content: page.teachingStage || "观察与发现", fontSize: 22, color: palette.warm, bold: true }) ) elements.push( textElement({ left: textLeft, top: 188, width: 500, height: 160, content: title, fontSize: fitFont(title, 500, 160, 56, 40), color: "#FFFFFF", bold: true }) ) elements.push(shapeElement({ left: textLeft, top: 382, width: 94, height: 10, fill: palette.accent, radius: 4 })) if (question) { elements.push( textElement({ left: textLeft, top: 454, width: 500, height: 200, content: question, fontSize: fitFont(question, 500, 200, 38, 28), color: "#FFFFFF", bold: true, lineHeight: 1.35 }) ) } return elements } 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[] = [shapeElement({ left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT, fill: palette.dark, radius: 0 })] // 教材原图只作为经过裁切的主视觉使用,避免整张扫描页直接成为封面。 elements.push(imageElement(page, { left: 650, top: 0, width: 950, height: 900 })) elements.push(shapeElement({ left: 570, top: 0, width: 240, height: 900, fill: palette.dark, opacity: 0.62, radius: 0 })) elements.push( textElement({ left: 100, top: 150, width: 470, height: 150, content: title, fontSize: fitFont(title, 470, 150, 66, 44), color: "#FFFFFF", bold: true }) ) if (subtitle) elements.push(textElement({ left: 104, top: 330, width: 470, height: 72, 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: 470, height: 44, content: point, fontSize: fitFont(point, 470, 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(textElement({ left: 92, top: 360, width: 380, height: 180, content: "听 · 唱 · 动", fontSize: 52, color: "#FFFFFF", bold: true })) 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 storyStageTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const title = displayPageTitle(page, outline) const points = studentPoints(page, 2) const elements: PPTElement[] = [] if (hasImage(page)) elements.push(imageElement(page, { left: 0, top: 0, width: 1600, height: 900 })) else { elements.push(shapeElement({ left: 0, top: 0, width: 1600, height: 900, fill: palette.soft, radius: 0 })) elements.push(shapeElement({ left: 0, top: 420, width: 1600, height: 480, fill: palette.primary, opacity: 0.16, radius: 0 })) } elements.push(shapeElement({ left: 0, top: 0, width: 1600, height: 190, fill: palette.dark, opacity: 0.76, radius: 0 })) elements.push( textElement({ left: 92, top: 54, width: 1320, height: 94, content: title, fontSize: fitFont(title, 1320, 94, 52, 38), color: "#FFFFFF", bold: true }) ) const dialogue = points[0] || activityTask(page) if (dialogue) { elements.push(shapeElement({ left: 130, top: 590, width: 1120, height: 190, fill: "#FFFFFF", opacity: 0.94, radius: 22, shadow: softShadow() })) elements.push( textElement({ left: 186, top: 632, width: 1000, height: 100, content: dialogue, fontSize: fitFont(dialogue, 1000, 100, 38, 28), color: palette.text, bold: true, lineHeight: 1.3 }) ) elements.push(shapeElement({ left: 1280, top: 620, width: 180, height: 120, fill: palette.accent, radius: 60 })) } return elements } function observeQuestionTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "观察与发现") const question = page.interaction?.question || activityTask(page) || studentPoints(page, 1)[0] if (hasImage(page)) elements.push(imageElement(page, { left: 70, top: 190, width: 940, height: 570, radius: 18 })) else elements.push(shapeElement({ left: 70, top: 190, width: 940, height: 570, fill: palette.soft, radius: 18 })) elements.push(shapeElement({ left: 1060, top: 220, width: 440, height: 500, fill: palette.surface, radius: 18, shadow: softShadow() })) elements.push(shapeElement({ left: 1110, top: 274, width: 72, height: 72, fill: palette.accent, radius: 36 })) elements.push(textElement({ left: 1131, top: 283, width: 34, height: 48, content: "?", fontSize: 38, color: "#FFFFFF", bold: true })) elements.push( textElement({ left: 1110, top: 390, width: 330, height: 190, content: question || "你发现了什么?", fontSize: fitFont(question || "你发现了什么?", 330, 190, 38, 27), color: palette.text, bold: true, lineHeight: 1.35 }) ) return elements } function listenCompareVisualTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "听辨对比") const points = studentPoints(page, 4) ;[0, 1].forEach(group => { const left = group === 0 ? 84 : 820 const color = group === 0 ? palette.primary : palette.secondary elements.push( shapeElement({ left, top: 205, width: 696, height: 500, fill: group === 0 ? palette.soft : palette.surface, radius: 18, shadow: lightShadow() }) ) elements.push(shapeElement({ left: left + 40, top: 250, width: 82, height: 82, fill: color, radius: 41 })) elements.push( textElement({ left: left + 65, top: 268, width: 34, height: 46, content: group === 0 ? "A" : "B", fontSize: 32, color: "#FFFFFF", bold: true }) ) for (let bar = 0; bar < 8; bar++) { const height = 28 + ((bar * 23 + group * 17) % 86) elements.push(shapeElement({ left: left + 170 + bar * 48, top: 350 - height / 2, width: 22, height, fill: color, opacity: 0.72, radius: 8 })) } const text = points[group] || (group === 0 ? "听第一段声音" : "听第二段声音") elements.push( textElement({ left: left + 54, top: 470, width: 580, height: 110, content: text, fontSize: fitFont(text, 580, 110, 34, 25), color: palette.text, bold: true }) ) }) return elements } function soundTimelineTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "声音变化") const points = studentPoints(page, 4) elements.push(lineElement(170, 480, 1430, 480, palette.muted, 6)) points.forEach((point, index) => { const left = 180 + index * (1200 / Math.max(1, points.length - 1)) const size = index % 2 === 0 ? 82 : 54 const color = index % 2 === 0 ? palette.primary : palette.secondary elements.push(shapeElement({ left: left - size / 2, top: 480 - size / 2, width: size, height: size, fill: color, radius: size / 2 })) elements.push( textElement({ left: left - 150, top: index % 2 === 0 ? 300 : 570, width: 300, height: 90, content: point, fontSize: fitFont(point, 300, 90, 30, 23), color: palette.text, bold: true, lineHeight: 1.3 }) ) }) return elements } function actionDemoTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "跟着做") const steps = activitySteps(page, 4) steps.forEach((step, index) => { const left = 110 + index * 370 const color = [palette.primary, palette.secondary, palette.accent, palette.warm][index] elements.push(shapeElement({ left, top: 280, width: 190, height: 190, fill: color, radius: 95, shadow: lightShadow() })) elements.push( textElement({ left: left + 62, top: 326, width: 70, height: 74, content: `${index + 1}`, fontSize: 54, color: "#FFFFFF", bold: true }) ) elements.push( textElement({ left: left - 45, top: 530, width: 280, height: 110, content: step, fontSize: fitFont(step, 280, 110, 31, 23), color: palette.text, bold: true, lineHeight: 1.3 }) ) if (index < steps.length - 1) elements.push(lineElement(left + 220, 376, left + 330, 376, palette.muted, 4)) }) return elements } function groupMissionTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "小组任务") const steps = activitySteps(page, 4) steps.forEach((step, index) => { const top = 210 + index * 142 const color = index % 2 === 0 ? palette.primary : palette.secondary elements.push(shapeElement({ left: 100, top, width: 150, height: 104, fill: color, radius: 16 })) elements.push( textElement({ left: 126, top: top + 27, width: 100, height: 45, content: `第${index + 1}组`, fontSize: 27, color: "#FFFFFF", bold: true }) ) elements.push(lineElement(270, top + 52, 350, top + 52, color, 4)) elements.push( textElement({ left: 390, top: top + 18, width: 1030, height: 68, content: step, fontSize: fitFont(step, 1030, 68, 34, 25), color: palette.text, bold: true }) ) }) return elements } function performanceStageTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "展示时刻") elements.push(shapeElement({ left: 130, top: 220, width: 1340, height: 520, fill: palette.dark, radius: 28, shadow: softShadow() })) elements.push(shapeElement({ left: 230, top: 300, width: 1140, height: 360, fill: palette.warm, opacity: 0.24, radius: 180 })) const roles = activitySteps(page, 4) roles.forEach((role, index) => { const left = 300 + index * 270 elements.push(shapeElement({ left, top: 430, width: 130, height: 130, fill: index % 2 ? palette.secondary : palette.accent, radius: 65 })) elements.push( textElement({ left: left - 30, top: 590, width: 190, height: 68, content: role, fontSize: fitFont(role, 190, 68, 27, 21), color: "#FFFFFF", bold: true }) ) }) return elements } function visualSummaryTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "今天的发现") const points = studentPoints(page, 4) points.forEach((point, index) => { const left = 170 + index * 350 const top = index % 2 === 0 ? 310 : 500 const color = [palette.primary, palette.secondary, palette.accent, palette.warm][index] elements.push(shapeElement({ left, top, width: 110, height: 110, fill: color, radius: 55 })) elements.push(textElement({ left: left + 38, top: top + 27, width: 40, height: 50, content: "✓", fontSize: 38, color: "#FFFFFF", bold: true })) elements.push( textElement({ left: left - 70, top: top + 135, width: 250, height: 86, content: point, fontSize: fitFont(point, 250, 86, 29, 22), color: palette.text, bold: true, lineHeight: 1.3 }) ) if (index < points.length - 1) elements.push(lineElement(left + 130, top + 55, left + 300, index % 2 === 0 ? 555 : 365, palette.muted, 3)) }) 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(...studentPanel(page, palette, 170, 500, 1260)) } 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 notationTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "旋律与谱例") const headline = studentHeadline(page) || "看一看,唱一唱" elements.push( textElement({ left: 92, top: 190, width: 1416, height: 72, content: headline, fontSize: fitFont(headline, 1416, 72, 40, 30), color: palette.primary, bold: true }) ) elements.push(...knowledgeList(studentPoints(page, 3), palette, 92, 310, 1416)) addClassroomCue(elements, page, palette) return elements } function instrumentTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "认识声音") const points = studentPoints(page, 3) if (hasImage(page)) { elements.push(imageElement(page, { left: 84, top: 190, width: 760, height: 540, radius: 8 })) elements.push( textElement({ left: 920, top: 220, width: 580, height: 96, content: studentHeadline(page) || page.title, fontSize: fitFont(studentHeadline(page) || page.title, 580, 96, 42, 30), color: palette.primary, bold: true }) ) points.forEach((point, index) => { const top = 360 + index * 104 elements.push( textElement({ left: 920, top, width: 56, height: 48, content: `0${index + 1}`, fontSize: 30, color: index % 2 ? palette.secondary : palette.accent, bold: true }) ) elements.push( textElement({ left: 992, top, width: 490, height: 64, content: point, fontSize: fitFont(point, 490, 64, 30, 23), color: palette.text, bold: true }) ) }) } else { elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 190, fill: palette.primary, radius: 8 })) elements.push( textElement({ left: 150, top: 260, width: 1300, height: 90, content: studentHeadline(page) || page.title, fontSize: fitFont(studentHeadline(page) || page.title, 1300, 90, 44, 30), color: "#FFFFFF", bold: true }) ) elements.push(...knowledgeList(points, palette, 170, 470, 1260)) } addClassroomCue(elements, page, palette) return elements } function compareTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "对比听赏") const points = studentPoints(page, 4) const leftPoints = points.filter((_, index) => index % 2 === 0) const rightPoints = points.filter((_, index) => index % 2 === 1) elements.push(shapeElement({ left: 92, top: 210, width: 690, height: 470, fill: palette.soft, radius: 8 })) elements.push(shapeElement({ left: 818, top: 210, width: 690, height: 470, fill: palette.surface, radius: 8, shadow: lightShadow() })) elements.push(textElement({ left: 140, top: 252, width: 570, height: 64, content: "听一听 A", fontSize: 36, color: palette.primary, bold: true })) elements.push(textElement({ left: 866, top: 252, width: 570, height: 64, content: "听一听 B", fontSize: 36, color: palette.secondary, bold: true })) ;[leftPoints, rightPoints].forEach((items, group) => items.forEach((point, index) => { const left = group === 0 ? 140 : 866 const top = 365 + index * 110 elements.push(shapeElement({ left, top: top + 8, width: 22, height: 22, fill: group === 0 ? palette.primary : palette.secondary, radius: 8 })) elements.push( textElement({ left: left + 48, top, width: 520, height: 70, content: point, fontSize: fitFont(point, 520, 70, 30, 23), color: palette.text, bold: true }) ) }) ) 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 candidate = page.interaction?.question || activityTask(page) || studentHeadline(page) || "你发现了什么?" const question = sameVisibleText(candidate, page.title) ? "" : candidate if (question) { 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) || "一起完成课堂挑战" if (!sameVisibleText(task, page.title)) { 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 performanceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "合作表现") const task = activityTask(page) || studentHeadline(page) || "一起完成音乐表演" if (!sameVisibleText(task, page.title)) { elements.push(shapeElement({ left: 92, top: 200, width: 1416, height: 150, fill: palette.primary, radius: 8 })) elements.push( textElement({ left: 150, top: 242, width: 1300, height: 70, content: task, fontSize: fitFont(task, 1300, 70, 42, 30), color: "#FFFFFF", bold: true }) ) } const steps = activitySteps(page, 4) steps.forEach((step, index) => { const left = 126 + index * 355 elements.push(shapeElement({ left, top: 438, width: 76, height: 76, fill: index % 2 ? palette.secondary : palette.accent, radius: 38 })) elements.push( textElement({ left: left + 23, top: 454, width: 34, height: 38, content: `${index + 1}`, fontSize: 28, color: "#FFFFFF", bold: true }) ) elements.push( textElement({ left: left - 16, top: 550, width: 280, height: 100, content: step, fontSize: fitFont(step, 280, 100, 29, 22), color: palette.text, bold: true }) ) if (index < steps.length - 1) elements.push(lineElement(left + 96, 476, left + 325, 476, palette.muted, 3)) }) addClassroomCue(elements, page, palette) return elements } function practiceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const elements = slideHeader(page, outline, palette, "课堂检测") const candidate = page.interaction?.question || studentHeadline(page) || activityTask(page) || "你能完成今天的音乐挑战吗?" const question = sameVisibleText(candidate, page.title) ? "" : candidate if (question) elements.push( textElement({ left: 92, top: 200, width: 1416, height: 82, content: question, fontSize: fitFont(question, 1416, 82, 42, 30), color: palette.text, bold: true }) ) const options = validInteractionOptions(page) const items = options.length >= 2 ? options : studentPoints(page, 3) items.forEach((point, index) => { const top = 330 + index * 94 elements.push(shapeElement({ left: 110, top, width: 70, height: 62, fill: index % 2 ? palette.secondary : palette.primary, radius: 8 })) elements.push( textElement({ left: 132, top: top + 14, width: 32, height: 34, content: String.fromCharCode(65 + index), fontSize: 26, color: "#FFFFFF", bold: true }) ) elements.push( textElement({ left: 220, top: top + 4, width: 1220, height: 58, content: point, fontSize: fitFont(point, 1220, 58, 31, 24), color: palette.text, bold: true }) ) elements.push(lineElement(220, top + 72, 1450, top + 72, palette.soft, 2)) }) 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 addClassroomCue(elements: PPTElement[], page: AiCoursewarePage, palette: Palette) { // 保留调用点以兼容既有模板;课堂任务由页面主体呈现,不再生成重复底栏。 void elements void page void palette } function dedicatedResourceElement(page: AiCoursewarePage): PPTElement | undefined { const binding = (page.resourceBindings || []).find(item => item.verified !== false && item.dedicatedPage) if (!binding || binding.resourceType !== "MUSIC") return undefined const base = { id: nanoid(10), left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT, rotate: 0 } return { ...base, type: "elf", subtype: "elf-sing-play", sid: binding.resourceId, title: binding.name || page.title } as PPTCloudCoachElement } function inlineResourceElements(page: AiCoursewarePage): PPTElement[] { const binding = (page.resourceBindings || []).find(item => item.verified !== false && item.resourceType === "SONG" && item.contentUrl) if (!binding) return [] return [ { id: nanoid(10), type: "elf", subtype: "elf-enjoy", left: 110, top: 700, width: 1380, height: 100, rotate: 0, sid: binding.resourceId, title: binding.name || page.title, src: binding.contentUrl || "", enjoyList: [{ id: binding.resourceId, title: binding.name || page.title, src: binding.contentUrl || "" }] } as PPTEnjoyElement ] } function videoBinding(page: AiCoursewarePage) { return (page.resourceBindings || []).find(item => item.verified !== false && item.resourceType === "VIDEO" && item.contentUrl) } function videoResourceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] { const binding = videoBinding(page)! const elements = slideHeader(page, outline, palette, page.teachingStage || "观看与发现") elements.push({ id: nanoid(10), type: "elf", subtype: "elf-video", left: 190, top: 190, width: 1220, height: 570, rotate: 0, src: binding.contentUrl || "", poster: binding.coverUrl, autoplay: false } as PPTVideoElement) addClassroomCue(elements, page, palette) return elements } 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("objective") || kind.includes("goal")) return "goal" if (kind.includes("practice_check") || kind.includes("assessment")) return "practice" if (kind.includes("compare")) return "compare" if (kind.includes("instrument")) return "instrument" if (kind.includes("melody") || kind.includes("score") || kind.includes("notation")) return "notation" if (kind.includes("performance") || kind.includes("expressive") || kind.includes("creative")) return "performance" if (kind.includes("listen")) return "listen" if (kind.includes("lyric") || kind.includes("phrase_sing") || kind.includes("sing_focus")) return "lyric" if (kind.includes("rhythm") || kind.includes("skill")) return "rhythm" if (kind.includes("knowledge")) return "knowledge" if (kind.includes("interaction")) return "interaction" if (kind.includes("activity")) return "activity" if (kind.includes("summary")) return "summary" if (kind.includes("homework") || kind.includes("extension")) return "homework" if (kind.includes("intro") || kind.includes("scene")) return "intro" if (kind.includes("context") || 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) { const headline = cleanText(page.studentContent?.headline || page.subtitle || "") return sameVisibleText(headline, page.title) ? "" : headline } 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 const excluded = [page.title, studentHeadline(page)].map(normalizeVisibleText).filter(Boolean) const unique = uniqueVisibleTexts(points).filter(item => !excluded.includes(normalizeVisibleText(item))) return (unique.length ? unique : [studentHeadline(page) || cleanText(page.title) || "课堂内容"]).slice(0, limit) } function activityTask(page: AiCoursewarePage) { // activity 属于教师组织信息,不能回退到学生投屏画面。 return cleanText(page.classroomAction?.studentTask || studentHeadline(page) || "") } function activitySteps(page: AiCoursewarePage, limit: number) { const task = activityTask(page) const points = studentPoints(page, limit).filter(item => !sameVisibleText(item, task)) return uniqueVisibleTexts(points).slice(0, limit) } function validInteractionOptions(page: AiCoursewarePage) { return uniqueVisibleTexts((page.interaction?.options || []).map(cleanText).filter(Boolean)) .filter(item => !/^(?:准备好了吗|开始吧|请回答|想一想|说一说|听一听)[??!!。]*$/.test(item)) .slice(0, 4) } function slideRemark(page: AiCoursewarePage) { const lessonMeta = [page.teachingStage, page.durationMinutes ? `建议用时:${page.durationMinutes}分钟` : ""].filter(Boolean).join(" · ") const evidence = page.learningEvidence ? `学习表现:${page.learningEvidence}` : "" const activity = [page.activity?.task, ...(page.activity?.steps || [])].map(cleanText).filter(Boolean).join("\n") return [lessonMeta, page.teachingPurpose, activity, page.speakerNotes, page.teacherTips, page.classroomAction?.teacherInstruction, evidence] .map(cleanText) .filter(Boolean) .filter((item, index, all) => all.indexOf(item) === index) .join("\n") } function buildPalette(outline: AiCoursewareOutline, variant: number, templateId?: AiCoursewareTemplateId): Palette { const fallbackTemplate = outline.style === "cartoon" ? "playful-handdrawn" : "fresh-education" return { ...getAiCoursewarePalette(templateId || fallbackTemplate, variant) } } 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 { const lineHeight = params.lineHeight || 1.25 const minimum = Math.min(params.fontSize, params.fontSize >= 30 ? 22 : 18) const resolvedFontSize = fitFont(params.content, params.width - 20, params.height, params.fontSize, minimum) return { id: nanoid(10), type: "text", left: params.left, top: params.top, width: params.width, height: params.height, rotate: 0, // 永远保留完整文本。若最小字号仍放不下,由质量门禁阻止应用,而不是静默截断。 content: `
${escapeHtml(cleanText(params.content))}
`, defaultFontName: FONT_NAME, defaultColor: params.color, lineHeight, paragraphSpace: 0, 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 text?: PPTShapeElement["text"] }): 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, text: params.text } } 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 scaleElementsToViewport(elements: PPTElement[]): PPTElement[] { return elements.map(element => { if (element.type === "line") { return { ...element, left: element.left * VIEWPORT_SCALE, top: element.top * VIEWPORT_SCALE, width: element.width * VIEWPORT_SCALE, start: [element.start[0] * VIEWPORT_SCALE, element.start[1] * VIEWPORT_SCALE], end: [element.end[0] * VIEWPORT_SCALE, element.end[1] * VIEWPORT_SCALE] } } const scaled = { ...element, left: element.left * VIEWPORT_SCALE, top: element.top * VIEWPORT_SCALE, width: element.width * VIEWPORT_SCALE, height: element.height * VIEWPORT_SCALE } as PPTElement if (scaled.type === "text") { scaled.content = scaleInlineFontSizes(scaled.content) } if (scaled.type === "shape" && scaled.text) { scaled.text = { ...scaled.text, content: scaleInlineFontSizes(scaled.text.content) } } if ("radius" in scaled && typeof scaled.radius === "number") { scaled.radius *= VIEWPORT_SCALE } if ("shadow" in scaled && scaled.shadow) { scaled.shadow = { ...scaled.shadow, h: scaled.shadow.h * VIEWPORT_SCALE, v: scaled.shadow.v * VIEWPORT_SCALE, blur: scaled.shadow.blur * VIEWPORT_SCALE } } return scaled }) } function scaleInlineFontSizes(content: string) { return content.replace(/font-size:\s*([\d.]+)px/g, (_match, size) => `font-size:${Number(size) * VIEWPORT_SCALE}px`) } function runPreflight(slides: Slide[]) { slides.forEach((slide, index) => { const invalid = slide.elements.filter( element => element.left < 0 || element.top < 0 || element.left + element.width > VIEWPORT_WIDTH + 1 || (element.type !== "line" && element.top + element.height > VIEWPORT_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() } function normalizeVisibleText(value?: string) { return cleanText(value) .replace(/[,。!?、;:,.!?;:\s]+/g, "") .toLowerCase() } function sameVisibleText(left?: string, right?: string) { const normalizedLeft = normalizeVisibleText(left) return Boolean(normalizedLeft) && normalizedLeft === normalizeVisibleText(right) } function uniqueVisibleTexts(values: string[]) { const seen = new Set() return values.filter(value => { const key = normalizeVisibleText(value) if (!key || seen.has(key)) return false seen.add(key) return true }) }