|
|
@@ -21,6 +21,8 @@ import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
|
|
|
import { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts, modelPageContent, rhythmRows } from "@/utils/aiPptContent"
|
|
|
import { compilePageDesign, hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
|
|
|
import { teachingActionIcon } from '@/config/aiPptTeachingIcons'
|
|
|
+import { composeAiPptTypography, type TypographyDesign } from '@/utils/aiPptTypography'
|
|
|
+import { modelSlideDesignIssues, modelSlideTextReferences } from './aiPptModelSlideDesign'
|
|
|
import {
|
|
|
AI_COURSEWARE_TEMPLATES,
|
|
|
getAiCoursewarePalette,
|
|
|
@@ -133,12 +135,13 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
|
|
|
const role = pageRole(page, index)
|
|
|
const intent = pageRenderIntent(page, role)
|
|
|
const originalMaster = getAiCoursewareTemplateMaster(options.templateId!)
|
|
|
+ if (page.pageDesign) return buildModelDesignedSlide(page, originalMaster, palette, options)
|
|
|
const designedTheme = hasModelPageDesign(page)
|
|
|
// 保持整课同一组颜色,以内容角色区分纸底、情境底和章节重点底。
|
|
|
// 使用真实背景填色,不把装饰图当作缺失的教学图片。
|
|
|
if (designedTheme) {
|
|
|
const emphasis = role === 'cover' || role === 'summary'
|
|
|
- const tint = emphasis ? 20 : externalVisual(page) ? 10 : page.visualPlan?.visualMode === 'native_diagram' ? 5 : 0
|
|
|
+ const tint = options.templateId === 'blank' || page.visualPlan?.visualMode === 'text_only' ? 0 : emphasis ? 20 : externalVisual(page) ? 10 : page.visualPlan?.visualMode === 'native_diagram' ? 5 : 0
|
|
|
const candidate = tinycolor.mix(palette.background, palette.secondary, tint).toHexString()
|
|
|
const background = tinycolor.readability(candidate, palette.text) >= 4.5 ? candidate : palette.background
|
|
|
palette = { ...palette, background }
|
|
|
@@ -158,9 +161,15 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
|
|
|
let masterPage = designed
|
|
|
? compilePageDesign(page, index, master, intent.showMedia, (externalVisual(page) && hasImage(page)) || Boolean(videoBinding(page)), Boolean(headline))
|
|
|
: fitVisualSlots(adapted, hasImage(page) || Boolean(videoBinding(page)), nativeVisual)
|
|
|
- let contentElements = designed
|
|
|
+ const groupedSequence = page.diagram?.kind === 'sequence' && modelPageContent(page).groups.length >= 2
|
|
|
+ const typography = designed && !hasImage(page) && !videoBinding(page) && (!page.diagram || groupedSequence)
|
|
|
+ && (page.visualPlan?.visualMode === 'text_only' || externalVisual(page) || groupedSequence)
|
|
|
+ ? composeAiPptTypography(page, outline, index, intent.showMedia) : undefined
|
|
|
+ let contentElements = typography ? renderTypography(typography, master, palette)
|
|
|
+ : designed
|
|
|
? fillDesignedPage(master, masterPage, page, outline, palette, index, headline, intent)
|
|
|
: fillTemplatePage(master, masterPage, page, outline, palette, role, intent)
|
|
|
+ if (typography) addInlineMedia(contentElements, slotOf(masterPage, 'media'), page, master, palette, intent.showMedia)
|
|
|
const visualArea = (items: PPTElement[]) => Math.max(0, ...items.map(item => item.type === 'image' ? item.width * item.height : 0))
|
|
|
const smallMainImage = hasImage(page) && visualArea(contentElements) < SLIDE_WIDTH * SLIDE_HEIGHT * .18
|
|
|
if (designed && (aiPptTextGeometry(scaleElementsToViewport(contentElements)).length || smallMainImage)) {
|
|
|
@@ -179,8 +188,11 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
|
|
|
assertContentContract(master, masterPage, contentElements)
|
|
|
const elements = [
|
|
|
...renderTemplateMasterElements(masterPage.base),
|
|
|
- ...(designed && role === 'cover' && !hasImage(page) && !intent.showMedia
|
|
|
+ ...(typography ? [] : designed && role === 'cover' && !hasImage(page) && !intent.showMedia
|
|
|
? [lineElement(80, 70, 1520, 70, palette.primary, 6), lineElement(80, 842, 1520, 842, palette.primary, 2)]
|
|
|
+ : designed && options.templateId === 'blank'
|
|
|
+ ? [{ ...shapeElement({ left: slotOf(masterPage, 'title')!.left + 8, top: slotOf(masterPage, 'title')!.top + slotOf(masterPage, 'title')!.height + 4,
|
|
|
+ width: 110, height: 4, fill: palette.secondary }), name: '版式分隔线' }]
|
|
|
: designed ? [lineElement(52, slotOf(masterPage, 'title')!.top + 8, 52,
|
|
|
slotOf(masterPage, 'title')!.top + Math.min(slotOf(masterPage, 'title')!.height - 8, 64), palette.secondary, 6)] : []),
|
|
|
...contentElements,
|
|
|
@@ -214,9 +226,39 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
|
|
|
ratios.model_design_v4 = 1 // 整课一致性复核与内容驱动的图片尺寸。
|
|
|
ratios.model_design_v5 = 1 // 按拍分组、学生范例和内容复核完成状态。
|
|
|
ratios.model_design_v6 = 1 // 学生文字预算、去重及混合声音属性检查。
|
|
|
+ ratios.model_design_v7 = 1 // 模型输出逐页原生元素布局,支持渲染诊断反馈修复。
|
|
|
return ratios
|
|
|
}
|
|
|
|
|
|
+function buildModelDesignedSlide(page: AiCoursewarePage, master: TemplateMaster, palette: Palette, options: AiPptOptions): Slide {
|
|
|
+ const problems = modelSlideDesignIssues(page)
|
|
|
+ if (problems.length) throw new Error(`模型页面设计未通过:${problems.join(';')}`)
|
|
|
+ const design = page.pageDesign!, refs = modelSlideTextReferences(page), elements: PPTElement[] = []
|
|
|
+ // 色块作底,其他元素严格执行模型给定的几何;不重新选择模板、不缩小字号。
|
|
|
+ const nodes = [...design.elements.filter(e => e.kind === 'panel'), ...design.elements.filter(e => e.kind !== 'panel')]
|
|
|
+ for (const node of nodes) {
|
|
|
+ const rect = { left: node.x, top: node.y, width: node.width, height: node.height }
|
|
|
+ const slot = { ...rect, id: node.id, kind: 'image' } as TemplateSlot
|
|
|
+ const before = elements.length
|
|
|
+ if (node.kind === 'text') elements.push(textElement({ ...rect, content: refs[node.textRef!],
|
|
|
+ fontSize: node.fontSize!, minimumFontSize: node.fontSize!, color: node.color!, bold: node.bold, align: node.align }))
|
|
|
+ else if (node.kind === 'panel' || node.kind === 'line') elements.push({ ...shapeElement({ ...rect, fill: node.fill!, radius: node.radius || 0 }), name: '模型教学色块' })
|
|
|
+ else if (node.kind === 'arrow') elements.push({ ...sequenceArrow(node.x, node.y + node.height / 2, node.width, node.fill!), ...rect })
|
|
|
+ else if (node.kind === 'image' && hasImage(page)) elements.push(imageElement(page, rect))
|
|
|
+ else if (node.kind === 'diagram') addTeachingDiagram(elements, slot, page.diagram!, master, palette)
|
|
|
+ else if (node.kind === 'audio') addInlineMedia(elements, slot, page, master, palette, true)
|
|
|
+ else if (node.kind === 'score' || node.kind === 'video') addVisualOrResource(elements, slot, {
|
|
|
+ ...page, resourceBindings: page.resourceBindings?.filter(b => b.resourceType === (node.kind === 'score' ? 'MUSIC' : 'VIDEO')).map(b => ({ ...b, dedicatedPage: true }))
|
|
|
+ }, master, palette)
|
|
|
+ elements.slice(before).forEach(element => { element.id = `${node.id}-${element.id}` })
|
|
|
+ }
|
|
|
+ const scaled = scaleElementsToViewport(elements), errors = aiPptTextGeometry(scaled)
|
|
|
+ if (errors.length) throw new Error(`模型页面设计文字超出投屏安全区:${errors.join(';')}`)
|
|
|
+ return { id: nanoid(10), elements: scaled, background: { type: 'solid', color: design.background },
|
|
|
+ remark: `${slideRemark(page)}\n设计依据:${design.rationale}`, animations: options.enableAnimations ? entranceAnimations(elements) : undefined,
|
|
|
+ turningMode: options.enableAnimations ? 'fade' : 'no' }
|
|
|
+}
|
|
|
+
|
|
|
function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number, headline: string, intent: PageRenderIntent): PPTElement[] {
|
|
|
const elements: PPTElement[] = []
|
|
|
const content = modelPageContent(page)
|
|
|
@@ -241,6 +283,8 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
|
|
|
values.forEach((value, i) => addSlotText(elements, { ...coverSlot, top: coverSlot.top + i * (height + gap), height }, value, master.textColor, false, 'center'))
|
|
|
return
|
|
|
}
|
|
|
+ if (hasImage(page) && ['cards', 'grid'].includes(format)
|
|
|
+ && addIllustratedPanels(elements, slot, values, master, palette)) return
|
|
|
if (addEditorialRows(elements, slot, values, format, master, palette)) return
|
|
|
if (!hasImage(page) && !page.diagram && !content.groups.length
|
|
|
&& addDesignedPanels(elements, slot, values, format, master, palette)) return
|
|
|
@@ -353,6 +397,66 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
|
|
|
return elements
|
|
|
}
|
|
|
|
|
|
+function renderTypography(design: TypographyDesign, master: TemplateMaster, palette: Palette): PPTElement[] {
|
|
|
+ const accent = tinycolor(palette.secondary)
|
|
|
+ while (tinycolor.readability(accent, palette.background) < 3 && accent.getLuminance() > .05) accent.darken(5)
|
|
|
+ const body = tinycolor.mix(master.textColor, '#404040', 65).toHexString()
|
|
|
+ const colors = { title: master.titleColor, body: tinycolor.readability(body, palette.background) >= 4.5 ? body : master.textColor,
|
|
|
+ muted: master.mutedColor, accent: tinycolor.readability(accent, palette.background) >= 3 ? accent.toHexString() : master.titleColor }
|
|
|
+ return [
|
|
|
+ ...design.panels.map(panel => ({ ...shapeElement({ ...panel, fill: semanticPanelFill(panel.tone, palette), radius: 12 }), name: '教学分组色块' })),
|
|
|
+ ...design.arrows.map(arrow => sequenceArrow(arrow.left, arrow.top, arrow.width, master.mutedColor)),
|
|
|
+ // 用有面积的原生细矩形作分隔线,避免零高度路径在 PPTX 阅读器中消失。
|
|
|
+ ...design.rules.map(rule => ({ ...shapeElement({ left: rule.left, top: rule.top,
|
|
|
+ width: Math.max(rule.right - rule.left, rule.accent ? 4 : 1), height: Math.max(rule.bottom - rule.top, rule.accent ? 4 : 1),
|
|
|
+ fill: rule.accent ? palette.secondary : tinycolor.mix(palette.background, master.titleColor, 20).toHexString() }), name: '版式分隔线' })),
|
|
|
+ ...design.texts.map(block => {
|
|
|
+ const element = textElement({ ...block, content: block.text, fontSize: block.font, minimumFontSize: block.font,
|
|
|
+ color: colors[block.tone], align: block.center ? 'center' : 'left' })
|
|
|
+ if (block.lead) {
|
|
|
+ const rest = block.text.slice(block.lead.length)
|
|
|
+ element.content = element.content.replace(escapeHtml(block.text),
|
|
|
+ `<p style="margin:0;font-size:${block.font + 4}px;color:${master.titleColor}"><strong>${escapeHtml(block.lead)}</strong></p><p style="margin:8px 0 0">${escapeHtml(rest)}</p>`)
|
|
|
+ }
|
|
|
+ return element
|
|
|
+ })
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+function semanticPanelFill(index: number, palette: Palette) {
|
|
|
+ // 浅色表示分组,标题和正文继续使用深色,避免用颜色单独传达含义。
|
|
|
+ return tinycolor.mix(palette.background, ['#75BBD9', palette.secondary, '#8DBA99'][index % 3], index % 3 === 1 ? 20 : 28).toHexString()
|
|
|
+}
|
|
|
+
|
|
|
+function addIllustratedPanels(elements: PPTElement[], slot: TemplateSlot, values: string[], master: TemplateMaster, palette: Palette) {
|
|
|
+ if (values.length < 2 || values.length > 3 || slot.width < 400) return false
|
|
|
+ const labels = values.map(value => value.match(/^(.{2,14}[::])(.+)$/))
|
|
|
+ if (!labels.every(Boolean)) return false
|
|
|
+ const padding = 24, width = slot.width - padding * 2
|
|
|
+ const heightOf = (value: string, font: number) => {
|
|
|
+ const label = value.match(/^(.{2,14}[::])(.+)$/)!
|
|
|
+ return measureAiPptText(label[1], width, font + 4, 1.2, true) + measureAiPptText(label[2], width, font) - 16 + 8
|
|
|
+ }
|
|
|
+ const font = [38, 36, 34, 32, 30, 28].find(font => values.reduce((sum, value) => sum + heightOf(value, font) + padding * 2, 0) + 24 * (values.length - 1) <= slot.height)
|
|
|
+ if (!font) return false
|
|
|
+ const design: TypographyDesign = { panels: [], texts: [], rules: [], arrows: [] }
|
|
|
+ let top = slot.top
|
|
|
+ values.forEach((text, i) => {
|
|
|
+ const height = heightOf(text, font)
|
|
|
+ design.panels.push({ left: slot.left, top, width: slot.width, height: height + padding * 2, tone: i })
|
|
|
+ design.texts.push({ text, left: slot.left + padding, top: top + padding, width, height, font, tone: 'body', lead: labels[i]![1] })
|
|
|
+ top += height + padding * 2 + 24
|
|
|
+ })
|
|
|
+ elements.push(...renderTypography(design, master, palette))
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+function sequenceArrow(left: number, center: number, width: number, color: string): PPTShapeElement {
|
|
|
+ // 箭头是可编辑原生路径,有非零面积,浏览器和 PPTX 使用相同几何。
|
|
|
+ return { ...shapeElement({ left, top: center - 12, width, height: 24, fill: color }),
|
|
|
+ name: '教学顺序箭头', viewBox: [100, 24], path: 'M0,10 L84,10 L84,0 L100,12 L84,24 L84,14 L0,14 Z' }
|
|
|
+}
|
|
|
+
|
|
|
/** 长步骤采用纵向阅读;明确的“要点:说明”保留原文并突出要点,不自动发明小标题。 */
|
|
|
function addEditorialRows(elements: PPTElement[], slot: TemplateSlot, values: string[], format: string, master: TemplateMaster, palette: Palette) {
|
|
|
if (slot.width < 900 || values.length < 2 || values.length > 5 || values.some(value => value.startsWith('例子:'))) return false
|
|
|
@@ -455,6 +559,22 @@ export function validTeachingDiagram(diagram: NonNullable<AiCoursewarePage['diag
|
|
|
}
|
|
|
|
|
|
function addTeachingDiagram(elements: PPTElement[], slot: TemplateSlot, diagram: NonNullable<AiCoursewarePage['diagram']>, master: TemplateMaster, palette: Palette) {
|
|
|
+ if (diagram.kind === 'sequence') {
|
|
|
+ const captionHeight = diagram.caption ? measureAiPptText(diagram.caption, slot.width, 32) + 24 : 0
|
|
|
+ const gap = 56, width = (slot.width - gap * (diagram.items.length - 1)) / diagram.items.length
|
|
|
+ const height = Math.min(180, slot.height - captionHeight)
|
|
|
+ if (height >= 90) {
|
|
|
+ const top = slot.top + captionHeight + Math.min(36, (slot.height - captionHeight - height) / 2)
|
|
|
+ if (diagram.caption) addSlotText(elements, { ...slot, height: captionHeight - 24, preferredFontSize: 32 }, diagram.caption, master.titleColor, true)
|
|
|
+ diagram.items.forEach((item, i) => {
|
|
|
+ const left = slot.left + i * (width + gap)
|
|
|
+ elements.push({ ...shapeElement({ left, top, width, height, fill: semanticPanelFill(i, palette), radius: 12 }), name: '教学分组色块' })
|
|
|
+ addSlotText(elements, { ...slot, left: left + 20, top: top + 20, width: width - 40, height: height - 40, preferredFontSize: 36 }, item.label, master.titleColor, true, 'center')
|
|
|
+ if (i < diagram.items.length - 1) elements.push(sequenceArrow(left + width + 10, top + height / 2, gap - 20, master.mutedColor))
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
const captionHeight = diagram.caption ? Math.max(54, measureAiPptText(diagram.caption, slot.width, 32)) : 0
|
|
|
const area = { ...slot, height: slot.height - captionHeight - 16 }
|
|
|
const gap = 32, count = diagram.items.length
|
|
|
@@ -1054,7 +1174,7 @@ function addVisualOrResource(elements: PPTElement[], slot: TemplateSlot | undefi
|
|
|
return
|
|
|
}
|
|
|
if (hasImage(page)) {
|
|
|
- elements.push(imageElement(page, { left: slot.left, top: slot.top, width: slot.width, height: slot.height, radius: 18 }))
|
|
|
+ elements.push(imageElement(page, { left: slot.left, top: slot.top, width: slot.width, height: slot.height, radius: hasModelPageDesign(page) ? 0 : 18 }))
|
|
|
return
|
|
|
}
|
|
|
// 无教学图片时留给文字排版;通用装饰不能充当观察对象。
|
|
|
@@ -1273,7 +1393,7 @@ function textElement(params: {
|
|
|
minimumFontSize?: number
|
|
|
color: string
|
|
|
bold?: boolean
|
|
|
- align?: 'left' | 'center'
|
|
|
+ align?: 'left' | 'center' | 'right'
|
|
|
lineHeight?: number
|
|
|
}): PPTTextElement {
|
|
|
const lineHeight = params.lineHeight || 1.25
|
|
|
@@ -1288,7 +1408,7 @@ function textElement(params: {
|
|
|
height: params.height,
|
|
|
rotate: 0,
|
|
|
// 永远保留完整文本。若最小字号仍放不下,由质量门禁阻止应用,而不是静默截断。
|
|
|
- content: `<div style="box-sizing:border-box;width:calc(100% - 20px);text-align:${params.align || 'left'};font-family:${FONT_NAME};font-size:${resolvedFontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(cleanText(params.content))}</div>`,
|
|
|
+ content: `<div style="box-sizing:border-box;width:calc(100% - 20px);text-align:${params.align || 'left'};font-family:${FONT_NAME};font-size:${resolvedFontSize}px;${params.bold ? "font-weight:bold;" : ""}">${escapeHtml(cleanText(params.content))}</div>`,
|
|
|
defaultFontName: FONT_NAME,
|
|
|
defaultColor: params.color,
|
|
|
lineHeight,
|