import { nanoid } from "nanoid" import type { Gradient, PPTElement, PPTElementShadow, PPTImageElement, PPTVideoElement, PPTAudioElement, PPTCloudCoachElement, PPTLineElement, PPTAnimation, PPTShapeElement, PPTTextElement, Slide, SlideBackground, SlideTheme } from "@/types/slides" import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware" import { audioBindings, scoreBinding, isScorePage, usableResourceUrl } from "@/utils/aiPptContent" import { AI_COURSEWARE_TEMPLATES, getAiCoursewarePalette, getAiCoursewareTemplate, recommendAiCoursewareTemplate, type AiCoursewareTemplateId, type AiCoursewareThemePalette } from "@/config/aiCoursewareTemplates" import { getAiCoursewareTemplateMaster, getTemplateMasterPage, selectTemplateMasterPageVariant, validateTemplateMasterRegistry, type TemplateMaster, type TemplateMasterElement, type TemplateMasterPage, type TemplatePageRole, type TemplateSlot } from "@/config/aiCoursewareTemplateMasters" interface AiPptOptions { backgroundImage?: string enableAnimations?: boolean paletteVariant?: number templateId?: AiCoursewareTemplateId } type PageRole = TemplatePageRole type Palette = AiCoursewareThemePalette interface ImageRect { left: number top: number width: number height: number radius?: number } interface PageRenderIntent { role: PageRole density: "low" | "medium" | "high" visualKind: "image" | "notation" | "rhythm" | "compare" | "activity" | "text" showMedia: boolean } 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) validateTemplateMasterRegistry(AI_COURSEWARE_TEMPLATES.map(template => template.id)) const requestedTemplateId = options.templateId || normalized.templateId const templateId = requestedTemplateId ? getAiCoursewareTemplate(requestedTemplateId).id : recommendAiCoursewareTemplate(normalized).id const palette = buildPalette(normalized, options.paletteVariant || 0, templateId) const resolvedOptions = { ...options, templateId } const layoutOccurrences = new Map() const slides = normalized.pages.map((page, index) => buildSlide(page, normalized, palette, resolvedOptions, index, layoutOccurrences)) 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, layoutOccurrences: Map): Slide { const role = pageRole(page, index) const intent = pageRenderIntent(page, role) const master = getAiCoursewareTemplateMaster(options.templateId!) const requested = preferredLayout(page, intent) const teachingImage = hasImage(page) && page.visualPlan?.imagePurpose !== "decoration" && page.visualPlan?.imagePurpose !== "none" const needsImageSlot = role !== "cover" && teachingImage && (page.interaction?.question || ["text_focus", "compare", "activity", "summary", "objectives"].includes(requested || "")) const baseMasterPage = getTemplateMasterPage(master, role, isScorePage(page) ? "diagram_focus" : videoBinding(page) || page.requiredVisual || needsImageSlot ? "split_content" : page.interaction?.question ? "text_focus" : requested) const occurrence = layoutOccurrences.get(baseMasterPage.id) || 0 layoutOccurrences.set(baseMasterPage.id, occurrence + 1) const masterPage = adaptMasterPage(selectTemplateMasterPageVariant(baseMasterPage, occurrence), intent) const contentElements = fillTemplatePage(master, masterPage, page, outline, palette, role, intent) assertContentContract(master, masterPage, contentElements) const elements = [ ...renderTemplateMasterElements(masterPage.base), ...contentElements, ...renderTemplateMasterElements(masterPage.chrome) ] return { id: nanoid(10), elements: scaleElementsToViewport(keepInCanvas(elements)), background: backgroundConfig(masterPage, options), remark: slideRemark(page), animations: options.enableAnimations ? entranceAnimations(contentElements) : undefined, turningMode: options.enableAnimations ? "fade" : "no" } } /** 传给图片生成器的真实模板槽位比例;媒体行存在时使用独立尺寸。 */ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): Record { const master = getAiCoursewareTemplateMaster(templateId) const layouts: Record = { cover: "cover_scene", hero: "hero_focus", media: "media_focus", split: "split_content", text: "text_focus", diagram: "diagram_focus", compare: "compare", activity: "activity", summary: "summary", objectives: "objectives" } const ratios: Record = {} for (const page of master.pages) { for (const showMedia of [true, false]) { const adapted = adaptMasterPage(page, { role: page.roles[0], density: "medium", visualKind: "image", showMedia }) const slot = adapted.slots.find(slot => slot.kind === "image" && slot.enabled !== false) if (slot) ratios[layouts[page.layout] + (showMedia ? ":media" : "")] = slot.width / slot.height } } return ratios } function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, role: PageRole = masterPage.roles[0], intent = pageRenderIntent(page, role)): PPTElement[] { master = { ...master, titleColor: masterPage.titleColor || master.titleColor, coverTitleColor: masterPage.coverTitleColor || master.coverTitleColor, textColor: masterPage.textColor || master.textColor, mutedColor: masterPage.mutedColor || master.mutedColor } const elements: PPTElement[] = [] const title = masterPage.layout === "cover" ? chapterTitle(outline, page) : displayPageTitle(page, outline) // 学生可见内容必须与编辑区保持一致,不能因模板容量静默丢弃条目。 const headline = activityTask(page) || studentHeadline(page) const points = studentPoints(page).filter(point => !sameVisibleText(point, headline)) addSlotText(elements, slotOf(masterPage, "title"), title, masterPage.layout === "cover" ? master.coverTitleColor : master.titleColor, true) if (role !== "cover" && isScorePage(page)) { addNotationVisual(elements, slotOf(masterPage, "image"), page, master, palette) addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points, page.interaction?.question || "", ...(page.interaction?.options || [])]), master, palette, false) addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia) return elements } if (role !== "cover" && page.interaction?.question) { addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, false) addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([page.interaction.question, headline, ...points, ...(page.interaction.options || [])]), master, palette, false) addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia) return elements } if (masterPage.layout === "cover") { const meta = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ") const coverSecondaryColor = master.coverTitleColor.toUpperCase() === "#FFFFFF" ? "#DDE6F4" : master.mutedColor addSlotText(elements, slotOf(masterPage, "meta"), meta, coverSecondaryColor) addSlotList(elements, slotOf(masterPage, "headline"), uniqueVisibleTexts([headline, ...points]), master, palette, false, master.coverTitleColor) addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual) return elements } if (masterPage.layout === "hero") { addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual) const body = uniqueVisibleTexts([page.interaction?.question || "", headline, ...points]) addSlotList(elements, slotOf(masterPage, "body"), body, master, palette) addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia) return elements } if (masterPage.layout === "objectives") { addCardGrid(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette) return elements } if (masterPage.layout === "media") { addSlotText(elements, slotOf(masterPage, "headline"), headline || page.interaction?.question || "带着问题听音乐", palette.primary, true) addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual) addSlotList(elements, slotOf(masterPage, "body"), points, master, palette) addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia) return elements } if (masterPage.layout === "split") { addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual) addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette) addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia) return elements } if (masterPage.layout === "text") { addSlotText(elements, slotOf(masterPage, "headline"), headline || (role === "lyric" ? "读一读,再唱一唱" : "抓住这一页的关键信息"), palette.primary, true) if (role === "lyric") addLyricLines(elements, slotOf(masterPage, "body"), points, master, palette) else addSlotList(elements, slotOf(masterPage, "body"), points, master, palette, false) addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia) return elements } if (masterPage.layout === "diagram") { if (role === "notation") addNotationVisual(elements, slotOf(masterPage, "image"), page, master, palette, false) else if (role === "rhythm") addStepGrid(elements, slotOf(masterPage, "image"), activitySteps(page), master, palette) else addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, true) if (role !== "rhythm") addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette) else addSlotText(elements, slotOf(masterPage, "body"), headline, master.textColor) addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia) return elements } if (masterPage.layout === "compare") { const values = uniqueVisibleTexts([...(page.interaction?.options || []), ...points]) const midpoint = Math.max(1, Math.ceil(values.length / 2)) addSlotText(elements, slotOf(masterPage, "headline"), headline || page.interaction?.question || "听一听,它们有什么不同?", palette.primary, true) addSlotList(elements, slotOf(masterPage, "left"), values.slice(0, midpoint), master, palette) addSlotList(elements, slotOf(masterPage, "right"), values.slice(midpoint), master, palette) addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia) return elements } if (masterPage.layout === "activity") { addSlotText(elements, slotOf(masterPage, "headline"), headline || "一起完成音乐任务", palette.primary, true) addStepGrid(elements, slotOf(masterPage, "steps"), activitySteps(page), master, palette) addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia) return elements } addChecklist(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette) addSlotText(elements, slotOf(masterPage, "headline"), page.learningEvidence || "说一说今天的收获", palette.primary, true) return elements } function slotOf(page: TemplateMasterPage, id: string) { return page.slots.find(item => item.id === id && item.enabled !== false) } function pageRenderIntent(page: AiCoursewarePage, role: PageRole): PageRenderIntent { const densityValue = String(page.visualPlan?.visualDensity || "").toLowerCase() const pointCount = Math.max(page.studentContent?.points?.length || 0, page.bullets?.length || 0, page.activity?.steps?.length || 0) const density: PageRenderIntent["density"] = densityValue === "low" || densityValue === "high" ? densityValue : pointCount <= 2 ? "low" : pointCount >= 5 ? "high" : "medium" const hasBoundMedia = (page.resourceBindings || []).some(binding => binding.verified !== false && ["SONG", "VIDEO"].includes(binding.resourceType) && Boolean(binding.contentUrl) ) const expectsMedia = role === "listen" || (page.resourceBindings || []).some(binding => ["SONG", "VIDEO"].includes(binding.resourceType) && binding.required === true ) const visualKind: PageRenderIntent["visualKind"] = role === "notation" ? "notation" : role === "rhythm" ? "rhythm" : role === "compare" ? "compare" : ["activity", "performance"].includes(role) ? "activity" : ["goal", "summary", "homework", "practice", "lyric"].includes(role) ? "text" : "image" return { role, density, visualKind, showMedia: hasBoundMedia || expectsMedia } } function preferredLayout(page: AiCoursewarePage, intent: PageRenderIntent) { const requested = page.visualPlan?.layout || page.visual?.layout if (requested) return requested if (intent.visualKind === "notation" || intent.visualKind === "rhythm") return "diagram_focus" if (intent.visualKind === "compare") return "compare" if (intent.visualKind === "activity") return "activity" if (!hasImage(page) && intent.visualKind === "text" && !intent.showMedia) { if (intent.role === "goal") return "objectives" if (intent.role === "summary" || intent.role === "practice") return "summary" return "text_focus" } return requested } function adaptMasterPage(page: TemplateMasterPage, intent: PageRenderIntent): TemplateMasterPage { if (intent.showMedia) return page const media = page.slots.find(slot => slot.kind === "media" && slot.enabled !== false) if (!media) return page const availableBottom = media.top + media.height const expandable = new Set(["body", "image", "steps", "left", "right"]) return { ...page, slots: page.slots .filter(slot => slot.kind !== "media") .map(slot => { if (!expandable.has(slot.kind) || slot.top + slot.height > media.top + 24) return { ...slot } return { ...slot, height: Math.max(slot.height, availableBottom - slot.top) } }) } } function assertContentContract(master: TemplateMaster, page: TemplateMasterPage, elements: PPTElement[]) { if (!page.safeArea || !page.reservedZones) return type ElementBounds = { left: number; top: number; width: number; height: number } const boundedElements = elements.filter((element): element is PPTElement & ElementBounds => "height" in element && typeof element.height === "number" ) const overlaps = (left: ElementBounds, right: ElementBounds) => left.left < right.left + right.width && left.left + left.width > right.left && left.top < right.top + right.height && left.top + left.height > right.top const outsideSafeArea = boundedElements.find(element => element.left < page.safeArea!.left || element.top < page.safeArea!.top || element.left + element.width > page.safeArea!.left + page.safeArea!.width || element.top + element.height > page.safeArea!.top + page.safeArea!.height ) if (outsideSafeArea) throw new Error(`模板 ${master.id}/${page.id} 生成内容超出投屏安全区,请调整该页面内容槽。`) const collision = page.reservedZones.find(zone => boundedElements.some(element => overlaps(element, zone))) if (collision) throw new Error(`模板 ${master.id}/${page.id} 生成内容侵入“${collision.purpose}”保留区,请更换版式或缩减内容。`) } function addSlotText(elements: PPTElement[], slot: TemplateSlot | undefined, content: string, color: string, bold = false) { if (!slot || !cleanText(content)) return elements.push(textElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, content, fontSize: fitFont(content, slot.width, slot.height, slot.preferredFontSize || 30, slot.minimumFontSize || 22), minimumFontSize: slot.minimumFontSize, color, bold })) } function addSlotList( elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette, cards = true, textColor = master.textColor ) { if (!slot) return const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean)) if (!items.length) return const gap = collectionGap(slot.height, items.length, 16) const rowHeight = (slot.height - gap * (items.length - 1)) / items.length items.forEach((item, index) => { const top = slot.top + index * (rowHeight + gap) const inset = cards ? Math.min(34, Math.max(12, rowHeight * 0.22)) : 0 const textTopInset = Math.min(6, Math.max(1, rowHeight * 0.08)) const textHeight = Math.max(12, rowHeight - textTopInset * 2) const textWidth = Math.max(40, slot.width - (cards ? inset + 24 : 0)) const minimumFontSize = adaptiveMinimumFont(slot, textHeight) if (cards) { addCardFrame(elements, slot.left, top, slot.width, rowHeight, index, master, palette) } elements.push( textElement({ left: slot.left + inset, top: top + textTopInset, width: textWidth, height: textHeight, content: item, fontSize: fitFont(item, textWidth, textHeight, slot.preferredFontSize || 30, minimumFontSize), minimumFontSize, color: textColor, bold: index === 0 && items.length === 1 }) ) }) } function collectionGap(height: number, count: number, preferred: number) { if (count <= 1) return 0 return Math.max(1, Math.min(preferred, height / Math.max(8, count * 8))) } function adaptiveMinimumFont(slot: TemplateSlot, availableHeight: number) { // 内容过多时交给质量门禁提示拆页,不能靠无限缩小正文隐藏问题。 return Math.max(24, slot.minimumFontSize || 24) } function addCardFrame(elements: PPTElement[], left: number, top: number, width: number, height: number, index: number, master: TemplateMaster, palette: Palette) { const accent = index % 2 ? palette.secondary : palette.primary if (master.cardStyle === "ink") { elements.push(shapeElement({ left, top: top + height - 3, width, height: 3, fill: master.borderColor, opacity: 0.52 })) elements.push(shapeElement({ left, top: top + 12, width: 14, height: 14, fill: accent, radius: 7 })) return } if (master.cardStyle === "sticker") { const fills = [palette.warm, palette.soft, master.surfaceColor] elements.push(shapeElement({ left, top, width, height, fill: fills[index % fills.length], radius: 22, shadow: lightShadow() })) elements.push(shapeElement({ left: left + 12, top: top + 12, width: 38, height: 38, fill: accent, radius: 19 })) return } if (master.cardStyle === "bubble") { elements.push(shapeElement({ left, top, width, height, fill: index % 2 ? palette.soft : master.surfaceColor, radius: Math.min(28, height / 2), shadow: lightShadow() })) elements.push(shapeElement({ left: left + 14, top: top + height / 2 - 8, width: 16, height: 16, fill: accent, radius: 8 })) return } if (master.cardStyle === "grid") { elements.push(shapeElement({ left, top, width, height, fill: master.surfaceColor, radius: 4 })) elements.push(shapeElement({ left, top, width: 14, height: 14, fill: accent, radius: 0 })) elements.push(shapeElement({ left: left + width - 26, top: top + height - 10, width: 26, height: 10, fill: accent, opacity: 0.5 })) return } if (master.cardStyle === "cinematic") { elements.push(shapeElement({ left, top, width, height, fill: master.surfaceColor, radius: 6, shadow: lightShadow() })) elements.push(shapeElement({ left, top, width: 8, height, fill: accent })) return } elements.push(shapeElement({ left, top, width, height, fill: master.surfaceColor, radius: 14, shadow: lightShadow() })) elements.push(shapeElement({ left, top, width: 10, height, fill: accent })) } function addCardGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) { if (!slot) return const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean)) if (!items.length) return const columns = items.length === 1 ? 1 : Math.min(3, items.length) const rows = Math.ceil(items.length / columns) const gap = collectionGap(slot.height, rows, 22) const width = (slot.width - gap * (columns - 1)) / columns const height = (slot.height - gap * (rows - 1)) / rows items.forEach((item, index) => { const left = slot.left + (index % columns) * (width + gap) const top = slot.top + Math.floor(index / columns) * (height + gap) const inset = Math.min(34, Math.max(10, Math.min(width, height) * 0.1)) const textWidth = Math.max(36, width - inset * 2) const textHeight = Math.max(12, height - inset * 2) const minimumFontSize = adaptiveMinimumFont(slot, textHeight) addCardFrame(elements, left, top, width, height, index, master, palette) elements.push( textElement({ left: left + inset, top: top + inset, width: textWidth, height: textHeight, content: item, fontSize: fitFont(item, textWidth, textHeight, slot.preferredFontSize || 32, minimumFontSize), minimumFontSize, color: master.textColor, bold: true }) ) }) } function addLyricLines(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) { if (!slot) return const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean)) const gap = collectionGap(slot.height, items.length, 12) const height = (slot.height - gap * Math.max(0, items.length - 1)) / Math.max(1, items.length) items.forEach((item, index) => { const top = slot.top + index * (height + gap) const inset = Math.min(8, Math.max(1, height * 0.08)) const textHeight = Math.max(12, height - inset * 2) const minimumFontSize = adaptiveMinimumFont(slot, textHeight) if (index % 2 === 0) elements.push(shapeElement({ left: slot.left, top, width: slot.width, height, fill: palette.soft, opacity: 0.55, radius: 18 })) elements.push( textElement({ left: slot.left + 45, top: top + inset, width: slot.width - 90, height: textHeight, content: `♪ ${item}`, fontSize: fitFont(item, slot.width - 90, textHeight, slot.preferredFontSize || 38, minimumFontSize), minimumFontSize, color: master.textColor, bold: index === 0 }) ) }) } function addNotationVisual(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage, master: TemplateMaster, palette: Palette, backgroundVisual = false) { if (!slot) return const binding = scoreBinding(page) if (!binding) return // 缺谱在教师质量面板中提示,绝不绘制虚构谱例。 if (usableResourceUrl(binding.scoreImageUrl)) { // 保持整张曲谱,不使用 cover 裁切,防止丢失谱号、小节或歌词。 elements.push({ id: nanoid(10), type: "image", src: binding.scoreImageUrl!, left: slot.left, top: slot.top, width: slot.width, height: slot.height, fixedRatio: true, rotate: 0 } as PPTImageElement) } else { elements.push({ id: nanoid(10), type: "elf", subtype: "elf-sing-play", sid: binding.resourceId, title: binding.name || page.title, left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0 } as PPTCloudCoachElement) } } function addStepGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) { if (!slot) return const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean)) if (!items.length) return const columns = Math.min(4, items.length) const rows = Math.ceil(items.length / columns) const gap = collectionGap(slot.height, rows, 22) const width = (slot.width - gap * (columns - 1)) / columns const height = (slot.height - gap * (rows - 1)) / rows items.forEach((item, index) => { const left = slot.left + (index % columns) * (width + gap) const top = slot.top + Math.floor(index / columns) * (height + gap) const badgeSize = Math.min(72, Math.max(24, height * 0.2)) const cardTop = top + badgeSize * 0.55 const cardHeight = Math.max(12, height - badgeSize * 0.55) const textInset = Math.min(30, Math.max(8, width * 0.08)) const textTop = cardTop + Math.min(70, Math.max(18, cardHeight * 0.22)) const textHeight = Math.max(12, top + height - textTop - 8) const minimumFontSize = adaptiveMinimumFont(slot, textHeight) addCardFrame(elements, left, cardTop, width, cardHeight, index, master, palette) elements.push( shapeElement({ left: left + textInset, top, width: badgeSize, height: badgeSize, fill: index % 2 ? palette.secondary : palette.primary, radius: badgeSize / 2 }) ) elements.push( textElement({ left: left + textInset, top: top + badgeSize * 0.15, width: badgeSize, height: badgeSize * 0.62, content: String(index + 1), fontSize: Math.min(28, Math.max(14, badgeSize * 0.4)), minimumFontSize: 12, color: "#FFFFFF", bold: true }) ) elements.push( textElement({ left: left + textInset, top: textTop, width: Math.max(36, width - textInset * 2), height: textHeight, content: item, fontSize: fitFont(item, width - textInset * 2, textHeight, 29, minimumFontSize), minimumFontSize, color: master.textColor, bold: true }) ) }) } function addChecklist(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) { if (!slot) return const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean)) const gap = collectionGap(slot.height, items.length, 16) const height = (slot.height - gap * Math.max(0, items.length - 1)) / Math.max(1, items.length) items.forEach((item, index) => { const top = slot.top + index * (height + gap) const iconSize = Math.min(42, Math.max(16, height * 0.58)) const iconTop = top + Math.max(0, (height - iconSize) / 2) const textLeft = slot.left + iconSize + Math.min(30, Math.max(8, iconSize * 0.7)) const textWidth = Math.max(40, slot.left + slot.width - textLeft) const minimumFontSize = adaptiveMinimumFont(slot, height) elements.push( shapeElement({ left: slot.left, top: iconTop, width: iconSize, height: iconSize, fill: index % 2 ? palette.secondary : palette.primary, radius: iconSize / 2 }) ) elements.push( textElement({ left: slot.left + iconSize * 0.2, top: iconTop + iconSize * 0.12, width: iconSize * 0.6, height: iconSize * 0.7, content: "✓", fontSize: Math.min(24, Math.max(10, iconSize * 0.55)), minimumFontSize: 10, color: "#FFFFFF", bold: true }) ) elements.push( textElement({ left: textLeft, top, width: textWidth, height, content: item, fontSize: fitFont(item, textWidth, height, slot.preferredFontSize || 32, minimumFontSize), minimumFontSize, color: master.textColor, bold: index === 0 }) ) }) } function addVisualOrResource(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage, master: TemplateMaster, palette: Palette, allowPlaceholder = true) { if (!slot) return const dedicated = (page.resourceBindings || []).find(item => item.verified !== false && item.dedicatedPage) if (dedicated?.resourceType === "MUSIC") { elements.push({ id: nanoid(10), type: "elf", subtype: "elf-sing-play", sid: dedicated.resourceId, title: dedicated.name || page.title, left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0 } as PPTCloudCoachElement) return } const video = videoBinding(page) if (video) { elements.push({ id: nanoid(10), type: "elf", subtype: "elf-video", left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0, src: video.contentUrl || "", poster: video.coverUrl, autoplay: false } as PPTVideoElement) return } if (hasImage(page)) { elements.push(imageElement(page, { left: slot.left, top: slot.top, width: slot.width, height: slot.height, radius: 18 })) return } // 无教学图片时留给文字排版;通用装饰不能充当观察对象。 } function addSemanticVisual(elements: PPTElement[], slot: TemplateSlot, page: AiCoursewarePage, master: TemplateMaster, palette: Palette) { elements.push(shapeElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, fill: master.surfaceColor, opacity: 0.86, radius: 18, shadow: lightShadow() })) const landscapeTop = slot.top + slot.height * 0.48 elements.push(shapeElement({ left: slot.left + slot.width * 0.08, top: landscapeTop, width: slot.width * 0.84, height: slot.height * 0.35, fill: palette.soft, opacity: 0.9, radius: 28 })) elements.push(shapeElement({ left: slot.left + slot.width * 0.68, top: slot.top + slot.height * 0.12, width: Math.min(84, slot.width * 0.16), height: Math.min(84, slot.width * 0.16), fill: palette.warm, opacity: 0.85, radius: 42 })) elements.push(shapeElement({ left: slot.left + slot.width * 0.12, top: landscapeTop + slot.height * 0.12, width: slot.width * 0.46, height: slot.height * 0.2, fill: palette.secondary, opacity: 0.55, radius: 40 })) elements.push(shapeElement({ left: slot.left + slot.width * 0.42, top: landscapeTop + slot.height * 0.08, width: slot.width * 0.42, height: slot.height * 0.24, fill: palette.primary, opacity: 0.45, radius: 44 })) const requestedCue = cleanText(page.visualPlan?.focalContent || page.visualPlan?.mainVisual || page.visual?.mainVisual || page.imageKeywords?.[0]) const cue = !requestedCue || sameVisibleText(requestedCue, page.title) ? `画面线索:${cleanText(page.title) || "观察音乐变化"}` : requestedCue elements.push(textElement({ left: slot.left + slot.width * 0.1, top: slot.top + slot.height * 0.13, width: slot.width * 0.54, height: slot.height * 0.25, content: cue, fontSize: fitFont(cue, slot.width * 0.54, slot.height * 0.25, 30, 22), minimumFontSize: 22, color: master.titleColor, bold: true })) } function addInlineMedia(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage, master: TemplateMaster, palette: Palette, showMedia: boolean) { if (!slot || !showMedia) return const bindings = audioBindings(page) const width = slot.width / Math.max(1, bindings.length) bindings.forEach((binding, index) => { elements.push({ id: nanoid(10), type: "elf", subtype: "elf-audio", left: slot.left + index * width, top: slot.top + (slot.height - 60) / 2, width: 60, height: 60, rotate: 0, fixedRatio: true, color: palette.primary, autoplay: false, loop: false, src: binding.contentUrl! } as PPTAudioElement) addSlotText(elements, { ...slot, left: slot.left + index * width + 76, width: width - 92 }, `${bindings.length > 1 ? String.fromCharCode(65 + index) + " · " : ""}${binding.name || "课堂音频"}`, master.textColor) }) } function renderTemplateMasterElements(elements: TemplateMasterElement[]): PPTElement[] { return elements.map(element => element.type === "text" ? textElement({ left: element.left, top: element.top, width: element.width, height: element.height, content: element.content || "", fontSize: element.fontSize || 14, color: element.color || "#20313A", bold: element.bold }) : shapeElement({ left: element.left, top: element.top, width: element.width, height: element.height, fill: element.fill || "#FFFFFF", opacity: element.opacity, radius: element.radius }) ) } function entranceAnimations(elements: PPTElement[]): PPTAnimation[] { return elements .filter( element => element.type === "text" && 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 videoBinding(page: AiCoursewarePage) { return (page.resourceBindings || []).find(item => item.verified === true && item.resourceType === "VIDEO" && usableResourceUrl(item.contentUrl)) } 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(page.title || outline.title || outline.chapterName || "音乐课件") } 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) { const structured = (page.studentContent?.points || []).map(cleanText).filter(Boolean) const legacy = (page.bullets || []).map(cleanText).filter(Boolean) const points = [...(structured.length ? structured : legacy), studentHeadline(page)].filter(Boolean) const excluded = [page.title].map(normalizeVisibleText).filter(Boolean) const unique = uniqueVisibleTexts(points).filter(item => !excluded.includes(normalizeVisibleText(item))) return unique.length ? unique : [studentHeadline(page) || cleanText(page.title) || "课堂内容"] } function activityTask(page: AiCoursewarePage) { // activity 属于教师组织信息,不能回退到学生投屏画面。 const task = cleanText(page.classroomAction?.studentTask || studentHeadline(page) || "") return sameVisibleText(task, page.title) ? "" : task } function activitySteps(page: AiCoursewarePage) { const task = activityTask(page) const points = studentPoints(page).filter(item => !sameVisibleText(item, task)) return uniqueVisibleTexts(points) } function slideRemark(page: AiCoursewarePage) { const lessonMeta = page.teachingStage || "" const evidence = page.learningEvidence ? `学习表现:${page.learningEvidence}` : "" const activity = [page.activity?.task, ...(page.activity?.steps || [])].map(cleanText).filter(Boolean).join("\n") const fallbackInstruction = cleanText(page.classroomAction?.teacherInstruction || page.teacherTips || page.speakerNotes) || `引导学生完成“${activityTask(page) || page.title}”,观察学生是否能用语言、动作或声音表达本页学习结果。` const answer = page.interaction?.answer ? `参考答案:${page.interaction.answer}` : "" const explanation = page.interaction?.explanation ? `解析:${page.interaction.explanation}` : "" const resources = (page.resourceBindings || []).map(item => `${item.name || item.resourceType}:${item.purpose || "课堂资源"}`).join("\n") const mediaReminder = /listen|sing/.test(page.type) && audioBindings(page).length === 0 ? "资源准备:请为本页听唱任务补充对应音频。" : "" return [lessonMeta, page.teachingPurpose, activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, page.imageReview] .map(cleanText) .filter(Boolean) .filter((item, index, all) => all.indexOf(item) === index) .join("\n") } function buildPalette(outline: AiCoursewareOutline, variant: number, templateId?: AiCoursewareTemplateId): Palette { const resolvedTemplateId = templateId || recommendAiCoursewareTemplate(outline).id return { ...getAiCoursewarePalette(resolvedTemplateId, variant), ...(resolvedTemplateId === "dark-lute" ? { soft: "#16485C" } : {}) } } function backgroundConfig(masterPage: TemplateMasterPage, options: AiPptOptions): SlideBackground { if (options.backgroundImage) return { type: "image", image: options.backgroundImage, imageSize: "cover" } if (masterPage.backgroundImage) return { type: "image", image: masterPage.backgroundImage, imageSize: "cover" } return { type: "solid", color: masterPage.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 minimumFontSize?: number color: string bold?: boolean lineHeight?: number }): PPTTextElement { const lineHeight = params.lineHeight || 1.25 const minimum = Math.min(params.fontSize, params.minimumFontSize ?? (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 const contain = page.requiredVisual || page.visualPlan?.imagePurpose !== "decoration" || page.visualPlan?.imageFit !== "cover" const scale = width > 0 && height > 0 ? Math.min(rect.width / width, rect.height / height) : 0 const fittedWidth = contain && scale ? width * scale : rect.width const fittedHeight = contain && scale ? height * scale : rect.height return { id: nanoid(10), type: "image", left: rect.left + (rect.width - fittedWidth) / 2, top: rect.top + (rect.height - fittedHeight) / 2, width: fittedWidth, height: fittedHeight, rotate: 0, src: page.image?.url || "", fixedRatio: Boolean(contain), radius: rect.radius || 0, shadow: rect.radius ? softShadow() : undefined, clip: !contain && 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 }) }