aiPptTemplateEngine.ts 45 KB

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