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