|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
SlideTheme
|
|
|
} from "@/types/slides"
|
|
|
import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
|
|
|
-import { audioBindings, scoreBinding, isScorePage, usableResourceUrl } from "@/utils/aiPptContent"
|
|
|
+import { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts } from "@/utils/aiPptContent"
|
|
|
import {
|
|
|
AI_COURSEWARE_TEMPLATES,
|
|
|
getAiCoursewarePalette,
|
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
getAiCoursewareTemplateMaster,
|
|
|
getTemplateMasterPage,
|
|
|
selectTemplateMasterPageVariant,
|
|
|
+ resolveTemplatePageLayout,
|
|
|
validateTemplateMasterRegistry,
|
|
|
type TemplateMaster,
|
|
|
type TemplateMasterElement,
|
|
|
@@ -103,13 +104,12 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
|
|
|
const role = pageRole(page, index)
|
|
|
const intent = pageRenderIntent(page, role)
|
|
|
const master = getAiCoursewareTemplateMaster(options.templateId!)
|
|
|
- const requested = preferredLayout(page, intent)
|
|
|
- const teachingImage = hasImage(page) && page.visualPlan?.imagePurpose !== "decoration" && page.visualPlan?.imagePurpose !== "none"
|
|
|
- const needsImageSlot = role !== "cover" && teachingImage && (page.interaction?.question || ["text_focus", "compare", "activity", "summary", "objectives"].includes(requested || ""))
|
|
|
- const baseMasterPage = getTemplateMasterPage(master, role, isScorePage(page) ? "diagram_focus" : videoBinding(page) || page.requiredVisual || needsImageSlot ? "split_content" : page.interaction?.question ? "text_focus" : requested)
|
|
|
+ const baseMasterPage = getTemplateMasterPage(master, role, resolveAiPptLayout(page, index))
|
|
|
const occurrence = layoutOccurrences.get(baseMasterPage.id) || 0
|
|
|
layoutOccurrences.set(baseMasterPage.id, occurrence + 1)
|
|
|
- const masterPage = adaptMasterPage(selectTemplateMasterPageVariant(baseMasterPage, occurrence), intent)
|
|
|
+ const adapted = adaptMasterPage(selectTemplateMasterPageVariant(baseMasterPage, occurrence), intent)
|
|
|
+ const nativeVisual = isScorePage(page) || (role === "rhythm" && adapted.layout === "diagram")
|
|
|
+ const masterPage = fitVisualSlots(adapted, hasImage(page) || Boolean(videoBinding(page)), nativeVisual)
|
|
|
const contentElements = fillTemplatePage(master, masterPage, page, outline, palette, role, intent)
|
|
|
assertContentContract(master, masterPage, contentElements)
|
|
|
const elements = [
|
|
|
@@ -134,7 +134,7 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
|
|
|
const ratios: Record<string, number> = {}
|
|
|
for (const page of master.pages) {
|
|
|
for (const showMedia of [true, false]) {
|
|
|
- const adapted = adaptMasterPage(page, { role: page.roles[0], density: "medium", visualKind: "image", showMedia })
|
|
|
+ const adapted = fitVisualSlots(adaptMasterPage(page, { role: page.roles[0], density: "medium", visualKind: "image", showMedia }), true, false)
|
|
|
const slot = adapted.slots.find(slot => slot.kind === "image" && slot.enabled !== false)
|
|
|
if (slot) ratios[layouts[page.layout] + (showMedia ? ":media" : "")] = slot.width / slot.height
|
|
|
}
|
|
|
@@ -153,8 +153,9 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
|
|
|
const elements: PPTElement[] = []
|
|
|
const title = masterPage.layout === "cover" ? chapterTitle(outline, page) : displayPageTitle(page, outline)
|
|
|
// 学生可见内容必须与编辑区保持一致,不能因模板容量静默丢弃条目。
|
|
|
- const headline = activityTask(page) || studentHeadline(page)
|
|
|
- const points = studentPoints(page).filter(point => !sameVisibleText(point, headline))
|
|
|
+ const headline = cleanText(page.interaction?.question) || activityTask(page) || studentHeadline(page)
|
|
|
+ const points = uniqueVisibleTexts([activityTask(page), ...studentPoints(page), ...(page.interaction?.options || [])])
|
|
|
+ .filter(point => !sameVisibleText(point, headline))
|
|
|
|
|
|
addSlotText(elements, slotOf(masterPage, "title"), title, masterPage.layout === "cover" ? master.coverTitleColor : master.titleColor, true)
|
|
|
|
|
|
@@ -164,12 +165,6 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
|
|
|
addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
|
|
|
return elements
|
|
|
}
|
|
|
- if (role !== "cover" && page.interaction?.question) {
|
|
|
- addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, false)
|
|
|
- addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([page.interaction.question, headline, ...points, ...(page.interaction.options || [])]), master, palette, false)
|
|
|
- addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
|
|
|
- return elements
|
|
|
- }
|
|
|
|
|
|
if (masterPage.layout === "cover") {
|
|
|
const meta = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
|
|
|
@@ -189,6 +184,7 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
|
|
|
}
|
|
|
|
|
|
if (masterPage.layout === "objectives") {
|
|
|
+ addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
|
|
|
addCardGrid(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
|
|
|
return elements
|
|
|
}
|
|
|
@@ -209,7 +205,8 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
|
|
|
}
|
|
|
|
|
|
if (masterPage.layout === "text") {
|
|
|
- addSlotText(elements, slotOf(masterPage, "headline"), headline || (role === "lyric" ? "读一读,再唱一唱" : "抓住这一页的关键信息"), palette.primary, true)
|
|
|
+ addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
|
|
|
+ addSlotText(elements, slotOf(masterPage, "headline"), headline || (role === "lyric" ? "读一读,再唱一唱" : "抓住这一页的关键信息"), page.interaction?.question ? master.textColor : palette.primary, true)
|
|
|
if (role === "lyric") addLyricLines(elements, slotOf(masterPage, "body"), points, master, palette)
|
|
|
else addSlotList(elements, slotOf(masterPage, "body"), points, master, palette, false)
|
|
|
addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
|
|
|
@@ -217,16 +214,19 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
|
|
|
}
|
|
|
|
|
|
if (masterPage.layout === "diagram") {
|
|
|
+ const rhythmPattern = role === "rhythm" ? [headline, ...points].join(" ").match(/[短长](?:\s*[-—·、]\s*[短长]){1,7}/)?.[0].match(/[短长]/g) : undefined
|
|
|
if (role === "notation") addNotationVisual(elements, slotOf(masterPage, "image"), page, master, palette, false)
|
|
|
- else if (role === "rhythm") addStepGrid(elements, slotOf(masterPage, "image"), activitySteps(page), master, palette)
|
|
|
+ else if (rhythmPattern) addDurationDiagram(elements, slotOf(masterPage, "image"), rhythmPattern, master, palette)
|
|
|
+ else if (role === "rhythm") addStepGrid(elements, slotOf(masterPage, "image"), points, master, palette)
|
|
|
else addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, true)
|
|
|
- if (role !== "rhythm") addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
|
|
|
+ if (role !== "rhythm" || rhythmPattern) addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
|
|
|
else addSlotText(elements, slotOf(masterPage, "body"), headline, master.textColor)
|
|
|
addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
|
|
|
return elements
|
|
|
}
|
|
|
|
|
|
if (masterPage.layout === "compare") {
|
|
|
+ addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
|
|
|
const values = uniqueVisibleTexts([...(page.interaction?.options || []), ...points])
|
|
|
const midpoint = Math.max(1, Math.ceil(values.length / 2))
|
|
|
addSlotText(elements, slotOf(masterPage, "headline"), headline || page.interaction?.question || "听一听,它们有什么不同?", palette.primary, true)
|
|
|
@@ -237,14 +237,16 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
|
|
|
}
|
|
|
|
|
|
if (masterPage.layout === "activity") {
|
|
|
+ addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
|
|
|
addSlotText(elements, slotOf(masterPage, "headline"), headline || "一起完成音乐任务", palette.primary, true)
|
|
|
- addStepGrid(elements, slotOf(masterPage, "steps"), activitySteps(page), master, palette)
|
|
|
+ addStepGrid(elements, slotOf(masterPage, "steps"), points, master, palette)
|
|
|
addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
|
|
|
return elements
|
|
|
}
|
|
|
|
|
|
- addChecklist(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
|
|
|
- addSlotText(elements, slotOf(masterPage, "headline"), page.learningEvidence || "说一说今天的收获", palette.primary, true)
|
|
|
+ addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
|
|
|
+ addChecklist(elements, slotOf(masterPage, "body"), points, master, palette)
|
|
|
+ addSlotText(elements, slotOf(masterPage, "headline"), headline, palette.primary, true)
|
|
|
return elements
|
|
|
}
|
|
|
|
|
|
@@ -278,18 +280,53 @@ function pageRenderIntent(page: AiCoursewarePage, role: PageRole): PageRenderInt
|
|
|
return { role, density, visualKind, showMedia: hasBoundMedia || expectsMedia }
|
|
|
}
|
|
|
|
|
|
-function preferredLayout(page: AiCoursewarePage, intent: PageRenderIntent) {
|
|
|
- const requested = page.visualPlan?.layout || page.visual?.layout
|
|
|
- if (requested) return requested
|
|
|
- if (intent.visualKind === "notation" || intent.visualKind === "rhythm") return "diagram_focus"
|
|
|
- if (intent.visualKind === "compare") return "compare"
|
|
|
- if (intent.visualKind === "activity") return "activity"
|
|
|
- if (!hasImage(page) && intent.visualKind === "text" && !intent.showMedia) {
|
|
|
- if (intent.role === "goal") return "objectives"
|
|
|
- if (intent.role === "summary" || intent.role === "practice") return "summary"
|
|
|
- return "text_focus"
|
|
|
+export function resolveAiPptLayout(page: AiCoursewarePage, index: number): string {
|
|
|
+ const role = pageRole(page, index)
|
|
|
+ if (role === "cover") return "cover_scene"
|
|
|
+ if (isScorePage(page)) return "diagram_focus"
|
|
|
+ if (role === "practice") return "text_focus"
|
|
|
+ const requested = resolveTemplatePageLayout(page.visualPlan?.layout || page.visual?.layout)
|
|
|
+ // 历史大纲可能已被服务端统一改为 split,优先恢复教学页型;配图由槽位适配处理。
|
|
|
+ if (role === "rhythm") return hasImage(page) ? "activity" : "diagram_focus"
|
|
|
+ if (requested && requested !== "split" && requested !== "cover") return requested
|
|
|
+ const layouts: Partial<Record<PageRole, string>> = {
|
|
|
+ intro: "hero_focus", story: "hero_focus", goal: "objectives", knowledge: "objectives",
|
|
|
+ listen: "media_focus", compare: "compare", activity: "activity", performance: "activity",
|
|
|
+ summary: "summary", homework: "activity", practice: "text_focus", interaction: "text_focus", lyric: "text_focus"
|
|
|
+ }
|
|
|
+ return layouts[role] || "split_content"
|
|
|
+}
|
|
|
+
|
|
|
+function fitVisualSlots(page: TemplateMasterPage, hasVisual: boolean, nativeVisual: boolean): TemplateMasterPage {
|
|
|
+ if (nativeVisual) return page
|
|
|
+ const image = slotOf(page, "image")
|
|
|
+ const body = slotOf(page, "body")
|
|
|
+ if (!hasVisual) {
|
|
|
+ if (!image || !body) return page
|
|
|
+ // 只合并并排区域,不能跨过标题或侵入模板保留区域。
|
|
|
+ if (Math.abs(image.top - body.top) > 40) return page
|
|
|
+ const left = Math.min(image.left, body.left)
|
|
|
+ const right = Math.max(image.left + image.width, body.left + body.width)
|
|
|
+ return { ...page, slots: page.slots.filter(s => s.kind !== "image").map(s => s.id === body.id ? { ...s, left, width: right - left } : s) }
|
|
|
}
|
|
|
- return requested
|
|
|
+ if (image) return page
|
|
|
+ // 在既有安全内容区内给教学图片分配位置,保留对比、步骤、总结的语义结构。
|
|
|
+ const content = page.slots.filter(s => s.enabled !== false && ["body", "steps", "left", "right"].includes(s.kind))
|
|
|
+ if (!content.length) return page // 自带封面主视觉的母版保持原有背景契约。
|
|
|
+ const left = Math.min(...content.map(s => s.left)), top = Math.min(...content.map(s => s.top))
|
|
|
+ const width = Math.max(...content.map(s => s.left + s.width)) - left
|
|
|
+ const height = Math.max(...content.map(s => s.top + s.height)) - top
|
|
|
+ // 对比图片置于两栏上方,左右内容保持同等面积,避免暗示一侧更重要。
|
|
|
+ if (content.length > 1) {
|
|
|
+ const imageHeight = Math.min(...content.map(s => s.height)) * 0.42
|
|
|
+ const imageWidth = Math.min(width, imageHeight * 2)
|
|
|
+ return { ...page, slots: [...page.slots.map(s => content.some(c => c.id === s.id)
|
|
|
+ ? { ...s, top: s.top + imageHeight + 20, height: s.height - imageHeight - 20 } : s),
|
|
|
+ { id: "image", kind: "image", left: left + (width - imageWidth) / 2, top, width: imageWidth, height: imageHeight }] }
|
|
|
+ }
|
|
|
+ const imageWidth = width * 0.4
|
|
|
+ return { ...page, slots: [...page.slots.map(s => s.id === content[0].id ? { ...s, left: left + imageWidth + 28, width: width - imageWidth - 28 } : s),
|
|
|
+ { id: "image", kind: "image", left, top, width: imageWidth, height }] }
|
|
|
}
|
|
|
|
|
|
function adaptMasterPage(page: TemplateMasterPage, intent: PageRenderIntent): TemplateMasterPage {
|
|
|
@@ -501,6 +538,20 @@ function addNotationVisual(elements: PPTElement[], slot: TemplateSlot | undefine
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function addDurationDiagram(elements: PPTElement[], slot: TemplateSlot | undefined, pattern: string[], master: TemplateMaster, palette: Palette) {
|
|
|
+ if (!slot) return
|
|
|
+ // 只可视化正文明确给出的长短顺序,不猜拍号、音符时值或教材谱例。
|
|
|
+ const gap = 24, unit = (slot.width - gap * (pattern.length - 1)) / pattern.reduce((sum, item) => sum + (item === "长" ? 2 : 1), 0)
|
|
|
+ let left = slot.left
|
|
|
+ const barHeight = Math.min(80, slot.height * 0.22), top = slot.top + slot.height * 0.36
|
|
|
+ pattern.forEach(item => {
|
|
|
+ const width = unit * (item === "长" ? 2 : 1)
|
|
|
+ elements.push(shapeElement({ left, top, width, height: barHeight, fill: item === "长" ? palette.secondary : palette.primary, radius: 12 }))
|
|
|
+ addSlotText(elements, { id: "duration-label", kind: "body", left, top: top + barHeight + 22, width, height: 70, preferredFontSize: 40, minimumFontSize: 24 }, item, master.textColor, true)
|
|
|
+ left += width + gap
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
function addStepGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
|
|
|
if (!slot) return
|
|
|
const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
|
|
|
@@ -768,7 +819,7 @@ function hasImage(page: AiCoursewarePage) {
|
|
|
|
|
|
function studentHeadline(page: AiCoursewarePage) {
|
|
|
const headline = cleanText(page.studentContent?.headline || page.subtitle || "")
|
|
|
- return sameVisibleText(headline, page.title) ? "" : headline
|
|
|
+ return sameVisibleText(headline, page.title) || isTeacherFacingText(headline) ? "" : headline
|
|
|
}
|
|
|
|
|
|
function studentPoints(page: AiCoursewarePage) {
|
|
|
@@ -776,20 +827,13 @@ function studentPoints(page: AiCoursewarePage) {
|
|
|
const legacy = (page.bullets || []).map(cleanText).filter(Boolean)
|
|
|
const points = [...(structured.length ? structured : legacy), studentHeadline(page)].filter(Boolean)
|
|
|
const excluded = [page.title].map(normalizeVisibleText).filter(Boolean)
|
|
|
- const unique = uniqueVisibleTexts(points).filter(item => !excluded.includes(normalizeVisibleText(item)))
|
|
|
- return unique.length ? unique : [studentHeadline(page) || cleanText(page.title) || "课堂内容"]
|
|
|
+ return uniqueVisibleTexts(points).filter(item => !excluded.includes(normalizeVisibleText(item)) && !isTeacherFacingText(item))
|
|
|
}
|
|
|
|
|
|
function activityTask(page: AiCoursewarePage) {
|
|
|
// activity 属于教师组织信息,不能回退到学生投屏画面。
|
|
|
const task = cleanText(page.classroomAction?.studentTask || studentHeadline(page) || "")
|
|
|
- return sameVisibleText(task, page.title) ? "" : task
|
|
|
-}
|
|
|
-
|
|
|
-function activitySteps(page: AiCoursewarePage) {
|
|
|
- const task = activityTask(page)
|
|
|
- const points = studentPoints(page).filter(item => !sameVisibleText(item, task))
|
|
|
- return uniqueVisibleTexts(points)
|
|
|
+ return sameVisibleText(task, page.title) || isTeacherFacingText(task) ? "" : task
|
|
|
}
|
|
|
|
|
|
function slideRemark(page: AiCoursewarePage) {
|
|
|
@@ -802,7 +846,7 @@ function slideRemark(page: AiCoursewarePage) {
|
|
|
const explanation = page.interaction?.explanation ? `解析:${page.interaction.explanation}` : ""
|
|
|
const resources = (page.resourceBindings || []).map(item => `${item.name || item.resourceType}:${item.purpose || "课堂资源"}`).join("\n")
|
|
|
const mediaReminder = /listen|sing/.test(page.type) && audioBindings(page).length === 0 ? "资源准备:请为本页听唱任务补充对应音频。" : ""
|
|
|
- return [lessonMeta, page.teachingPurpose, activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, page.imageReview]
|
|
|
+ return [lessonMeta, page.teachingPurpose, ...teacherFacingTexts(page), activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, page.imageReview]
|
|
|
.map(cleanText)
|
|
|
.filter(Boolean)
|
|
|
.filter((item, index, all) => all.indexOf(item) === index)
|