aiPptTemplateEngine.ts 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201
  1. import { nanoid } from "nanoid"
  2. import type {
  3. Gradient,
  4. PPTElement,
  5. PPTElementShadow,
  6. PPTImageElement,
  7. PPTVideoElement,
  8. PPTAudioElement,
  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 { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts, modelPageContent, rhythmRows } from "@/utils/aiPptContent"
  20. import { compilePageDesign, hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
  21. import {
  22. AI_COURSEWARE_TEMPLATES,
  23. getAiCoursewarePalette,
  24. getAiCoursewareTemplate,
  25. DEFAULT_AI_COURSEWARE_TEMPLATE_ID,
  26. type AiCoursewareTemplateId,
  27. type AiCoursewareThemePalette
  28. } from "@/config/aiCoursewareTemplates"
  29. import {
  30. getAiCoursewareTemplateMaster,
  31. getTemplateMasterPage,
  32. selectTemplateMasterPageVariant,
  33. resolveTemplatePageLayout,
  34. validateTemplateMasterRegistry,
  35. type TemplateMaster,
  36. type TemplateMasterElement,
  37. type TemplateMasterPage,
  38. type TemplatePageRole,
  39. type TemplateSlot
  40. } from "@/config/aiCoursewareTemplateMasters"
  41. interface AiPptOptions {
  42. backgroundImage?: string
  43. enableAnimations?: boolean
  44. paletteVariant?: number
  45. templateId?: AiCoursewareTemplateId
  46. }
  47. type PageRole = TemplatePageRole
  48. type Palette = AiCoursewareThemePalette
  49. interface ImageRect {
  50. left: number
  51. top: number
  52. width: number
  53. height: number
  54. radius?: number
  55. }
  56. interface PageRenderIntent {
  57. role: PageRole
  58. density: "low" | "medium" | "high"
  59. visualKind: "image" | "notation" | "rhythm" | "compare" | "activity" | "text"
  60. showMedia: boolean
  61. }
  62. const SLIDE_WIDTH = 1600
  63. const SLIDE_HEIGHT = 900
  64. const VIEWPORT_WIDTH = 1920
  65. const VIEWPORT_HEIGHT = 1080
  66. const VIEWPORT_SCALE = VIEWPORT_WIDTH / SLIDE_WIDTH
  67. const FONT_NAME = "Microsoft Yahei"
  68. const GENERIC_PAGE_TITLES = new Set(["封面", "课程导入", "故事导入", "导入", "首页", "cover"])
  69. export function buildAiPptSlides(
  70. outline: AiCoursewareOutline,
  71. options: AiPptOptions = {}
  72. ): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
  73. const normalized = normalizeOutline(outline)
  74. validateTemplateMasterRegistry(AI_COURSEWARE_TEMPLATES.map(template => template.id))
  75. const requestedTemplateId = options.templateId || normalized.templateId
  76. const templateId = requestedTemplateId
  77. ? getAiCoursewareTemplate(requestedTemplateId).id
  78. : DEFAULT_AI_COURSEWARE_TEMPLATE_ID
  79. const palette = buildPalette(normalized, options.paletteVariant || 0, templateId)
  80. const resolvedOptions = { ...options, templateId }
  81. const layoutOccurrences = new Map<string, number>()
  82. const slides = normalized.pages.map((page, index) => buildSlide(page, normalized, palette, resolvedOptions, index, layoutOccurrences))
  83. runPreflight(slides)
  84. return {
  85. title: normalized.title || normalized.chapterName || "音乐课件",
  86. theme: {
  87. themeColor: palette.primary,
  88. backgroundColor: palette.background,
  89. fontColor: palette.text,
  90. fontName: FONT_NAME,
  91. fontSize: "36px"
  92. },
  93. slides
  94. }
  95. }
  96. function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions, index: number, layoutOccurrences: Map<string, number>): Slide {
  97. const role = pageRole(page, index)
  98. const intent = pageRenderIntent(page, role)
  99. const master = getAiCoursewareTemplateMaster(options.templateId!)
  100. const baseMasterPage = getTemplateMasterPage(master, role, resolveAiPptLayout(page, index))
  101. const occurrence = layoutOccurrences.get(baseMasterPage.id) || 0
  102. layoutOccurrences.set(baseMasterPage.id, occurrence + 1)
  103. const adapted = adaptMasterPage(selectTemplateMasterPageVariant(baseMasterPage, occurrence), intent)
  104. const nativeVisual = isScorePage(page) || (role === "rhythm" && adapted.layout === "diagram")
  105. const designed = hasModelPageDesign(page) && !isScorePage(page)
  106. const headline = designed ? modelPageContent(page).heading : cleanText(page.interaction?.question) || activityTask(page) || studentHeadline(page)
  107. const masterPage = designed
  108. ? compilePageDesign(page, index, master, intent.showMedia, (externalVisual(page) && hasImage(page)) || Boolean(videoBinding(page)), Boolean(headline))
  109. : fitVisualSlots(adapted, hasImage(page) || Boolean(videoBinding(page)), nativeVisual)
  110. const contentElements = designed
  111. ? fillDesignedPage(master, masterPage, page, outline, palette, index, headline, intent)
  112. : fillTemplatePage(master, masterPage, page, outline, palette, role, intent)
  113. assertContentContract(master, masterPage, contentElements)
  114. const elements = [
  115. ...renderTemplateMasterElements(masterPage.base),
  116. ...contentElements,
  117. ...renderTemplateMasterElements(masterPage.chrome)
  118. ]
  119. return {
  120. id: nanoid(10),
  121. elements: scaleElementsToViewport(keepInCanvas(elements)),
  122. background: backgroundConfig(masterPage, options),
  123. remark: slideRemark(page),
  124. animations: options.enableAnimations ? entranceAnimations(contentElements) : undefined,
  125. turningMode: options.enableAnimations ? "fade" : "no"
  126. }
  127. }
  128. /** 传给图片生成器的真实模板槽位比例;媒体行存在时使用独立尺寸。 */
  129. export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): Record<string, number> {
  130. const master = getAiCoursewareTemplateMaster(templateId)
  131. const layouts: Record<string, string> = { cover: "cover_scene", hero: "hero_focus", media: "media_focus", split: "split_content", text: "text_focus", diagram: "diagram_focus", compare: "compare", activity: "activity", summary: "summary", objectives: "objectives" }
  132. const ratios: Record<string, number> = {}
  133. for (const page of master.pages) {
  134. for (const showMedia of [true, false]) {
  135. const adapted = fitVisualSlots(adaptMasterPage(page, { role: page.roles[0], density: "medium", visualKind: "image", showMedia }), true, false)
  136. const slot = adapted.slots.find(slot => slot.kind === "image" && slot.enabled !== false)
  137. if (slot) ratios[layouts[page.layout] + (showMedia ? ":media" : "")] = slot.width / slot.height
  138. }
  139. }
  140. ratios.model_design_v1 = 1 // 服务端依据本版编译器的安全区域和模型比例计算图片尺寸。
  141. ratios.model_design_v2 = 1
  142. return ratios
  143. }
  144. function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number, headline: string, intent: PageRenderIntent): PPTElement[] {
  145. const elements: PPTElement[] = []
  146. const content = modelPageContent(page)
  147. const points = content.points
  148. addSlotText(elements, slotOf(layout, 'title'), page.title, master.titleColor, true)
  149. if (index === 0) addSlotText(elements, slotOf(layout, 'meta'), [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(' · '), master.mutedColor)
  150. addSlotText(elements, slotOf(layout, 'headline'), headline, master.textColor, true)
  151. addSlotText(elements, layout.slots.find(slot => slot.id === 'instruction'), content.task, master.mutedColor)
  152. addVisualOrResource(elements, slotOf(layout, 'image'), page, master, palette)
  153. const composition = page.visualPlan!.composition
  154. const presentation = page.visualPlan!.textPresentation || 'plain'
  155. const paintText = (slot: TemplateSlot | undefined, values: string[]) => {
  156. if (slot) slot = compactTextSlot(slot, values, presentation)
  157. if (presentation === 'steps') addStepGrid(elements, slot, values, master, palette)
  158. else if (presentation === 'grid') addCardGrid(elements, slot, values, master, palette)
  159. else addSlotList(elements, slot, values, master, palette, presentation === 'cards')
  160. }
  161. if (composition === 'comparison' && content.groups.length === 2) {
  162. content.groups.forEach((group, i) => {
  163. const slot = slotOf(layout, i === 0 ? 'left' : 'right')!
  164. if (group.label) addSlotText(elements, { ...slot, height: 52 }, group.label, master.titleColor, true)
  165. paintText(group.label ? { ...slot, top: slot.top + 66, height: slot.height - 66 } : slot, group.points)
  166. })
  167. paintText(slotOf(layout, 'body'), points)
  168. } else {
  169. const body = slotOf(layout, 'body')!
  170. const rows = page.visualPlan?.visualMode === 'native_diagram' ? rhythmRows(points) : []
  171. const pattern = page.visualPlan?.visualMode === 'native_diagram' && pageRole(page, index) === 'rhythm'
  172. ? [headline, ...points].join(' ').match(/[短长](?:\s*[-—·、]\s*[短长]){1,7}/)?.[0].match(/[短长]/g) : undefined
  173. if (!content.steps.length && rows.length && rows.length === points.length && body.height >= rows.length * 160) {
  174. addRhythmRows(elements, body, rows, master, palette)
  175. } else if (pattern && !content.steps.length) {
  176. addDurationDiagram(elements, { ...body, height: body.height * .55 }, pattern, master, palette)
  177. paintText({ ...body, top: body.top + body.height * .60, height: body.height * .40 }, points)
  178. } else paintText(body, [...content.steps, ...points])
  179. }
  180. addInlineMedia(elements, slotOf(layout, 'media'), page, master, palette, intent.showMedia)
  181. return elements
  182. }
  183. function compactTextSlot(slot: TemplateSlot, values: string[], presentation: string): TemplateSlot {
  184. const grid = presentation === 'grid' || presentation === 'steps'
  185. const columns = grid ? Math.max(1, Math.min(presentation === 'steps' ? 4 : 3, values.length, Math.floor(slot.width / 300))) : 1
  186. const rows = Math.max(1, Math.ceil(values.length / columns))
  187. const width = (slot.width - 22 * (columns - 1)) / columns
  188. const lines = Math.max(1, ...values.map(text => Math.ceil(text.length / Math.max(4, (width - 68) / 34))))
  189. const rowHeight = Math.max(grid ? 190 : 80, lines * 44 + (presentation === 'steps' ? 84 : presentation === 'plain' ? 16 : 48))
  190. const height = Math.min(slot.height, rowHeight * rows + 22 * (rows - 1))
  191. return { ...slot, top: slot.top + (grid ? Math.min(100, (slot.height - height) / 2) : 0), height, preferredFontSize: Math.max(slot.preferredFontSize || 34, 38) }
  192. }
  193. function addRhythmRows(elements: PPTElement[], slot: TemplateSlot, rows: ReturnType<typeof rhythmRows>, master: TemplateMaster, palette: Palette) {
  194. const rowHeight = Math.min(220, (slot.height - 24 * (rows.length - 1)) / rows.length)
  195. rows.forEach((row, i) => {
  196. const top = slot.top + i * (rowHeight + 24)
  197. addSlotText(elements, { ...slot, top, height: 58, preferredFontSize: 38 }, row.source, master.textColor, true)
  198. const width = Math.min(160, (slot.width - 12 * (row.tokens.length - 1)) / row.tokens.length)
  199. row.tokens.forEach((token, j) => {
  200. const cell = { left: slot.left + j * (width + 12), top: top + 76, width, height: Math.max(48, rowHeight - 82) }
  201. if (!/[||]/.test(token)) elements.push(shapeElement({ ...cell, fill: palette.soft, radius: 10 }))
  202. addSlotText(elements, { ...slot, ...cell, preferredFontSize: 44 }, token, master.textColor, true)
  203. })
  204. })
  205. }
  206. function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, role: PageRole = masterPage.roles[0], intent = pageRenderIntent(page, role)): PPTElement[] {
  207. master = {
  208. ...master,
  209. titleColor: masterPage.titleColor || master.titleColor,
  210. coverTitleColor: masterPage.coverTitleColor || master.coverTitleColor,
  211. textColor: masterPage.textColor || master.textColor,
  212. mutedColor: masterPage.mutedColor || master.mutedColor
  213. }
  214. const elements: PPTElement[] = []
  215. const title = masterPage.layout === "cover" ? chapterTitle(outline, page) : displayPageTitle(page, outline)
  216. // 学生可见内容必须与编辑区保持一致,不能因模板容量静默丢弃条目。
  217. const headline = cleanText(page.interaction?.question) || activityTask(page) || studentHeadline(page)
  218. const points = uniqueVisibleTexts([activityTask(page), ...studentPoints(page), ...(page.interaction?.options || [])])
  219. .filter(point => !sameVisibleText(point, headline))
  220. addSlotText(elements, slotOf(masterPage, "title"), title, masterPage.layout === "cover" ? master.coverTitleColor : master.titleColor, true)
  221. if (role !== "cover" && isScorePage(page)) {
  222. addNotationVisual(elements, slotOf(masterPage, "image"), page, master, palette)
  223. addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points, page.interaction?.question || "", ...(page.interaction?.options || [])]), master, palette, false)
  224. addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
  225. return elements
  226. }
  227. if (masterPage.layout === "cover") {
  228. const meta = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
  229. const coverSecondaryColor = master.coverTitleColor.toUpperCase() === "#FFFFFF" ? "#DDE6F4" : master.mutedColor
  230. addSlotText(elements, slotOf(masterPage, "meta"), meta, coverSecondaryColor)
  231. addSlotList(elements, slotOf(masterPage, "headline"), uniqueVisibleTexts([headline, ...points]), master, palette, false, master.coverTitleColor)
  232. addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual)
  233. return elements
  234. }
  235. if (masterPage.layout === "hero") {
  236. addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual)
  237. const body = uniqueVisibleTexts([page.interaction?.question || "", headline, ...points])
  238. addSlotList(elements, slotOf(masterPage, "body"), body, master, palette)
  239. addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
  240. return elements
  241. }
  242. if (masterPage.layout === "objectives") {
  243. addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
  244. addCardGrid(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
  245. return elements
  246. }
  247. if (masterPage.layout === "media") {
  248. addSlotText(elements, slotOf(masterPage, "headline"), headline || page.interaction?.question || "带着问题听音乐", palette.primary, true)
  249. addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual)
  250. addSlotList(elements, slotOf(masterPage, "body"), points, master, palette)
  251. addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
  252. return elements
  253. }
  254. if (masterPage.layout === "split") {
  255. addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual)
  256. addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
  257. addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
  258. return elements
  259. }
  260. if (masterPage.layout === "text") {
  261. addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
  262. addSlotText(elements, slotOf(masterPage, "headline"), headline || (role === "lyric" ? "读一读,再唱一唱" : "抓住这一页的关键信息"), page.interaction?.question ? master.textColor : palette.primary, true)
  263. if (role === "lyric") addLyricLines(elements, slotOf(masterPage, "body"), points, master, palette)
  264. else addSlotList(elements, slotOf(masterPage, "body"), points, master, palette, false)
  265. addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
  266. return elements
  267. }
  268. if (masterPage.layout === "diagram") {
  269. const rhythmPattern = role === "rhythm" ? [headline, ...points].join(" ").match(/[短长](?:\s*[-—·、]\s*[短长]){1,7}/)?.[0].match(/[短长]/g) : undefined
  270. if (role === "notation") addNotationVisual(elements, slotOf(masterPage, "image"), page, master, palette, false)
  271. else if (rhythmPattern) addDurationDiagram(elements, slotOf(masterPage, "image"), rhythmPattern, master, palette)
  272. else if (role === "rhythm") addStepGrid(elements, slotOf(masterPage, "image"), points, master, palette)
  273. else addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, true)
  274. if (role !== "rhythm" || rhythmPattern) addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
  275. else addSlotText(elements, slotOf(masterPage, "body"), headline, master.textColor)
  276. addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
  277. return elements
  278. }
  279. if (masterPage.layout === "compare") {
  280. addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
  281. const values = uniqueVisibleTexts([...(page.interaction?.options || []), ...points])
  282. const midpoint = Math.max(1, Math.ceil(values.length / 2))
  283. addSlotText(elements, slotOf(masterPage, "headline"), headline || page.interaction?.question || "听一听,它们有什么不同?", palette.primary, true)
  284. addSlotList(elements, slotOf(masterPage, "left"), values.slice(0, midpoint), master, palette)
  285. addSlotList(elements, slotOf(masterPage, "right"), values.slice(midpoint), master, palette)
  286. addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
  287. return elements
  288. }
  289. if (masterPage.layout === "activity") {
  290. addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
  291. addSlotText(elements, slotOf(masterPage, "headline"), headline || "一起完成音乐任务", palette.primary, true)
  292. addStepGrid(elements, slotOf(masterPage, "steps"), points, master, palette)
  293. addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
  294. return elements
  295. }
  296. addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
  297. addChecklist(elements, slotOf(masterPage, "body"), points, master, palette)
  298. addSlotText(elements, slotOf(masterPage, "headline"), headline, palette.primary, true)
  299. return elements
  300. }
  301. function slotOf(page: TemplateMasterPage, id: string) {
  302. return page.slots.find(item => item.id === id && item.enabled !== false)
  303. }
  304. function pageRenderIntent(page: AiCoursewarePage, role: PageRole): PageRenderIntent {
  305. const densityValue = String(page.visualPlan?.visualDensity || "").toLowerCase()
  306. const pointCount = Math.max(page.studentContent?.points?.length || 0, page.bullets?.length || 0, page.activity?.steps?.length || 0)
  307. const density: PageRenderIntent["density"] = densityValue === "low" || densityValue === "high"
  308. ? densityValue
  309. : pointCount <= 2 ? "low" : pointCount >= 5 ? "high" : "medium"
  310. const hasBoundMedia = (page.resourceBindings || []).some(binding =>
  311. binding.verified !== false && ["SONG", "VIDEO"].includes(binding.resourceType) && Boolean(binding.contentUrl)
  312. )
  313. const expectsMedia = role === "listen" || (page.resourceBindings || []).some(binding =>
  314. ["SONG", "VIDEO"].includes(binding.resourceType) && binding.required === true
  315. )
  316. const visualKind: PageRenderIntent["visualKind"] = role === "notation"
  317. ? "notation"
  318. : role === "rhythm"
  319. ? "rhythm"
  320. : role === "compare"
  321. ? "compare"
  322. : ["activity", "performance"].includes(role)
  323. ? "activity"
  324. : ["goal", "summary", "homework", "practice", "lyric"].includes(role)
  325. ? "text"
  326. : "image"
  327. return { role, density, visualKind, showMedia: hasBoundMedia || expectsMedia }
  328. }
  329. export function resolveAiPptLayout(page: AiCoursewarePage, index: number): string {
  330. const role = pageRole(page, index)
  331. if (role === "cover") return "cover_scene"
  332. if (isScorePage(page)) return "diagram_focus"
  333. if (role === "practice") return "text_focus"
  334. const requested = resolveTemplatePageLayout(page.visualPlan?.layout || page.visual?.layout)
  335. // 历史大纲可能已被服务端统一改为 split,优先恢复教学页型;配图由槽位适配处理。
  336. if (role === "rhythm") return hasImage(page) ? "activity" : "diagram_focus"
  337. if (requested && requested !== "split" && requested !== "cover") return requested
  338. const layouts: Partial<Record<PageRole, string>> = {
  339. intro: "hero_focus", story: "hero_focus", goal: "objectives", knowledge: "objectives",
  340. listen: "media_focus", compare: "compare", activity: "activity", performance: "activity",
  341. summary: "summary", homework: "activity", practice: "text_focus", interaction: "text_focus", lyric: "text_focus"
  342. }
  343. return layouts[role] || "split_content"
  344. }
  345. function fitVisualSlots(page: TemplateMasterPage, hasVisual: boolean, nativeVisual: boolean): TemplateMasterPage {
  346. if (nativeVisual) return page
  347. const image = slotOf(page, "image")
  348. const body = slotOf(page, "body")
  349. if (!hasVisual) {
  350. if (!image || !body) return page
  351. // 只合并并排区域,不能跨过标题或侵入模板保留区域。
  352. if (Math.abs(image.top - body.top) > 40) return page
  353. const left = Math.min(image.left, body.left)
  354. const right = Math.max(image.left + image.width, body.left + body.width)
  355. return { ...page, slots: page.slots.filter(s => s.kind !== "image").map(s => s.id === body.id ? { ...s, left, width: right - left } : s) }
  356. }
  357. if (image) return page
  358. // 在既有安全内容区内给教学图片分配位置,保留对比、步骤、总结的语义结构。
  359. const content = page.slots.filter(s => s.enabled !== false && ["body", "steps", "left", "right"].includes(s.kind))
  360. if (!content.length) return page // 自带封面主视觉的母版保持原有背景契约。
  361. const left = Math.min(...content.map(s => s.left)), top = Math.min(...content.map(s => s.top))
  362. const width = Math.max(...content.map(s => s.left + s.width)) - left
  363. const height = Math.max(...content.map(s => s.top + s.height)) - top
  364. // 对比图片置于两栏上方,左右内容保持同等面积,避免暗示一侧更重要。
  365. if (content.length > 1) {
  366. const imageHeight = Math.min(...content.map(s => s.height)) * 0.42
  367. const imageWidth = Math.min(width, imageHeight * 2)
  368. return { ...page, slots: [...page.slots.map(s => content.some(c => c.id === s.id)
  369. ? { ...s, top: s.top + imageHeight + 20, height: s.height - imageHeight - 20 } : s),
  370. { id: "image", kind: "image", left: left + (width - imageWidth) / 2, top, width: imageWidth, height: imageHeight }] }
  371. }
  372. const imageWidth = width * 0.4
  373. return { ...page, slots: [...page.slots.map(s => s.id === content[0].id ? { ...s, left: left + imageWidth + 28, width: width - imageWidth - 28 } : s),
  374. { id: "image", kind: "image", left, top, width: imageWidth, height }] }
  375. }
  376. function adaptMasterPage(page: TemplateMasterPage, intent: PageRenderIntent): TemplateMasterPage {
  377. if (intent.showMedia) return page
  378. const media = page.slots.find(slot => slot.kind === "media" && slot.enabled !== false)
  379. if (!media) return page
  380. const availableBottom = media.top + media.height
  381. const expandable = new Set(["body", "image", "steps", "left", "right"])
  382. return {
  383. ...page,
  384. slots: page.slots
  385. .filter(slot => slot.kind !== "media")
  386. .map(slot => {
  387. if (!expandable.has(slot.kind) || slot.top + slot.height > media.top + 24) return { ...slot }
  388. return { ...slot, height: Math.max(slot.height, availableBottom - slot.top) }
  389. })
  390. }
  391. }
  392. function assertContentContract(master: TemplateMaster, page: TemplateMasterPage, elements: PPTElement[]) {
  393. if (!page.safeArea || !page.reservedZones) return
  394. type ElementBounds = { left: number; top: number; width: number; height: number }
  395. const boundedElements = elements.filter((element): element is PPTElement & ElementBounds =>
  396. "height" in element && typeof element.height === "number"
  397. )
  398. const overlaps = (left: ElementBounds, right: ElementBounds) =>
  399. left.left < right.left + right.width && left.left + left.width > right.left && left.top < right.top + right.height && left.top + left.height > right.top
  400. const outsideSafeArea = boundedElements.find(element =>
  401. element.left < page.safeArea!.left ||
  402. element.top < page.safeArea!.top ||
  403. element.left + element.width > page.safeArea!.left + page.safeArea!.width ||
  404. element.top + element.height > page.safeArea!.top + page.safeArea!.height
  405. )
  406. if (outsideSafeArea) throw new Error(`模板 ${master.id}/${page.id} 生成内容超出投屏安全区,请调整该页面内容槽。`)
  407. const collision = page.reservedZones.find(zone => boundedElements.some(element => overlaps(element, zone)))
  408. if (collision) throw new Error(`模板 ${master.id}/${page.id} 生成内容侵入“${collision.purpose}”保留区,请更换版式或缩减内容。`)
  409. }
  410. function addSlotText(elements: PPTElement[], slot: TemplateSlot | undefined, content: string, color: string, bold = false) {
  411. if (!slot || !cleanText(content)) return
  412. elements.push(textElement({
  413. left: slot.left,
  414. top: slot.top,
  415. width: slot.width,
  416. height: slot.height,
  417. content,
  418. fontSize: fitFont(content, slot.width, slot.height, slot.preferredFontSize || 30, slot.minimumFontSize || 22),
  419. minimumFontSize: slot.minimumFontSize,
  420. color,
  421. bold
  422. }))
  423. }
  424. function addSlotList(
  425. elements: PPTElement[],
  426. slot: TemplateSlot | undefined,
  427. sourceItems: string[],
  428. master: TemplateMaster,
  429. palette: Palette,
  430. cards = true,
  431. textColor = master.textColor
  432. ) {
  433. if (!slot) return
  434. const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
  435. if (!items.length) return
  436. const gap = collectionGap(slot.height, items.length, 16)
  437. const rowHeight = (slot.height - gap * (items.length - 1)) / items.length
  438. items.forEach((item, index) => {
  439. const top = slot.top + index * (rowHeight + gap)
  440. const inset = cards ? Math.min(34, Math.max(12, rowHeight * 0.22)) : 0
  441. const textTopInset = Math.min(6, Math.max(1, rowHeight * 0.08))
  442. const textHeight = Math.max(12, rowHeight - textTopInset * 2)
  443. const textWidth = Math.max(40, slot.width - (cards ? inset + 24 : 0))
  444. const minimumFontSize = adaptiveMinimumFont(slot, textHeight)
  445. if (cards) {
  446. addCardFrame(elements, slot.left, top, slot.width, rowHeight, index, master, palette)
  447. }
  448. elements.push(
  449. textElement({
  450. left: slot.left + inset,
  451. top: top + textTopInset,
  452. width: textWidth,
  453. height: textHeight,
  454. content: item,
  455. fontSize: fitFont(item, textWidth, textHeight, slot.preferredFontSize || 30, minimumFontSize),
  456. minimumFontSize,
  457. color: textColor,
  458. bold: index === 0 && items.length === 1
  459. })
  460. )
  461. })
  462. }
  463. function collectionGap(height: number, count: number, preferred: number) {
  464. if (count <= 1) return 0
  465. return Math.max(1, Math.min(preferred, height / Math.max(8, count * 8)))
  466. }
  467. function adaptiveMinimumFont(slot: TemplateSlot, availableHeight: number) {
  468. // 内容过多时交给质量门禁提示拆页,不能靠无限缩小正文隐藏问题。
  469. return Math.max(24, slot.minimumFontSize || 24)
  470. }
  471. function addCardFrame(elements: PPTElement[], left: number, top: number, width: number, height: number, index: number, master: TemplateMaster, palette: Palette) {
  472. const accent = index % 2 ? palette.secondary : palette.primary
  473. if (master.cardStyle === "ink") {
  474. elements.push(shapeElement({ left, top: top + height - 3, width, height: 3, fill: master.borderColor, opacity: 0.52 }))
  475. elements.push(shapeElement({ left, top: top + 12, width: 14, height: 14, fill: accent, radius: 7 }))
  476. return
  477. }
  478. if (master.cardStyle === "sticker") {
  479. const fills = [palette.warm, palette.soft, master.surfaceColor]
  480. elements.push(shapeElement({ left, top, width, height, fill: fills[index % fills.length], radius: 22, shadow: lightShadow() }))
  481. elements.push(shapeElement({ left: left + 12, top: top + 12, width: 38, height: 38, fill: accent, radius: 19 }))
  482. return
  483. }
  484. if (master.cardStyle === "bubble") {
  485. elements.push(shapeElement({ left, top, width, height, fill: index % 2 ? palette.soft : master.surfaceColor, radius: Math.min(28, height / 2), shadow: lightShadow() }))
  486. elements.push(shapeElement({ left: left + 14, top: top + height / 2 - 8, width: 16, height: 16, fill: accent, radius: 8 }))
  487. return
  488. }
  489. if (master.cardStyle === "grid") {
  490. elements.push(shapeElement({ left, top, width, height, fill: master.surfaceColor, radius: 4 }))
  491. elements.push(shapeElement({ left, top, width: 14, height: 14, fill: accent, radius: 0 }))
  492. elements.push(shapeElement({ left: left + width - 26, top: top + height - 10, width: 26, height: 10, fill: accent, opacity: 0.5 }))
  493. return
  494. }
  495. if (master.cardStyle === "cinematic") {
  496. elements.push(shapeElement({ left, top, width, height, fill: master.surfaceColor, radius: 6, shadow: lightShadow() }))
  497. elements.push(shapeElement({ left, top, width: 8, height, fill: accent }))
  498. return
  499. }
  500. elements.push(shapeElement({ left, top, width, height, fill: master.surfaceColor, radius: 14, shadow: lightShadow() }))
  501. elements.push(shapeElement({ left, top, width: 10, height, fill: accent }))
  502. }
  503. function addCardGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
  504. if (!slot) return
  505. const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
  506. if (!items.length) return
  507. const columns = items.length === 1 ? 1 : Math.max(1, Math.min(3, items.length, (slot.minimumFontSize || 0) >= 28 ? Math.floor(slot.width / 300) : 3))
  508. const rows = Math.ceil(items.length / columns)
  509. const gap = collectionGap(slot.height, rows, 22)
  510. const width = (slot.width - gap * (columns - 1)) / columns
  511. const height = (slot.height - gap * (rows - 1)) / rows
  512. items.forEach((item, index) => {
  513. const left = slot.left + (index % columns) * (width + gap)
  514. const top = slot.top + Math.floor(index / columns) * (height + gap)
  515. const inset = Math.min(34, Math.max(10, Math.min(width, height) * 0.1))
  516. const textWidth = Math.max(36, width - inset * 2)
  517. const textHeight = Math.max(12, height - inset * 2)
  518. const minimumFontSize = adaptiveMinimumFont(slot, textHeight)
  519. addCardFrame(elements, left, top, width, height, index, master, palette)
  520. elements.push(
  521. textElement({
  522. left: left + inset,
  523. top: top + inset,
  524. width: textWidth,
  525. height: textHeight,
  526. content: item,
  527. fontSize: fitFont(item, textWidth, textHeight, slot.preferredFontSize || 32, minimumFontSize),
  528. minimumFontSize,
  529. color: master.textColor,
  530. bold: true
  531. })
  532. )
  533. })
  534. }
  535. function addLyricLines(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
  536. if (!slot) return
  537. const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
  538. const gap = collectionGap(slot.height, items.length, 12)
  539. const height = (slot.height - gap * Math.max(0, items.length - 1)) / Math.max(1, items.length)
  540. items.forEach((item, index) => {
  541. const top = slot.top + index * (height + gap)
  542. const inset = Math.min(8, Math.max(1, height * 0.08))
  543. const textHeight = Math.max(12, height - inset * 2)
  544. const minimumFontSize = adaptiveMinimumFont(slot, textHeight)
  545. if (index % 2 === 0)
  546. elements.push(shapeElement({ left: slot.left, top, width: slot.width, height, fill: palette.soft, opacity: 0.55, radius: 18 }))
  547. elements.push(
  548. textElement({
  549. left: slot.left + 45,
  550. top: top + inset,
  551. width: slot.width - 90,
  552. height: textHeight,
  553. content: `♪ ${item}`,
  554. fontSize: fitFont(item, slot.width - 90, textHeight, slot.preferredFontSize || 38, minimumFontSize),
  555. minimumFontSize,
  556. color: master.textColor,
  557. bold: index === 0
  558. })
  559. )
  560. })
  561. }
  562. function addNotationVisual(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage, master: TemplateMaster, palette: Palette, backgroundVisual = false) {
  563. if (!slot) return
  564. const binding = scoreBinding(page)
  565. if (!binding) return // 缺谱在教师质量面板中提示,绝不绘制虚构谱例。
  566. if (usableResourceUrl(binding.scoreImageUrl)) {
  567. // 保持整张曲谱,不使用 cover 裁切,防止丢失谱号、小节或歌词。
  568. elements.push({ id: nanoid(10), type: "image", src: binding.scoreImageUrl!, left: slot.left, top: slot.top,
  569. width: slot.width, height: slot.height, fixedRatio: true, rotate: 0 } as PPTImageElement)
  570. } else {
  571. elements.push({ id: nanoid(10), type: "elf", subtype: "elf-sing-play", sid: binding.resourceId,
  572. title: binding.name || page.title, left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0 } as PPTCloudCoachElement)
  573. }
  574. }
  575. function addDurationDiagram(elements: PPTElement[], slot: TemplateSlot | undefined, pattern: string[], master: TemplateMaster, palette: Palette) {
  576. if (!slot) return
  577. // 只可视化正文明确给出的长短顺序,不猜拍号、音符时值或教材谱例。
  578. const gap = 24, unit = (slot.width - gap * (pattern.length - 1)) / pattern.reduce((sum, item) => sum + (item === "长" ? 2 : 1), 0)
  579. let left = slot.left
  580. const barHeight = Math.min(80, slot.height * 0.22), top = slot.top + slot.height * 0.36
  581. pattern.forEach(item => {
  582. const width = unit * (item === "长" ? 2 : 1)
  583. elements.push(shapeElement({ left, top, width, height: barHeight, fill: item === "长" ? palette.secondary : palette.primary, radius: 12 }))
  584. addSlotText(elements, { id: "duration-label", kind: "body", left, top: top + barHeight + 22, width, height: 70, preferredFontSize: 40, minimumFontSize: 24 }, item, master.textColor, true)
  585. left += width + gap
  586. })
  587. }
  588. function addStepGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
  589. if (!slot) return
  590. const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
  591. if (!items.length) return
  592. const columns = Math.max(1, Math.min(4, items.length, (slot.minimumFontSize || 0) >= 28 ? Math.floor(slot.width / 300) : 4))
  593. const rows = Math.ceil(items.length / columns)
  594. const gap = collectionGap(slot.height, rows, 22)
  595. const width = (slot.width - gap * (columns - 1)) / columns
  596. const height = (slot.height - gap * (rows - 1)) / rows
  597. items.forEach((item, index) => {
  598. const left = slot.left + (index % columns) * (width + gap)
  599. const top = slot.top + Math.floor(index / columns) * (height + gap)
  600. const badgeSize = Math.min(72, Math.max((slot.minimumFontSize || 0) >= 28 ? 52 : 24, height * 0.2))
  601. const cardTop = top + badgeSize * 0.55
  602. const cardHeight = Math.max(12, height - badgeSize * 0.55)
  603. const textInset = Math.min(30, Math.max(8, width * 0.08))
  604. const textTop = cardTop + Math.min(70, Math.max(18, cardHeight * 0.22))
  605. const textHeight = Math.max(12, top + height - textTop - 8)
  606. const minimumFontSize = adaptiveMinimumFont(slot, textHeight)
  607. addCardFrame(elements, left, cardTop, width, cardHeight, index, master, palette)
  608. elements.push(
  609. shapeElement({
  610. left: left + textInset,
  611. top,
  612. width: badgeSize,
  613. height: badgeSize,
  614. fill: index % 2 ? palette.secondary : palette.primary,
  615. radius: badgeSize / 2
  616. })
  617. )
  618. elements.push(
  619. textElement({
  620. left: left + textInset,
  621. top: top + badgeSize * 0.15,
  622. width: badgeSize,
  623. height: badgeSize * 0.62,
  624. content: String(index + 1),
  625. fontSize: Math.min(28, Math.max(14, badgeSize * 0.4)),
  626. minimumFontSize: 12,
  627. color: "#FFFFFF",
  628. bold: true
  629. })
  630. )
  631. elements.push(
  632. textElement({
  633. left: left + textInset,
  634. top: textTop,
  635. width: Math.max(36, width - textInset * 2),
  636. height: textHeight,
  637. content: item,
  638. fontSize: fitFont(item, width - textInset * 2, textHeight, (slot.minimumFontSize || 0) >= 28 ? 40 : 29, minimumFontSize),
  639. minimumFontSize,
  640. color: master.textColor,
  641. bold: true
  642. })
  643. )
  644. })
  645. }
  646. function addChecklist(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
  647. if (!slot) return
  648. const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
  649. const gap = collectionGap(slot.height, items.length, 16)
  650. const height = (slot.height - gap * Math.max(0, items.length - 1)) / Math.max(1, items.length)
  651. items.forEach((item, index) => {
  652. const top = slot.top + index * (height + gap)
  653. const iconSize = Math.min(42, Math.max(16, height * 0.58))
  654. const iconTop = top + Math.max(0, (height - iconSize) / 2)
  655. const textLeft = slot.left + iconSize + Math.min(30, Math.max(8, iconSize * 0.7))
  656. const textWidth = Math.max(40, slot.left + slot.width - textLeft)
  657. const minimumFontSize = adaptiveMinimumFont(slot, height)
  658. elements.push(
  659. shapeElement({
  660. left: slot.left,
  661. top: iconTop,
  662. width: iconSize,
  663. height: iconSize,
  664. fill: index % 2 ? palette.secondary : palette.primary,
  665. radius: iconSize / 2
  666. })
  667. )
  668. elements.push(
  669. textElement({
  670. left: slot.left + iconSize * 0.2,
  671. top: iconTop + iconSize * 0.12,
  672. width: iconSize * 0.6,
  673. height: iconSize * 0.7,
  674. content: "✓",
  675. fontSize: Math.min(24, Math.max(10, iconSize * 0.55)),
  676. minimumFontSize: 10,
  677. color: "#FFFFFF",
  678. bold: true
  679. })
  680. )
  681. elements.push(
  682. textElement({
  683. left: textLeft,
  684. top,
  685. width: textWidth,
  686. height,
  687. content: item,
  688. fontSize: fitFont(item, textWidth, height, slot.preferredFontSize || 32, minimumFontSize),
  689. minimumFontSize,
  690. color: master.textColor,
  691. bold: index === 0
  692. })
  693. )
  694. })
  695. }
  696. function addVisualOrResource(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage, master: TemplateMaster, palette: Palette, allowPlaceholder = true) {
  697. if (!slot) return
  698. const dedicated = (page.resourceBindings || []).find(item => item.verified !== false && item.dedicatedPage)
  699. if (dedicated?.resourceType === "MUSIC") {
  700. elements.push({ id: nanoid(10), type: "elf", subtype: "elf-sing-play", sid: dedicated.resourceId, title: dedicated.name || page.title, left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0 } as PPTCloudCoachElement)
  701. return
  702. }
  703. const video = videoBinding(page)
  704. if (video) {
  705. elements.push({ id: nanoid(10), type: "elf", subtype: "elf-video", left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0, src: video.contentUrl || "", poster: video.coverUrl, autoplay: false } as PPTVideoElement)
  706. return
  707. }
  708. if (hasImage(page)) {
  709. elements.push(imageElement(page, { left: slot.left, top: slot.top, width: slot.width, height: slot.height, radius: 18 }))
  710. return
  711. }
  712. // 无教学图片时留给文字排版;通用装饰不能充当观察对象。
  713. }
  714. function addSemanticVisual(elements: PPTElement[], slot: TemplateSlot, page: AiCoursewarePage, master: TemplateMaster, palette: Palette) {
  715. elements.push(shapeElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, fill: master.surfaceColor, opacity: 0.86, radius: 18, shadow: lightShadow() }))
  716. const landscapeTop = slot.top + slot.height * 0.48
  717. elements.push(shapeElement({ left: slot.left + slot.width * 0.08, top: landscapeTop, width: slot.width * 0.84, height: slot.height * 0.35, fill: palette.soft, opacity: 0.9, radius: 28 }))
  718. elements.push(shapeElement({ left: slot.left + slot.width * 0.68, top: slot.top + slot.height * 0.12, width: Math.min(84, slot.width * 0.16), height: Math.min(84, slot.width * 0.16), fill: palette.warm, opacity: 0.85, radius: 42 }))
  719. elements.push(shapeElement({ left: slot.left + slot.width * 0.12, top: landscapeTop + slot.height * 0.12, width: slot.width * 0.46, height: slot.height * 0.2, fill: palette.secondary, opacity: 0.55, radius: 40 }))
  720. elements.push(shapeElement({ left: slot.left + slot.width * 0.42, top: landscapeTop + slot.height * 0.08, width: slot.width * 0.42, height: slot.height * 0.24, fill: palette.primary, opacity: 0.45, radius: 44 }))
  721. const requestedCue = cleanText(page.visualPlan?.focalContent || page.visualPlan?.mainVisual || page.visual?.mainVisual || page.imageKeywords?.[0])
  722. const cue = !requestedCue || sameVisibleText(requestedCue, page.title)
  723. ? `画面线索:${cleanText(page.title) || "观察音乐变化"}`
  724. : requestedCue
  725. elements.push(textElement({
  726. left: slot.left + slot.width * 0.1,
  727. top: slot.top + slot.height * 0.13,
  728. width: slot.width * 0.54,
  729. height: slot.height * 0.25,
  730. content: cue,
  731. fontSize: fitFont(cue, slot.width * 0.54, slot.height * 0.25, 30, 22),
  732. minimumFontSize: 22,
  733. color: master.titleColor,
  734. bold: true
  735. }))
  736. }
  737. function addInlineMedia(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage, master: TemplateMaster, palette: Palette, showMedia: boolean) {
  738. if (!slot || !showMedia) return
  739. const bindings = audioBindings(page)
  740. const width = slot.width / Math.max(1, bindings.length)
  741. bindings.forEach((binding, index) => {
  742. elements.push({ id: nanoid(10), type: "elf", subtype: "elf-audio", left: slot.left + index * width,
  743. top: slot.top + (slot.height - 60) / 2, width: 60, height: 60, rotate: 0, fixedRatio: true,
  744. color: palette.primary, autoplay: false, loop: false, src: binding.contentUrl! } as PPTAudioElement)
  745. addSlotText(elements, { ...slot, left: slot.left + index * width + 76, width: width - 92 },
  746. `${bindings.length > 1 ? String.fromCharCode(65 + index) + " · " : ""}${binding.name || "课堂音频"}`, master.textColor)
  747. })
  748. }
  749. function renderTemplateMasterElements(elements: TemplateMasterElement[]): PPTElement[] {
  750. return elements.map(element =>
  751. element.type === "text"
  752. ? textElement({
  753. left: element.left,
  754. top: element.top,
  755. width: element.width,
  756. height: element.height,
  757. content: element.content || "",
  758. fontSize: element.fontSize || 14,
  759. color: element.color || "#20313A",
  760. bold: element.bold
  761. })
  762. : shapeElement({
  763. left: element.left,
  764. top: element.top,
  765. width: element.width,
  766. height: element.height,
  767. fill: element.fill || "#FFFFFF",
  768. opacity: element.opacity,
  769. radius: element.radius
  770. })
  771. )
  772. }
  773. function entranceAnimations(elements: PPTElement[]): PPTAnimation[] {
  774. return elements
  775. .filter(
  776. element => element.type === "text" && element.width >= 120 && element.height >= 45
  777. )
  778. .slice(0, 6)
  779. .map((element, index) => ({
  780. id: nanoid(10),
  781. elId: element.id,
  782. effect: index === 0 ? "fadeIn" : "fadeInUp",
  783. type: "in",
  784. duration: 500,
  785. trigger: index === 0 ? "auto" : "click"
  786. }))
  787. }
  788. function videoBinding(page: AiCoursewarePage) {
  789. return (page.resourceBindings || []).find(item => item.verified === true && item.resourceType === "VIDEO" && usableResourceUrl(item.contentUrl))
  790. }
  791. function normalizeOutline(outline: AiCoursewareOutline): AiCoursewareOutline {
  792. const chapter = cleanText(outline.chapterName || outline.title || "音乐课件")
  793. return {
  794. ...outline,
  795. title: GENERIC_PAGE_TITLES.has(cleanText(outline.title).toLowerCase()) ? chapter : cleanText(outline.title || chapter),
  796. pages: (outline.pages || []).map((page, index) => ({
  797. ...page,
  798. pageNo: index + 1,
  799. title: index === 0 && GENERIC_PAGE_TITLES.has(cleanText(page.title).toLowerCase()) ? chapter : cleanText(page.title || chapter),
  800. bullets: (page.bullets || []).map(cleanText).filter(Boolean),
  801. imageKeywords: (page.imageKeywords || []).map(cleanText).filter(Boolean)
  802. }))
  803. }
  804. }
  805. function pageRole(page: AiCoursewarePage, index: number): PageRole {
  806. if (index === 0) return "cover"
  807. const kind = `${page.type || ""} ${page.visualPlan?.layout || ""} ${page.visual?.layout || ""}`.toLowerCase()
  808. if (kind.includes("objective") || kind.includes("goal")) return "goal"
  809. if (kind.includes("practice_check") || kind.includes("assessment")) return "practice"
  810. if (kind.includes("compare")) return "compare"
  811. if (kind.includes("instrument")) return "instrument"
  812. if (kind.includes("melody") || kind.includes("score") || kind.includes("notation")) return "notation"
  813. if (kind.includes("performance") || kind.includes("expressive") || kind.includes("creative")) return "performance"
  814. if (kind.includes("listen")) return "listen"
  815. if (kind.includes("lyric") || kind.includes("phrase_sing") || kind.includes("sing_focus")) return "lyric"
  816. if (kind.includes("rhythm") || kind.includes("skill")) return "rhythm"
  817. if (kind.includes("knowledge")) return "knowledge"
  818. if (kind.includes("interaction")) return "interaction"
  819. if (kind.includes("activity")) return "activity"
  820. if (kind.includes("summary")) return "summary"
  821. if (kind.includes("homework") || kind.includes("extension")) return "homework"
  822. if (kind.includes("intro") || kind.includes("scene")) return "intro"
  823. if (kind.includes("context") || kind.includes("background") || kind.includes("story")) return "story"
  824. return "content"
  825. }
  826. function displayPageTitle(page: AiCoursewarePage, outline: AiCoursewareOutline) {
  827. const title = cleanText(page.title)
  828. return !title || GENERIC_PAGE_TITLES.has(title.toLowerCase()) ? chapterTitle(outline, page) : title
  829. }
  830. function chapterTitle(outline: AiCoursewareOutline, page: AiCoursewarePage) {
  831. return cleanText(page.title || outline.title || outline.chapterName || "音乐课件")
  832. }
  833. function conciseTextbook(value?: string) {
  834. return cleanText(value).replace(/^.*?·/, "")
  835. }
  836. function hasImage(page: AiCoursewarePage) {
  837. return Boolean(page.image?.url)
  838. }
  839. function studentHeadline(page: AiCoursewarePage) {
  840. const headline = cleanText(page.studentContent?.headline || page.subtitle || "")
  841. return sameVisibleText(headline, page.title) || isTeacherFacingText(headline) ? "" : headline
  842. }
  843. function studentPoints(page: AiCoursewarePage) {
  844. const structured = (page.studentContent?.points || []).map(cleanText).filter(Boolean)
  845. const legacy = (page.bullets || []).map(cleanText).filter(Boolean)
  846. const points = [...(structured.length ? structured : legacy), studentHeadline(page)].filter(Boolean)
  847. const excluded = [page.title].map(normalizeVisibleText).filter(Boolean)
  848. return uniqueVisibleTexts(points).filter(item => !excluded.includes(normalizeVisibleText(item)) && !isTeacherFacingText(item))
  849. }
  850. function activityTask(page: AiCoursewarePage) {
  851. // activity 属于教师组织信息,不能回退到学生投屏画面。
  852. const task = cleanText(page.classroomAction?.studentTask || studentHeadline(page) || "")
  853. return sameVisibleText(task, page.title) || isTeacherFacingText(task) ? "" : task
  854. }
  855. function slideRemark(page: AiCoursewarePage) {
  856. const lessonMeta = page.teachingStage || ""
  857. const evidence = page.learningEvidence ? `学习表现:${page.learningEvidence}` : ""
  858. const activity = [page.activity?.task, ...(page.activity?.steps || [])].map(cleanText).filter(Boolean).join("\n")
  859. const fallbackInstruction = cleanText(page.classroomAction?.teacherInstruction || page.teacherTips || page.speakerNotes)
  860. || `引导学生完成“${activityTask(page) || page.title}”,观察学生是否能用语言、动作或声音表达本页学习结果。`
  861. const answer = page.interaction?.answer ? `参考答案:${page.interaction.answer}` : ""
  862. const explanation = page.interaction?.explanation ? `解析:${page.interaction.explanation}` : ""
  863. const resources = (page.resourceBindings || []).map(item => `${item.name || item.resourceType}:${item.purpose || "课堂资源"}`).join("\n")
  864. const mediaReminder = /listen|sing/.test(page.type) && audioBindings(page).length === 0 ? "资源准备:请为本页听唱任务补充对应音频。" : ""
  865. return [lessonMeta, page.teachingPurpose, ...teacherFacingTexts(page), activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, page.imageReview]
  866. .map(cleanText)
  867. .filter(Boolean)
  868. .filter((item, index, all) => all.indexOf(item) === index)
  869. .join("\n")
  870. }
  871. function buildPalette(outline: AiCoursewareOutline, variant: number, templateId?: AiCoursewareTemplateId): Palette {
  872. const resolvedTemplateId = templateId || DEFAULT_AI_COURSEWARE_TEMPLATE_ID
  873. return { ...getAiCoursewarePalette(resolvedTemplateId, variant), ...(resolvedTemplateId === "dark-lute" ? { soft: "#16485C" } : {}) }
  874. }
  875. function backgroundConfig(masterPage: TemplateMasterPage, options: AiPptOptions): SlideBackground {
  876. if (options.backgroundImage) return { type: "image", image: options.backgroundImage, imageSize: "cover" }
  877. if (masterPage.backgroundImage) return { type: "image", image: masterPage.backgroundImage, imageSize: "cover" }
  878. return { type: "solid", color: masterPage.background }
  879. }
  880. function fitFont(content: string, width: number, height: number, preferred: number, minimum: number) {
  881. const text = cleanText(content)
  882. if (!text) return preferred
  883. let size = preferred
  884. while (size > minimum) {
  885. const charsPerLine = Math.max(1, Math.floor(width / size))
  886. const lines = Math.ceil(text.length / charsPerLine)
  887. if (lines * size * 1.35 <= height) break
  888. size -= 2
  889. }
  890. return size
  891. }
  892. function textElement(params: {
  893. left: number
  894. top: number
  895. width: number
  896. height: number
  897. content: string
  898. fontSize: number
  899. minimumFontSize?: number
  900. color: string
  901. bold?: boolean
  902. lineHeight?: number
  903. }): PPTTextElement {
  904. const lineHeight = params.lineHeight || 1.25
  905. const minimum = Math.min(params.fontSize, params.minimumFontSize ?? (params.fontSize >= 30 ? 22 : 18))
  906. const resolvedFontSize = fitFont(params.content, params.width - 20, params.height, params.fontSize, minimum)
  907. return {
  908. id: nanoid(10),
  909. type: "text",
  910. left: params.left,
  911. top: params.top,
  912. width: params.width,
  913. height: params.height,
  914. rotate: 0,
  915. // 永远保留完整文本。若最小字号仍放不下,由质量门禁阻止应用,而不是静默截断。
  916. 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>`,
  917. defaultFontName: FONT_NAME,
  918. defaultColor: params.color,
  919. lineHeight,
  920. paragraphSpace: 0,
  921. wordSpace: 0,
  922. opacity: 1
  923. }
  924. }
  925. function imageElement(page: AiCoursewarePage, rect: ImageRect): PPTImageElement {
  926. const width = page.image?.width || 0
  927. const height = page.image?.height || 0
  928. const contain = page.requiredVisual || page.visualPlan?.imagePurpose !== "decoration" || page.visualPlan?.imageFit !== "cover"
  929. const scale = width > 0 && height > 0 ? Math.min(rect.width / width, rect.height / height) : 0
  930. const fittedWidth = contain && scale ? width * scale : rect.width
  931. const fittedHeight = contain && scale ? height * scale : rect.height
  932. return {
  933. id: nanoid(10),
  934. type: "image",
  935. left: rect.left + (rect.width - fittedWidth) / 2,
  936. top: rect.top + (rect.height - fittedHeight) / 2,
  937. width: fittedWidth,
  938. height: fittedHeight,
  939. rotate: 0,
  940. src: page.image?.url || "",
  941. fixedRatio: Boolean(contain),
  942. radius: rect.radius || 0,
  943. shadow: rect.radius ? softShadow() : undefined,
  944. clip: !contain && width > 0 && height > 0 ? { shape: "rect", range: coverRange(width, height, rect.width, rect.height) } : undefined
  945. }
  946. }
  947. function coverRange(sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number): [[number, number], [number, number]] {
  948. const sourceRatio = sourceWidth / sourceHeight
  949. const targetRatio = targetWidth / targetHeight
  950. if (sourceRatio > targetRatio) {
  951. const visible = (targetRatio / sourceRatio) * 100
  952. const start = (100 - visible) / 2
  953. return [
  954. [start, 0],
  955. [100 - start, 100]
  956. ]
  957. }
  958. const visible = (sourceRatio / targetRatio) * 100
  959. const start = (100 - visible) / 2
  960. return [
  961. [0, start],
  962. [100, 100 - start]
  963. ]
  964. }
  965. function shapeElement(params: {
  966. left: number
  967. top: number
  968. width: number
  969. height: number
  970. fill: string
  971. opacity?: number
  972. radius?: number
  973. shadow?: PPTElementShadow
  974. gradient?: Gradient
  975. text?: PPTShapeElement["text"]
  976. }): PPTShapeElement {
  977. return {
  978. id: nanoid(10),
  979. type: "shape",
  980. left: params.left,
  981. top: params.top,
  982. width: params.width,
  983. height: params.height,
  984. rotate: 0,
  985. viewBox: [200, 200],
  986. path: roundedRectPath(params.radius || 0),
  987. fixedRatio: false,
  988. fill: params.fill,
  989. opacity: params.opacity ?? 1,
  990. shadow: params.shadow,
  991. gradient: params.gradient,
  992. text: params.text
  993. }
  994. }
  995. function lineElement(x1: number, y1: number, x2: number, y2: number, color: string, width = 2): PPTLineElement {
  996. const left = Math.min(x1, x2)
  997. const top = Math.min(y1, y2)
  998. const lineWidth = Math.abs(x2 - x1)
  999. const lineHeight = Math.abs(y2 - y1)
  1000. return {
  1001. id: nanoid(10),
  1002. type: "line",
  1003. left,
  1004. top,
  1005. width,
  1006. start: [x1 === left ? 0 : lineWidth, y1 === top ? 0 : lineHeight],
  1007. end: [x2 === left ? 0 : lineWidth, y2 === top ? 0 : lineHeight],
  1008. style: "solid",
  1009. color,
  1010. points: ["", ""]
  1011. }
  1012. }
  1013. function keepInCanvas(elements: PPTElement[]) {
  1014. return elements.map(element => {
  1015. const left = Math.max(0, Math.min(element.left, SLIDE_WIDTH - 1))
  1016. const top = Math.max(0, Math.min(element.top, SLIDE_HEIGHT - 1))
  1017. if (element.type === "line") return { ...element, left, top, width: Math.max(1, Math.min(element.width, 20)) }
  1018. return {
  1019. ...element,
  1020. left,
  1021. top,
  1022. width: Math.max(1, Math.min(element.width, SLIDE_WIDTH - left)),
  1023. height: Math.max(1, Math.min(element.height, SLIDE_HEIGHT - top))
  1024. }
  1025. }) as PPTElement[]
  1026. }
  1027. function scaleElementsToViewport(elements: PPTElement[]): PPTElement[] {
  1028. return elements.map(element => {
  1029. if (element.type === "line") {
  1030. return {
  1031. ...element,
  1032. left: element.left * VIEWPORT_SCALE,
  1033. top: element.top * VIEWPORT_SCALE,
  1034. width: element.width * VIEWPORT_SCALE,
  1035. start: [element.start[0] * VIEWPORT_SCALE, element.start[1] * VIEWPORT_SCALE],
  1036. end: [element.end[0] * VIEWPORT_SCALE, element.end[1] * VIEWPORT_SCALE]
  1037. }
  1038. }
  1039. const scaled = {
  1040. ...element,
  1041. left: element.left * VIEWPORT_SCALE,
  1042. top: element.top * VIEWPORT_SCALE,
  1043. width: element.width * VIEWPORT_SCALE,
  1044. height: element.height * VIEWPORT_SCALE
  1045. } as PPTElement
  1046. if (scaled.type === "text") {
  1047. scaled.content = scaleInlineFontSizes(scaled.content)
  1048. }
  1049. if (scaled.type === "shape" && scaled.text) {
  1050. scaled.text = { ...scaled.text, content: scaleInlineFontSizes(scaled.text.content) }
  1051. }
  1052. if ("radius" in scaled && typeof scaled.radius === "number") {
  1053. scaled.radius *= VIEWPORT_SCALE
  1054. }
  1055. if ("shadow" in scaled && scaled.shadow) {
  1056. scaled.shadow = {
  1057. ...scaled.shadow,
  1058. h: scaled.shadow.h * VIEWPORT_SCALE,
  1059. v: scaled.shadow.v * VIEWPORT_SCALE,
  1060. blur: scaled.shadow.blur * VIEWPORT_SCALE
  1061. }
  1062. }
  1063. return scaled
  1064. })
  1065. }
  1066. function scaleInlineFontSizes(content: string) {
  1067. return content.replace(/font-size:\s*([\d.]+)px/g, (_match, size) => `font-size:${Number(size) * VIEWPORT_SCALE}px`)
  1068. }
  1069. function runPreflight(slides: Slide[]) {
  1070. slides.forEach((slide, index) => {
  1071. const invalid = slide.elements.filter(
  1072. element =>
  1073. element.left < 0 ||
  1074. element.top < 0 ||
  1075. element.left + element.width > VIEWPORT_WIDTH + 1 ||
  1076. (element.type !== "line" && element.top + element.height > VIEWPORT_HEIGHT + 1)
  1077. )
  1078. const emptyText = slide.elements.filter(element => element.type === "text" && !stripHtml(element.content))
  1079. if (invalid.length || emptyText.length)
  1080. console.warn("AI PPT preflight issue", { pageNo: index + 1, invalid: invalid.length, emptyText: emptyText.length })
  1081. })
  1082. }
  1083. function roundedRectPath(radius: number) {
  1084. const r = Math.max(0, Math.min(radius, 100))
  1085. 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`
  1086. }
  1087. function softShadow(): PPTElementShadow {
  1088. return { h: 0, v: 8, blur: 22, color: "rgba(23, 34, 46, 0.14)" }
  1089. }
  1090. function lightShadow(): PPTElementShadow {
  1091. return { h: 0, v: 4, blur: 14, color: "rgba(23, 34, 46, 0.1)" }
  1092. }
  1093. function cleanText(value?: string) {
  1094. return String(value || "")
  1095. .replace(/\s+/g, " ")
  1096. .trim()
  1097. }
  1098. function escapeHtml(value: string) {
  1099. return cleanText(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
  1100. }
  1101. function stripHtml(value: string) {
  1102. return value.replace(/<[^>]+>/g, "").trim()
  1103. }
  1104. function normalizeVisibleText(value?: string) {
  1105. return cleanText(value)
  1106. .replace(/[,。!?、;:,.!?;:\s]+/g, "")
  1107. .toLowerCase()
  1108. }
  1109. function sameVisibleText(left?: string, right?: string) {
  1110. const normalizedLeft = normalizeVisibleText(left)
  1111. return Boolean(normalizedLeft) && normalizedLeft === normalizeVisibleText(right)
  1112. }
  1113. function uniqueVisibleTexts(values: string[]) {
  1114. const seen = new Set<string>()
  1115. return values.filter(value => {
  1116. const key = normalizeVisibleText(value)
  1117. if (!key || seen.has(key)) return false
  1118. seen.add(key)
  1119. return true
  1120. })
  1121. }