aiPptTemplateEngine.ts 61 KB

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