|
@@ -1,4 +1,5 @@
|
|
|
import { nanoid } from "nanoid"
|
|
import { nanoid } from "nanoid"
|
|
|
|
|
+import tinycolor from 'tinycolor2'
|
|
|
import { measureAiPptText, aiPptBlockHeight, aiPptTextGeometry } from "@/utils/aiPptTextMetrics"
|
|
import { measureAiPptText, aiPptBlockHeight, aiPptTextGeometry } from "@/utils/aiPptTextMetrics"
|
|
|
import type {
|
|
import type {
|
|
|
Gradient,
|
|
Gradient,
|
|
@@ -105,7 +106,12 @@ export function buildAiPptSlides(
|
|
|
function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions, index: number, layoutOccurrences: Map<string, number>): Slide {
|
|
function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions, index: number, layoutOccurrences: Map<string, number>): Slide {
|
|
|
const role = pageRole(page, index)
|
|
const role = pageRole(page, index)
|
|
|
const intent = pageRenderIntent(page, role)
|
|
const intent = pageRenderIntent(page, role)
|
|
|
- const master = getAiCoursewareTemplateMaster(options.templateId!)
|
|
|
|
|
|
|
+ const originalMaster = getAiCoursewareTemplateMaster(options.templateId!)
|
|
|
|
|
+ const master = options.templateId === 'blank' && hasModelPageDesign(page) ? {
|
|
|
|
|
+ ...originalMaster, titleColor: palette.primary, coverTitleColor: palette.primary,
|
|
|
|
|
+ textColor: palette.text, mutedColor: palette.muted,
|
|
|
|
|
+ pages: originalMaster.pages.map(item => ({ ...item, background: palette.background }))
|
|
|
|
|
+ } : originalMaster
|
|
|
const baseMasterPage = getTemplateMasterPage(master, role, resolveAiPptLayout(page, index))
|
|
const baseMasterPage = getTemplateMasterPage(master, role, resolveAiPptLayout(page, index))
|
|
|
const occurrence = layoutOccurrences.get(baseMasterPage.id) || 0
|
|
const occurrence = layoutOccurrences.get(baseMasterPage.id) || 0
|
|
|
layoutOccurrences.set(baseMasterPage.id, occurrence + 1)
|
|
layoutOccurrences.set(baseMasterPage.id, occurrence + 1)
|
|
@@ -160,6 +166,7 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
|
|
|
}
|
|
}
|
|
|
ratios.model_design_v1 = 1 // 服务端依据本版编译器的安全区域和模型比例计算图片尺寸。
|
|
ratios.model_design_v1 = 1 // 服务端依据本版编译器的安全区域和模型比例计算图片尺寸。
|
|
|
ratios.model_design_v2 = 1
|
|
ratios.model_design_v2 = 1
|
|
|
|
|
+ ratios.model_design_v3 = 1 // 完整示范、判断标准及结构化教学图示。
|
|
|
return ratios
|
|
return ratios
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -187,11 +194,33 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
|
|
|
else if (presentation === 'cards' && values.length === 4 && slot && slot.width >= 1000) addCardGrid(elements, slot, values, master, palette, 2)
|
|
else if (presentation === 'cards' && values.length === 4 && slot && slot.width >= 1000) addCardGrid(elements, slot, values, master, palette, 2)
|
|
|
else addSlotList(elements, slot, values, master, palette, presentation === 'cards')
|
|
else addSlotList(elements, slot, values, master, palette, presentation === 'cards')
|
|
|
}
|
|
}
|
|
|
|
|
+ const focusBody = slotOf(layout, 'body')
|
|
|
|
|
+ if (composition === 'focus_question' && page.interaction?.options?.length && !hasImage(page)
|
|
|
|
|
+ && !content.steps.length && !content.groups.length && points.length >= 2 && points.length <= 4 && focusBody) {
|
|
|
|
|
+ const columns = 2, rows = Math.ceil(points.length / columns), gap = 30
|
|
|
|
|
+ const width = (focusBody.width - gap) / 2
|
|
|
|
|
+ const height = Math.min(150, (focusBody.height - gap * (rows - 1)) / rows)
|
|
|
|
|
+ points.forEach((value, index) => {
|
|
|
|
|
+ const left = focusBody.left + index % columns * (width + gap), top = focusBody.top + Math.floor(index / columns) * (height + gap)
|
|
|
|
|
+ elements.push(shapeElement({ left, top, width, height, fill: palette.soft, radius: 12 }))
|
|
|
|
|
+ addSlotText(elements, { ...focusBody, left: left + 32, top: top + 20, width: width - 64, height: height - 40, preferredFontSize: 44 }, value, master.textColor, true)
|
|
|
|
|
+ })
|
|
|
|
|
+ addInlineMedia(elements, slotOf(layout, 'media'), page, master, palette, intent.showMedia)
|
|
|
|
|
+ return elements
|
|
|
|
|
+ }
|
|
|
if (composition === 'comparison' && content.groups.length === 2) {
|
|
if (composition === 'comparison' && content.groups.length === 2) {
|
|
|
content.groups.forEach((group, i) => {
|
|
content.groups.forEach((group, i) => {
|
|
|
const slot = slotOf(layout, i === 0 ? 'left' : 'right')!
|
|
const slot = slotOf(layout, i === 0 ? 'left' : 'right')!
|
|
|
- if (group.label) addSlotText(elements, { ...slot, height: 52 }, group.label, master.titleColor, true)
|
|
|
|
|
- paintText(group.label ? { ...slot, top: slot.top + 66, height: slot.height - 66 } : slot, group.points)
|
|
|
|
|
|
|
+ const spacious = !hasImage(page) && slot.height >= 300 && group.label.length <= 12 && group.points.length <= 3 && group.points.every(point => point.length <= 32)
|
|
|
|
|
+ if (spacious) {
|
|
|
|
|
+ elements.push(shapeElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, fill: i === 0 ? palette.soft : palette.surface, radius: 12 }))
|
|
|
|
|
+ elements.push(lineElement(slot.left + 34, slot.top + 100, slot.left + slot.width - 34, slot.top + 100, palette.secondary, 4))
|
|
|
|
|
+ if (group.label) addSlotText(elements, { ...slot, left: slot.left + 34, top: slot.top + 20, width: slot.width - 68, height: 72, preferredFontSize: 48 }, group.label, master.titleColor, true)
|
|
|
|
|
+ paintText({ ...slot, left: slot.left + 34, width: slot.width - 68, top: slot.top + 134, height: slot.height - 160 }, group.points)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (group.label) addSlotText(elements, { ...slot, height: 52 }, group.label, master.titleColor, true)
|
|
|
|
|
+ paintText(group.label ? { ...slot, top: slot.top + 66, height: slot.height - 66 } : slot, group.points)
|
|
|
|
|
+ }
|
|
|
})
|
|
})
|
|
|
paintText(slotOf(layout, 'body'), points)
|
|
paintText(slotOf(layout, 'body'), points)
|
|
|
} else {
|
|
} else {
|
|
@@ -199,7 +228,18 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
|
|
|
const rows = page.visualPlan?.visualMode === 'native_diagram' ? rhythmRows(points) : []
|
|
const rows = page.visualPlan?.visualMode === 'native_diagram' ? rhythmRows(points) : []
|
|
|
const pattern = page.visualPlan?.visualMode === 'native_diagram' && pageRole(page, index) === 'rhythm'
|
|
const pattern = page.visualPlan?.visualMode === 'native_diagram' && pageRole(page, index) === 'rhythm'
|
|
|
? [headline, ...points].join(' ').match(/[短长](?:\s*[-—·、]\s*[短长]){1,7}/)?.[0].match(/[短长]/g) : undefined
|
|
? [headline, ...points].join(' ').match(/[短长](?:\s*[-—·、]\s*[短长]){1,7}/)?.[0].match(/[短长]/g) : undefined
|
|
|
- if (!content.steps.length && rows.length && rows.length === points.length && body.height >= rows.length * 160) {
|
|
|
|
|
|
|
+ if (page.diagram && page.visualPlan?.visualMode === 'native_diagram') {
|
|
|
|
|
+ const values = [...content.steps, ...points]
|
|
|
|
|
+ const textHeight = values.length ? aiPptBlockHeight(values, body.width, presentation) : 0
|
|
|
|
|
+ const diagramHeight = body.height - textHeight - (values.length ? 24 : 0)
|
|
|
|
|
+ if (diagramHeight >= 210 && validTeachingDiagram(page.diagram)) {
|
|
|
|
|
+ addTeachingDiagram(elements, { ...body, height: diagramHeight }, page.diagram, master, palette)
|
|
|
|
|
+ if (values.length) paintText({ ...body, top: body.top + diagramHeight + 24, height: textHeight }, values)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 异常或过密数据保留完整文本,由质量检查提示,不编造或截掉图示内容。
|
|
|
|
|
+ paintText(body, [page.diagram.caption, ...(page.diagram.items || []).map(item => item?.label || ''), ...values].filter(Boolean))
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (!content.steps.length && rows.length && rows.length === points.length && body.height >= rows.length * 160) {
|
|
|
addRhythmRows(elements, body, rows, master, palette)
|
|
addRhythmRows(elements, body, rows, master, palette)
|
|
|
} else if (pattern && !content.steps.length) {
|
|
} else if (pattern && !content.steps.length) {
|
|
|
addDurationDiagram(elements, { ...body, height: body.height * .55 }, pattern, master, palette)
|
|
addDurationDiagram(elements, { ...body, height: body.height * .55 }, pattern, master, palette)
|
|
@@ -210,6 +250,42 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
|
|
|
return elements
|
|
return elements
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+export function validTeachingDiagram(diagram: NonNullable<AiCoursewarePage['diagram']>) {
|
|
|
|
|
+ return ['beats', 'duration', 'sequence'].includes(diagram.kind) && Array.isArray(diagram.items) && diagram.items.length >= 2 && diagram.items.length <= 6
|
|
|
|
|
+ && diagram.items.every(item => item?.label?.trim() && item.label.length <= 12)
|
|
|
|
|
+ && (diagram.kind === 'sequence' || diagram.items.every(item => Number.isFinite(item.value) && item.value! > 0 && item.value! <= 8))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function addTeachingDiagram(elements: PPTElement[], slot: TemplateSlot, diagram: NonNullable<AiCoursewarePage['diagram']>, master: TemplateMaster, palette: Palette) {
|
|
|
|
|
+ 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
|
|
|
|
|
+ const total = diagram.items.reduce((sum, item) => sum + (item.value || 1), 0)
|
|
|
|
|
+ const max = Math.max(...diagram.items.map(item => item.value || 1))
|
|
|
|
|
+ const column = (slot.width - gap * (count - 1)) / count
|
|
|
|
|
+ const shapeHeight = Math.min(150, area.height - 80)
|
|
|
|
|
+ let left = slot.left
|
|
|
|
|
+ diagram.items.forEach((item, index) => {
|
|
|
|
|
+ const width = diagram.kind === 'duration' ? (slot.width - gap * (count - 1)) * item.value! / total : column
|
|
|
|
|
+ const size = diagram.kind === 'beats' ? Math.min(width - 24, shapeHeight) * Math.sqrt(item.value! / max) : shapeHeight
|
|
|
|
|
+ const top = area.top + Math.max(0, (area.height - shapeHeight - 68) / 2)
|
|
|
|
|
+ const color = index % 2 ? palette.secondary : palette.primary
|
|
|
|
|
+ if (diagram.kind === 'beats') {
|
|
|
|
|
+ const circle = shapeElement({ left: left + (width - size) / 2, top: top + (shapeHeight - size) / 2, width: size, height: size, fill: color })
|
|
|
|
|
+ circle.path = 'M100,0 A100,100 0 1,1 99.99,0 Z'
|
|
|
|
|
+ elements.push(circle)
|
|
|
|
|
+ } else if (diagram.kind === 'duration') {
|
|
|
|
|
+ elements.push(shapeElement({ left, top: top + shapeHeight * .25, width, height: shapeHeight * .5, fill: color, radius: 8 }))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ elements.push(lineElement(left, top + shapeHeight / 2, left + width, top + shapeHeight / 2, palette.secondary, 4))
|
|
|
|
|
+ addSlotText(elements, { ...slot, left, top, width, height: 65, preferredFontSize: 44 }, String(index + 1).padStart(2, '0'), palette.primary, true)
|
|
|
|
|
+ }
|
|
|
|
|
+ addSlotText(elements, { ...slot, left, top: top + shapeHeight + 12, width, height: 64, preferredFontSize: 32 }, item.label, master.textColor, true, diagram.kind === 'beats' ? 'center' : 'left')
|
|
|
|
|
+ left += width + gap
|
|
|
|
|
+ })
|
|
|
|
|
+ if (diagram.caption) addSlotText(elements, { ...slot, top: slot.top + slot.height - captionHeight, height: captionHeight, preferredFontSize: 32 }, diagram.caption, master.mutedColor)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function compactTextSlot(slot: TemplateSlot, values: string[], presentation: string): TemplateSlot {
|
|
function compactTextSlot(slot: TemplateSlot, values: string[], presentation: string): TemplateSlot {
|
|
|
const grid = presentation === 'grid' || presentation === 'steps'
|
|
const grid = presentation === 'grid' || presentation === 'steps'
|
|
|
const height = Math.min(slot.height, aiPptBlockHeight(values, slot.width, presentation))
|
|
const height = Math.min(slot.height, aiPptBlockHeight(values, slot.width, presentation))
|
|
@@ -453,7 +529,7 @@ function assertContentContract(master: TemplateMaster, page: TemplateMasterPage,
|
|
|
if (collision) throw new Error(`模板 ${master.id}/${page.id} 生成内容侵入“${collision.purpose}”保留区,请更换版式或缩减内容。`)
|
|
if (collision) throw new Error(`模板 ${master.id}/${page.id} 生成内容侵入“${collision.purpose}”保留区,请更换版式或缩减内容。`)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function addSlotText(elements: PPTElement[], slot: TemplateSlot | undefined, content: string, color: string, bold = false) {
|
|
|
|
|
|
|
+function addSlotText(elements: PPTElement[], slot: TemplateSlot | undefined, content: string, color: string, bold = false, align: 'left' | 'center' = 'left') {
|
|
|
if (!slot || !cleanText(content)) return
|
|
if (!slot || !cleanText(content)) return
|
|
|
elements.push(textElement({
|
|
elements.push(textElement({
|
|
|
left: slot.left,
|
|
left: slot.left,
|
|
@@ -464,7 +540,8 @@ function addSlotText(elements: PPTElement[], slot: TemplateSlot | undefined, con
|
|
|
fontSize: fitFont(content, slot.width, slot.height, slot.preferredFontSize || 30, slot.minimumFontSize || 22),
|
|
fontSize: fitFont(content, slot.width, slot.height, slot.preferredFontSize || 30, slot.minimumFontSize || 22),
|
|
|
minimumFontSize: slot.minimumFontSize,
|
|
minimumFontSize: slot.minimumFontSize,
|
|
|
color,
|
|
color,
|
|
|
- bold
|
|
|
|
|
|
|
+ bold,
|
|
|
|
|
+ align
|
|
|
}))
|
|
}))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -934,7 +1011,10 @@ function slideRemark(page: AiCoursewarePage) {
|
|
|
const explanation = page.interaction?.explanation ? `解析:${page.interaction.explanation}` : ""
|
|
const explanation = page.interaction?.explanation ? `解析:${page.interaction.explanation}` : ""
|
|
|
const resources = (page.resourceBindings || []).map(item => `${item.name || item.resourceType}:${item.purpose || "课堂资源"}`).join("\n")
|
|
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 ? "资源准备:请为本页听唱任务补充对应音频。" : ""
|
|
const mediaReminder = /listen|sing/.test(page.type) && audioBindings(page).length === 0 ? "资源准备:请为本页听唱任务补充对应音频。" : ""
|
|
|
- return [lessonMeta, page.teachingPurpose, ...teacherFacingTexts(page), activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, page.imageReview]
|
|
|
|
|
|
|
+ const scoreReminder = /melody|score/.test(page.type) && !scoreBinding(page) ? '资源准备:如需展示本曲谱例,请在授课前插入已核验曲谱。' : ''
|
|
|
|
|
+ const example = page.classroomAction?.modelExample ? `教学示范:${page.classroomAction.modelExample}` : ''
|
|
|
|
|
+ const criteria = page.classroomAction?.successCriteria ? `判断标准:${page.classroomAction.successCriteria}` : ''
|
|
|
|
|
+ return [lessonMeta, page.teachingPurpose, ...teacherFacingTexts(page), activity, fallbackInstruction, page.classroomAction?.storyScript, example, criteria, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, scoreReminder, page.imageReview]
|
|
|
.map(cleanText)
|
|
.map(cleanText)
|
|
|
.filter(Boolean)
|
|
.filter(Boolean)
|
|
|
.filter((item, index, all) => all.indexOf(item) === index)
|
|
.filter((item, index, all) => all.indexOf(item) === index)
|
|
@@ -943,7 +1023,15 @@ function slideRemark(page: AiCoursewarePage) {
|
|
|
|
|
|
|
|
function buildPalette(outline: AiCoursewareOutline, variant: number, templateId?: AiCoursewareTemplateId): Palette {
|
|
function buildPalette(outline: AiCoursewareOutline, variant: number, templateId?: AiCoursewareTemplateId): Palette {
|
|
|
const resolvedTemplateId = templateId || DEFAULT_AI_COURSEWARE_TEMPLATE_ID
|
|
const resolvedTemplateId = templateId || DEFAULT_AI_COURSEWARE_TEMPLATE_ID
|
|
|
- return { ...getAiCoursewarePalette(resolvedTemplateId, variant), ...(resolvedTemplateId === "dark-lute" ? { soft: "#16485C" } : {}) }
|
|
|
|
|
|
|
+ const base = { ...getAiCoursewarePalette(resolvedTemplateId, variant), ...(resolvedTemplateId === "dark-lute" ? { soft: "#16485C" } : {}) }
|
|
|
|
|
+ if (resolvedTemplateId !== 'blank') return base
|
|
|
|
|
+ const colors = (outline.visualBible?.palette || []).filter(color => /^#[0-9a-f]{6}$/i.test(color))
|
|
|
|
|
+ if (colors.length < 2) return base
|
|
|
|
|
+ const background = colors.find(color => tinycolor(color).getLuminance() > .8) || '#FAF8F2'
|
|
|
|
|
+ const ink = colors.find(color => tinycolor.readability(color, background) >= 7) || '#203345'
|
|
|
|
|
+ const accent = colors.find(color => color !== ink && color !== background && tinycolor(color).getLuminance() < .65) || base.secondary
|
|
|
|
|
+ return { ...base, primary: ink, secondary: accent, accent, background, text: ink, dark: ink,
|
|
|
|
|
+ muted: tinycolor.mix(ink, background, 18).toHexString(), soft: tinycolor.mix(background, accent, 10).toHexString() }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function backgroundConfig(masterPage: TemplateMasterPage, options: AiPptOptions): SlideBackground {
|
|
function backgroundConfig(masterPage: TemplateMasterPage, options: AiPptOptions): SlideBackground {
|
|
@@ -975,6 +1063,7 @@ function textElement(params: {
|
|
|
minimumFontSize?: number
|
|
minimumFontSize?: number
|
|
|
color: string
|
|
color: string
|
|
|
bold?: boolean
|
|
bold?: boolean
|
|
|
|
|
+ align?: 'left' | 'center'
|
|
|
lineHeight?: number
|
|
lineHeight?: number
|
|
|
}): PPTTextElement {
|
|
}): PPTTextElement {
|
|
|
const lineHeight = params.lineHeight || 1.25
|
|
const lineHeight = params.lineHeight || 1.25
|
|
@@ -989,7 +1078,7 @@ function textElement(params: {
|
|
|
height: params.height,
|
|
height: params.height,
|
|
|
rotate: 0,
|
|
rotate: 0,
|
|
|
// 永远保留完整文本。若最小字号仍放不下,由质量门禁阻止应用,而不是静默截断。
|
|
// 永远保留完整文本。若最小字号仍放不下,由质量门禁阻止应用,而不是静默截断。
|
|
|
- content: `<div style="box-sizing:border-box;width:calc(100% - 20px);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:700;" : ""}">${escapeHtml(cleanText(params.content))}</div>`,
|
|
|
defaultFontName: FONT_NAME,
|
|
defaultFontName: FONT_NAME,
|
|
|
defaultColor: params.color,
|
|
defaultColor: params.color,
|
|
|
lineHeight,
|
|
lineHeight,
|