aiPptTemplateEngine.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. import { nanoid } from "nanoid"
  2. import type {
  3. Gradient,
  4. PPTElement,
  5. PPTElementShadow,
  6. PPTImageElement,
  7. PPTLineElement,
  8. PPTShapeElement,
  9. PPTTextElement,
  10. Slide,
  11. SlideBackground,
  12. SlideTheme,
  13. } from "@/types/slides"
  14. import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
  15. interface AiPptOptions {
  16. backgroundImage?: string
  17. }
  18. type PageRole = "cover" | "intro" | "goal" | "story" | "listen" | "lyric" | "notation" | "instrument" | "compare" | "knowledge" | "rhythm" | "interaction" | "performance" | "practice" | "activity" | "summary" | "homework" | "content"
  19. interface Palette {
  20. primary: string
  21. secondary: string
  22. accent: string
  23. warm: string
  24. background: string
  25. surface: string
  26. soft: string
  27. text: string
  28. muted: string
  29. dark: string
  30. }
  31. interface ImageRect {
  32. left: number
  33. top: number
  34. width: number
  35. height: number
  36. radius?: number
  37. }
  38. const SLIDE_WIDTH = 1600
  39. const SLIDE_HEIGHT = 900
  40. const FONT_NAME = "Microsoft Yahei"
  41. const GENERIC_PAGE_TITLES = new Set(["封面", "课程导入", "故事导入", "导入", "首页", "cover"])
  42. export function buildAiPptSlides(outline: AiCoursewareOutline, options: AiPptOptions = {}): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
  43. const normalized = normalizeOutline(outline)
  44. const palette = buildPalette(normalized)
  45. const slides = normalized.pages.map((page, index) => buildSlide(page, normalized, palette, options, index))
  46. runPreflight(slides)
  47. return {
  48. title: normalized.title || normalized.chapterName || "音乐课件",
  49. theme: {
  50. themeColor: palette.primary,
  51. backgroundColor: palette.background,
  52. fontColor: palette.text,
  53. fontName: FONT_NAME,
  54. fontSize: "36px",
  55. },
  56. slides,
  57. }
  58. }
  59. function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions, index: number): Slide {
  60. const role = pageRole(page, index)
  61. const elements = templateFor(role)(page, outline, palette, index)
  62. return {
  63. id: nanoid(10),
  64. elements: keepInCanvas(elements),
  65. background: backgroundConfig(role, palette, options),
  66. remark: slideRemark(page),
  67. }
  68. }
  69. function templateFor(role: PageRole) {
  70. switch (role) {
  71. case "cover": return coverTemplate
  72. case "goal": return goalTemplate
  73. case "listen": return listenTemplate
  74. case "lyric": return lyricTemplate
  75. case "notation": return notationTemplate
  76. case "instrument": return instrumentTemplate
  77. case "compare": return compareTemplate
  78. case "rhythm": return rhythmTemplate
  79. case "knowledge": return knowledgeTemplate
  80. case "interaction": return interactionTemplate
  81. case "performance": return performanceTemplate
  82. case "practice": return practiceTemplate
  83. case "activity": return activityTemplate
  84. case "summary": return summaryTemplate
  85. case "homework": return homeworkTemplate
  86. case "intro":
  87. case "story": return storyTemplate
  88. default: return contentTemplate
  89. }
  90. }
  91. function coverTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  92. const title = chapterTitle(outline, page)
  93. const subtitle = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
  94. const points = studentPoints(page, 2)
  95. if (hasImage(page)) {
  96. const elements: PPTElement[] = [imageElement(page, { left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT })]
  97. elements.push(shapeElement({ left: 0, top: 0, width: 910, height: 900, fill: palette.dark, opacity: 0.78, radius: 0 }))
  98. elements.push(textElement({ left: 100, top: 150, width: 700, height: 150, content: title, fontSize: fitFont(title, 700, 150, 66, 48), color: "#FFFFFF", bold: true }))
  99. if (subtitle) elements.push(textElement({ left: 104, top: 330, width: 680, height: 48, content: subtitle, fontSize: 24, color: "#EAF2F7" }))
  100. elements.push(shapeElement({ left: 104, top: 430, width: 84, height: 10, fill: palette.accent, radius: 4 }))
  101. 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 })))
  102. return elements
  103. }
  104. const elements: PPTElement[] = []
  105. elements.push(shapeElement({ left: 0, top: 0, width: 560, height: 900, fill: palette.primary, radius: 0 }))
  106. elements.push(shapeElement({ left: 560, top: 0, width: 1040, height: 900, fill: palette.background, radius: 0 }))
  107. elements.push(textElement({ left: 92, top: 112, width: 380, height: 70, content: "音乐课堂", fontSize: 30, color: "#FFFFFF", bold: true }))
  108. 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 }))
  109. if (subtitle) elements.push(textElement({ left: 656, top: 370, width: 740, height: 44, content: subtitle, fontSize: 24, color: palette.muted }))
  110. elements.push(...melodyStaff(120, 350, 360, palette))
  111. elements.push(shapeElement({ left: 654, top: 486, width: 120, height: 10, fill: palette.secondary, radius: 4 }))
  112. 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 })))
  113. return elements
  114. }
  115. function storyTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
  116. const elements = slideHeader(page, outline, palette, "进入情境")
  117. const imageLeft = index % 2 === 0
  118. if (hasImage(page)) {
  119. const imageRect = imageLeft ? { left: 86, top: 190, width: 860, height: 560, radius: 8 } : { left: 654, top: 190, width: 860, height: 560, radius: 8 }
  120. elements.push(imageElement(page, imageRect))
  121. const panelLeft = imageLeft ? 1010 : 86
  122. elements.push(shapeElement({ left: panelLeft, top: 230, width: 500, height: 470, fill: palette.surface, radius: 8, shadow: softShadow() }))
  123. elements.push(...studentPanel(page, palette, panelLeft + 42, 278, 416))
  124. } else {
  125. elements.push(shapeElement({ left: 86, top: 210, width: 1428, height: 480, fill: palette.surface, radius: 8, shadow: softShadow() }))
  126. 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 }))
  127. elements.push(...storySequence(studentPoints(page, 4), palette, 156, 420, 1280))
  128. }
  129. addClassroomCue(elements, page, palette)
  130. return elements
  131. }
  132. function goalTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  133. const elements = slideHeader(page, outline, palette, "本课目标")
  134. const points = studentPoints(page, 3)
  135. points.forEach((point, index) => {
  136. const left = 92 + index * 500
  137. const colors = [palette.primary, palette.secondary, palette.accent]
  138. elements.push(shapeElement({ left, top: 240, width: 430, height: 380, fill: palette.surface, radius: 8, shadow: softShadow() }))
  139. elements.push(shapeElement({ left, top: 240, width: 430, height: 18, fill: colors[index], radius: 0 }))
  140. elements.push(textElement({ left: left + 34, top: 300, width: 96, height: 74, content: `0${index + 1}`, fontSize: 50, color: colors[index], bold: true }))
  141. 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 }))
  142. })
  143. addClassroomCue(elements, page, palette)
  144. return elements
  145. }
  146. function listenTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  147. const elements = slideHeader(page, outline, palette, "听赏任务")
  148. const task = activityTask(page) || studentHeadline(page) || "安静聆听,找一找音乐中的声音"
  149. if (hasImage(page)) {
  150. elements.push(imageElement(page, { left: 84, top: 185, width: 1432, height: 520, radius: 8 }))
  151. elements.push(shapeElement({ left: 84, top: 510, width: 1432, height: 195, fill: palette.dark, opacity: 0.8, radius: 0 }))
  152. elements.push(textElement({ left: 130, top: 550, width: 1340, height: 84, content: task, fontSize: fitFont(task, 1340, 84, 42, 30), color: "#FFFFFF", bold: true }))
  153. } else {
  154. elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 220, fill: palette.dark, radius: 8 }))
  155. elements.push(textElement({ left: 150, top: 270, width: 1300, height: 92, content: task, fontSize: fitFont(task, 1300, 92, 44, 30), color: "#FFFFFF", bold: true }))
  156. elements.push(...soundWave(250, 520, palette, 1080, 150))
  157. }
  158. addClassroomCue(elements, page, palette)
  159. return elements
  160. }
  161. function lyricTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  162. const elements = slideHeader(page, outline, palette, "学唱与歌词")
  163. const points = studentPoints(page, 4)
  164. elements.push(shapeElement({ left: 90, top: 205, width: 1420, height: 470, fill: palette.surface, radius: 8, shadow: softShadow() }))
  165. elements.push(shapeElement({ left: 90, top: 205, width: 26, height: 470, fill: palette.secondary, radius: 0 }))
  166. points.forEach((point, index) => {
  167. const top = 265 + index * 92
  168. 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 }))
  169. })
  170. addClassroomCue(elements, page, palette)
  171. return elements
  172. }
  173. function notationTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  174. const elements = slideHeader(page, outline, palette, "旋律与谱例")
  175. const headline = studentHeadline(page) || "看一看,唱一唱"
  176. 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 }))
  177. elements.push(shapeElement({ left: 92, top: 300, width: 1416, height: 300, fill: palette.surface, radius: 8, shadow: softShadow() }))
  178. for (let i = 0; i < 5; i++) elements.push(lineElement(160, 370 + i * 42, 1440, 370 + i * 42, palette.muted, 2))
  179. studentPoints(page, 4).forEach((point, index) => {
  180. const left = 180 + index * 315
  181. const top = 392 + (index % 2) * 44
  182. elements.push(shapeElement({ left, top, width: 42, height: 34, fill: index % 2 ? palette.secondary : palette.primary, radius: 8 }))
  183. elements.push(lineElement(left + 38, top - 52, left + 38, top + 12, index % 2 ? palette.secondary : palette.primary, 5))
  184. elements.push(textElement({ left: left - 26, top: 610, width: 250, height: 50, content: point, fontSize: fitFont(point, 250, 50, 27, 21), color: palette.text, bold: true }))
  185. })
  186. addClassroomCue(elements, page, palette)
  187. return elements
  188. }
  189. function instrumentTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  190. const elements = slideHeader(page, outline, palette, "认识声音")
  191. const points = studentPoints(page, 3)
  192. if (hasImage(page)) {
  193. elements.push(imageElement(page, { left: 84, top: 190, width: 760, height: 540, radius: 8 }))
  194. 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 }))
  195. points.forEach((point, index) => {
  196. const top = 360 + index * 104
  197. 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 }))
  198. elements.push(textElement({ left: 992, top, width: 490, height: 64, content: point, fontSize: fitFont(point, 490, 64, 30, 23), color: palette.text, bold: true }))
  199. })
  200. } else {
  201. elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 190, fill: palette.primary, radius: 8 }))
  202. 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 }))
  203. elements.push(...soundWave(210, 500, palette, 1180, 150))
  204. }
  205. addClassroomCue(elements, page, palette)
  206. return elements
  207. }
  208. function compareTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  209. const elements = slideHeader(page, outline, palette, "对比听赏")
  210. const points = studentPoints(page, 4)
  211. const leftPoints = points.filter((_, index) => index % 2 === 0)
  212. const rightPoints = points.filter((_, index) => index % 2 === 1)
  213. elements.push(shapeElement({ left: 92, top: 210, width: 690, height: 470, fill: palette.soft, radius: 8 }))
  214. elements.push(shapeElement({ left: 818, top: 210, width: 690, height: 470, fill: palette.surface, radius: 8, shadow: lightShadow() }))
  215. elements.push(textElement({ left: 140, top: 252, width: 570, height: 64, content: "听一听 A", fontSize: 36, color: palette.primary, bold: true }))
  216. elements.push(textElement({ left: 866, top: 252, width: 570, height: 64, content: "听一听 B", fontSize: 36, color: palette.secondary, bold: true }))
  217. ;[leftPoints, rightPoints].forEach((items, group) => items.forEach((point, index) => {
  218. const left = group === 0 ? 140 : 866
  219. const top = 365 + index * 110
  220. elements.push(shapeElement({ left, top: top + 8, width: 22, height: 22, fill: group === 0 ? palette.primary : palette.secondary, radius: 8 }))
  221. 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 }))
  222. }))
  223. addClassroomCue(elements, page, palette)
  224. return elements
  225. }
  226. function rhythmTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  227. const elements = slideHeader(page, outline, palette, "节奏练习")
  228. const task = activityTask(page) || studentHeadline(page) || "拍一拍,感受节奏"
  229. 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 }))
  230. const beats = activitySteps(page, 4)
  231. beats.forEach((beat, index) => {
  232. const left = 92 + index * 370
  233. elements.push(shapeElement({ left, top: 330, width: 320, height: 260, fill: index % 2 === 0 ? palette.soft : palette.surface, radius: 8, shadow: lightShadow() }))
  234. 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 }))
  235. 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 }))
  236. })
  237. addClassroomCue(elements, page, palette)
  238. return elements
  239. }
  240. function knowledgeTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
  241. const elements = slideHeader(page, outline, palette, "音乐知识")
  242. const points = studentPoints(page, 4)
  243. if (hasImage(page)) {
  244. const imageRight = index % 2 === 0
  245. 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 }))
  246. elements.push(...knowledgeList(points, palette, imageRight ? 90 : 770, 220, 720))
  247. } else {
  248. elements.push(...knowledgeList(points, palette, 90, 220, 1420))
  249. }
  250. addClassroomCue(elements, page, palette)
  251. return elements
  252. }
  253. function interactionTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  254. const elements = slideHeader(page, outline, palette, "互动挑战")
  255. const question = page.interaction?.question || activityTask(page) || studentHeadline(page) || "你发现了什么?"
  256. elements.push(shapeElement({ left: 90, top: 200, width: 1420, height: 190, fill: palette.primary, radius: 8 }))
  257. elements.push(textElement({ left: 150, top: 245, width: 1300, height: 100, content: question, fontSize: fitFont(question, 1300, 100, 44, 30), color: "#FFFFFF", bold: true }))
  258. const steps = activitySteps(page, 3)
  259. steps.forEach((step, index) => {
  260. const left = 90 + index * 490
  261. 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 }))
  262. 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 }))
  263. if (index < steps.length - 1) elements.push(lineElement(left + 452, 492, left + 478, 492, palette.muted))
  264. })
  265. addClassroomCue(elements, page, palette)
  266. return elements
  267. }
  268. function activityTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  269. const elements = slideHeader(page, outline, palette, "课堂活动")
  270. const task = activityTask(page) || studentHeadline(page) || "一起完成课堂挑战"
  271. 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 }))
  272. elements.push(...storySequence(activitySteps(page, 4), palette, 92, 360, 1416))
  273. addClassroomCue(elements, page, palette)
  274. return elements
  275. }
  276. function performanceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  277. const elements = slideHeader(page, outline, palette, "合作表现")
  278. const task = activityTask(page) || studentHeadline(page) || "一起完成音乐表演"
  279. elements.push(shapeElement({ left: 92, top: 200, width: 1416, height: 150, fill: palette.primary, radius: 8 }))
  280. elements.push(textElement({ left: 150, top: 242, width: 1300, height: 70, content: task, fontSize: fitFont(task, 1300, 70, 42, 30), color: "#FFFFFF", bold: true }))
  281. const steps = activitySteps(page, 4)
  282. steps.forEach((step, index) => {
  283. const left = 126 + index * 355
  284. elements.push(shapeElement({ left, top: 438, width: 76, height: 76, fill: index % 2 ? palette.secondary : palette.accent, radius: 38 }))
  285. elements.push(textElement({ left: left + 23, top: 454, width: 34, height: 38, content: `${index + 1}`, fontSize: 28, color: "#FFFFFF", bold: true }))
  286. 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 }))
  287. if (index < steps.length - 1) elements.push(lineElement(left + 96, 476, left + 325, 476, palette.muted, 3))
  288. })
  289. addClassroomCue(elements, page, palette)
  290. return elements
  291. }
  292. function practiceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  293. const elements = slideHeader(page, outline, palette, "课堂检测")
  294. const question = page.interaction?.question || studentHeadline(page) || activityTask(page) || "你能完成今天的音乐挑战吗?"
  295. 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 }))
  296. studentPoints(page, 4).forEach((point, index) => {
  297. const top = 330 + index * 94
  298. elements.push(shapeElement({ left: 110, top, width: 70, height: 62, fill: index % 2 ? palette.secondary : palette.primary, radius: 8 }))
  299. elements.push(textElement({ left: 132, top: top + 14, width: 32, height: 34, content: String.fromCharCode(65 + index), fontSize: 26, color: "#FFFFFF", bold: true }))
  300. 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 }))
  301. elements.push(lineElement(220, top + 72, 1450, top + 72, palette.soft, 2))
  302. })
  303. addClassroomCue(elements, page, palette)
  304. return elements
  305. }
  306. function summaryTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  307. const elements = slideHeader(page, outline, palette, "课堂小结")
  308. studentPoints(page, 4).forEach((point, index) => {
  309. const top = 220 + index * 112
  310. const color = index % 2 === 0 ? palette.secondary : palette.primary
  311. elements.push(shapeElement({ left: 100, top, width: 62, height: 62, fill: color, radius: 8 }))
  312. elements.push(textElement({ left: 118, top: top + 12, width: 30, height: 30, content: "✓", fontSize: 28, color: "#FFFFFF", bold: true }))
  313. 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 }))
  314. })
  315. return elements
  316. }
  317. function homeworkTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  318. const elements = slideHeader(page, outline, palette, "课后延伸")
  319. elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 440, fill: palette.surface, radius: 8, shadow: softShadow() }))
  320. activitySteps(page, 4).forEach((step, index) => {
  321. const top = 270 + index * 86
  322. elements.push(textElement({ left: 150, top, width: 70, height: 48, content: `${index + 1}.`, fontSize: 34, color: palette.accent, bold: true }))
  323. elements.push(textElement({ left: 230, top, width: 1180, height: 58, content: step, fontSize: fitFont(step, 1180, 58, 31, 24), color: palette.text, bold: true }))
  324. })
  325. return elements
  326. }
  327. function contentTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
  328. return knowledgeTemplate(page, outline, palette, index)
  329. }
  330. function slideHeader(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, eyebrow: string): PPTElement[] {
  331. const title = displayPageTitle(page, outline)
  332. return [
  333. textElement({ left: 92, top: 64, width: 300, height: 34, content: eyebrow, fontSize: 20, color: palette.secondary, bold: true }),
  334. textElement({ left: 92, top: 102, width: 1320, height: 66, content: title, fontSize: fitFont(title, 1320, 66, 42, 32), color: palette.text, bold: true }),
  335. shapeElement({ left: 1450, top: 72, width: 58, height: 8, fill: palette.accent, radius: 4 }),
  336. ]
  337. }
  338. function studentPanel(page: AiCoursewarePage, palette: Palette, left: number, top: number, width: number): PPTElement[] {
  339. const elements: PPTElement[] = []
  340. const headline = studentHeadline(page)
  341. if (headline) elements.push(textElement({ left, top, width, height: 90, content: headline, fontSize: fitFont(headline, width, 90, 36, 27), color: palette.primary, bold: true }))
  342. studentPoints(page, 3).forEach((point, index) => {
  343. const itemTop = top + (headline ? 120 : 0) + index * 74
  344. elements.push(shapeElement({ left, top: itemTop + 8, width: 18, height: 18, fill: index % 2 ? palette.secondary : palette.accent, radius: 4 }))
  345. 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 }))
  346. })
  347. return elements
  348. }
  349. function storySequence(items: string[], palette: Palette, left: number, top: number, width: number): PPTElement[] {
  350. const elements: PPTElement[] = []
  351. const count = Math.max(1, Math.min(items.length, 4))
  352. const gap = 28
  353. const cardWidth = (width - gap * (count - 1)) / count
  354. items.slice(0, 4).forEach((item, index) => {
  355. const cardLeft = left + index * (cardWidth + gap)
  356. elements.push(shapeElement({ left: cardLeft, top, width: cardWidth, height: 190, fill: index % 2 === 0 ? palette.soft : palette.surface, radius: 8, shadow: lightShadow() }))
  357. 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 }))
  358. 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 }))
  359. })
  360. return elements
  361. }
  362. function knowledgeList(items: string[], palette: Palette, left: number, top: number, width: number): PPTElement[] {
  363. const elements: PPTElement[] = []
  364. items.forEach((item, index) => {
  365. const itemTop = top + index * 108
  366. elements.push(shapeElement({ left, top: itemTop, width, height: 82, fill: index % 2 === 0 ? palette.surface : palette.soft, radius: 8, shadow: lightShadow() }))
  367. elements.push(shapeElement({ left, top: itemTop, width: 14, height: 82, fill: index % 2 === 0 ? palette.primary : palette.secondary, radius: 0 }))
  368. 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 }))
  369. })
  370. return elements
  371. }
  372. function melodyStaff(left: number, top: number, width: number, palette: Palette): PPTElement[] {
  373. const elements: PPTElement[] = []
  374. 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)"))
  375. ;[0.12, 0.36, 0.62, 0.84].forEach((position, index) => {
  376. 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 }))
  377. 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))
  378. })
  379. return elements
  380. }
  381. function soundWave(left: number, top: number, palette: Palette, width: number, maxHeight: number): PPTElement[] {
  382. const heights = [0.28, 0.55, 0.82, 0.45, 1, 0.7, 0.38, 0.64, 0.32]
  383. return heights.map((scale, index) => shapeElement({
  384. left: left + index * (width / heights.length), top: top + (maxHeight - maxHeight * scale) / 2,
  385. width: Math.max(18, width / 24), height: maxHeight * scale,
  386. fill: index % 3 === 0 ? palette.accent : index % 2 === 0 ? palette.secondary : palette.primary, radius: 8,
  387. }))
  388. }
  389. function addClassroomCue(elements: PPTElement[], page: AiCoursewarePage, palette: Palette) {
  390. const cue = page.interaction?.question || activityTask(page)
  391. if (!cue) return
  392. elements.push(shapeElement({ left: 92, top: 754, width: 1416, height: 62, fill: palette.soft, radius: 8 }))
  393. 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 }))
  394. }
  395. function normalizeOutline(outline: AiCoursewareOutline): AiCoursewareOutline {
  396. const chapter = cleanText(outline.chapterName || outline.title || "音乐课件")
  397. return {
  398. ...outline,
  399. title: GENERIC_PAGE_TITLES.has(cleanText(outline.title).toLowerCase()) ? chapter : cleanText(outline.title || chapter),
  400. pages: (outline.pages || []).map((page, index) => ({
  401. ...page,
  402. pageNo: index + 1,
  403. title: index === 0 && GENERIC_PAGE_TITLES.has(cleanText(page.title).toLowerCase()) ? chapter : cleanText(page.title || chapter),
  404. bullets: (page.bullets || []).map(cleanText).filter(Boolean),
  405. imageKeywords: (page.imageKeywords || []).map(cleanText).filter(Boolean),
  406. })),
  407. }
  408. }
  409. function pageRole(page: AiCoursewarePage, index: number): PageRole {
  410. if (index === 0) return "cover"
  411. const kind = `${page.type || ""} ${page.visualPlan?.layout || ""} ${page.visual?.layout || ""}`.toLowerCase()
  412. if (kind.includes("objective") || kind.includes("goal")) return "goal"
  413. if (kind.includes("practice_check") || kind.includes("assessment")) return "practice"
  414. if (kind.includes("compare")) return "compare"
  415. if (kind.includes("instrument")) return "instrument"
  416. if (kind.includes("melody") || kind.includes("score") || kind.includes("notation")) return "notation"
  417. if (kind.includes("performance") || kind.includes("expressive") || kind.includes("creative")) return "performance"
  418. if (kind.includes("listen")) return "listen"
  419. if (kind.includes("lyric") || kind.includes("phrase_sing") || kind.includes("sing_focus")) return "lyric"
  420. if (kind.includes("rhythm") || kind.includes("skill")) return "rhythm"
  421. if (kind.includes("knowledge")) return "knowledge"
  422. if (kind.includes("interaction")) return "interaction"
  423. if (kind.includes("activity")) return "activity"
  424. if (kind.includes("summary")) return "summary"
  425. if (kind.includes("homework") || kind.includes("extension")) return "homework"
  426. if (kind.includes("intro") || kind.includes("scene")) return "intro"
  427. if (kind.includes("context") || kind.includes("background") || kind.includes("story")) return "story"
  428. return "content"
  429. }
  430. function displayPageTitle(page: AiCoursewarePage, outline: AiCoursewareOutline) {
  431. const title = cleanText(page.title)
  432. return !title || GENERIC_PAGE_TITLES.has(title.toLowerCase()) ? chapterTitle(outline, page) : title
  433. }
  434. function chapterTitle(outline: AiCoursewareOutline, page: AiCoursewarePage) {
  435. return cleanText(outline.chapterName || outline.title || page.title || "音乐课件")
  436. }
  437. function conciseTextbook(value?: string) {
  438. return cleanText(value).replace(/^.*?·/, "")
  439. }
  440. function hasImage(page: AiCoursewarePage) {
  441. return Boolean(page.image?.url)
  442. }
  443. function studentHeadline(page: AiCoursewarePage) {
  444. return cleanText(page.studentContent?.headline || page.subtitle || "")
  445. }
  446. function studentPoints(page: AiCoursewarePage, limit: number) {
  447. const structured = (page.studentContent?.points || []).map(cleanText).filter(Boolean)
  448. const legacy = (page.bullets || []).map(cleanText).filter(Boolean)
  449. const points = structured.length ? structured : legacy
  450. return (points.length ? points : [studentHeadline(page) || cleanText(page.title) || "课堂内容"]).slice(0, limit)
  451. }
  452. function activityTask(page: AiCoursewarePage) {
  453. return cleanText(page.classroomAction?.studentTask || page.activity?.task || "")
  454. }
  455. function activitySteps(page: AiCoursewarePage, limit: number) {
  456. const steps = (page.activity?.steps || []).map(cleanText).filter(Boolean)
  457. const task = activityTask(page)
  458. const values = steps.length ? steps : task ? [task, ...studentPoints(page, limit)] : studentPoints(page, limit)
  459. return values.filter((value, index, all) => all.indexOf(value) === index).slice(0, limit)
  460. }
  461. function slideRemark(page: AiCoursewarePage) {
  462. return [page.speakerNotes, page.teacherTips, page.classroomAction?.teacherInstruction, page.teachingPurpose]
  463. .map(cleanText).filter(Boolean).filter((item, index, all) => all.indexOf(item) === index).join("\n")
  464. }
  465. function buildPalette(outline: AiCoursewareOutline): Palette {
  466. const clean = outline.style === "clean" || /[初高]中|七年级|八年级|九年级/.test(outline.gradeName || "")
  467. if (clean) return {
  468. primary: "#2563EB", secondary: "#0F766E", accent: "#E9553D", warm: "#F5D06F",
  469. background: "#F4F7FB", surface: "#FFFFFF", soft: "#EAF1F8", text: "#17222E", muted: "#64748B", dark: "#132A3A",
  470. }
  471. return {
  472. primary: "#E64A78", secondary: "#168F86", accent: "#F28C28", warm: "#FFD66B",
  473. background: "#FFF9F5", surface: "#FFFFFF", soft: "#EAF7F4", text: "#263238", muted: "#6B7C8F", dark: "#283B4A",
  474. }
  475. }
  476. function backgroundConfig(_role: PageRole, palette: Palette, options: AiPptOptions): SlideBackground {
  477. if (options.backgroundImage) return { type: "image", image: options.backgroundImage, imageSize: "cover" }
  478. return { type: "solid", color: palette.background }
  479. }
  480. function fitFont(content: string, width: number, height: number, preferred: number, minimum: number) {
  481. const text = cleanText(content)
  482. if (!text) return preferred
  483. let size = preferred
  484. while (size > minimum) {
  485. const charsPerLine = Math.max(1, Math.floor(width / size))
  486. const lines = Math.ceil(text.length / charsPerLine)
  487. if (lines * size * 1.35 <= height) break
  488. size -= 2
  489. }
  490. return size
  491. }
  492. function textElement(params: { left: number; top: number; width: number; height: number; content: string; fontSize: number; color: string; bold?: boolean; lineHeight?: number }): PPTTextElement {
  493. const lineHeight = params.lineHeight || 1.25
  494. const visibleContent = fitVisibleText(params.content, params.width - 20, params.height, params.fontSize, lineHeight)
  495. return {
  496. id: nanoid(10), type: "text", left: params.left, top: params.top, width: params.width, height: params.height, rotate: 0,
  497. content: `<div style="box-sizing:border-box;width:calc(100% - 20px);font-family:${FONT_NAME};font-size:${params.fontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(visibleContent)}</div>`,
  498. defaultFontName: FONT_NAME, defaultColor: params.color, lineHeight, paragraphSpace: 0, wordSpace: 0, opacity: 1,
  499. }
  500. }
  501. function fitVisibleText(content: string, width: number, height: number, fontSize: number, lineHeight: number) {
  502. const text = cleanText(content)
  503. if (!text) return ""
  504. const safeWidth = Math.max(1, width)
  505. const safeHeight = Math.max(1, height)
  506. const maxLines = Math.max(1, Math.floor(safeHeight / (fontSize * lineHeight)))
  507. const maxUnits = Math.max(1, Math.floor((safeWidth / fontSize) * maxLines))
  508. if (textUnits(text) <= maxUnits) return text
  509. let units = 0
  510. let result = ""
  511. for (const char of text) {
  512. const charUnits = /[\x00-\xff]/.test(char) ? 0.55 : 1
  513. if (units + charUnits > Math.max(1, maxUnits - 1)) break
  514. result += char
  515. units += charUnits
  516. }
  517. return `${result.trim()}…`
  518. }
  519. function textUnits(content: string) {
  520. return Array.from(content).reduce((total, char) => total + (/[\x00-\xff]/.test(char) ? 0.55 : 1), 0)
  521. }
  522. function imageElement(page: AiCoursewarePage, rect: ImageRect): PPTImageElement {
  523. const width = page.image?.width || 0
  524. const height = page.image?.height || 0
  525. return {
  526. id: nanoid(10), type: "image", left: rect.left, top: rect.top, width: rect.width, height: rect.height, rotate: 0,
  527. src: page.image?.url || "", fixedRatio: false, radius: rect.radius || 0, shadow: rect.radius ? softShadow() : undefined,
  528. clip: width > 0 && height > 0 ? { shape: "rect", range: coverRange(width, height, rect.width, rect.height) } : undefined,
  529. }
  530. }
  531. function coverRange(sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number): [[number, number], [number, number]] {
  532. const sourceRatio = sourceWidth / sourceHeight
  533. const targetRatio = targetWidth / targetHeight
  534. if (sourceRatio > targetRatio) {
  535. const visible = (targetRatio / sourceRatio) * 100
  536. const start = (100 - visible) / 2
  537. return [[start, 0], [100 - start, 100]]
  538. }
  539. const visible = (sourceRatio / targetRatio) * 100
  540. const start = (100 - visible) / 2
  541. return [[0, start], [100, 100 - start]]
  542. }
  543. function shapeElement(params: { left: number; top: number; width: number; height: number; fill: string; opacity?: number; radius?: number; shadow?: PPTElementShadow; gradient?: Gradient }): PPTShapeElement {
  544. return {
  545. id: nanoid(10), type: "shape", left: params.left, top: params.top, width: params.width, height: params.height, rotate: 0,
  546. viewBox: [200, 200], path: roundedRectPath(params.radius || 0), fixedRatio: false, fill: params.fill,
  547. opacity: params.opacity ?? 1, shadow: params.shadow, gradient: params.gradient,
  548. }
  549. }
  550. function lineElement(x1: number, y1: number, x2: number, y2: number, color: string, width = 2): PPTLineElement {
  551. const left = Math.min(x1, x2)
  552. const top = Math.min(y1, y2)
  553. const lineWidth = Math.abs(x2 - x1)
  554. const lineHeight = Math.abs(y2 - y1)
  555. return {
  556. id: nanoid(10), type: "line", left, top, width,
  557. start: [x1 === left ? 0 : lineWidth, y1 === top ? 0 : lineHeight],
  558. end: [x2 === left ? 0 : lineWidth, y2 === top ? 0 : lineHeight],
  559. style: "solid", color, points: ["", ""],
  560. }
  561. }
  562. function keepInCanvas(elements: PPTElement[]) {
  563. return elements.map(element => {
  564. const left = Math.max(0, Math.min(element.left, SLIDE_WIDTH - 1))
  565. const top = Math.max(0, Math.min(element.top, SLIDE_HEIGHT - 1))
  566. if (element.type === "line") return { ...element, left, top, width: Math.max(1, Math.min(element.width, 20)) }
  567. 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)) }
  568. }) as PPTElement[]
  569. }
  570. function runPreflight(slides: Slide[]) {
  571. slides.forEach((slide, index) => {
  572. 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))
  573. const emptyText = slide.elements.filter(element => element.type === "text" && !stripHtml(element.content))
  574. if (invalid.length || emptyText.length) console.warn("AI PPT preflight issue", { pageNo: index + 1, invalid: invalid.length, emptyText: emptyText.length })
  575. })
  576. }
  577. function roundedRectPath(radius: number) {
  578. const r = Math.max(0, Math.min(radius, 100))
  579. 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`
  580. }
  581. function softShadow(): PPTElementShadow {
  582. return { h: 0, v: 8, blur: 22, color: "rgba(23, 34, 46, 0.14)" }
  583. }
  584. function lightShadow(): PPTElementShadow {
  585. return { h: 0, v: 4, blur: 14, color: "rgba(23, 34, 46, 0.1)" }
  586. }
  587. function cleanText(value?: string) {
  588. return String(value || "").replace(/\s+/g, " ").trim()
  589. }
  590. function escapeHtml(value: string) {
  591. return cleanText(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
  592. }
  593. function stripHtml(value: string) {
  594. return value.replace(/<[^>]+>/g, "").trim()
  595. }