|
@@ -20,6 +20,7 @@ import type {
|
|
|
import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
|
|
import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
|
|
|
import { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts, modelPageContent, rhythmRows } from "@/utils/aiPptContent"
|
|
import { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts, modelPageContent, rhythmRows } from "@/utils/aiPptContent"
|
|
|
import { compilePageDesign, hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
|
|
import { compilePageDesign, hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
|
|
|
|
|
+import { teachingActionIcon } from '@/config/aiPptTeachingIcons'
|
|
|
import {
|
|
import {
|
|
|
AI_COURSEWARE_TEMPLATES,
|
|
AI_COURSEWARE_TEMPLATES,
|
|
|
getAiCoursewarePalette,
|
|
getAiCoursewarePalette,
|
|
@@ -107,7 +108,17 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
|
|
|
const role = pageRole(page, index)
|
|
const role = pageRole(page, index)
|
|
|
const intent = pageRenderIntent(page, role)
|
|
const intent = pageRenderIntent(page, role)
|
|
|
const originalMaster = getAiCoursewareTemplateMaster(options.templateId!)
|
|
const originalMaster = getAiCoursewareTemplateMaster(options.templateId!)
|
|
|
- const master = options.templateId === 'blank' && hasModelPageDesign(page) ? {
|
|
|
|
|
|
|
+ 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 candidate = tinycolor.mix(palette.background, palette.secondary, tint).toHexString()
|
|
|
|
|
+ const background = tinycolor.readability(candidate, palette.text) >= 4.5 ? candidate : palette.background
|
|
|
|
|
+ palette = { ...palette, background }
|
|
|
|
|
+ }
|
|
|
|
|
+ const master = designedTheme ? {
|
|
|
...originalMaster, titleColor: palette.primary, coverTitleColor: palette.primary,
|
|
...originalMaster, titleColor: palette.primary, coverTitleColor: palette.primary,
|
|
|
textColor: palette.text, mutedColor: palette.muted,
|
|
textColor: palette.text, mutedColor: palette.muted,
|
|
|
pages: originalMaster.pages.map(item => ({ ...item, background: palette.background }))
|
|
pages: originalMaster.pages.map(item => ({ ...item, background: palette.background }))
|
|
@@ -143,6 +154,8 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
|
|
|
assertContentContract(master, masterPage, contentElements)
|
|
assertContentContract(master, masterPage, contentElements)
|
|
|
const elements = [
|
|
const elements = [
|
|
|
...renderTemplateMasterElements(masterPage.base),
|
|
...renderTemplateMasterElements(masterPage.base),
|
|
|
|
|
+ ...(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,
|
|
...contentElements,
|
|
|
...renderTemplateMasterElements(masterPage.chrome)
|
|
...renderTemplateMasterElements(masterPage.chrome)
|
|
|
]
|
|
]
|
|
@@ -151,7 +164,7 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
|
|
|
elements: scaleElementsToViewport(keepInCanvas(elements)),
|
|
elements: scaleElementsToViewport(keepInCanvas(elements)),
|
|
|
background: backgroundConfig(masterPage, options),
|
|
background: backgroundConfig(masterPage, options),
|
|
|
remark: slideRemark(page),
|
|
remark: slideRemark(page),
|
|
|
- animations: options.enableAnimations ? entranceAnimations(contentElements) : undefined,
|
|
|
|
|
|
|
+ animations: options.enableAnimations ? entranceAnimations(designed ? elements : contentElements) : undefined,
|
|
|
turningMode: options.enableAnimations ? "fade" : "no"
|
|
turningMode: options.enableAnimations ? "fade" : "no"
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -180,7 +193,10 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
|
|
|
function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number, headline: string, intent: PageRenderIntent): PPTElement[] {
|
|
function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number, headline: string, intent: PageRenderIntent): PPTElement[] {
|
|
|
const elements: PPTElement[] = []
|
|
const elements: PPTElement[] = []
|
|
|
const content = modelPageContent(page)
|
|
const content = modelPageContent(page)
|
|
|
- const points = content.points
|
|
|
|
|
|
|
+ const exampleSlot = layout.slots.find(slot => slot.id === 'example')
|
|
|
|
|
+ const examples = exampleSlot ? content.points.filter(text => text.startsWith('例子:')) : []
|
|
|
|
|
+ const points = content.points.filter(text => !examples.includes(text))
|
|
|
|
|
+ if (exampleSlot) addMeasuredFlow(elements, exampleSlot, examples, master.textColor, false, 32)
|
|
|
addSlotText(elements, slotOf(layout, 'title'), page.title, master.titleColor, true)
|
|
addSlotText(elements, slotOf(layout, 'title'), page.title, master.titleColor, true)
|
|
|
if (index === 0) addSlotText(elements, slotOf(layout, 'meta'), [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(' · '), master.mutedColor)
|
|
if (index === 0) addSlotText(elements, slotOf(layout, 'meta'), [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(' · '), master.mutedColor)
|
|
|
addSlotText(elements, slotOf(layout, 'headline'), headline, master.textColor, true)
|
|
addSlotText(elements, slotOf(layout, 'headline'), headline, master.textColor, true)
|
|
@@ -190,6 +206,8 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
|
|
|
const presentation = page.visualPlan!.textPresentation || 'plain'
|
|
const presentation = page.visualPlan!.textPresentation || 'plain'
|
|
|
const paintText = (slot: TemplateSlot | undefined, values: string[], format = presentation) => {
|
|
const paintText = (slot: TemplateSlot | undefined, values: string[], format = presentation) => {
|
|
|
if (!slot || !values.length) return
|
|
if (!slot || !values.length) return
|
|
|
|
|
+ if (!hasImage(page) && !page.diagram && !content.groups.length
|
|
|
|
|
+ && addDesignedPanels(elements, slot, values, format, master, palette)) return
|
|
|
if (slot?.textFlow === 'columns' && values.length) {
|
|
if (slot?.textFlow === 'columns' && values.length) {
|
|
|
const width = (slot.width - 28 * (values.length - 1)) / values.length
|
|
const width = (slot.width - 28 * (values.length - 1)) / values.length
|
|
|
for (let index = 0; index < values.length; index++) addSlotText(elements, { ...slot, left: slot.left + index * (width + 28), width,
|
|
for (let index = 0; index < values.length; index++) addSlotText(elements, { ...slot, left: slot.left + index * (width + 28), width,
|
|
@@ -296,6 +314,50 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
|
|
|
return elements
|
|
return elements
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** 短任务用有语义的图标和色块形成阅读顺序,密集正文继续采用测量后的文字流。 */
|
|
|
|
|
+function addDesignedPanels(elements: PPTElement[], slot: TemplateSlot, values: string[], format: string, master: TemplateMaster, palette: Palette) {
|
|
|
|
|
+ if (values.length < 2 || values.length > 4 || slot.width < 1100 || slot.height < 240
|
|
|
|
|
+ || values.some(value => value.length > 36 || value.startsWith('例子:'))) return false
|
|
|
|
|
+ const icons = values.map(teachingActionIcon)
|
|
|
|
|
+ if (['plain', 'steps'].includes(format) && !icons.every(Boolean)) return false
|
|
|
|
|
+ const gap = 28, width = (slot.width - gap * (values.length - 1)) / values.length
|
|
|
|
|
+ const font = 36, inset = 28, iconSize = 64
|
|
|
|
|
+ const textWidth = width - inset * 2
|
|
|
|
|
+ const measure = (value: string) => measureAiPptText(value, textWidth, font, 1.2, true)
|
|
|
|
|
+ // 过长的任务优先在已有标点处分行,避免“力度”等词只剩一个字落到下一行。
|
|
|
|
|
+ const clauses = values.map(value => {
|
|
|
|
|
+ const split = value.match(/^(.{3,}[,;:])(.+)$/)
|
|
|
|
|
+ return split && measure(value) > measure('字') * 1.2 ? [split[1], split[2]] : [value]
|
|
|
|
|
+ })
|
|
|
|
|
+ const textHeight = Math.max(...clauses.map(parts => parts.reduce((sum, part) => sum + measure(part), 0)))
|
|
|
|
|
+ const hasIcons = icons.some(Boolean)
|
|
|
|
|
+ const textTop = hasIcons || format === 'steps' ? 124 : 48
|
|
|
|
|
+ const height = Math.max(hasIcons ? 260 : 150, textTop + textHeight + 30)
|
|
|
|
|
+ if (height > slot.height) return false
|
|
|
|
|
+ const top = slot.top + Math.min(50, (slot.height - height) / 2)
|
|
|
|
|
+ values.forEach((value, i) => {
|
|
|
|
|
+ const left = slot.left + i * (width + gap)
|
|
|
|
|
+ const tint = tinycolor.mix(palette.background, i % 2 ? palette.primary : palette.secondary, 12).toHexString()
|
|
|
|
|
+ const fill = tinycolor.readability(tint, master.textColor) >= 4.5 ? tint : palette.background
|
|
|
|
|
+ elements.push(shapeElement({ left, top, width, height, fill }))
|
|
|
|
|
+ elements.push(lineElement(left, top, left + width, top, i % 2 ? palette.primary : palette.secondary, 5))
|
|
|
|
|
+ const icon = icons[i]
|
|
|
|
|
+ if (icon) elements.push({ id: nanoid(10), name: `教学图标:${icon.label}`, type: 'shape',
|
|
|
|
|
+ left: left + inset, top: top + 28, width: iconSize, height: iconSize, rotate: 0,
|
|
|
|
|
+ viewBox: [48, 48], path: icon.path, fixedRatio: true, fill: 'none',
|
|
|
|
|
+ outline: { style: 'solid', color: master.textColor, width: 3 } })
|
|
|
|
|
+ if (format === 'steps') addSlotText(elements, { ...slot, left: left + width - 88, top: top + 28, width: 60, height: 60, preferredFontSize: 32 }, String(i + 1), master.titleColor, true)
|
|
|
|
|
+ addSlotText(elements, { ...slot, left: left + inset, top: top + textTop, width: textWidth,
|
|
|
|
|
+ height: textHeight, preferredFontSize: font, minimumFontSize: font }, value, master.textColor, true)
|
|
|
|
|
+ if (clauses[i].length === 2) {
|
|
|
|
|
+ const text = elements[elements.length - 1] as PPTTextElement
|
|
|
|
|
+ // 编辑器会把 br 归一化为空格,使用原生段落才能在编辑与导出时保留断行。
|
|
|
|
|
+ text.content = text.content.replace(escapeHtml(value), clauses[i].map(part => `<p style="margin:0">${escapeHtml(part)}</p>`).join(''))
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ return true
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function flowHeight(values: string[], width: number, font: number, numbered = false) {
|
|
function flowHeight(values: string[], width: number, font: number, numbered = false) {
|
|
|
return values.reduce((sum, text, i) => sum + measureAiPptText(numbered ? `${i + 1}. ${text}` : text, width, font), 0)
|
|
return values.reduce((sum, text, i) => sum + measureAiPptText(numbered ? `${i + 1}. ${text}` : text, width, font), 0)
|
|
|
+ Math.max(0, values.length - 1) * 16
|
|
+ Math.max(0, values.length - 1) * 16
|