|
|
@@ -17,7 +17,7 @@ interface AiPptOptions {
|
|
|
backgroundImage?: string
|
|
|
}
|
|
|
|
|
|
-type PageRole = "cover" | "intro" | "goal" | "story" | "listen" | "lyric" | "knowledge" | "rhythm" | "interaction" | "activity" | "summary" | "homework" | "content"
|
|
|
+type PageRole = "cover" | "intro" | "goal" | "story" | "listen" | "lyric" | "notation" | "instrument" | "compare" | "knowledge" | "rhythm" | "interaction" | "performance" | "practice" | "activity" | "summary" | "homework" | "content"
|
|
|
|
|
|
interface Palette {
|
|
|
primary: string
|
|
|
@@ -80,9 +80,14 @@ function templateFor(role: PageRole) {
|
|
|
case "goal": return goalTemplate
|
|
|
case "listen": return listenTemplate
|
|
|
case "lyric": return lyricTemplate
|
|
|
+ case "notation": return notationTemplate
|
|
|
+ case "instrument": return instrumentTemplate
|
|
|
+ case "compare": return compareTemplate
|
|
|
case "rhythm": return rhythmTemplate
|
|
|
case "knowledge": return knowledgeTemplate
|
|
|
case "interaction": return interactionTemplate
|
|
|
+ case "performance": return performanceTemplate
|
|
|
+ case "practice": return practiceTemplate
|
|
|
case "activity": return activityTemplate
|
|
|
case "summary": return summaryTemplate
|
|
|
case "homework": return homeworkTemplate
|
|
|
@@ -180,6 +185,62 @@ function lyricTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, pal
|
|
|
return elements
|
|
|
}
|
|
|
|
|
|
+function notationTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements = slideHeader(page, outline, palette, "旋律与谱例")
|
|
|
+ const headline = studentHeadline(page) || "看一看,唱一唱"
|
|
|
+ elements.push(textElement({ left: 92, top: 190, width: 1416, height: 72, content: headline, fontSize: fitFont(headline, 1416, 72, 40, 30), color: palette.primary, bold: true }))
|
|
|
+ elements.push(shapeElement({ left: 92, top: 300, width: 1416, height: 300, fill: palette.surface, radius: 8, shadow: softShadow() }))
|
|
|
+ for (let i = 0; i < 5; i++) elements.push(lineElement(160, 370 + i * 42, 1440, 370 + i * 42, palette.muted, 2))
|
|
|
+ studentPoints(page, 4).forEach((point, index) => {
|
|
|
+ const left = 180 + index * 315
|
|
|
+ const top = 392 + (index % 2) * 44
|
|
|
+ elements.push(shapeElement({ left, top, width: 42, height: 34, fill: index % 2 ? palette.secondary : palette.primary, radius: 8 }))
|
|
|
+ elements.push(lineElement(left + 38, top - 52, left + 38, top + 12, index % 2 ? palette.secondary : palette.primary, 5))
|
|
|
+ elements.push(textElement({ left: left - 26, top: 610, width: 250, height: 50, content: point, fontSize: fitFont(point, 250, 50, 27, 21), color: palette.text, bold: true }))
|
|
|
+ })
|
|
|
+ addClassroomCue(elements, page, palette)
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function instrumentTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements = slideHeader(page, outline, palette, "认识声音")
|
|
|
+ const points = studentPoints(page, 3)
|
|
|
+ if (hasImage(page)) {
|
|
|
+ elements.push(imageElement(page, { left: 84, top: 190, width: 760, height: 540, radius: 8 }))
|
|
|
+ elements.push(textElement({ left: 920, top: 220, width: 580, height: 96, content: studentHeadline(page) || page.title, fontSize: fitFont(studentHeadline(page) || page.title, 580, 96, 42, 30), color: palette.primary, bold: true }))
|
|
|
+ points.forEach((point, index) => {
|
|
|
+ const top = 360 + index * 104
|
|
|
+ elements.push(textElement({ left: 920, top, width: 56, height: 48, content: `0${index + 1}`, fontSize: 30, color: index % 2 ? palette.secondary : palette.accent, bold: true }))
|
|
|
+ elements.push(textElement({ left: 992, top, width: 490, height: 64, content: point, fontSize: fitFont(point, 490, 64, 30, 23), color: palette.text, bold: true }))
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 190, fill: palette.primary, radius: 8 }))
|
|
|
+ elements.push(textElement({ left: 150, top: 260, width: 1300, height: 90, content: studentHeadline(page) || page.title, fontSize: fitFont(studentHeadline(page) || page.title, 1300, 90, 44, 30), color: "#FFFFFF", bold: true }))
|
|
|
+ elements.push(...soundWave(210, 500, palette, 1180, 150))
|
|
|
+ }
|
|
|
+ addClassroomCue(elements, page, palette)
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function compareTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements = slideHeader(page, outline, palette, "对比听赏")
|
|
|
+ const points = studentPoints(page, 4)
|
|
|
+ const leftPoints = points.filter((_, index) => index % 2 === 0)
|
|
|
+ const rightPoints = points.filter((_, index) => index % 2 === 1)
|
|
|
+ elements.push(shapeElement({ left: 92, top: 210, width: 690, height: 470, fill: palette.soft, radius: 8 }))
|
|
|
+ elements.push(shapeElement({ left: 818, top: 210, width: 690, height: 470, fill: palette.surface, radius: 8, shadow: lightShadow() }))
|
|
|
+ elements.push(textElement({ left: 140, top: 252, width: 570, height: 64, content: "听一听 A", fontSize: 36, color: palette.primary, bold: true }))
|
|
|
+ elements.push(textElement({ left: 866, top: 252, width: 570, height: 64, content: "听一听 B", fontSize: 36, color: palette.secondary, bold: true }))
|
|
|
+ ;[leftPoints, rightPoints].forEach((items, group) => items.forEach((point, index) => {
|
|
|
+ const left = group === 0 ? 140 : 866
|
|
|
+ const top = 365 + index * 110
|
|
|
+ elements.push(shapeElement({ left, top: top + 8, width: 22, height: 22, fill: group === 0 ? palette.primary : palette.secondary, radius: 8 }))
|
|
|
+ elements.push(textElement({ left: left + 48, top, width: 520, height: 70, content: point, fontSize: fitFont(point, 520, 70, 30, 23), color: palette.text, bold: true }))
|
|
|
+ }))
|
|
|
+ addClassroomCue(elements, page, palette)
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
function rhythmTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
const elements = slideHeader(page, outline, palette, "节奏练习")
|
|
|
const task = activityTask(page) || studentHeadline(page) || "拍一拍,感受节奏"
|
|
|
@@ -234,6 +295,38 @@ function activityTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline,
|
|
|
return elements
|
|
|
}
|
|
|
|
|
|
+function performanceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements = slideHeader(page, outline, palette, "合作表现")
|
|
|
+ const task = activityTask(page) || studentHeadline(page) || "一起完成音乐表演"
|
|
|
+ elements.push(shapeElement({ left: 92, top: 200, width: 1416, height: 150, fill: palette.primary, radius: 8 }))
|
|
|
+ elements.push(textElement({ left: 150, top: 242, width: 1300, height: 70, content: task, fontSize: fitFont(task, 1300, 70, 42, 30), color: "#FFFFFF", bold: true }))
|
|
|
+ const steps = activitySteps(page, 4)
|
|
|
+ steps.forEach((step, index) => {
|
|
|
+ const left = 126 + index * 355
|
|
|
+ elements.push(shapeElement({ left, top: 438, width: 76, height: 76, fill: index % 2 ? palette.secondary : palette.accent, radius: 38 }))
|
|
|
+ elements.push(textElement({ left: left + 23, top: 454, width: 34, height: 38, content: `${index + 1}`, fontSize: 28, color: "#FFFFFF", bold: true }))
|
|
|
+ elements.push(textElement({ left: left - 16, top: 550, width: 280, height: 100, content: step, fontSize: fitFont(step, 280, 100, 29, 22), color: palette.text, bold: true }))
|
|
|
+ if (index < steps.length - 1) elements.push(lineElement(left + 96, 476, left + 325, 476, palette.muted, 3))
|
|
|
+ })
|
|
|
+ addClassroomCue(elements, page, palette)
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function practiceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements = slideHeader(page, outline, palette, "课堂检测")
|
|
|
+ const question = page.interaction?.question || studentHeadline(page) || activityTask(page) || "你能完成今天的音乐挑战吗?"
|
|
|
+ elements.push(textElement({ left: 92, top: 200, width: 1416, height: 82, content: question, fontSize: fitFont(question, 1416, 82, 42, 30), color: palette.text, bold: true }))
|
|
|
+ studentPoints(page, 4).forEach((point, index) => {
|
|
|
+ const top = 330 + index * 94
|
|
|
+ elements.push(shapeElement({ left: 110, top, width: 70, height: 62, fill: index % 2 ? palette.secondary : palette.primary, radius: 8 }))
|
|
|
+ elements.push(textElement({ left: 132, top: top + 14, width: 32, height: 34, content: String.fromCharCode(65 + index), fontSize: 26, color: "#FFFFFF", bold: true }))
|
|
|
+ elements.push(textElement({ left: 220, top: top + 4, width: 1220, height: 58, content: point, fontSize: fitFont(point, 1220, 58, 31, 24), color: palette.text, bold: true }))
|
|
|
+ elements.push(lineElement(220, top + 72, 1450, top + 72, palette.soft, 2))
|
|
|
+ })
|
|
|
+ addClassroomCue(elements, page, palette)
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
function summaryTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
const elements = slideHeader(page, outline, palette, "课堂小结")
|
|
|
studentPoints(page, 4).forEach((point, index) => {
|
|
|
@@ -351,17 +444,22 @@ function normalizeOutline(outline: AiCoursewareOutline): AiCoursewareOutline {
|
|
|
function pageRole(page: AiCoursewarePage, index: number): PageRole {
|
|
|
if (index === 0) return "cover"
|
|
|
const kind = `${page.type || ""} ${page.visualPlan?.layout || ""} ${page.visual?.layout || ""}`.toLowerCase()
|
|
|
- if (kind.includes("goal")) return "goal"
|
|
|
+ if (kind.includes("objective") || kind.includes("goal")) return "goal"
|
|
|
+ if (kind.includes("practice_check") || kind.includes("assessment")) return "practice"
|
|
|
+ if (kind.includes("compare")) return "compare"
|
|
|
+ if (kind.includes("instrument")) return "instrument"
|
|
|
+ if (kind.includes("melody") || kind.includes("score") || kind.includes("notation")) return "notation"
|
|
|
+ if (kind.includes("performance") || kind.includes("expressive") || kind.includes("creative")) return "performance"
|
|
|
if (kind.includes("listen")) return "listen"
|
|
|
- if (kind.includes("lyric") || kind.includes("sing")) return "lyric"
|
|
|
+ if (kind.includes("lyric") || kind.includes("phrase_sing") || kind.includes("sing_focus")) return "lyric"
|
|
|
if (kind.includes("rhythm") || kind.includes("skill")) return "rhythm"
|
|
|
- if (kind.includes("knowledge") || kind.includes("extension")) return "knowledge"
|
|
|
+ if (kind.includes("knowledge")) return "knowledge"
|
|
|
if (kind.includes("interaction")) return "interaction"
|
|
|
if (kind.includes("activity")) return "activity"
|
|
|
if (kind.includes("summary")) return "summary"
|
|
|
- if (kind.includes("homework")) return "homework"
|
|
|
- if (kind.includes("intro")) return "intro"
|
|
|
- if (kind.includes("background") || kind.includes("story")) return "story"
|
|
|
+ if (kind.includes("homework") || kind.includes("extension")) return "homework"
|
|
|
+ if (kind.includes("intro") || kind.includes("scene")) return "intro"
|
|
|
+ if (kind.includes("context") || kind.includes("background") || kind.includes("story")) return "story"
|
|
|
return "content"
|
|
|
}
|
|
|
|
|
|
@@ -440,13 +538,39 @@ function fitFont(content: string, width: number, height: number, preferred: numb
|
|
|
}
|
|
|
|
|
|
function textElement(params: { left: number; top: number; width: number; height: number; content: string; fontSize: number; color: string; bold?: boolean; lineHeight?: number }): PPTTextElement {
|
|
|
+ const lineHeight = params.lineHeight || 1.25
|
|
|
+ const visibleContent = fitVisibleText(params.content, params.width - 20, params.height, params.fontSize, lineHeight)
|
|
|
return {
|
|
|
id: nanoid(10), type: "text", left: params.left, top: params.top, width: params.width, height: params.height, rotate: 0,
|
|
|
- content: `<div style="font-family:${FONT_NAME};font-size:${params.fontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(params.content)}</div>`,
|
|
|
- defaultFontName: FONT_NAME, defaultColor: params.color, lineHeight: params.lineHeight || 1.25, wordSpace: 0, opacity: 1,
|
|
|
+ content: `<div style="box-sizing:border-box;width:calc(100% - 20px);font-family:${FONT_NAME};font-size:${params.fontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(visibleContent)}</div>`,
|
|
|
+ defaultFontName: FONT_NAME, defaultColor: params.color, lineHeight, paragraphSpace: 0, wordSpace: 0, opacity: 1,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function fitVisibleText(content: string, width: number, height: number, fontSize: number, lineHeight: number) {
|
|
|
+ const text = cleanText(content)
|
|
|
+ if (!text) return ""
|
|
|
+ const safeWidth = Math.max(1, width)
|
|
|
+ const safeHeight = Math.max(1, height)
|
|
|
+ const maxLines = Math.max(1, Math.floor(safeHeight / (fontSize * lineHeight)))
|
|
|
+ const maxUnits = Math.max(1, Math.floor((safeWidth / fontSize) * maxLines))
|
|
|
+ if (textUnits(text) <= maxUnits) return text
|
|
|
+
|
|
|
+ let units = 0
|
|
|
+ let result = ""
|
|
|
+ for (const char of text) {
|
|
|
+ const charUnits = /[\x00-\xff]/.test(char) ? 0.55 : 1
|
|
|
+ if (units + charUnits > Math.max(1, maxUnits - 1)) break
|
|
|
+ result += char
|
|
|
+ units += charUnits
|
|
|
+ }
|
|
|
+ return `${result.trim()}…`
|
|
|
+}
|
|
|
+
|
|
|
+function textUnits(content: string) {
|
|
|
+ return Array.from(content).reduce((total, char) => total + (/[\x00-\xff]/.test(char) ? 0.55 : 1), 0)
|
|
|
+}
|
|
|
+
|
|
|
function imageElement(page: AiCoursewarePage, rect: ImageRect): PPTImageElement {
|
|
|
const width = page.image?.width || 0
|
|
|
const height = page.image?.height || 0
|