aiPptTemplateEngine.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  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" | "knowledge" | "rhythm" | "interaction" | "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 "rhythm": return rhythmTemplate
  76. case "knowledge": return knowledgeTemplate
  77. case "interaction": return interactionTemplate
  78. case "activity": return activityTemplate
  79. case "summary": return summaryTemplate
  80. case "homework": return homeworkTemplate
  81. case "intro":
  82. case "story": return storyTemplate
  83. default: return contentTemplate
  84. }
  85. }
  86. function coverTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  87. const title = chapterTitle(outline, page)
  88. const subtitle = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
  89. const points = studentPoints(page, 2)
  90. if (hasImage(page)) {
  91. const elements: PPTElement[] = [imageElement(page, { left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT })]
  92. elements.push(shapeElement({ left: 0, top: 0, width: 910, height: 900, fill: palette.dark, opacity: 0.78, radius: 0 }))
  93. elements.push(textElement({ left: 100, top: 150, width: 700, height: 150, content: title, fontSize: fitFont(title, 700, 150, 66, 48), color: "#FFFFFF", bold: true }))
  94. if (subtitle) elements.push(textElement({ left: 104, top: 330, width: 680, height: 48, content: subtitle, fontSize: 24, color: "#EAF2F7" }))
  95. elements.push(shapeElement({ left: 104, top: 430, width: 84, height: 10, fill: palette.accent, radius: 4 }))
  96. 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 })))
  97. return elements
  98. }
  99. const elements: PPTElement[] = []
  100. elements.push(shapeElement({ left: 0, top: 0, width: 560, height: 900, fill: palette.primary, radius: 0 }))
  101. elements.push(shapeElement({ left: 560, top: 0, width: 1040, height: 900, fill: palette.background, radius: 0 }))
  102. elements.push(textElement({ left: 92, top: 112, width: 380, height: 70, content: "音乐课堂", fontSize: 30, color: "#FFFFFF", bold: true }))
  103. 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 }))
  104. if (subtitle) elements.push(textElement({ left: 656, top: 370, width: 740, height: 44, content: subtitle, fontSize: 24, color: palette.muted }))
  105. elements.push(...melodyStaff(120, 350, 360, palette))
  106. elements.push(shapeElement({ left: 654, top: 486, width: 120, height: 10, fill: palette.secondary, radius: 4 }))
  107. 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 })))
  108. return elements
  109. }
  110. function storyTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
  111. const elements = slideHeader(page, outline, palette, "进入情境")
  112. const imageLeft = index % 2 === 0
  113. if (hasImage(page)) {
  114. const imageRect = imageLeft ? { left: 86, top: 190, width: 860, height: 560, radius: 8 } : { left: 654, top: 190, width: 860, height: 560, radius: 8 }
  115. elements.push(imageElement(page, imageRect))
  116. const panelLeft = imageLeft ? 1010 : 86
  117. elements.push(shapeElement({ left: panelLeft, top: 230, width: 500, height: 470, fill: palette.surface, radius: 8, shadow: softShadow() }))
  118. elements.push(...studentPanel(page, palette, panelLeft + 42, 278, 416))
  119. } else {
  120. elements.push(shapeElement({ left: 86, top: 210, width: 1428, height: 480, fill: palette.surface, radius: 8, shadow: softShadow() }))
  121. 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 }))
  122. elements.push(...storySequence(studentPoints(page, 4), palette, 156, 420, 1280))
  123. }
  124. addClassroomCue(elements, page, palette)
  125. return elements
  126. }
  127. function goalTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  128. const elements = slideHeader(page, outline, palette, "本课目标")
  129. const points = studentPoints(page, 3)
  130. points.forEach((point, index) => {
  131. const left = 92 + index * 500
  132. const colors = [palette.primary, palette.secondary, palette.accent]
  133. elements.push(shapeElement({ left, top: 240, width: 430, height: 380, fill: palette.surface, radius: 8, shadow: softShadow() }))
  134. elements.push(shapeElement({ left, top: 240, width: 430, height: 18, fill: colors[index], radius: 0 }))
  135. elements.push(textElement({ left: left + 34, top: 300, width: 96, height: 74, content: `0${index + 1}`, fontSize: 50, color: colors[index], bold: true }))
  136. 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 }))
  137. })
  138. addClassroomCue(elements, page, palette)
  139. return elements
  140. }
  141. function listenTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  142. const elements = slideHeader(page, outline, palette, "听赏任务")
  143. const task = activityTask(page) || studentHeadline(page) || "安静聆听,找一找音乐中的声音"
  144. if (hasImage(page)) {
  145. elements.push(imageElement(page, { left: 84, top: 185, width: 1432, height: 520, radius: 8 }))
  146. elements.push(shapeElement({ left: 84, top: 510, width: 1432, height: 195, fill: palette.dark, opacity: 0.8, radius: 0 }))
  147. elements.push(textElement({ left: 130, top: 550, width: 1340, height: 84, content: task, fontSize: fitFont(task, 1340, 84, 42, 30), color: "#FFFFFF", bold: true }))
  148. } else {
  149. elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 220, fill: palette.dark, radius: 8 }))
  150. elements.push(textElement({ left: 150, top: 270, width: 1300, height: 92, content: task, fontSize: fitFont(task, 1300, 92, 44, 30), color: "#FFFFFF", bold: true }))
  151. elements.push(...soundWave(250, 520, palette, 1080, 150))
  152. }
  153. addClassroomCue(elements, page, palette)
  154. return elements
  155. }
  156. function lyricTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  157. const elements = slideHeader(page, outline, palette, "学唱与歌词")
  158. const points = studentPoints(page, 4)
  159. elements.push(shapeElement({ left: 90, top: 205, width: 1420, height: 470, fill: palette.surface, radius: 8, shadow: softShadow() }))
  160. elements.push(shapeElement({ left: 90, top: 205, width: 26, height: 470, fill: palette.secondary, radius: 0 }))
  161. points.forEach((point, index) => {
  162. const top = 265 + index * 92
  163. 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 }))
  164. })
  165. addClassroomCue(elements, page, palette)
  166. return elements
  167. }
  168. function rhythmTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  169. const elements = slideHeader(page, outline, palette, "节奏练习")
  170. const task = activityTask(page) || studentHeadline(page) || "拍一拍,感受节奏"
  171. 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 }))
  172. const beats = activitySteps(page, 4)
  173. beats.forEach((beat, index) => {
  174. const left = 92 + index * 370
  175. elements.push(shapeElement({ left, top: 330, width: 320, height: 260, fill: index % 2 === 0 ? palette.soft : palette.surface, radius: 8, shadow: lightShadow() }))
  176. 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 }))
  177. 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 }))
  178. })
  179. addClassroomCue(elements, page, palette)
  180. return elements
  181. }
  182. function knowledgeTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
  183. const elements = slideHeader(page, outline, palette, "音乐知识")
  184. const points = studentPoints(page, 4)
  185. if (hasImage(page)) {
  186. const imageRight = index % 2 === 0
  187. 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 }))
  188. elements.push(...knowledgeList(points, palette, imageRight ? 90 : 770, 220, 720))
  189. } else {
  190. elements.push(...knowledgeList(points, palette, 90, 220, 1420))
  191. }
  192. addClassroomCue(elements, page, palette)
  193. return elements
  194. }
  195. function interactionTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  196. const elements = slideHeader(page, outline, palette, "互动挑战")
  197. const question = page.interaction?.question || activityTask(page) || studentHeadline(page) || "你发现了什么?"
  198. elements.push(shapeElement({ left: 90, top: 200, width: 1420, height: 190, fill: palette.primary, radius: 8 }))
  199. elements.push(textElement({ left: 150, top: 245, width: 1300, height: 100, content: question, fontSize: fitFont(question, 1300, 100, 44, 30), color: "#FFFFFF", bold: true }))
  200. const steps = activitySteps(page, 3)
  201. steps.forEach((step, index) => {
  202. const left = 90 + index * 490
  203. 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 }))
  204. 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 }))
  205. if (index < steps.length - 1) elements.push(lineElement(left + 452, 492, left + 478, 492, palette.muted))
  206. })
  207. addClassroomCue(elements, page, palette)
  208. return elements
  209. }
  210. function activityTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  211. const elements = slideHeader(page, outline, palette, "课堂活动")
  212. const task = activityTask(page) || studentHeadline(page) || "一起完成课堂挑战"
  213. 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 }))
  214. elements.push(...storySequence(activitySteps(page, 4), palette, 92, 360, 1416))
  215. addClassroomCue(elements, page, palette)
  216. return elements
  217. }
  218. function summaryTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  219. const elements = slideHeader(page, outline, palette, "课堂小结")
  220. studentPoints(page, 4).forEach((point, index) => {
  221. const top = 220 + index * 112
  222. const color = index % 2 === 0 ? palette.secondary : palette.primary
  223. elements.push(shapeElement({ left: 100, top, width: 62, height: 62, fill: color, radius: 8 }))
  224. elements.push(textElement({ left: 118, top: top + 12, width: 30, height: 30, content: "✓", fontSize: 28, color: "#FFFFFF", bold: true }))
  225. 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 }))
  226. })
  227. return elements
  228. }
  229. function homeworkTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  230. const elements = slideHeader(page, outline, palette, "课后延伸")
  231. elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 440, fill: palette.surface, radius: 8, shadow: softShadow() }))
  232. activitySteps(page, 4).forEach((step, index) => {
  233. const top = 270 + index * 86
  234. elements.push(textElement({ left: 150, top, width: 70, height: 48, content: `${index + 1}.`, fontSize: 34, color: palette.accent, bold: true }))
  235. elements.push(textElement({ left: 230, top, width: 1180, height: 58, content: step, fontSize: fitFont(step, 1180, 58, 31, 24), color: palette.text, bold: true }))
  236. })
  237. return elements
  238. }
  239. function contentTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
  240. return knowledgeTemplate(page, outline, palette, index)
  241. }
  242. function slideHeader(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, eyebrow: string): PPTElement[] {
  243. const title = displayPageTitle(page, outline)
  244. return [
  245. textElement({ left: 92, top: 64, width: 300, height: 34, content: eyebrow, fontSize: 20, color: palette.secondary, bold: true }),
  246. textElement({ left: 92, top: 102, width: 1320, height: 66, content: title, fontSize: fitFont(title, 1320, 66, 42, 32), color: palette.text, bold: true }),
  247. shapeElement({ left: 1450, top: 72, width: 58, height: 8, fill: palette.accent, radius: 4 }),
  248. ]
  249. }
  250. function studentPanel(page: AiCoursewarePage, palette: Palette, left: number, top: number, width: number): PPTElement[] {
  251. const elements: PPTElement[] = []
  252. const headline = studentHeadline(page)
  253. if (headline) elements.push(textElement({ left, top, width, height: 90, content: headline, fontSize: fitFont(headline, width, 90, 36, 27), color: palette.primary, bold: true }))
  254. studentPoints(page, 3).forEach((point, index) => {
  255. const itemTop = top + (headline ? 120 : 0) + index * 74
  256. elements.push(shapeElement({ left, top: itemTop + 8, width: 18, height: 18, fill: index % 2 ? palette.secondary : palette.accent, radius: 4 }))
  257. 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 }))
  258. })
  259. return elements
  260. }
  261. function storySequence(items: string[], palette: Palette, left: number, top: number, width: number): PPTElement[] {
  262. const elements: PPTElement[] = []
  263. const count = Math.max(1, Math.min(items.length, 4))
  264. const gap = 28
  265. const cardWidth = (width - gap * (count - 1)) / count
  266. items.slice(0, 4).forEach((item, index) => {
  267. const cardLeft = left + index * (cardWidth + gap)
  268. elements.push(shapeElement({ left: cardLeft, top, width: cardWidth, height: 190, fill: index % 2 === 0 ? palette.soft : palette.surface, radius: 8, shadow: lightShadow() }))
  269. 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 }))
  270. 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 }))
  271. })
  272. return elements
  273. }
  274. function knowledgeList(items: string[], palette: Palette, left: number, top: number, width: number): PPTElement[] {
  275. const elements: PPTElement[] = []
  276. items.forEach((item, index) => {
  277. const itemTop = top + index * 108
  278. elements.push(shapeElement({ left, top: itemTop, width, height: 82, fill: index % 2 === 0 ? palette.surface : palette.soft, radius: 8, shadow: lightShadow() }))
  279. elements.push(shapeElement({ left, top: itemTop, width: 14, height: 82, fill: index % 2 === 0 ? palette.primary : palette.secondary, radius: 0 }))
  280. 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 }))
  281. })
  282. return elements
  283. }
  284. function melodyStaff(left: number, top: number, width: number, palette: Palette): PPTElement[] {
  285. const elements: PPTElement[] = []
  286. 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)"))
  287. ;[0.12, 0.36, 0.62, 0.84].forEach((position, index) => {
  288. 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 }))
  289. 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))
  290. })
  291. return elements
  292. }
  293. function soundWave(left: number, top: number, palette: Palette, width: number, maxHeight: number): PPTElement[] {
  294. const heights = [0.28, 0.55, 0.82, 0.45, 1, 0.7, 0.38, 0.64, 0.32]
  295. return heights.map((scale, index) => shapeElement({
  296. left: left + index * (width / heights.length), top: top + (maxHeight - maxHeight * scale) / 2,
  297. width: Math.max(18, width / 24), height: maxHeight * scale,
  298. fill: index % 3 === 0 ? palette.accent : index % 2 === 0 ? palette.secondary : palette.primary, radius: 8,
  299. }))
  300. }
  301. function addClassroomCue(elements: PPTElement[], page: AiCoursewarePage, palette: Palette) {
  302. const cue = page.interaction?.question || activityTask(page)
  303. if (!cue) return
  304. elements.push(shapeElement({ left: 92, top: 754, width: 1416, height: 62, fill: palette.soft, radius: 8 }))
  305. 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 }))
  306. }
  307. function normalizeOutline(outline: AiCoursewareOutline): AiCoursewareOutline {
  308. const chapter = cleanText(outline.chapterName || outline.title || "音乐课件")
  309. return {
  310. ...outline,
  311. title: GENERIC_PAGE_TITLES.has(cleanText(outline.title).toLowerCase()) ? chapter : cleanText(outline.title || chapter),
  312. pages: (outline.pages || []).map((page, index) => ({
  313. ...page,
  314. pageNo: index + 1,
  315. title: index === 0 && GENERIC_PAGE_TITLES.has(cleanText(page.title).toLowerCase()) ? chapter : cleanText(page.title || chapter),
  316. bullets: (page.bullets || []).map(cleanText).filter(Boolean),
  317. imageKeywords: (page.imageKeywords || []).map(cleanText).filter(Boolean),
  318. })),
  319. }
  320. }
  321. function pageRole(page: AiCoursewarePage, index: number): PageRole {
  322. if (index === 0) return "cover"
  323. const kind = `${page.type || ""} ${page.visualPlan?.layout || ""} ${page.visual?.layout || ""}`.toLowerCase()
  324. if (kind.includes("goal")) return "goal"
  325. if (kind.includes("listen")) return "listen"
  326. if (kind.includes("lyric") || kind.includes("sing")) return "lyric"
  327. if (kind.includes("rhythm") || kind.includes("skill")) return "rhythm"
  328. if (kind.includes("knowledge") || kind.includes("extension")) return "knowledge"
  329. if (kind.includes("interaction")) return "interaction"
  330. if (kind.includes("activity")) return "activity"
  331. if (kind.includes("summary")) return "summary"
  332. if (kind.includes("homework")) return "homework"
  333. if (kind.includes("intro")) return "intro"
  334. if (kind.includes("background") || kind.includes("story")) return "story"
  335. return "content"
  336. }
  337. function displayPageTitle(page: AiCoursewarePage, outline: AiCoursewareOutline) {
  338. const title = cleanText(page.title)
  339. return !title || GENERIC_PAGE_TITLES.has(title.toLowerCase()) ? chapterTitle(outline, page) : title
  340. }
  341. function chapterTitle(outline: AiCoursewareOutline, page: AiCoursewarePage) {
  342. return cleanText(outline.chapterName || outline.title || page.title || "音乐课件")
  343. }
  344. function conciseTextbook(value?: string) {
  345. return cleanText(value).replace(/^.*?·/, "")
  346. }
  347. function hasImage(page: AiCoursewarePage) {
  348. return Boolean(page.image?.url)
  349. }
  350. function studentHeadline(page: AiCoursewarePage) {
  351. return cleanText(page.studentContent?.headline || page.subtitle || "")
  352. }
  353. function studentPoints(page: AiCoursewarePage, limit: number) {
  354. const structured = (page.studentContent?.points || []).map(cleanText).filter(Boolean)
  355. const legacy = (page.bullets || []).map(cleanText).filter(Boolean)
  356. const points = structured.length ? structured : legacy
  357. return (points.length ? points : [studentHeadline(page) || cleanText(page.title) || "课堂内容"]).slice(0, limit)
  358. }
  359. function activityTask(page: AiCoursewarePage) {
  360. return cleanText(page.classroomAction?.studentTask || page.activity?.task || "")
  361. }
  362. function activitySteps(page: AiCoursewarePage, limit: number) {
  363. const steps = (page.activity?.steps || []).map(cleanText).filter(Boolean)
  364. const task = activityTask(page)
  365. const values = steps.length ? steps : task ? [task, ...studentPoints(page, limit)] : studentPoints(page, limit)
  366. return values.filter((value, index, all) => all.indexOf(value) === index).slice(0, limit)
  367. }
  368. function slideRemark(page: AiCoursewarePage) {
  369. return [page.speakerNotes, page.teacherTips, page.classroomAction?.teacherInstruction, page.teachingPurpose]
  370. .map(cleanText).filter(Boolean).filter((item, index, all) => all.indexOf(item) === index).join("\n")
  371. }
  372. function buildPalette(outline: AiCoursewareOutline): Palette {
  373. const clean = outline.style === "clean" || /[初高]中|七年级|八年级|九年级/.test(outline.gradeName || "")
  374. if (clean) return {
  375. primary: "#2563EB", secondary: "#0F766E", accent: "#E9553D", warm: "#F5D06F",
  376. background: "#F4F7FB", surface: "#FFFFFF", soft: "#EAF1F8", text: "#17222E", muted: "#64748B", dark: "#132A3A",
  377. }
  378. return {
  379. primary: "#E64A78", secondary: "#168F86", accent: "#F28C28", warm: "#FFD66B",
  380. background: "#FFF9F5", surface: "#FFFFFF", soft: "#EAF7F4", text: "#263238", muted: "#6B7C8F", dark: "#283B4A",
  381. }
  382. }
  383. function backgroundConfig(_role: PageRole, palette: Palette, options: AiPptOptions): SlideBackground {
  384. if (options.backgroundImage) return { type: "image", image: options.backgroundImage, imageSize: "cover" }
  385. return { type: "solid", color: palette.background }
  386. }
  387. function fitFont(content: string, width: number, height: number, preferred: number, minimum: number) {
  388. const text = cleanText(content)
  389. if (!text) return preferred
  390. let size = preferred
  391. while (size > minimum) {
  392. const charsPerLine = Math.max(1, Math.floor(width / size))
  393. const lines = Math.ceil(text.length / charsPerLine)
  394. if (lines * size * 1.35 <= height) break
  395. size -= 2
  396. }
  397. return size
  398. }
  399. function textElement(params: { left: number; top: number; width: number; height: number; content: string; fontSize: number; color: string; bold?: boolean; lineHeight?: number }): PPTTextElement {
  400. return {
  401. id: nanoid(10), type: "text", left: params.left, top: params.top, width: params.width, height: params.height, rotate: 0,
  402. content: `<div style="font-family:${FONT_NAME};font-size:${params.fontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(params.content)}</div>`,
  403. defaultFontName: FONT_NAME, defaultColor: params.color, lineHeight: params.lineHeight || 1.25, wordSpace: 0, opacity: 1,
  404. }
  405. }
  406. function imageElement(page: AiCoursewarePage, rect: ImageRect): PPTImageElement {
  407. const width = page.image?.width || 0
  408. const height = page.image?.height || 0
  409. return {
  410. id: nanoid(10), type: "image", left: rect.left, top: rect.top, width: rect.width, height: rect.height, rotate: 0,
  411. src: page.image?.url || "", fixedRatio: false, radius: rect.radius || 0, shadow: rect.radius ? softShadow() : undefined,
  412. clip: width > 0 && height > 0 ? { shape: "rect", range: coverRange(width, height, rect.width, rect.height) } : undefined,
  413. }
  414. }
  415. function coverRange(sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number): [[number, number], [number, number]] {
  416. const sourceRatio = sourceWidth / sourceHeight
  417. const targetRatio = targetWidth / targetHeight
  418. if (sourceRatio > targetRatio) {
  419. const visible = (targetRatio / sourceRatio) * 100
  420. const start = (100 - visible) / 2
  421. return [[start, 0], [100 - start, 100]]
  422. }
  423. const visible = (sourceRatio / targetRatio) * 100
  424. const start = (100 - visible) / 2
  425. return [[0, start], [100, 100 - start]]
  426. }
  427. function shapeElement(params: { left: number; top: number; width: number; height: number; fill: string; opacity?: number; radius?: number; shadow?: PPTElementShadow; gradient?: Gradient }): PPTShapeElement {
  428. return {
  429. id: nanoid(10), type: "shape", left: params.left, top: params.top, width: params.width, height: params.height, rotate: 0,
  430. viewBox: [200, 200], path: roundedRectPath(params.radius || 0), fixedRatio: false, fill: params.fill,
  431. opacity: params.opacity ?? 1, shadow: params.shadow, gradient: params.gradient,
  432. }
  433. }
  434. function lineElement(x1: number, y1: number, x2: number, y2: number, color: string, width = 2): PPTLineElement {
  435. const left = Math.min(x1, x2)
  436. const top = Math.min(y1, y2)
  437. const lineWidth = Math.abs(x2 - x1)
  438. const lineHeight = Math.abs(y2 - y1)
  439. return {
  440. id: nanoid(10), type: "line", left, top, width,
  441. start: [x1 === left ? 0 : lineWidth, y1 === top ? 0 : lineHeight],
  442. end: [x2 === left ? 0 : lineWidth, y2 === top ? 0 : lineHeight],
  443. style: "solid", color, points: ["", ""],
  444. }
  445. }
  446. function keepInCanvas(elements: PPTElement[]) {
  447. return elements.map(element => {
  448. const left = Math.max(0, Math.min(element.left, SLIDE_WIDTH - 1))
  449. const top = Math.max(0, Math.min(element.top, SLIDE_HEIGHT - 1))
  450. if (element.type === "line") return { ...element, left, top, width: Math.max(1, Math.min(element.width, 20)) }
  451. 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)) }
  452. }) as PPTElement[]
  453. }
  454. function runPreflight(slides: Slide[]) {
  455. slides.forEach((slide, index) => {
  456. 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))
  457. const emptyText = slide.elements.filter(element => element.type === "text" && !stripHtml(element.content))
  458. if (invalid.length || emptyText.length) console.warn("AI PPT preflight issue", { pageNo: index + 1, invalid: invalid.length, emptyText: emptyText.length })
  459. })
  460. }
  461. function roundedRectPath(radius: number) {
  462. const r = Math.max(0, Math.min(radius, 100))
  463. 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`
  464. }
  465. function softShadow(): PPTElementShadow {
  466. return { h: 0, v: 8, blur: 22, color: "rgba(23, 34, 46, 0.14)" }
  467. }
  468. function lightShadow(): PPTElementShadow {
  469. return { h: 0, v: 4, blur: 14, color: "rgba(23, 34, 46, 0.1)" }
  470. }
  471. function cleanText(value?: string) {
  472. return String(value || "").replace(/\s+/g, " ").trim()
  473. }
  474. function escapeHtml(value: string) {
  475. return cleanText(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
  476. }
  477. function stripHtml(value: string) {
  478. return value.replace(/<[^>]+>/g, "").trim()
  479. }