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