aiPptTemplateEngine.ts 60 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707
  1. import { nanoid } from "nanoid"
  2. import type {
  3. Gradient,
  4. PPTElement,
  5. PPTElementShadow,
  6. PPTImageElement,
  7. PPTVideoElement,
  8. PPTEnjoyElement,
  9. PPTCloudCoachElement,
  10. PPTLineElement,
  11. PPTAnimation,
  12. PPTShapeElement,
  13. PPTTextElement,
  14. Slide,
  15. SlideBackground,
  16. SlideTheme
  17. } from "@/types/slides"
  18. import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
  19. import { getAiCoursewarePalette, type AiCoursewareTemplateId, type AiCoursewareThemePalette } from "@/config/aiCoursewareTemplates"
  20. interface AiPptOptions {
  21. backgroundImage?: string
  22. enableAnimations?: boolean
  23. paletteVariant?: number
  24. templateId?: AiCoursewareTemplateId
  25. }
  26. type PageRole =
  27. | "cover"
  28. | "intro"
  29. | "goal"
  30. | "story"
  31. | "listen"
  32. | "lyric"
  33. | "notation"
  34. | "instrument"
  35. | "compare"
  36. | "knowledge"
  37. | "rhythm"
  38. | "interaction"
  39. | "performance"
  40. | "practice"
  41. | "activity"
  42. | "summary"
  43. | "homework"
  44. | "content"
  45. type Palette = AiCoursewareThemePalette
  46. interface ImageRect {
  47. left: number
  48. top: number
  49. width: number
  50. height: number
  51. radius?: number
  52. }
  53. const SLIDE_WIDTH = 1600
  54. const SLIDE_HEIGHT = 900
  55. const VIEWPORT_WIDTH = 1920
  56. const VIEWPORT_HEIGHT = 1080
  57. const VIEWPORT_SCALE = VIEWPORT_WIDTH / SLIDE_WIDTH
  58. const FONT_NAME = "Microsoft Yahei"
  59. const GENERIC_PAGE_TITLES = new Set(["封面", "课程导入", "故事导入", "导入", "首页", "cover"])
  60. export function buildAiPptSlides(
  61. outline: AiCoursewareOutline,
  62. options: AiPptOptions = {}
  63. ): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
  64. const normalized = normalizeOutline(outline)
  65. const palette = buildPalette(normalized, options.paletteVariant || 0, options.templateId)
  66. const slides = normalized.pages.map((page, index) => buildSlide(page, normalized, palette, options, index))
  67. runPreflight(slides)
  68. return {
  69. title: normalized.title || normalized.chapterName || "音乐课件",
  70. theme: {
  71. themeColor: palette.primary,
  72. backgroundColor: palette.background,
  73. fontColor: palette.text,
  74. fontName: FONT_NAME,
  75. fontSize: "36px"
  76. },
  77. slides
  78. }
  79. }
  80. function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions, index: number): Slide {
  81. const role = pageRole(page, index)
  82. const dedicatedResource = dedicatedResourceElement(page)
  83. const contentElements = dedicatedResource
  84. ? [dedicatedResource]
  85. : videoBinding(page)
  86. ? videoResourceTemplate(page, outline, palette)
  87. : [...templateForPage(page, role)(page, outline, palette, index), ...inlineResourceElements(page)]
  88. const elements = dedicatedResource ? contentElements : [...templateDecorationElements(options.templateId, palette, role), ...contentElements]
  89. return {
  90. id: nanoid(10),
  91. elements: scaleElementsToViewport(keepInCanvas(elements)),
  92. background: backgroundConfig(role, palette, options),
  93. remark: slideRemark(page),
  94. animations: options.enableAnimations ? entranceAnimations(contentElements) : undefined,
  95. turningMode: options.enableAnimations ? "fade" : "no"
  96. }
  97. }
  98. function templateDecorationElements(templateId: AiCoursewareTemplateId | undefined, palette: Palette, role: PageRole): PPTElement[] {
  99. if (!templateId || role === "cover") return []
  100. switch (templateId) {
  101. case "playful-handdrawn":
  102. return [
  103. shapeElement({ left: 1450, top: 36, width: 74, height: 74, fill: palette.warm, opacity: 0.5, radius: 37 }),
  104. shapeElement({ left: 1518, top: 92, width: 42, height: 42, fill: palette.accent, opacity: 0.45, radius: 21 }),
  105. shapeElement({ left: 26, top: 792, width: 58, height: 58, fill: palette.secondary, opacity: 0.26, radius: 29 })
  106. ]
  107. case "fresh-education":
  108. return [
  109. shapeElement({ left: 0, top: 0, width: 1600, height: 12, fill: palette.secondary, opacity: 0.85, radius: 0 }),
  110. shapeElement({ left: 1530, top: 742, width: 70, height: 158, fill: palette.soft, opacity: 0.8, radius: 0 })
  111. ]
  112. case "academic-blue":
  113. return [
  114. shapeElement({ left: 0, top: 0, width: 1600, height: 18, fill: palette.primary, radius: 0 }),
  115. shapeElement({ left: 1548, top: 38, width: 18, height: 132, fill: palette.accent, radius: 0 }),
  116. shapeElement({ left: 1574, top: 38, width: 8, height: 84, fill: palette.secondary, radius: 0 })
  117. ]
  118. case "oriental-culture":
  119. return [
  120. shapeElement({ left: 1460, top: 42, width: 88, height: 88, fill: palette.primary, opacity: 0.14, radius: 44 }),
  121. shapeElement({ left: 1499, top: 52, width: 10, height: 68, fill: palette.primary, opacity: 0.62, radius: 2 }),
  122. shapeElement({ left: 1434, top: 148, width: 114, height: 4, fill: palette.accent, opacity: 0.55, radius: 2 })
  123. ]
  124. case "digital-tech":
  125. return [
  126. shapeElement({ left: 80, top: 120, width: 1440, height: 2, fill: palette.secondary, opacity: 0.16, radius: 0 }),
  127. shapeElement({ left: 80, top: 780, width: 1440, height: 2, fill: palette.primary, opacity: 0.16, radius: 0 }),
  128. shapeElement({ left: 118, top: 80, width: 2, height: 740, fill: palette.secondary, opacity: 0.12, radius: 0 }),
  129. shapeElement({ left: 1480, top: 80, width: 2, height: 740, fill: palette.primary, opacity: 0.12, radius: 0 }),
  130. shapeElement({ left: 1468, top: 108, width: 26, height: 26, fill: palette.secondary, opacity: 0.75, radius: 4 })
  131. ]
  132. case "active-classroom":
  133. return [
  134. shapeElement({ left: 1390, top: 30, width: 58, height: 18, fill: palette.accent, radius: 9 }),
  135. shapeElement({ left: 1458, top: 30, width: 92, height: 18, fill: palette.secondary, radius: 9 }),
  136. shapeElement({ left: 44, top: 816, width: 116, height: 20, fill: palette.warm, opacity: 0.8, radius: 10 })
  137. ]
  138. }
  139. }
  140. function entranceAnimations(elements: PPTElement[]): PPTAnimation[] {
  141. return elements
  142. .filter(
  143. element => (element.type === "text" || element.type === "image" || element.type === "shape") && element.width >= 120 && element.height >= 45
  144. )
  145. .slice(0, 6)
  146. .map((element, index) => ({
  147. id: nanoid(10),
  148. elId: element.id,
  149. effect: index === 0 ? "fadeIn" : "fadeInUp",
  150. type: "in",
  151. duration: 500,
  152. trigger: index === 0 ? "auto" : "click"
  153. }))
  154. }
  155. function templateForPage(page: AiCoursewarePage, role: PageRole) {
  156. const layout = cleanText(page.visualPlan?.layout || page.visual?.layout).toLowerCase()
  157. switch (layout) {
  158. case "cover_scene":
  159. return coverTemplate
  160. case "full_image_question":
  161. return immersiveImageTemplate
  162. case "split_image":
  163. return storyTemplate
  164. case "goal_cards":
  165. return goalTemplate
  166. case "listen_wave":
  167. return listenTemplate
  168. case "lyric_focus":
  169. return lyricTemplate
  170. case "score_focus":
  171. return notationTemplate
  172. case "rhythm_grid":
  173. return rhythmTemplate
  174. case "instrument_focus":
  175. return instrumentTemplate
  176. case "compare_listen":
  177. return compareTemplate
  178. case "knowledge_cards":
  179. return knowledgeTemplate
  180. case "activity_steps":
  181. return activityTemplate
  182. case "practice_check":
  183. return practiceTemplate
  184. case "summary_checklist":
  185. return summaryTemplate
  186. case "homework_list":
  187. return homeworkTemplate
  188. case "story_stage":
  189. return storyStageTemplate
  190. case "observe_question":
  191. return observeQuestionTemplate
  192. case "listen_compare_visual":
  193. return listenCompareVisualTemplate
  194. case "sound_timeline":
  195. return soundTimelineTemplate
  196. case "action_demo":
  197. return actionDemoTemplate
  198. case "group_mission":
  199. return groupMissionTemplate
  200. case "performance_stage":
  201. return performanceStageTemplate
  202. case "visual_summary":
  203. return visualSummaryTemplate
  204. default:
  205. return templateForRole(role)
  206. }
  207. }
  208. function templateForRole(role: PageRole) {
  209. switch (role) {
  210. case "cover":
  211. return coverTemplate
  212. case "goal":
  213. return goalTemplate
  214. case "listen":
  215. return listenTemplate
  216. case "lyric":
  217. return lyricTemplate
  218. case "notation":
  219. return notationTemplate
  220. case "instrument":
  221. return instrumentTemplate
  222. case "compare":
  223. return compareTemplate
  224. case "rhythm":
  225. return rhythmTemplate
  226. case "knowledge":
  227. return knowledgeTemplate
  228. case "interaction":
  229. return interactionTemplate
  230. case "performance":
  231. return performanceTemplate
  232. case "practice":
  233. return practiceTemplate
  234. case "activity":
  235. return activityTemplate
  236. case "summary":
  237. return summaryTemplate
  238. case "homework":
  239. return homeworkTemplate
  240. case "intro":
  241. case "story":
  242. return storyTemplate
  243. default:
  244. return contentTemplate
  245. }
  246. }
  247. function immersiveImageTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
  248. if (!hasImage(page)) return storyTemplate(page, outline, palette, index)
  249. const title = displayPageTitle(page, outline)
  250. const question = page.interaction?.question || activityTask(page) || studentHeadline(page) || studentPoints(page, 1)[0]
  251. const panelOnLeft = index % 2 === 0
  252. const panelLeft = panelOnLeft ? 0 : 930
  253. const textLeft = panelOnLeft ? 104 : 1010
  254. const elements: PPTElement[] = [imageElement(page, { left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT })]
  255. elements.push(shapeElement({ left: panelLeft, top: 0, width: 670, height: 900, fill: palette.dark, opacity: 0.82, radius: 0 }))
  256. elements.push(
  257. textElement({
  258. left: textLeft,
  259. top: 110,
  260. width: 480,
  261. height: 48,
  262. content: page.teachingStage || "观察与发现",
  263. fontSize: 22,
  264. color: palette.warm,
  265. bold: true
  266. })
  267. )
  268. elements.push(
  269. textElement({
  270. left: textLeft,
  271. top: 188,
  272. width: 500,
  273. height: 160,
  274. content: title,
  275. fontSize: fitFont(title, 500, 160, 56, 40),
  276. color: "#FFFFFF",
  277. bold: true
  278. })
  279. )
  280. elements.push(shapeElement({ left: textLeft, top: 382, width: 94, height: 10, fill: palette.accent, radius: 4 }))
  281. if (question) {
  282. elements.push(
  283. textElement({
  284. left: textLeft,
  285. top: 454,
  286. width: 500,
  287. height: 200,
  288. content: question,
  289. fontSize: fitFont(question, 500, 200, 38, 28),
  290. color: "#FFFFFF",
  291. bold: true,
  292. lineHeight: 1.35
  293. })
  294. )
  295. }
  296. return elements
  297. }
  298. function coverTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  299. const title = chapterTitle(outline, page)
  300. const subtitle = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
  301. const points = studentPoints(page, 2)
  302. if (hasImage(page)) {
  303. const elements: PPTElement[] = [shapeElement({ left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT, fill: palette.dark, radius: 0 })]
  304. // 教材原图只作为经过裁切的主视觉使用,避免整张扫描页直接成为封面。
  305. elements.push(imageElement(page, { left: 650, top: 0, width: 950, height: 900 }))
  306. elements.push(shapeElement({ left: 570, top: 0, width: 240, height: 900, fill: palette.dark, opacity: 0.62, radius: 0 }))
  307. elements.push(
  308. textElement({
  309. left: 100,
  310. top: 150,
  311. width: 470,
  312. height: 150,
  313. content: title,
  314. fontSize: fitFont(title, 470, 150, 66, 44),
  315. color: "#FFFFFF",
  316. bold: true
  317. })
  318. )
  319. if (subtitle) elements.push(textElement({ left: 104, top: 330, width: 470, height: 72, content: subtitle, fontSize: 24, color: "#EAF2F7" }))
  320. elements.push(shapeElement({ left: 104, top: 430, width: 84, height: 10, fill: palette.accent, radius: 4 }))
  321. points.forEach((point, index) =>
  322. elements.push(
  323. textElement({
  324. left: 104,
  325. top: 500 + index * 64,
  326. width: 470,
  327. height: 44,
  328. content: point,
  329. fontSize: fitFont(point, 470, 44, 28, 22),
  330. color: "#FFFFFF",
  331. bold: index === 0
  332. })
  333. )
  334. )
  335. return elements
  336. }
  337. const elements: PPTElement[] = []
  338. elements.push(shapeElement({ left: 0, top: 0, width: 560, height: 900, fill: palette.primary, radius: 0 }))
  339. elements.push(shapeElement({ left: 560, top: 0, width: 1040, height: 900, fill: palette.background, radius: 0 }))
  340. elements.push(textElement({ left: 92, top: 112, width: 380, height: 70, content: "音乐课堂", fontSize: 30, color: "#FFFFFF", bold: true }))
  341. elements.push(
  342. textElement({
  343. left: 650,
  344. top: 170,
  345. width: 780,
  346. height: 170,
  347. content: title,
  348. fontSize: fitFont(title, 780, 170, 70, 50),
  349. color: palette.primary,
  350. bold: true
  351. })
  352. )
  353. if (subtitle) elements.push(textElement({ left: 656, top: 370, width: 740, height: 44, content: subtitle, fontSize: 24, color: palette.muted }))
  354. elements.push(textElement({ left: 92, top: 360, width: 380, height: 180, content: "听 · 唱 · 动", fontSize: 52, color: "#FFFFFF", bold: true }))
  355. elements.push(shapeElement({ left: 654, top: 486, width: 120, height: 10, fill: palette.secondary, radius: 4 }))
  356. points.forEach((point, index) =>
  357. elements.push(
  358. textElement({
  359. left: 656,
  360. top: 548 + index * 66,
  361. width: 720,
  362. height: 46,
  363. content: point,
  364. fontSize: fitFont(point, 720, 46, 30, 23),
  365. color: palette.text,
  366. bold: index === 0
  367. })
  368. )
  369. )
  370. return elements
  371. }
  372. function storyTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
  373. const elements = slideHeader(page, outline, palette, "进入情境")
  374. const imageLeft = index % 2 === 0
  375. if (hasImage(page)) {
  376. const imageRect = imageLeft
  377. ? { left: 86, top: 190, width: 860, height: 560, radius: 8 }
  378. : { left: 654, top: 190, width: 860, height: 560, radius: 8 }
  379. elements.push(imageElement(page, imageRect))
  380. const panelLeft = imageLeft ? 1010 : 86
  381. elements.push(shapeElement({ left: panelLeft, top: 230, width: 500, height: 470, fill: palette.surface, radius: 8, shadow: softShadow() }))
  382. elements.push(...studentPanel(page, palette, panelLeft + 42, 278, 416))
  383. } else {
  384. elements.push(shapeElement({ left: 86, top: 210, width: 1428, height: 480, fill: palette.surface, radius: 8, shadow: softShadow() }))
  385. elements.push(
  386. textElement({
  387. left: 156,
  388. top: 270,
  389. width: 1280,
  390. height: 90,
  391. content: studentHeadline(page) || page.title,
  392. fontSize: fitFont(studentHeadline(page) || page.title, 1280, 90, 42, 30),
  393. color: palette.primary,
  394. bold: true
  395. })
  396. )
  397. elements.push(...storySequence(studentPoints(page, 4), palette, 156, 420, 1280))
  398. }
  399. addClassroomCue(elements, page, palette)
  400. return elements
  401. }
  402. function storyStageTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  403. const title = displayPageTitle(page, outline)
  404. const points = studentPoints(page, 2)
  405. const elements: PPTElement[] = []
  406. if (hasImage(page)) elements.push(imageElement(page, { left: 0, top: 0, width: 1600, height: 900 }))
  407. else {
  408. elements.push(shapeElement({ left: 0, top: 0, width: 1600, height: 900, fill: palette.soft, radius: 0 }))
  409. elements.push(shapeElement({ left: 0, top: 420, width: 1600, height: 480, fill: palette.primary, opacity: 0.16, radius: 0 }))
  410. }
  411. elements.push(shapeElement({ left: 0, top: 0, width: 1600, height: 190, fill: palette.dark, opacity: 0.76, radius: 0 }))
  412. elements.push(
  413. textElement({
  414. left: 92,
  415. top: 54,
  416. width: 1320,
  417. height: 94,
  418. content: title,
  419. fontSize: fitFont(title, 1320, 94, 52, 38),
  420. color: "#FFFFFF",
  421. bold: true
  422. })
  423. )
  424. const dialogue = points[0] || activityTask(page)
  425. if (dialogue) {
  426. elements.push(shapeElement({ left: 130, top: 590, width: 1120, height: 190, fill: "#FFFFFF", opacity: 0.94, radius: 22, shadow: softShadow() }))
  427. elements.push(
  428. textElement({
  429. left: 186,
  430. top: 632,
  431. width: 1000,
  432. height: 100,
  433. content: dialogue,
  434. fontSize: fitFont(dialogue, 1000, 100, 38, 28),
  435. color: palette.text,
  436. bold: true,
  437. lineHeight: 1.3
  438. })
  439. )
  440. elements.push(shapeElement({ left: 1280, top: 620, width: 180, height: 120, fill: palette.accent, radius: 60 }))
  441. }
  442. return elements
  443. }
  444. function observeQuestionTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  445. const elements = slideHeader(page, outline, palette, "观察与发现")
  446. const question = page.interaction?.question || activityTask(page) || studentPoints(page, 1)[0]
  447. if (hasImage(page)) elements.push(imageElement(page, { left: 70, top: 190, width: 940, height: 570, radius: 18 }))
  448. else elements.push(shapeElement({ left: 70, top: 190, width: 940, height: 570, fill: palette.soft, radius: 18 }))
  449. elements.push(shapeElement({ left: 1060, top: 220, width: 440, height: 500, fill: palette.surface, radius: 18, shadow: softShadow() }))
  450. elements.push(shapeElement({ left: 1110, top: 274, width: 72, height: 72, fill: palette.accent, radius: 36 }))
  451. elements.push(textElement({ left: 1131, top: 283, width: 34, height: 48, content: "?", fontSize: 38, color: "#FFFFFF", bold: true }))
  452. elements.push(
  453. textElement({
  454. left: 1110,
  455. top: 390,
  456. width: 330,
  457. height: 190,
  458. content: question || "你发现了什么?",
  459. fontSize: fitFont(question || "你发现了什么?", 330, 190, 38, 27),
  460. color: palette.text,
  461. bold: true,
  462. lineHeight: 1.35
  463. })
  464. )
  465. return elements
  466. }
  467. function listenCompareVisualTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  468. const elements = slideHeader(page, outline, palette, "听辨对比")
  469. const points = studentPoints(page, 4)
  470. ;[0, 1].forEach(group => {
  471. const left = group === 0 ? 84 : 820
  472. const color = group === 0 ? palette.primary : palette.secondary
  473. elements.push(
  474. shapeElement({ left, top: 205, width: 696, height: 500, fill: group === 0 ? palette.soft : palette.surface, radius: 18, shadow: lightShadow() })
  475. )
  476. elements.push(shapeElement({ left: left + 40, top: 250, width: 82, height: 82, fill: color, radius: 41 }))
  477. elements.push(
  478. textElement({ left: left + 65, top: 268, width: 34, height: 46, content: group === 0 ? "A" : "B", fontSize: 32, color: "#FFFFFF", bold: true })
  479. )
  480. for (let bar = 0; bar < 8; bar++) {
  481. const height = 28 + ((bar * 23 + group * 17) % 86)
  482. elements.push(shapeElement({ left: left + 170 + bar * 48, top: 350 - height / 2, width: 22, height, fill: color, opacity: 0.72, radius: 8 }))
  483. }
  484. const text = points[group] || (group === 0 ? "听第一段声音" : "听第二段声音")
  485. elements.push(
  486. textElement({
  487. left: left + 54,
  488. top: 470,
  489. width: 580,
  490. height: 110,
  491. content: text,
  492. fontSize: fitFont(text, 580, 110, 34, 25),
  493. color: palette.text,
  494. bold: true
  495. })
  496. )
  497. })
  498. return elements
  499. }
  500. function soundTimelineTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  501. const elements = slideHeader(page, outline, palette, "声音变化")
  502. const points = studentPoints(page, 4)
  503. elements.push(lineElement(170, 480, 1430, 480, palette.muted, 6))
  504. points.forEach((point, index) => {
  505. const left = 180 + index * (1200 / Math.max(1, points.length - 1))
  506. const size = index % 2 === 0 ? 82 : 54
  507. const color = index % 2 === 0 ? palette.primary : palette.secondary
  508. elements.push(shapeElement({ left: left - size / 2, top: 480 - size / 2, width: size, height: size, fill: color, radius: size / 2 }))
  509. elements.push(
  510. textElement({
  511. left: left - 150,
  512. top: index % 2 === 0 ? 300 : 570,
  513. width: 300,
  514. height: 90,
  515. content: point,
  516. fontSize: fitFont(point, 300, 90, 30, 23),
  517. color: palette.text,
  518. bold: true,
  519. lineHeight: 1.3
  520. })
  521. )
  522. })
  523. return elements
  524. }
  525. function actionDemoTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  526. const elements = slideHeader(page, outline, palette, "跟着做")
  527. const steps = activitySteps(page, 4)
  528. steps.forEach((step, index) => {
  529. const left = 110 + index * 370
  530. const color = [palette.primary, palette.secondary, palette.accent, palette.warm][index]
  531. elements.push(shapeElement({ left, top: 280, width: 190, height: 190, fill: color, radius: 95, shadow: lightShadow() }))
  532. elements.push(
  533. textElement({ left: left + 62, top: 326, width: 70, height: 74, content: `${index + 1}`, fontSize: 54, color: "#FFFFFF", bold: true })
  534. )
  535. elements.push(
  536. textElement({
  537. left: left - 45,
  538. top: 530,
  539. width: 280,
  540. height: 110,
  541. content: step,
  542. fontSize: fitFont(step, 280, 110, 31, 23),
  543. color: palette.text,
  544. bold: true,
  545. lineHeight: 1.3
  546. })
  547. )
  548. if (index < steps.length - 1) elements.push(lineElement(left + 220, 376, left + 330, 376, palette.muted, 4))
  549. })
  550. return elements
  551. }
  552. function groupMissionTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  553. const elements = slideHeader(page, outline, palette, "小组任务")
  554. const steps = activitySteps(page, 4)
  555. steps.forEach((step, index) => {
  556. const top = 210 + index * 142
  557. const color = index % 2 === 0 ? palette.primary : palette.secondary
  558. elements.push(shapeElement({ left: 100, top, width: 150, height: 104, fill: color, radius: 16 }))
  559. elements.push(
  560. textElement({ left: 126, top: top + 27, width: 100, height: 45, content: `第${index + 1}组`, fontSize: 27, color: "#FFFFFF", bold: true })
  561. )
  562. elements.push(lineElement(270, top + 52, 350, top + 52, color, 4))
  563. elements.push(
  564. textElement({
  565. left: 390,
  566. top: top + 18,
  567. width: 1030,
  568. height: 68,
  569. content: step,
  570. fontSize: fitFont(step, 1030, 68, 34, 25),
  571. color: palette.text,
  572. bold: true
  573. })
  574. )
  575. })
  576. return elements
  577. }
  578. function performanceStageTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  579. const elements = slideHeader(page, outline, palette, "展示时刻")
  580. elements.push(shapeElement({ left: 130, top: 220, width: 1340, height: 520, fill: palette.dark, radius: 28, shadow: softShadow() }))
  581. elements.push(shapeElement({ left: 230, top: 300, width: 1140, height: 360, fill: palette.warm, opacity: 0.24, radius: 180 }))
  582. const roles = activitySteps(page, 4)
  583. roles.forEach((role, index) => {
  584. const left = 300 + index * 270
  585. elements.push(shapeElement({ left, top: 430, width: 130, height: 130, fill: index % 2 ? palette.secondary : palette.accent, radius: 65 }))
  586. elements.push(
  587. textElement({
  588. left: left - 30,
  589. top: 590,
  590. width: 190,
  591. height: 68,
  592. content: role,
  593. fontSize: fitFont(role, 190, 68, 27, 21),
  594. color: "#FFFFFF",
  595. bold: true
  596. })
  597. )
  598. })
  599. return elements
  600. }
  601. function visualSummaryTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  602. const elements = slideHeader(page, outline, palette, "今天的发现")
  603. const points = studentPoints(page, 4)
  604. points.forEach((point, index) => {
  605. const left = 170 + index * 350
  606. const top = index % 2 === 0 ? 310 : 500
  607. const color = [palette.primary, palette.secondary, palette.accent, palette.warm][index]
  608. elements.push(shapeElement({ left, top, width: 110, height: 110, fill: color, radius: 55 }))
  609. elements.push(textElement({ left: left + 38, top: top + 27, width: 40, height: 50, content: "✓", fontSize: 38, color: "#FFFFFF", bold: true }))
  610. elements.push(
  611. textElement({
  612. left: left - 70,
  613. top: top + 135,
  614. width: 250,
  615. height: 86,
  616. content: point,
  617. fontSize: fitFont(point, 250, 86, 29, 22),
  618. color: palette.text,
  619. bold: true,
  620. lineHeight: 1.3
  621. })
  622. )
  623. if (index < points.length - 1) elements.push(lineElement(left + 130, top + 55, left + 300, index % 2 === 0 ? 555 : 365, palette.muted, 3))
  624. })
  625. return elements
  626. }
  627. function goalTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  628. const elements = slideHeader(page, outline, palette, "本课目标")
  629. const points = studentPoints(page, 3)
  630. points.forEach((point, index) => {
  631. const left = 92 + index * 500
  632. const colors = [palette.primary, palette.secondary, palette.accent]
  633. elements.push(shapeElement({ left, top: 240, width: 430, height: 380, fill: palette.surface, radius: 8, shadow: softShadow() }))
  634. elements.push(shapeElement({ left, top: 240, width: 430, height: 18, fill: colors[index], radius: 0 }))
  635. elements.push(
  636. textElement({ left: left + 34, top: 300, width: 96, height: 74, content: `0${index + 1}`, fontSize: 50, color: colors[index], bold: true })
  637. )
  638. elements.push(
  639. textElement({
  640. left: left + 34,
  641. top: 410,
  642. width: 350,
  643. height: 120,
  644. content: point,
  645. fontSize: fitFont(point, 350, 120, 32, 25),
  646. color: palette.text,
  647. bold: true,
  648. lineHeight: 1.35
  649. })
  650. )
  651. })
  652. addClassroomCue(elements, page, palette)
  653. return elements
  654. }
  655. function listenTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  656. const elements = slideHeader(page, outline, palette, "听赏任务")
  657. const task = activityTask(page) || studentHeadline(page) || "安静聆听,找一找音乐中的声音"
  658. if (hasImage(page)) {
  659. elements.push(imageElement(page, { left: 84, top: 185, width: 1432, height: 520, radius: 8 }))
  660. elements.push(shapeElement({ left: 84, top: 510, width: 1432, height: 195, fill: palette.dark, opacity: 0.8, radius: 0 }))
  661. elements.push(
  662. textElement({
  663. left: 130,
  664. top: 550,
  665. width: 1340,
  666. height: 84,
  667. content: task,
  668. fontSize: fitFont(task, 1340, 84, 42, 30),
  669. color: "#FFFFFF",
  670. bold: true
  671. })
  672. )
  673. } else {
  674. elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 220, fill: palette.dark, radius: 8 }))
  675. elements.push(
  676. textElement({
  677. left: 150,
  678. top: 270,
  679. width: 1300,
  680. height: 92,
  681. content: task,
  682. fontSize: fitFont(task, 1300, 92, 44, 30),
  683. color: "#FFFFFF",
  684. bold: true
  685. })
  686. )
  687. elements.push(...studentPanel(page, palette, 170, 500, 1260))
  688. }
  689. addClassroomCue(elements, page, palette)
  690. return elements
  691. }
  692. function lyricTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  693. const elements = slideHeader(page, outline, palette, "学唱与歌词")
  694. const points = studentPoints(page, 4)
  695. elements.push(shapeElement({ left: 90, top: 205, width: 1420, height: 470, fill: palette.surface, radius: 8, shadow: softShadow() }))
  696. elements.push(shapeElement({ left: 90, top: 205, width: 26, height: 470, fill: palette.secondary, radius: 0 }))
  697. points.forEach((point, index) => {
  698. const top = 265 + index * 92
  699. elements.push(
  700. textElement({
  701. left: 166,
  702. top,
  703. width: 1250,
  704. height: 58,
  705. content: point,
  706. fontSize: fitFont(point, 1250, 58, 36, 27),
  707. color: index % 2 === 0 ? palette.text : palette.primary,
  708. bold: index % 2 === 1
  709. })
  710. )
  711. })
  712. addClassroomCue(elements, page, palette)
  713. return elements
  714. }
  715. function notationTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  716. const elements = slideHeader(page, outline, palette, "旋律与谱例")
  717. const headline = studentHeadline(page) || "看一看,唱一唱"
  718. elements.push(
  719. textElement({
  720. left: 92,
  721. top: 190,
  722. width: 1416,
  723. height: 72,
  724. content: headline,
  725. fontSize: fitFont(headline, 1416, 72, 40, 30),
  726. color: palette.primary,
  727. bold: true
  728. })
  729. )
  730. elements.push(...knowledgeList(studentPoints(page, 3), palette, 92, 310, 1416))
  731. addClassroomCue(elements, page, palette)
  732. return elements
  733. }
  734. function instrumentTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  735. const elements = slideHeader(page, outline, palette, "认识声音")
  736. const points = studentPoints(page, 3)
  737. if (hasImage(page)) {
  738. elements.push(imageElement(page, { left: 84, top: 190, width: 760, height: 540, radius: 8 }))
  739. elements.push(
  740. textElement({
  741. left: 920,
  742. top: 220,
  743. width: 580,
  744. height: 96,
  745. content: studentHeadline(page) || page.title,
  746. fontSize: fitFont(studentHeadline(page) || page.title, 580, 96, 42, 30),
  747. color: palette.primary,
  748. bold: true
  749. })
  750. )
  751. points.forEach((point, index) => {
  752. const top = 360 + index * 104
  753. elements.push(
  754. textElement({
  755. left: 920,
  756. top,
  757. width: 56,
  758. height: 48,
  759. content: `0${index + 1}`,
  760. fontSize: 30,
  761. color: index % 2 ? palette.secondary : palette.accent,
  762. bold: true
  763. })
  764. )
  765. elements.push(
  766. textElement({
  767. left: 992,
  768. top,
  769. width: 490,
  770. height: 64,
  771. content: point,
  772. fontSize: fitFont(point, 490, 64, 30, 23),
  773. color: palette.text,
  774. bold: true
  775. })
  776. )
  777. })
  778. } else {
  779. elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 190, fill: palette.primary, radius: 8 }))
  780. elements.push(
  781. textElement({
  782. left: 150,
  783. top: 260,
  784. width: 1300,
  785. height: 90,
  786. content: studentHeadline(page) || page.title,
  787. fontSize: fitFont(studentHeadline(page) || page.title, 1300, 90, 44, 30),
  788. color: "#FFFFFF",
  789. bold: true
  790. })
  791. )
  792. elements.push(...knowledgeList(points, palette, 170, 470, 1260))
  793. }
  794. addClassroomCue(elements, page, palette)
  795. return elements
  796. }
  797. function compareTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  798. const elements = slideHeader(page, outline, palette, "对比听赏")
  799. const points = studentPoints(page, 4)
  800. const leftPoints = points.filter((_, index) => index % 2 === 0)
  801. const rightPoints = points.filter((_, index) => index % 2 === 1)
  802. elements.push(shapeElement({ left: 92, top: 210, width: 690, height: 470, fill: palette.soft, radius: 8 }))
  803. elements.push(shapeElement({ left: 818, top: 210, width: 690, height: 470, fill: palette.surface, radius: 8, shadow: lightShadow() }))
  804. elements.push(textElement({ left: 140, top: 252, width: 570, height: 64, content: "听一听 A", fontSize: 36, color: palette.primary, bold: true }))
  805. elements.push(textElement({ left: 866, top: 252, width: 570, height: 64, content: "听一听 B", fontSize: 36, color: palette.secondary, bold: true }))
  806. ;[leftPoints, rightPoints].forEach((items, group) =>
  807. items.forEach((point, index) => {
  808. const left = group === 0 ? 140 : 866
  809. const top = 365 + index * 110
  810. elements.push(shapeElement({ left, top: top + 8, width: 22, height: 22, fill: group === 0 ? palette.primary : palette.secondary, radius: 8 }))
  811. elements.push(
  812. textElement({
  813. left: left + 48,
  814. top,
  815. width: 520,
  816. height: 70,
  817. content: point,
  818. fontSize: fitFont(point, 520, 70, 30, 23),
  819. color: palette.text,
  820. bold: true
  821. })
  822. )
  823. })
  824. )
  825. addClassroomCue(elements, page, palette)
  826. return elements
  827. }
  828. function rhythmTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  829. const elements = slideHeader(page, outline, palette, "节奏练习")
  830. const task = activityTask(page) || studentHeadline(page) || "拍一拍,感受节奏"
  831. elements.push(
  832. textElement({
  833. left: 92,
  834. top: 190,
  835. width: 1410,
  836. height: 72,
  837. content: task,
  838. fontSize: fitFont(task, 1410, 72, 40, 30),
  839. color: palette.text,
  840. bold: true
  841. })
  842. )
  843. const beats = activitySteps(page, 4)
  844. beats.forEach((beat, index) => {
  845. const left = 92 + index * 370
  846. elements.push(
  847. shapeElement({
  848. left,
  849. top: 330,
  850. width: 320,
  851. height: 260,
  852. fill: index % 2 === 0 ? palette.soft : palette.surface,
  853. radius: 8,
  854. shadow: lightShadow()
  855. })
  856. )
  857. elements.push(
  858. textElement({
  859. left: left + 28,
  860. top: 370,
  861. width: 70,
  862. height: 56,
  863. content: `${index + 1}`,
  864. fontSize: 42,
  865. color: index % 2 === 0 ? palette.primary : palette.secondary,
  866. bold: true
  867. })
  868. )
  869. elements.push(
  870. textElement({
  871. left: left + 28,
  872. top: 460,
  873. width: 260,
  874. height: 90,
  875. content: beat,
  876. fontSize: fitFont(beat, 260, 90, 29, 23),
  877. color: palette.text,
  878. bold: true,
  879. lineHeight: 1.3
  880. })
  881. )
  882. })
  883. addClassroomCue(elements, page, palette)
  884. return elements
  885. }
  886. function knowledgeTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
  887. const elements = slideHeader(page, outline, palette, "音乐知识")
  888. const points = studentPoints(page, 4)
  889. if (hasImage(page)) {
  890. const imageRight = index % 2 === 0
  891. elements.push(
  892. imageElement(
  893. page,
  894. imageRight ? { left: 900, top: 190, width: 610, height: 500, radius: 8 } : { left: 90, top: 190, width: 610, height: 500, radius: 8 }
  895. )
  896. )
  897. elements.push(...knowledgeList(points, palette, imageRight ? 90 : 770, 220, 720))
  898. } else {
  899. elements.push(...knowledgeList(points, palette, 90, 220, 1420))
  900. }
  901. addClassroomCue(elements, page, palette)
  902. return elements
  903. }
  904. function interactionTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  905. const elements = slideHeader(page, outline, palette, "互动挑战")
  906. const candidate = page.interaction?.question || activityTask(page) || studentHeadline(page) || "你发现了什么?"
  907. const question = sameVisibleText(candidate, page.title) ? "" : candidate
  908. if (question) {
  909. elements.push(shapeElement({ left: 90, top: 200, width: 1420, height: 190, fill: palette.primary, radius: 8 }))
  910. elements.push(
  911. textElement({
  912. left: 150,
  913. top: 245,
  914. width: 1300,
  915. height: 100,
  916. content: question,
  917. fontSize: fitFont(question, 1300, 100, 44, 30),
  918. color: "#FFFFFF",
  919. bold: true
  920. })
  921. )
  922. }
  923. const steps = activitySteps(page, 3)
  924. steps.forEach((step, index) => {
  925. const left = 90 + index * 490
  926. elements.push(
  927. textElement({
  928. left,
  929. top: 468,
  930. width: 88,
  931. height: 54,
  932. content: `0${index + 1}`,
  933. fontSize: 40,
  934. color: [palette.primary, palette.secondary, palette.accent][index],
  935. bold: true
  936. })
  937. )
  938. elements.push(
  939. textElement({
  940. left: left + 96,
  941. top: 466,
  942. width: 340,
  943. height: 100,
  944. content: step,
  945. fontSize: fitFont(step, 340, 100, 29, 23),
  946. color: palette.text,
  947. bold: true,
  948. lineHeight: 1.3
  949. })
  950. )
  951. if (index < steps.length - 1) elements.push(lineElement(left + 452, 492, left + 478, 492, palette.muted))
  952. })
  953. addClassroomCue(elements, page, palette)
  954. return elements
  955. }
  956. function activityTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  957. const elements = slideHeader(page, outline, palette, "课堂活动")
  958. const task = activityTask(page) || studentHeadline(page) || "一起完成课堂挑战"
  959. if (!sameVisibleText(task, page.title)) {
  960. elements.push(
  961. textElement({
  962. left: 92,
  963. top: 190,
  964. width: 1400,
  965. height: 90,
  966. content: task,
  967. fontSize: fitFont(task, 1400, 90, 42, 30),
  968. color: palette.primary,
  969. bold: true
  970. })
  971. )
  972. }
  973. elements.push(...storySequence(activitySteps(page, 4), palette, 92, 360, 1416))
  974. addClassroomCue(elements, page, palette)
  975. return elements
  976. }
  977. function performanceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  978. const elements = slideHeader(page, outline, palette, "合作表现")
  979. const task = activityTask(page) || studentHeadline(page) || "一起完成音乐表演"
  980. if (!sameVisibleText(task, page.title)) {
  981. elements.push(shapeElement({ left: 92, top: 200, width: 1416, height: 150, fill: palette.primary, radius: 8 }))
  982. elements.push(
  983. textElement({
  984. left: 150,
  985. top: 242,
  986. width: 1300,
  987. height: 70,
  988. content: task,
  989. fontSize: fitFont(task, 1300, 70, 42, 30),
  990. color: "#FFFFFF",
  991. bold: true
  992. })
  993. )
  994. }
  995. const steps = activitySteps(page, 4)
  996. steps.forEach((step, index) => {
  997. const left = 126 + index * 355
  998. elements.push(shapeElement({ left, top: 438, width: 76, height: 76, fill: index % 2 ? palette.secondary : palette.accent, radius: 38 }))
  999. elements.push(
  1000. textElement({ left: left + 23, top: 454, width: 34, height: 38, content: `${index + 1}`, fontSize: 28, color: "#FFFFFF", bold: true })
  1001. )
  1002. elements.push(
  1003. textElement({
  1004. left: left - 16,
  1005. top: 550,
  1006. width: 280,
  1007. height: 100,
  1008. content: step,
  1009. fontSize: fitFont(step, 280, 100, 29, 22),
  1010. color: palette.text,
  1011. bold: true
  1012. })
  1013. )
  1014. if (index < steps.length - 1) elements.push(lineElement(left + 96, 476, left + 325, 476, palette.muted, 3))
  1015. })
  1016. addClassroomCue(elements, page, palette)
  1017. return elements
  1018. }
  1019. function practiceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  1020. const elements = slideHeader(page, outline, palette, "课堂检测")
  1021. const candidate = page.interaction?.question || studentHeadline(page) || activityTask(page) || "你能完成今天的音乐挑战吗?"
  1022. const question = sameVisibleText(candidate, page.title) ? "" : candidate
  1023. if (question)
  1024. elements.push(
  1025. textElement({
  1026. left: 92,
  1027. top: 200,
  1028. width: 1416,
  1029. height: 82,
  1030. content: question,
  1031. fontSize: fitFont(question, 1416, 82, 42, 30),
  1032. color: palette.text,
  1033. bold: true
  1034. })
  1035. )
  1036. const options = validInteractionOptions(page)
  1037. const items = options.length >= 2 ? options : studentPoints(page, 3)
  1038. items.forEach((point, index) => {
  1039. const top = 330 + index * 94
  1040. elements.push(shapeElement({ left: 110, top, width: 70, height: 62, fill: index % 2 ? palette.secondary : palette.primary, radius: 8 }))
  1041. elements.push(
  1042. textElement({
  1043. left: 132,
  1044. top: top + 14,
  1045. width: 32,
  1046. height: 34,
  1047. content: String.fromCharCode(65 + index),
  1048. fontSize: 26,
  1049. color: "#FFFFFF",
  1050. bold: true
  1051. })
  1052. )
  1053. elements.push(
  1054. textElement({
  1055. left: 220,
  1056. top: top + 4,
  1057. width: 1220,
  1058. height: 58,
  1059. content: point,
  1060. fontSize: fitFont(point, 1220, 58, 31, 24),
  1061. color: palette.text,
  1062. bold: true
  1063. })
  1064. )
  1065. elements.push(lineElement(220, top + 72, 1450, top + 72, palette.soft, 2))
  1066. })
  1067. addClassroomCue(elements, page, palette)
  1068. return elements
  1069. }
  1070. function summaryTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  1071. const elements = slideHeader(page, outline, palette, "课堂小结")
  1072. studentPoints(page, 4).forEach((point, index) => {
  1073. const top = 220 + index * 112
  1074. const color = index % 2 === 0 ? palette.secondary : palette.primary
  1075. elements.push(shapeElement({ left: 100, top, width: 62, height: 62, fill: color, radius: 8 }))
  1076. elements.push(textElement({ left: 118, top: top + 12, width: 30, height: 30, content: "✓", fontSize: 28, color: "#FFFFFF", bold: true }))
  1077. elements.push(
  1078. textElement({
  1079. left: 210,
  1080. top: top + 4,
  1081. width: 1240,
  1082. height: 64,
  1083. content: point,
  1084. fontSize: fitFont(point, 1240, 64, 34, 26),
  1085. color: palette.text,
  1086. bold: true
  1087. })
  1088. )
  1089. })
  1090. return elements
  1091. }
  1092. function homeworkTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  1093. const elements = slideHeader(page, outline, palette, "课后延伸")
  1094. elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 440, fill: palette.surface, radius: 8, shadow: softShadow() }))
  1095. activitySteps(page, 4).forEach((step, index) => {
  1096. const top = 270 + index * 86
  1097. elements.push(textElement({ left: 150, top, width: 70, height: 48, content: `${index + 1}.`, fontSize: 34, color: palette.accent, bold: true }))
  1098. elements.push(
  1099. textElement({
  1100. left: 230,
  1101. top,
  1102. width: 1180,
  1103. height: 58,
  1104. content: step,
  1105. fontSize: fitFont(step, 1180, 58, 31, 24),
  1106. color: palette.text,
  1107. bold: true
  1108. })
  1109. )
  1110. })
  1111. return elements
  1112. }
  1113. function contentTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
  1114. return knowledgeTemplate(page, outline, palette, index)
  1115. }
  1116. function slideHeader(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, eyebrow: string): PPTElement[] {
  1117. const title = displayPageTitle(page, outline)
  1118. return [
  1119. textElement({ left: 92, top: 64, width: 300, height: 34, content: eyebrow, fontSize: 20, color: palette.secondary, bold: true }),
  1120. textElement({
  1121. left: 92,
  1122. top: 102,
  1123. width: 1320,
  1124. height: 66,
  1125. content: title,
  1126. fontSize: fitFont(title, 1320, 66, 42, 32),
  1127. color: palette.text,
  1128. bold: true
  1129. }),
  1130. shapeElement({ left: 1450, top: 72, width: 58, height: 8, fill: palette.accent, radius: 4 })
  1131. ]
  1132. }
  1133. function studentPanel(page: AiCoursewarePage, palette: Palette, left: number, top: number, width: number): PPTElement[] {
  1134. const elements: PPTElement[] = []
  1135. const headline = studentHeadline(page)
  1136. if (headline)
  1137. elements.push(
  1138. textElement({
  1139. left,
  1140. top,
  1141. width,
  1142. height: 90,
  1143. content: headline,
  1144. fontSize: fitFont(headline, width, 90, 36, 27),
  1145. color: palette.primary,
  1146. bold: true
  1147. })
  1148. )
  1149. studentPoints(page, 3).forEach((point, index) => {
  1150. const itemTop = top + (headline ? 120 : 0) + index * 74
  1151. elements.push(shapeElement({ left, top: itemTop + 8, width: 18, height: 18, fill: index % 2 ? palette.secondary : palette.accent, radius: 4 }))
  1152. elements.push(
  1153. textElement({
  1154. left: left + 38,
  1155. top: itemTop,
  1156. width: width - 38,
  1157. height: 54,
  1158. content: point,
  1159. fontSize: fitFont(point, width - 38, 54, 27, 21),
  1160. color: palette.text,
  1161. lineHeight: 1.3
  1162. })
  1163. )
  1164. })
  1165. return elements
  1166. }
  1167. function storySequence(items: string[], palette: Palette, left: number, top: number, width: number): PPTElement[] {
  1168. const elements: PPTElement[] = []
  1169. const count = Math.max(1, Math.min(items.length, 4))
  1170. const gap = 28
  1171. const cardWidth = (width - gap * (count - 1)) / count
  1172. items.slice(0, 4).forEach((item, index) => {
  1173. const cardLeft = left + index * (cardWidth + gap)
  1174. elements.push(
  1175. shapeElement({
  1176. left: cardLeft,
  1177. top,
  1178. width: cardWidth,
  1179. height: 190,
  1180. fill: index % 2 === 0 ? palette.soft : palette.surface,
  1181. radius: 8,
  1182. shadow: lightShadow()
  1183. })
  1184. )
  1185. elements.push(
  1186. textElement({
  1187. left: cardLeft + 24,
  1188. top: top + 24,
  1189. width: 64,
  1190. height: 38,
  1191. content: `0${index + 1}`,
  1192. fontSize: 26,
  1193. color: index % 2 === 0 ? palette.primary : palette.secondary,
  1194. bold: true
  1195. })
  1196. )
  1197. elements.push(
  1198. textElement({
  1199. left: cardLeft + 24,
  1200. top: top + 82,
  1201. width: cardWidth - 48,
  1202. height: 78,
  1203. content: item,
  1204. fontSize: fitFont(item, cardWidth - 48, 78, 27, 21),
  1205. color: palette.text,
  1206. bold: true,
  1207. lineHeight: 1.3
  1208. })
  1209. )
  1210. })
  1211. return elements
  1212. }
  1213. function knowledgeList(items: string[], palette: Palette, left: number, top: number, width: number): PPTElement[] {
  1214. const elements: PPTElement[] = []
  1215. items.forEach((item, index) => {
  1216. const itemTop = top + index * 108
  1217. elements.push(
  1218. shapeElement({
  1219. left,
  1220. top: itemTop,
  1221. width,
  1222. height: 82,
  1223. fill: index % 2 === 0 ? palette.surface : palette.soft,
  1224. radius: 8,
  1225. shadow: lightShadow()
  1226. })
  1227. )
  1228. elements.push(shapeElement({ left, top: itemTop, width: 14, height: 82, fill: index % 2 === 0 ? palette.primary : palette.secondary, radius: 0 }))
  1229. elements.push(
  1230. textElement({
  1231. left: left + 42,
  1232. top: itemTop + 18,
  1233. width: width - 74,
  1234. height: 48,
  1235. content: item,
  1236. fontSize: fitFont(item, width - 74, 48, 29, 22),
  1237. color: palette.text,
  1238. bold: true
  1239. })
  1240. )
  1241. })
  1242. return elements
  1243. }
  1244. function addClassroomCue(elements: PPTElement[], page: AiCoursewarePage, palette: Palette) {
  1245. // 保留调用点以兼容既有模板;课堂任务由页面主体呈现,不再生成重复底栏。
  1246. void elements
  1247. void page
  1248. void palette
  1249. }
  1250. function dedicatedResourceElement(page: AiCoursewarePage): PPTElement | undefined {
  1251. const binding = (page.resourceBindings || []).find(item => item.verified !== false && item.dedicatedPage)
  1252. if (!binding || binding.resourceType !== "MUSIC") return undefined
  1253. const base = { id: nanoid(10), left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT, rotate: 0 }
  1254. return { ...base, type: "elf", subtype: "elf-sing-play", sid: binding.resourceId, title: binding.name || page.title } as PPTCloudCoachElement
  1255. }
  1256. function inlineResourceElements(page: AiCoursewarePage): PPTElement[] {
  1257. const binding = (page.resourceBindings || []).find(item => item.verified !== false && item.resourceType === "SONG" && item.contentUrl)
  1258. if (!binding) return []
  1259. return [
  1260. {
  1261. id: nanoid(10),
  1262. type: "elf",
  1263. subtype: "elf-enjoy",
  1264. left: 110,
  1265. top: 700,
  1266. width: 1380,
  1267. height: 100,
  1268. rotate: 0,
  1269. sid: binding.resourceId,
  1270. title: binding.name || page.title,
  1271. src: binding.contentUrl || "",
  1272. enjoyList: [{ id: binding.resourceId, title: binding.name || page.title, src: binding.contentUrl || "" }]
  1273. } as PPTEnjoyElement
  1274. ]
  1275. }
  1276. function videoBinding(page: AiCoursewarePage) {
  1277. return (page.resourceBindings || []).find(item => item.verified !== false && item.resourceType === "VIDEO" && item.contentUrl)
  1278. }
  1279. function videoResourceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
  1280. const binding = videoBinding(page)!
  1281. const elements = slideHeader(page, outline, palette, page.teachingStage || "观看与发现")
  1282. elements.push({
  1283. id: nanoid(10),
  1284. type: "elf",
  1285. subtype: "elf-video",
  1286. left: 190,
  1287. top: 190,
  1288. width: 1220,
  1289. height: 570,
  1290. rotate: 0,
  1291. src: binding.contentUrl || "",
  1292. poster: binding.coverUrl,
  1293. autoplay: false
  1294. } as PPTVideoElement)
  1295. addClassroomCue(elements, page, palette)
  1296. return elements
  1297. }
  1298. function normalizeOutline(outline: AiCoursewareOutline): AiCoursewareOutline {
  1299. const chapter = cleanText(outline.chapterName || outline.title || "音乐课件")
  1300. return {
  1301. ...outline,
  1302. title: GENERIC_PAGE_TITLES.has(cleanText(outline.title).toLowerCase()) ? chapter : cleanText(outline.title || chapter),
  1303. pages: (outline.pages || []).map((page, index) => ({
  1304. ...page,
  1305. pageNo: index + 1,
  1306. title: index === 0 && GENERIC_PAGE_TITLES.has(cleanText(page.title).toLowerCase()) ? chapter : cleanText(page.title || chapter),
  1307. bullets: (page.bullets || []).map(cleanText).filter(Boolean),
  1308. imageKeywords: (page.imageKeywords || []).map(cleanText).filter(Boolean)
  1309. }))
  1310. }
  1311. }
  1312. function pageRole(page: AiCoursewarePage, index: number): PageRole {
  1313. if (index === 0) return "cover"
  1314. const kind = `${page.type || ""} ${page.visualPlan?.layout || ""} ${page.visual?.layout || ""}`.toLowerCase()
  1315. if (kind.includes("objective") || kind.includes("goal")) return "goal"
  1316. if (kind.includes("practice_check") || kind.includes("assessment")) return "practice"
  1317. if (kind.includes("compare")) return "compare"
  1318. if (kind.includes("instrument")) return "instrument"
  1319. if (kind.includes("melody") || kind.includes("score") || kind.includes("notation")) return "notation"
  1320. if (kind.includes("performance") || kind.includes("expressive") || kind.includes("creative")) return "performance"
  1321. if (kind.includes("listen")) return "listen"
  1322. if (kind.includes("lyric") || kind.includes("phrase_sing") || kind.includes("sing_focus")) return "lyric"
  1323. if (kind.includes("rhythm") || kind.includes("skill")) return "rhythm"
  1324. if (kind.includes("knowledge")) return "knowledge"
  1325. if (kind.includes("interaction")) return "interaction"
  1326. if (kind.includes("activity")) return "activity"
  1327. if (kind.includes("summary")) return "summary"
  1328. if (kind.includes("homework") || kind.includes("extension")) return "homework"
  1329. if (kind.includes("intro") || kind.includes("scene")) return "intro"
  1330. if (kind.includes("context") || kind.includes("background") || kind.includes("story")) return "story"
  1331. return "content"
  1332. }
  1333. function displayPageTitle(page: AiCoursewarePage, outline: AiCoursewareOutline) {
  1334. const title = cleanText(page.title)
  1335. return !title || GENERIC_PAGE_TITLES.has(title.toLowerCase()) ? chapterTitle(outline, page) : title
  1336. }
  1337. function chapterTitle(outline: AiCoursewareOutline, page: AiCoursewarePage) {
  1338. return cleanText(outline.chapterName || outline.title || page.title || "音乐课件")
  1339. }
  1340. function conciseTextbook(value?: string) {
  1341. return cleanText(value).replace(/^.*?·/, "")
  1342. }
  1343. function hasImage(page: AiCoursewarePage) {
  1344. return Boolean(page.image?.url)
  1345. }
  1346. function studentHeadline(page: AiCoursewarePage) {
  1347. const headline = cleanText(page.studentContent?.headline || page.subtitle || "")
  1348. return sameVisibleText(headline, page.title) ? "" : headline
  1349. }
  1350. function studentPoints(page: AiCoursewarePage, limit: number) {
  1351. const structured = (page.studentContent?.points || []).map(cleanText).filter(Boolean)
  1352. const legacy = (page.bullets || []).map(cleanText).filter(Boolean)
  1353. const points = structured.length ? structured : legacy
  1354. const excluded = [page.title, studentHeadline(page)].map(normalizeVisibleText).filter(Boolean)
  1355. const unique = uniqueVisibleTexts(points).filter(item => !excluded.includes(normalizeVisibleText(item)))
  1356. return (unique.length ? unique : [studentHeadline(page) || cleanText(page.title) || "课堂内容"]).slice(0, limit)
  1357. }
  1358. function activityTask(page: AiCoursewarePage) {
  1359. // activity 属于教师组织信息,不能回退到学生投屏画面。
  1360. return cleanText(page.classroomAction?.studentTask || studentHeadline(page) || "")
  1361. }
  1362. function activitySteps(page: AiCoursewarePage, limit: number) {
  1363. const task = activityTask(page)
  1364. const points = studentPoints(page, limit).filter(item => !sameVisibleText(item, task))
  1365. return uniqueVisibleTexts(points).slice(0, limit)
  1366. }
  1367. function validInteractionOptions(page: AiCoursewarePage) {
  1368. return uniqueVisibleTexts((page.interaction?.options || []).map(cleanText).filter(Boolean))
  1369. .filter(item => !/^(?:准备好了吗|开始吧|请回答|想一想|说一说|听一听)[??!!。]*$/.test(item))
  1370. .slice(0, 4)
  1371. }
  1372. function slideRemark(page: AiCoursewarePage) {
  1373. const lessonMeta = [page.teachingStage, page.durationMinutes ? `建议用时:${page.durationMinutes}分钟` : ""].filter(Boolean).join(" · ")
  1374. const evidence = page.learningEvidence ? `学习表现:${page.learningEvidence}` : ""
  1375. const activity = [page.activity?.task, ...(page.activity?.steps || [])].map(cleanText).filter(Boolean).join("\n")
  1376. return [lessonMeta, page.teachingPurpose, activity, page.speakerNotes, page.teacherTips, page.classroomAction?.teacherInstruction, evidence]
  1377. .map(cleanText)
  1378. .filter(Boolean)
  1379. .filter((item, index, all) => all.indexOf(item) === index)
  1380. .join("\n")
  1381. }
  1382. function buildPalette(outline: AiCoursewareOutline, variant: number, templateId?: AiCoursewareTemplateId): Palette {
  1383. const fallbackTemplate = outline.style === "cartoon" ? "playful-handdrawn" : "fresh-education"
  1384. return { ...getAiCoursewarePalette(templateId || fallbackTemplate, variant) }
  1385. }
  1386. function backgroundConfig(_role: PageRole, palette: Palette, options: AiPptOptions): SlideBackground {
  1387. if (options.backgroundImage) return { type: "image", image: options.backgroundImage, imageSize: "cover" }
  1388. return { type: "solid", color: palette.background }
  1389. }
  1390. function fitFont(content: string, width: number, height: number, preferred: number, minimum: number) {
  1391. const text = cleanText(content)
  1392. if (!text) return preferred
  1393. let size = preferred
  1394. while (size > minimum) {
  1395. const charsPerLine = Math.max(1, Math.floor(width / size))
  1396. const lines = Math.ceil(text.length / charsPerLine)
  1397. if (lines * size * 1.35 <= height) break
  1398. size -= 2
  1399. }
  1400. return size
  1401. }
  1402. function textElement(params: {
  1403. left: number
  1404. top: number
  1405. width: number
  1406. height: number
  1407. content: string
  1408. fontSize: number
  1409. color: string
  1410. bold?: boolean
  1411. lineHeight?: number
  1412. }): PPTTextElement {
  1413. const lineHeight = params.lineHeight || 1.25
  1414. const minimum = Math.min(params.fontSize, params.fontSize >= 30 ? 22 : 18)
  1415. const resolvedFontSize = fitFont(params.content, params.width - 20, params.height, params.fontSize, minimum)
  1416. return {
  1417. id: nanoid(10),
  1418. type: "text",
  1419. left: params.left,
  1420. top: params.top,
  1421. width: params.width,
  1422. height: params.height,
  1423. rotate: 0,
  1424. // 永远保留完整文本。若最小字号仍放不下,由质量门禁阻止应用,而不是静默截断。
  1425. content: `<div style="box-sizing:border-box;width:calc(100% - 20px);font-family:${FONT_NAME};font-size:${resolvedFontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(cleanText(params.content))}</div>`,
  1426. defaultFontName: FONT_NAME,
  1427. defaultColor: params.color,
  1428. lineHeight,
  1429. paragraphSpace: 0,
  1430. wordSpace: 0,
  1431. opacity: 1
  1432. }
  1433. }
  1434. function imageElement(page: AiCoursewarePage, rect: ImageRect): PPTImageElement {
  1435. const width = page.image?.width || 0
  1436. const height = page.image?.height || 0
  1437. return {
  1438. id: nanoid(10),
  1439. type: "image",
  1440. left: rect.left,
  1441. top: rect.top,
  1442. width: rect.width,
  1443. height: rect.height,
  1444. rotate: 0,
  1445. src: page.image?.url || "",
  1446. fixedRatio: false,
  1447. radius: rect.radius || 0,
  1448. shadow: rect.radius ? softShadow() : undefined,
  1449. clip: width > 0 && height > 0 ? { shape: "rect", range: coverRange(width, height, rect.width, rect.height) } : undefined
  1450. }
  1451. }
  1452. function coverRange(sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number): [[number, number], [number, number]] {
  1453. const sourceRatio = sourceWidth / sourceHeight
  1454. const targetRatio = targetWidth / targetHeight
  1455. if (sourceRatio > targetRatio) {
  1456. const visible = (targetRatio / sourceRatio) * 100
  1457. const start = (100 - visible) / 2
  1458. return [
  1459. [start, 0],
  1460. [100 - start, 100]
  1461. ]
  1462. }
  1463. const visible = (sourceRatio / targetRatio) * 100
  1464. const start = (100 - visible) / 2
  1465. return [
  1466. [0, start],
  1467. [100, 100 - start]
  1468. ]
  1469. }
  1470. function shapeElement(params: {
  1471. left: number
  1472. top: number
  1473. width: number
  1474. height: number
  1475. fill: string
  1476. opacity?: number
  1477. radius?: number
  1478. shadow?: PPTElementShadow
  1479. gradient?: Gradient
  1480. text?: PPTShapeElement["text"]
  1481. }): PPTShapeElement {
  1482. return {
  1483. id: nanoid(10),
  1484. type: "shape",
  1485. left: params.left,
  1486. top: params.top,
  1487. width: params.width,
  1488. height: params.height,
  1489. rotate: 0,
  1490. viewBox: [200, 200],
  1491. path: roundedRectPath(params.radius || 0),
  1492. fixedRatio: false,
  1493. fill: params.fill,
  1494. opacity: params.opacity ?? 1,
  1495. shadow: params.shadow,
  1496. gradient: params.gradient,
  1497. text: params.text
  1498. }
  1499. }
  1500. function lineElement(x1: number, y1: number, x2: number, y2: number, color: string, width = 2): PPTLineElement {
  1501. const left = Math.min(x1, x2)
  1502. const top = Math.min(y1, y2)
  1503. const lineWidth = Math.abs(x2 - x1)
  1504. const lineHeight = Math.abs(y2 - y1)
  1505. return {
  1506. id: nanoid(10),
  1507. type: "line",
  1508. left,
  1509. top,
  1510. width,
  1511. start: [x1 === left ? 0 : lineWidth, y1 === top ? 0 : lineHeight],
  1512. end: [x2 === left ? 0 : lineWidth, y2 === top ? 0 : lineHeight],
  1513. style: "solid",
  1514. color,
  1515. points: ["", ""]
  1516. }
  1517. }
  1518. function keepInCanvas(elements: PPTElement[]) {
  1519. return elements.map(element => {
  1520. const left = Math.max(0, Math.min(element.left, SLIDE_WIDTH - 1))
  1521. const top = Math.max(0, Math.min(element.top, SLIDE_HEIGHT - 1))
  1522. if (element.type === "line") return { ...element, left, top, width: Math.max(1, Math.min(element.width, 20)) }
  1523. return {
  1524. ...element,
  1525. left,
  1526. top,
  1527. width: Math.max(1, Math.min(element.width, SLIDE_WIDTH - left)),
  1528. height: Math.max(1, Math.min(element.height, SLIDE_HEIGHT - top))
  1529. }
  1530. }) as PPTElement[]
  1531. }
  1532. function scaleElementsToViewport(elements: PPTElement[]): PPTElement[] {
  1533. return elements.map(element => {
  1534. if (element.type === "line") {
  1535. return {
  1536. ...element,
  1537. left: element.left * VIEWPORT_SCALE,
  1538. top: element.top * VIEWPORT_SCALE,
  1539. width: element.width * VIEWPORT_SCALE,
  1540. start: [element.start[0] * VIEWPORT_SCALE, element.start[1] * VIEWPORT_SCALE],
  1541. end: [element.end[0] * VIEWPORT_SCALE, element.end[1] * VIEWPORT_SCALE]
  1542. }
  1543. }
  1544. const scaled = {
  1545. ...element,
  1546. left: element.left * VIEWPORT_SCALE,
  1547. top: element.top * VIEWPORT_SCALE,
  1548. width: element.width * VIEWPORT_SCALE,
  1549. height: element.height * VIEWPORT_SCALE
  1550. } as PPTElement
  1551. if (scaled.type === "text") {
  1552. scaled.content = scaleInlineFontSizes(scaled.content)
  1553. }
  1554. if (scaled.type === "shape" && scaled.text) {
  1555. scaled.text = { ...scaled.text, content: scaleInlineFontSizes(scaled.text.content) }
  1556. }
  1557. if ("radius" in scaled && typeof scaled.radius === "number") {
  1558. scaled.radius *= VIEWPORT_SCALE
  1559. }
  1560. if ("shadow" in scaled && scaled.shadow) {
  1561. scaled.shadow = {
  1562. ...scaled.shadow,
  1563. h: scaled.shadow.h * VIEWPORT_SCALE,
  1564. v: scaled.shadow.v * VIEWPORT_SCALE,
  1565. blur: scaled.shadow.blur * VIEWPORT_SCALE
  1566. }
  1567. }
  1568. return scaled
  1569. })
  1570. }
  1571. function scaleInlineFontSizes(content: string) {
  1572. return content.replace(/font-size:\s*([\d.]+)px/g, (_match, size) => `font-size:${Number(size) * VIEWPORT_SCALE}px`)
  1573. }
  1574. function runPreflight(slides: Slide[]) {
  1575. slides.forEach((slide, index) => {
  1576. const invalid = slide.elements.filter(
  1577. element =>
  1578. element.left < 0 ||
  1579. element.top < 0 ||
  1580. element.left + element.width > VIEWPORT_WIDTH + 1 ||
  1581. (element.type !== "line" && element.top + element.height > VIEWPORT_HEIGHT + 1)
  1582. )
  1583. const emptyText = slide.elements.filter(element => element.type === "text" && !stripHtml(element.content))
  1584. if (invalid.length || emptyText.length)
  1585. console.warn("AI PPT preflight issue", { pageNo: index + 1, invalid: invalid.length, emptyText: emptyText.length })
  1586. })
  1587. }
  1588. function roundedRectPath(radius: number) {
  1589. const r = Math.max(0, Math.min(radius, 100))
  1590. 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`
  1591. }
  1592. function softShadow(): PPTElementShadow {
  1593. return { h: 0, v: 8, blur: 22, color: "rgba(23, 34, 46, 0.14)" }
  1594. }
  1595. function lightShadow(): PPTElementShadow {
  1596. return { h: 0, v: 4, blur: 14, color: "rgba(23, 34, 46, 0.1)" }
  1597. }
  1598. function cleanText(value?: string) {
  1599. return String(value || "")
  1600. .replace(/\s+/g, " ")
  1601. .trim()
  1602. }
  1603. function escapeHtml(value: string) {
  1604. return cleanText(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
  1605. }
  1606. function stripHtml(value: string) {
  1607. return value.replace(/<[^>]+>/g, "").trim()
  1608. }
  1609. function normalizeVisibleText(value?: string) {
  1610. return cleanText(value)
  1611. .replace(/[,。!?、;:,.!?;:\s]+/g, "")
  1612. .toLowerCase()
  1613. }
  1614. function sameVisibleText(left?: string, right?: string) {
  1615. const normalizedLeft = normalizeVisibleText(left)
  1616. return Boolean(normalizedLeft) && normalizedLeft === normalizeVisibleText(right)
  1617. }
  1618. function uniqueVisibleTexts(values: string[]) {
  1619. const seen = new Set<string>()
  1620. return values.filter(value => {
  1621. const key = normalizeVisibleText(value)
  1622. if (!key || seen.has(key)) return false
  1623. seen.add(key)
  1624. return true
  1625. })
  1626. }