aiPptPageDesign.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. import type { AiCoursewarePage } from "@/api/aiCourseware"
  2. import type { TemplateMaster, TemplateMasterPage, TemplateSlot } from "@/config/aiCoursewareTemplateMasters"
  3. import { modelPageContent } from '@/utils/aiPptContent'
  4. import { measureAiPptText, aiPptBlockHeight } from '@/utils/aiPptTextMetrics'
  5. export const PAGE_COMPOSITIONS = ['full_scene', 'image_left', 'image_right', 'image_top', 'comparison', 'steps', 'focus_question', 'concept_grid'] as const
  6. export const VISUAL_MODES = ['generated_image', 'reference_image', 'native_diagram', 'text_only'] as const
  7. export function hasModelPageDesign(page: AiCoursewarePage) {
  8. return PAGE_COMPOSITIONS.includes(page.visualPlan?.composition as typeof PAGE_COMPOSITIONS[number])
  9. && VISUAL_MODES.includes(page.visualPlan?.visualMode as typeof VISUAL_MODES[number])
  10. }
  11. export const externalVisual = (page: AiCoursewarePage) => ['generated_image', 'reference_image'].includes(page.visualPlan?.visualMode || '')
  12. // 模型提供构图、比例和阅读顺序,编译器只负责安全坐标。旧大纲继续走原母版。
  13. export function compilePageDesign(page: AiCoursewarePage, index: number, master: TemplateMaster, showMedia: boolean, hasVisual: boolean, hasHeadline: boolean, preferSide = false): TemplateMasterPage {
  14. const plan = page.visualPlan!
  15. const composition = plan.composition!
  16. const share = Number.isFinite(plan.visualShare) ? Math.round(Math.min(.70, Math.max(.35, plan.visualShare!)) * 20) / 20 : .55
  17. const cover = index === 0
  18. const content = modelPageContent(page)
  19. const slot = (id: string, left: number, top: number, width: number, height: number, font = 34): TemplateSlot => ({
  20. id, kind: id as TemplateSlot['kind'], left, top, width, height, preferredFontSize: font, minimumFontSize: 28
  21. })
  22. const slots: TemplateSlot[] = [slot('title', 80, 64, 1440, Math.max(cover ? 110 : 86, measureAiPptText(page.title, 1440, cover ? 68 : 50, 1.2, true)), cover ? 68 : 50)]
  23. if (cover) slots.push(slot('meta', 80, Math.max(174, slots[0].top + slots[0].height + 8), 1440, 62, 28))
  24. if (hasHeadline) slots.push(slot('headline', 80, cover ? 236 : 174, 1440, 82, composition === 'focus_question' ? 44 : 36))
  25. const heading = slots.find(item => item.id === 'headline')
  26. if (heading) {
  27. heading.top = Math.max(heading.top, ...slots.filter(item => ['title', 'meta'].includes(item.id)).map(item => item.top + item.height + 12))
  28. heading.height = Math.max(heading.height, measureAiPptText(content.heading, heading.width, heading.preferredFontSize!, 1.2, true))
  29. }
  30. const baseTop = Math.max(cover ? hasHeadline ? 340 : 250 : hasHeadline ? 280 : 190, ...slots.map(item => item.top + item.height + 18))
  31. const instructionHeight = content.task ? Math.max(62, measureAiPptText(content.task, 1440, 30)) : 0
  32. if (content.task) slots.push({ ...slot('instruction', 80, baseTop - 4, 1440, instructionHeight, 30), kind: 'headline' })
  33. const top = baseTop + (content.task ? instructionHeight + 14 : 0)
  34. const bottom = showMedia ? 730 : 830
  35. const height = bottom - top
  36. if (showMedia) slots.push(slot('media', 80, 758, 1440, 72, 30))
  37. const presentation = plan.textPresentation || 'plain'
  38. const bodyHeight = (width: number) => content.groups.length === 2
  39. ? Math.max(...content.groups.map(group => (group.label ? 66 : 0) + aiPptBlockHeight(group.points, (width - 40) / 2, presentation)))
  40. + (content.points.length ? aiPptBlockHeight(content.points, width, 'plain') + 20 : 0)
  41. : aiPptBlockHeight([...content.steps, ...content.points], width, presentation)
  42. let body = slot('body', 80, top, 1440, height)
  43. if (hasVisual) {
  44. if (preferSide || ['image_left', 'image_right', 'concept_grid', 'focus_question'].includes(composition)) {
  45. let imageWidth = (1440 - 36) * share
  46. // 文字较多时逐步让出横向空间,不把正文压成狭长小字。
  47. while (imageWidth > (1440 - 36) * .35 && bodyHeight(1440 - imageWidth - 36) > height) imageWidth = Math.max((1440 - 36) * .35, imageWidth - 70)
  48. const bodyWidth = 1440 - imageWidth - 36
  49. const imageLeft = composition === 'image_right' ? 80 + bodyWidth + 36 : 80
  50. slots.push(slot('image', imageLeft, top, imageWidth, height))
  51. body = slot('body', composition === 'image_right' ? 80 : 80 + imageWidth + 36, top, bodyWidth, height)
  52. } else {
  53. // 场景主图下的少量短提示横排,正文不能逐行挤掉主图。
  54. const captionWidth = (1440 - 28 * (content.points.length - 1)) / Math.max(1, content.points.length)
  55. const captionHeight = Math.max(80, ...content.points.map(text => measureAiPptText(text, captionWidth, 38)))
  56. const captionRow = ['full_scene', 'image_top'].includes(composition) && presentation === 'plain'
  57. && !content.steps.length && !content.groups.length && content.points.length >= 2 && content.points.length <= 3
  58. && captionHeight <= 140 && height - captionHeight - 28 >= 240
  59. // 短句必须保留可读空间,不能靠缩小字号挤到图片下面。
  60. const imageHeight = captionRow ? height - captionHeight - 28
  61. : Math.max(80, Math.min(height * share, height - Math.max(180, bodyHeight(1440)) - 28))
  62. const imageRatio = page.image?.width && page.image?.height ? page.image.width / page.image.height : 2.8
  63. const imageWidth = Math.min(1440, imageHeight * imageRatio)
  64. slots.push(slot('image', 80 + (1440 - imageWidth) / 2, top, imageWidth, imageHeight))
  65. body = slot('body', 80, top + imageHeight + 28, 1440, height - imageHeight - 28)
  66. if (captionRow) body.textFlow = 'columns'
  67. }
  68. }
  69. if (composition === 'comparison' && content.groups.length === 2) {
  70. if (content.points.length) {
  71. const sharedHeight = Math.min(aiPptBlockHeight(content.points, body.width, 'plain'), body.height * .4)
  72. slots.push({ ...body, top: body.top + body.height - sharedHeight, height: sharedHeight })
  73. body = { ...body, height: body.height - sharedHeight - 20 }
  74. }
  75. slots.push({ ...body, id: 'left', kind: 'left', width: (body.width - 40) / 2 })
  76. slots.push({ ...body, id: 'right', kind: 'right', left: body.left + (body.width + 40) / 2, width: (body.width - 40) / 2 })
  77. } else slots.push(body)
  78. if (cover && !hasVisual && !showMedia) {
  79. // 主图缺失时重新组织成文字封面,不把原图位置变成整页空洞。
  80. for (const item of slots) {
  81. if (item.id === 'title') { item.top = 230; item.height = 110; item.preferredFontSize = 76 }
  82. else if (item.id === 'meta') item.top = 365
  83. else if (item.id === 'headline') item.top = 445
  84. else if (item.id === 'instruction') item.top = 545
  85. else if (item.id === 'body') { item.top = content.task ? 625 : 550; item.height = 180 }
  86. }
  87. }
  88. return {
  89. id: `model-${composition}`, layout: cover ? 'cover' : 'text', roles: [cover ? 'cover' : 'content'],
  90. // 模型设计使用模板配色;固定插画背景不再限制模型构图,也不冒充教学主图。
  91. background: master.pages[0].background, backgroundKind: 'decorative', backgroundVisual: false,
  92. titleColor: master.titleColor, coverTitleColor: master.titleColor, textColor: master.textColor,
  93. base: [], chrome: [], slots, safeArea: { id: 'model-safe', purpose: '投屏内容', left: 64, top: 48, width: 1472, height: 804 }, reservedZones: []
  94. }
  95. }