aiPptTemplateEngine.ts 57 KB

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