aiPptTemplateEngine.ts 47 KB

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