| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743 |
- import { nanoid } from "nanoid"
- import type {
- Gradient,
- PPTElement,
- PPTElementShadow,
- PPTImageElement,
- PPTVideoElement,
- PPTEnjoyElement,
- PPTCloudCoachElement,
- PPTLineElement,
- PPTAnimation,
- PPTShapeElement,
- PPTTextElement,
- Slide,
- SlideBackground,
- SlideTheme
- } from "@/types/slides"
- import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
- import { getAiCoursewarePalette, type AiCoursewareTemplateId, type AiCoursewareThemePalette } from "@/config/aiCoursewareTemplates"
- interface AiPptOptions {
- backgroundImage?: string
- enableAnimations?: boolean
- paletteVariant?: number
- templateId?: AiCoursewareTemplateId
- }
- type PageRole =
- | "cover"
- | "intro"
- | "goal"
- | "story"
- | "listen"
- | "lyric"
- | "notation"
- | "instrument"
- | "compare"
- | "knowledge"
- | "rhythm"
- | "interaction"
- | "performance"
- | "practice"
- | "activity"
- | "summary"
- | "homework"
- | "content"
- type Palette = AiCoursewareThemePalette
- interface ImageRect {
- left: number
- top: number
- width: number
- height: number
- radius?: number
- }
- const SLIDE_WIDTH = 1600
- const SLIDE_HEIGHT = 900
- const VIEWPORT_WIDTH = 1920
- const VIEWPORT_HEIGHT = 1080
- const VIEWPORT_SCALE = VIEWPORT_WIDTH / SLIDE_WIDTH
- const FONT_NAME = "Microsoft Yahei"
- const GENERIC_PAGE_TITLES = new Set(["封面", "课程导入", "故事导入", "导入", "首页", "cover"])
- export function buildAiPptSlides(
- outline: AiCoursewareOutline,
- options: AiPptOptions = {}
- ): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
- const normalized = normalizeOutline(outline)
- const palette = buildPalette(normalized, options.paletteVariant || 0, options.templateId)
- const slides = normalized.pages.map((page, index) => buildSlide(page, normalized, palette, options, index))
- runPreflight(slides)
- return {
- title: normalized.title || normalized.chapterName || "音乐课件",
- theme: {
- themeColor: palette.primary,
- backgroundColor: palette.background,
- fontColor: palette.text,
- fontName: FONT_NAME,
- fontSize: "36px"
- },
- slides
- }
- }
- function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions, index: number): Slide {
- const role = pageRole(page, index)
- const dedicatedResource = dedicatedResourceElement(page)
- const contentElements = dedicatedResource
- ? [dedicatedResource]
- : videoBinding(page)
- ? videoResourceTemplate(page, outline, palette)
- : [...templateForPage(page, role)(page, outline, palette, index), ...inlineResourceElements(page)]
- const elements = dedicatedResource ? contentElements : [...templateDecorationElements(options.templateId, palette, role), ...contentElements]
- return {
- id: nanoid(10),
- elements: scaleElementsToViewport(keepInCanvas(elements)),
- background: backgroundConfig(role, palette, options),
- remark: slideRemark(page),
- animations: options.enableAnimations ? entranceAnimations(contentElements) : undefined,
- turningMode: options.enableAnimations ? "fade" : "no"
- }
- }
- function templateDecorationElements(templateId: AiCoursewareTemplateId | undefined, palette: Palette, role: PageRole): PPTElement[] {
- if (!templateId || role === "cover") return []
- switch (templateId) {
- case "playful-handdrawn":
- return [
- shapeElement({ left: 1450, top: 36, width: 74, height: 74, fill: palette.warm, opacity: 0.5, radius: 37 }),
- shapeElement({ left: 1518, top: 92, width: 42, height: 42, fill: palette.accent, opacity: 0.45, radius: 21 }),
- shapeElement({ left: 26, top: 792, width: 58, height: 58, fill: palette.secondary, opacity: 0.26, radius: 29 })
- ]
- case "fresh-education":
- return [
- shapeElement({ left: 0, top: 0, width: 1600, height: 12, fill: palette.secondary, opacity: 0.85, radius: 0 }),
- shapeElement({ left: 1530, top: 742, width: 70, height: 158, fill: palette.soft, opacity: 0.8, radius: 0 })
- ]
- case "academic-blue":
- return [
- shapeElement({ left: 0, top: 0, width: 1600, height: 18, fill: palette.primary, radius: 0 }),
- shapeElement({ left: 1548, top: 38, width: 18, height: 132, fill: palette.accent, radius: 0 }),
- shapeElement({ left: 1574, top: 38, width: 8, height: 84, fill: palette.secondary, radius: 0 })
- ]
- case "oriental-culture":
- return [
- shapeElement({ left: 1460, top: 42, width: 88, height: 88, fill: palette.primary, opacity: 0.14, radius: 44 }),
- shapeElement({ left: 1499, top: 52, width: 10, height: 68, fill: palette.primary, opacity: 0.62, radius: 2 }),
- shapeElement({ left: 1434, top: 148, width: 114, height: 4, fill: palette.accent, opacity: 0.55, radius: 2 })
- ]
- case "digital-tech":
- return [
- shapeElement({ left: 80, top: 120, width: 1440, height: 2, fill: palette.secondary, opacity: 0.16, radius: 0 }),
- shapeElement({ left: 80, top: 780, width: 1440, height: 2, fill: palette.primary, opacity: 0.16, radius: 0 }),
- shapeElement({ left: 118, top: 80, width: 2, height: 740, fill: palette.secondary, opacity: 0.12, radius: 0 }),
- shapeElement({ left: 1480, top: 80, width: 2, height: 740, fill: palette.primary, opacity: 0.12, radius: 0 }),
- shapeElement({ left: 1468, top: 108, width: 26, height: 26, fill: palette.secondary, opacity: 0.75, radius: 4 })
- ]
- case "active-classroom":
- return [
- shapeElement({ left: 1390, top: 30, width: 58, height: 18, fill: palette.accent, radius: 9 }),
- shapeElement({ left: 1458, top: 30, width: 92, height: 18, fill: palette.secondary, radius: 9 }),
- shapeElement({ left: 44, top: 816, width: 116, height: 20, fill: palette.warm, opacity: 0.8, radius: 10 })
- ]
- }
- }
- function entranceAnimations(elements: PPTElement[]): PPTAnimation[] {
- return elements
- .filter(
- element => (element.type === "text" || element.type === "image" || element.type === "shape") && element.width >= 120 && element.height >= 45
- )
- .slice(0, 6)
- .map((element, index) => ({
- id: nanoid(10),
- elId: element.id,
- effect: index === 0 ? "fadeIn" : "fadeInUp",
- type: "in",
- duration: 500,
- trigger: index === 0 ? "auto" : "click"
- }))
- }
- function templateForPage(page: AiCoursewarePage, role: PageRole) {
- const layout = cleanText(page.visualPlan?.layout || page.visual?.layout).toLowerCase()
- switch (layout) {
- case "cover_scene":
- return coverTemplate
- case "full_image_question":
- return immersiveImageTemplate
- case "split_image":
- return storyTemplate
- case "goal_cards":
- return goalTemplate
- case "listen_wave":
- return listenTemplate
- case "lyric_focus":
- return lyricTemplate
- case "score_focus":
- return notationTemplate
- case "rhythm_grid":
- return rhythmTemplate
- case "instrument_focus":
- return instrumentTemplate
- case "compare_listen":
- return compareTemplate
- case "knowledge_cards":
- return knowledgeTemplate
- case "activity_steps":
- return activityTemplate
- case "practice_check":
- return practiceTemplate
- case "summary_checklist":
- return summaryTemplate
- case "homework_list":
- return homeworkTemplate
- case "story_stage":
- return storyStageTemplate
- case "observe_question":
- return observeQuestionTemplate
- case "listen_compare_visual":
- return listenCompareVisualTemplate
- case "sound_timeline":
- return soundTimelineTemplate
- case "action_demo":
- return actionDemoTemplate
- case "group_mission":
- return groupMissionTemplate
- case "performance_stage":
- return performanceStageTemplate
- case "visual_summary":
- return visualSummaryTemplate
- default:
- return templateForRole(role)
- }
- }
- function templateForRole(role: PageRole) {
- switch (role) {
- case "cover":
- return coverTemplate
- 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
- case "intro":
- case "story":
- return storyTemplate
- default:
- return contentTemplate
- }
- }
- function immersiveImageTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
- if (!hasImage(page)) return storyTemplate(page, outline, palette, index)
- const title = displayPageTitle(page, outline)
- const question = page.interaction?.question || activityTask(page) || studentHeadline(page) || studentPoints(page, 1)[0]
- const panelOnLeft = index % 2 === 0
- const panelLeft = panelOnLeft ? 0 : 930
- const textLeft = panelOnLeft ? 104 : 1010
- const elements: PPTElement[] = [imageElement(page, { left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT })]
- elements.push(shapeElement({ left: panelLeft, top: 0, width: 670, height: 900, fill: palette.dark, opacity: 0.82, radius: 0 }))
- elements.push(
- textElement({
- left: textLeft,
- top: 110,
- width: 480,
- height: 48,
- content: page.teachingStage || "观察与发现",
- fontSize: 22,
- color: palette.warm,
- bold: true
- })
- )
- elements.push(
- textElement({
- left: textLeft,
- top: 188,
- width: 500,
- height: 160,
- content: title,
- fontSize: fitFont(title, 500, 160, 56, 40),
- color: "#FFFFFF",
- bold: true
- })
- )
- elements.push(shapeElement({ left: textLeft, top: 382, width: 94, height: 10, fill: palette.accent, radius: 4 }))
- if (question) {
- elements.push(
- textElement({
- left: textLeft,
- top: 454,
- width: 500,
- height: 200,
- content: question,
- fontSize: fitFont(question, 500, 200, 38, 28),
- color: "#FFFFFF",
- bold: true,
- lineHeight: 1.35
- })
- )
- }
- return elements
- }
- function coverTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const title = chapterTitle(outline, page)
- const subtitle = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
- const points = studentPoints(page, 2)
- if (hasImage(page)) {
- const elements: PPTElement[] = [imageElement(page, { left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT })]
- elements.push(shapeElement({ left: 0, top: 0, width: 910, height: 900, fill: palette.dark, opacity: 0.78, radius: 0 }))
- elements.push(
- textElement({
- left: 100,
- top: 150,
- width: 700,
- height: 150,
- content: title,
- fontSize: fitFont(title, 700, 150, 66, 48),
- color: "#FFFFFF",
- bold: true
- })
- )
- if (subtitle) elements.push(textElement({ left: 104, top: 330, width: 680, height: 48, content: subtitle, fontSize: 24, color: "#EAF2F7" }))
- elements.push(shapeElement({ left: 104, top: 430, width: 84, height: 10, fill: palette.accent, radius: 4 }))
- points.forEach((point, index) =>
- elements.push(
- textElement({
- left: 104,
- top: 500 + index * 64,
- width: 680,
- height: 44,
- content: point,
- fontSize: fitFont(point, 680, 44, 28, 22),
- color: "#FFFFFF",
- bold: index === 0
- })
- )
- )
- return elements
- }
- const elements: PPTElement[] = []
- elements.push(shapeElement({ left: 0, top: 0, width: 560, height: 900, fill: palette.primary, radius: 0 }))
- elements.push(shapeElement({ left: 560, top: 0, width: 1040, height: 900, fill: palette.background, radius: 0 }))
- elements.push(textElement({ left: 92, top: 112, width: 380, height: 70, content: "音乐课堂", fontSize: 30, color: "#FFFFFF", bold: true }))
- elements.push(
- textElement({
- left: 650,
- top: 170,
- width: 780,
- height: 170,
- content: title,
- fontSize: fitFont(title, 780, 170, 70, 50),
- color: palette.primary,
- bold: true
- })
- )
- if (subtitle) elements.push(textElement({ left: 656, top: 370, width: 740, height: 44, content: subtitle, fontSize: 24, color: palette.muted }))
- elements.push(textElement({ left: 92, top: 360, width: 380, height: 180, content: "听 · 唱 · 动", fontSize: 52, color: "#FFFFFF", bold: true }))
- elements.push(shapeElement({ left: 654, top: 486, width: 120, height: 10, fill: palette.secondary, radius: 4 }))
- points.forEach((point, index) =>
- elements.push(
- textElement({
- left: 656,
- top: 548 + index * 66,
- width: 720,
- height: 46,
- content: point,
- fontSize: fitFont(point, 720, 46, 30, 23),
- color: palette.text,
- bold: index === 0
- })
- )
- )
- return elements
- }
- function storyTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "进入情境")
- const imageLeft = index % 2 === 0
- if (hasImage(page)) {
- const imageRect = imageLeft
- ? { left: 86, top: 190, width: 860, height: 560, radius: 8 }
- : { left: 654, top: 190, width: 860, height: 560, radius: 8 }
- elements.push(imageElement(page, imageRect))
- const panelLeft = imageLeft ? 1010 : 86
- elements.push(shapeElement({ left: panelLeft, top: 230, width: 500, height: 470, fill: palette.surface, radius: 8, shadow: softShadow() }))
- elements.push(...studentPanel(page, palette, panelLeft + 42, 278, 416))
- } else {
- elements.push(shapeElement({ left: 86, top: 210, width: 1428, height: 480, fill: palette.surface, radius: 8, shadow: softShadow() }))
- elements.push(
- textElement({
- left: 156,
- top: 270,
- width: 1280,
- height: 90,
- content: studentHeadline(page) || page.title,
- fontSize: fitFont(studentHeadline(page) || page.title, 1280, 90, 42, 30),
- color: palette.primary,
- bold: true
- })
- )
- elements.push(...storySequence(studentPoints(page, 4), palette, 156, 420, 1280))
- }
- addClassroomCue(elements, page, palette)
- return elements
- }
- function storyStageTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const title = displayPageTitle(page, outline)
- const points = studentPoints(page, 2)
- const elements: PPTElement[] = []
- if (hasImage(page)) elements.push(imageElement(page, { left: 0, top: 0, width: 1600, height: 900 }))
- else {
- elements.push(shapeElement({ left: 0, top: 0, width: 1600, height: 900, fill: palette.soft, radius: 0 }))
- elements.push(shapeElement({ left: 0, top: 420, width: 1600, height: 480, fill: palette.primary, opacity: 0.16, radius: 0 }))
- }
- elements.push(shapeElement({ left: 0, top: 0, width: 1600, height: 190, fill: palette.dark, opacity: 0.76, radius: 0 }))
- elements.push(
- textElement({
- left: 92,
- top: 54,
- width: 1320,
- height: 94,
- content: title,
- fontSize: fitFont(title, 1320, 94, 52, 38),
- color: "#FFFFFF",
- bold: true
- })
- )
- const dialogue = points[0] || activityTask(page)
- if (dialogue) {
- elements.push(shapeElement({ left: 130, top: 590, width: 1120, height: 190, fill: "#FFFFFF", opacity: 0.94, radius: 22, shadow: softShadow() }))
- elements.push(
- textElement({
- left: 186,
- top: 632,
- width: 1000,
- height: 100,
- content: dialogue,
- fontSize: fitFont(dialogue, 1000, 100, 38, 28),
- color: palette.text,
- bold: true,
- lineHeight: 1.3
- })
- )
- elements.push(shapeElement({ left: 1280, top: 620, width: 180, height: 120, fill: palette.accent, radius: 60 }))
- }
- return elements
- }
- function observeQuestionTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "观察与发现")
- const question = page.interaction?.question || activityTask(page) || studentPoints(page, 1)[0]
- if (hasImage(page)) elements.push(imageElement(page, { left: 70, top: 190, width: 940, height: 570, radius: 18 }))
- else elements.push(shapeElement({ left: 70, top: 190, width: 940, height: 570, fill: palette.soft, radius: 18 }))
- elements.push(shapeElement({ left: 1060, top: 220, width: 440, height: 500, fill: palette.surface, radius: 18, shadow: softShadow() }))
- elements.push(shapeElement({ left: 1110, top: 274, width: 72, height: 72, fill: palette.accent, radius: 36 }))
- elements.push(textElement({ left: 1131, top: 283, width: 34, height: 48, content: "?", fontSize: 38, color: "#FFFFFF", bold: true }))
- elements.push(
- textElement({
- left: 1110,
- top: 390,
- width: 330,
- height: 190,
- content: question || "你发现了什么?",
- fontSize: fitFont(question || "你发现了什么?", 330, 190, 38, 27),
- color: palette.text,
- bold: true,
- lineHeight: 1.35
- })
- )
- return elements
- }
- function listenCompareVisualTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "听辨对比")
- const points = studentPoints(page, 4)
- ;[0, 1].forEach(group => {
- const left = group === 0 ? 84 : 820
- const color = group === 0 ? palette.primary : palette.secondary
- elements.push(
- shapeElement({ left, top: 205, width: 696, height: 500, fill: group === 0 ? palette.soft : palette.surface, radius: 18, shadow: lightShadow() })
- )
- elements.push(shapeElement({ left: left + 40, top: 250, width: 82, height: 82, fill: color, radius: 41 }))
- elements.push(
- textElement({ left: left + 65, top: 268, width: 34, height: 46, content: group === 0 ? "A" : "B", fontSize: 32, color: "#FFFFFF", bold: true })
- )
- for (let bar = 0; bar < 8; bar++) {
- const height = 28 + ((bar * 23 + group * 17) % 86)
- elements.push(shapeElement({ left: left + 170 + bar * 48, top: 350 - height / 2, width: 22, height, fill: color, opacity: 0.72, radius: 8 }))
- }
- const text = points[group] || (group === 0 ? "听第一段声音" : "听第二段声音")
- elements.push(
- textElement({
- left: left + 54,
- top: 470,
- width: 580,
- height: 110,
- content: text,
- fontSize: fitFont(text, 580, 110, 34, 25),
- color: palette.text,
- bold: true
- })
- )
- })
- return elements
- }
- function soundTimelineTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "声音变化")
- const points = studentPoints(page, 4)
- elements.push(lineElement(170, 480, 1430, 480, palette.muted, 6))
- points.forEach((point, index) => {
- const left = 180 + index * (1200 / Math.max(1, points.length - 1))
- const size = index % 2 === 0 ? 82 : 54
- const color = index % 2 === 0 ? palette.primary : palette.secondary
- elements.push(shapeElement({ left: left - size / 2, top: 480 - size / 2, width: size, height: size, fill: color, radius: size / 2 }))
- elements.push(
- textElement({
- left: left - 150,
- top: index % 2 === 0 ? 300 : 570,
- width: 300,
- height: 90,
- content: point,
- fontSize: fitFont(point, 300, 90, 30, 23),
- color: palette.text,
- bold: true,
- lineHeight: 1.3
- })
- )
- })
- return elements
- }
- function actionDemoTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "跟着做")
- const steps = activitySteps(page, 4)
- steps.forEach((step, index) => {
- const left = 110 + index * 370
- const color = [palette.primary, palette.secondary, palette.accent, palette.warm][index]
- elements.push(shapeElement({ left, top: 280, width: 190, height: 190, fill: color, radius: 95, shadow: lightShadow() }))
- elements.push(
- textElement({ left: left + 62, top: 326, width: 70, height: 74, content: `${index + 1}`, fontSize: 54, color: "#FFFFFF", bold: true })
- )
- elements.push(
- textElement({
- left: left - 45,
- top: 530,
- width: 280,
- height: 110,
- content: step,
- fontSize: fitFont(step, 280, 110, 31, 23),
- color: palette.text,
- bold: true,
- lineHeight: 1.3
- })
- )
- if (index < steps.length - 1) elements.push(lineElement(left + 220, 376, left + 330, 376, palette.muted, 4))
- })
- return elements
- }
- function groupMissionTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "小组任务")
- const steps = activitySteps(page, 4)
- steps.forEach((step, index) => {
- const top = 210 + index * 142
- const color = index % 2 === 0 ? palette.primary : palette.secondary
- elements.push(shapeElement({ left: 100, top, width: 150, height: 104, fill: color, radius: 16 }))
- elements.push(
- textElement({ left: 126, top: top + 27, width: 100, height: 45, content: `第${index + 1}组`, fontSize: 27, color: "#FFFFFF", bold: true })
- )
- elements.push(lineElement(270, top + 52, 350, top + 52, color, 4))
- elements.push(
- textElement({
- left: 390,
- top: top + 18,
- width: 1030,
- height: 68,
- content: step,
- fontSize: fitFont(step, 1030, 68, 34, 25),
- color: palette.text,
- bold: true
- })
- )
- })
- return elements
- }
- function performanceStageTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "展示时刻")
- elements.push(shapeElement({ left: 130, top: 220, width: 1340, height: 520, fill: palette.dark, radius: 28, shadow: softShadow() }))
- elements.push(shapeElement({ left: 230, top: 300, width: 1140, height: 360, fill: palette.warm, opacity: 0.24, radius: 180 }))
- const roles = activitySteps(page, 4)
- roles.forEach((role, index) => {
- const left = 300 + index * 270
- elements.push(shapeElement({ left, top: 430, width: 130, height: 130, fill: index % 2 ? palette.secondary : palette.accent, radius: 65 }))
- elements.push(
- textElement({
- left: left - 30,
- top: 590,
- width: 190,
- height: 68,
- content: role,
- fontSize: fitFont(role, 190, 68, 27, 21),
- color: "#FFFFFF",
- bold: true
- })
- )
- })
- return elements
- }
- function visualSummaryTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "今天的发现")
- const points = studentPoints(page, 4)
- points.forEach((point, index) => {
- const left = 170 + index * 350
- const top = index % 2 === 0 ? 310 : 500
- const color = [palette.primary, palette.secondary, palette.accent, palette.warm][index]
- elements.push(shapeElement({ left, top, width: 110, height: 110, fill: color, radius: 55 }))
- elements.push(textElement({ left: left + 38, top: top + 27, width: 40, height: 50, content: "✓", fontSize: 38, color: "#FFFFFF", bold: true }))
- elements.push(
- textElement({
- left: left - 70,
- top: top + 135,
- width: 250,
- height: 86,
- content: point,
- fontSize: fitFont(point, 250, 86, 29, 22),
- color: palette.text,
- bold: true,
- lineHeight: 1.3
- })
- )
- if (index < points.length - 1) elements.push(lineElement(left + 130, top + 55, left + 300, index % 2 === 0 ? 555 : 365, palette.muted, 3))
- })
- return elements
- }
- function goalTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "本课目标")
- const points = studentPoints(page, 3)
- points.forEach((point, index) => {
- const left = 92 + index * 500
- const colors = [palette.primary, palette.secondary, palette.accent]
- elements.push(shapeElement({ left, top: 240, width: 430, height: 380, fill: palette.surface, radius: 8, shadow: softShadow() }))
- elements.push(shapeElement({ left, top: 240, width: 430, height: 18, fill: colors[index], radius: 0 }))
- elements.push(
- textElement({ left: left + 34, top: 300, width: 96, height: 74, content: `0${index + 1}`, fontSize: 50, color: colors[index], bold: true })
- )
- elements.push(
- textElement({
- left: left + 34,
- top: 410,
- width: 350,
- height: 120,
- content: point,
- fontSize: fitFont(point, 350, 120, 32, 25),
- color: palette.text,
- bold: true,
- lineHeight: 1.35
- })
- )
- })
- addClassroomCue(elements, page, palette)
- return elements
- }
- function listenTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "听赏任务")
- const task = activityTask(page) || studentHeadline(page) || "安静聆听,找一找音乐中的声音"
- if (hasImage(page)) {
- elements.push(imageElement(page, { left: 84, top: 185, width: 1432, height: 520, radius: 8 }))
- elements.push(shapeElement({ left: 84, top: 510, width: 1432, height: 195, fill: palette.dark, opacity: 0.8, radius: 0 }))
- elements.push(
- textElement({
- left: 130,
- top: 550,
- width: 1340,
- height: 84,
- content: task,
- fontSize: fitFont(task, 1340, 84, 42, 30),
- color: "#FFFFFF",
- bold: true
- })
- )
- } else {
- elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 220, fill: palette.dark, radius: 8 }))
- elements.push(
- textElement({
- left: 150,
- top: 270,
- width: 1300,
- height: 92,
- content: task,
- fontSize: fitFont(task, 1300, 92, 44, 30),
- color: "#FFFFFF",
- bold: true
- })
- )
- elements.push(...studentPanel(page, palette, 170, 500, 1260))
- }
- addClassroomCue(elements, page, palette)
- return elements
- }
- function lyricTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "学唱与歌词")
- const points = studentPoints(page, 4)
- elements.push(shapeElement({ left: 90, top: 205, width: 1420, height: 470, fill: palette.surface, radius: 8, shadow: softShadow() }))
- elements.push(shapeElement({ left: 90, top: 205, width: 26, height: 470, fill: palette.secondary, radius: 0 }))
- points.forEach((point, index) => {
- const top = 265 + index * 92
- elements.push(
- textElement({
- left: 166,
- top,
- width: 1250,
- height: 58,
- content: point,
- fontSize: fitFont(point, 1250, 58, 36, 27),
- color: index % 2 === 0 ? palette.text : palette.primary,
- bold: index % 2 === 1
- })
- )
- })
- addClassroomCue(elements, page, palette)
- 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(...knowledgeList(studentPoints(page, 3), palette, 92, 310, 1416))
- 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(...knowledgeList(points, palette, 170, 470, 1260))
- }
- 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) || "拍一拍,感受节奏"
- elements.push(
- textElement({
- left: 92,
- top: 190,
- width: 1410,
- height: 72,
- content: task,
- fontSize: fitFont(task, 1410, 72, 40, 30),
- color: palette.text,
- bold: true
- })
- )
- const beats = activitySteps(page, 4)
- beats.forEach((beat, index) => {
- const left = 92 + index * 370
- elements.push(
- shapeElement({
- left,
- top: 330,
- width: 320,
- height: 260,
- fill: index % 2 === 0 ? palette.soft : palette.surface,
- radius: 8,
- shadow: lightShadow()
- })
- )
- elements.push(
- textElement({
- left: left + 28,
- top: 370,
- width: 70,
- height: 56,
- content: `${index + 1}`,
- fontSize: 42,
- color: index % 2 === 0 ? palette.primary : palette.secondary,
- bold: true
- })
- )
- elements.push(
- textElement({
- left: left + 28,
- top: 460,
- width: 260,
- height: 90,
- content: beat,
- fontSize: fitFont(beat, 260, 90, 29, 23),
- color: palette.text,
- bold: true,
- lineHeight: 1.3
- })
- )
- })
- addClassroomCue(elements, page, palette)
- return elements
- }
- function knowledgeTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "音乐知识")
- const points = studentPoints(page, 4)
- if (hasImage(page)) {
- const imageRight = index % 2 === 0
- elements.push(
- imageElement(
- page,
- imageRight ? { left: 900, top: 190, width: 610, height: 500, radius: 8 } : { left: 90, top: 190, width: 610, height: 500, radius: 8 }
- )
- )
- elements.push(...knowledgeList(points, palette, imageRight ? 90 : 770, 220, 720))
- } else {
- elements.push(...knowledgeList(points, palette, 90, 220, 1420))
- }
- addClassroomCue(elements, page, palette)
- return elements
- }
- function interactionTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "互动挑战")
- const candidate = page.interaction?.question || activityTask(page) || studentHeadline(page) || "你发现了什么?"
- const question = sameVisibleText(candidate, page.title) ? "" : candidate
- if (question) {
- elements.push(shapeElement({ left: 90, top: 200, width: 1420, height: 190, fill: palette.primary, radius: 8 }))
- elements.push(
- textElement({
- left: 150,
- top: 245,
- width: 1300,
- height: 100,
- content: question,
- fontSize: fitFont(question, 1300, 100, 44, 30),
- color: "#FFFFFF",
- bold: true
- })
- )
- }
- const steps = activitySteps(page, 3)
- steps.forEach((step, index) => {
- const left = 90 + index * 490
- elements.push(
- textElement({
- left,
- top: 468,
- width: 88,
- height: 54,
- content: `0${index + 1}`,
- fontSize: 40,
- color: [palette.primary, palette.secondary, palette.accent][index],
- bold: true
- })
- )
- elements.push(
- textElement({
- left: left + 96,
- top: 466,
- width: 340,
- height: 100,
- content: step,
- fontSize: fitFont(step, 340, 100, 29, 23),
- color: palette.text,
- bold: true,
- lineHeight: 1.3
- })
- )
- if (index < steps.length - 1) elements.push(lineElement(left + 452, 492, left + 478, 492, palette.muted))
- })
- addClassroomCue(elements, page, palette)
- return elements
- }
- function activityTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "课堂活动")
- const task = activityTask(page) || studentHeadline(page) || "一起完成课堂挑战"
- if (!sameVisibleText(task, page.title)) {
- elements.push(
- textElement({
- left: 92,
- top: 190,
- width: 1400,
- height: 90,
- content: task,
- fontSize: fitFont(task, 1400, 90, 42, 30),
- color: palette.primary,
- bold: true
- })
- )
- }
- elements.push(...storySequence(activitySteps(page, 4), palette, 92, 360, 1416))
- addClassroomCue(elements, page, palette)
- return elements
- }
- function performanceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "合作表现")
- const task = activityTask(page) || studentHeadline(page) || "一起完成音乐表演"
- if (!sameVisibleText(task, page.title)) {
- 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 candidate = page.interaction?.question || studentHeadline(page) || activityTask(page) || "你能完成今天的音乐挑战吗?"
- const question = sameVisibleText(candidate, page.title) ? "" : candidate
- if (question)
- 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) => {
- const top = 220 + index * 112
- const color = index % 2 === 0 ? palette.secondary : palette.primary
- elements.push(shapeElement({ left: 100, top, width: 62, height: 62, fill: color, radius: 8 }))
- elements.push(textElement({ left: 118, top: top + 12, width: 30, height: 30, content: "✓", fontSize: 28, color: "#FFFFFF", bold: true }))
- elements.push(
- textElement({
- left: 210,
- top: top + 4,
- width: 1240,
- height: 64,
- content: point,
- fontSize: fitFont(point, 1240, 64, 34, 26),
- color: palette.text,
- bold: true
- })
- )
- })
- return elements
- }
- function homeworkTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const elements = slideHeader(page, outline, palette, "课后延伸")
- elements.push(shapeElement({ left: 92, top: 210, width: 1416, height: 440, fill: palette.surface, radius: 8, shadow: softShadow() }))
- activitySteps(page, 4).forEach((step, index) => {
- const top = 270 + index * 86
- elements.push(textElement({ left: 150, top, width: 70, height: 48, content: `${index + 1}.`, fontSize: 34, color: palette.accent, bold: true }))
- elements.push(
- textElement({
- left: 230,
- top,
- width: 1180,
- height: 58,
- content: step,
- fontSize: fitFont(step, 1180, 58, 31, 24),
- color: palette.text,
- bold: true
- })
- )
- })
- return elements
- }
- function contentTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number): PPTElement[] {
- return knowledgeTemplate(page, outline, palette, index)
- }
- function slideHeader(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, eyebrow: string): PPTElement[] {
- const title = displayPageTitle(page, outline)
- return [
- textElement({ left: 92, top: 64, width: 300, height: 34, content: eyebrow, fontSize: 20, color: palette.secondary, bold: true }),
- textElement({
- left: 92,
- top: 102,
- width: 1320,
- height: 66,
- content: title,
- fontSize: fitFont(title, 1320, 66, 42, 32),
- color: palette.text,
- bold: true
- }),
- shapeElement({ left: 1450, top: 72, width: 58, height: 8, fill: palette.accent, radius: 4 })
- ]
- }
- function studentPanel(page: AiCoursewarePage, palette: Palette, left: number, top: number, width: number): PPTElement[] {
- const elements: PPTElement[] = []
- const headline = studentHeadline(page)
- if (headline)
- elements.push(
- textElement({
- left,
- top,
- width,
- height: 90,
- content: headline,
- fontSize: fitFont(headline, width, 90, 36, 27),
- color: palette.primary,
- bold: true
- })
- )
- studentPoints(page, 3).forEach((point, index) => {
- const itemTop = top + (headline ? 120 : 0) + index * 74
- elements.push(shapeElement({ left, top: itemTop + 8, width: 18, height: 18, fill: index % 2 ? palette.secondary : palette.accent, radius: 4 }))
- elements.push(
- textElement({
- left: left + 38,
- top: itemTop,
- width: width - 38,
- height: 54,
- content: point,
- fontSize: fitFont(point, width - 38, 54, 27, 21),
- color: palette.text,
- lineHeight: 1.3
- })
- )
- })
- return elements
- }
- function storySequence(items: string[], palette: Palette, left: number, top: number, width: number): PPTElement[] {
- const elements: PPTElement[] = []
- const count = Math.max(1, Math.min(items.length, 4))
- const gap = 28
- const cardWidth = (width - gap * (count - 1)) / count
- items.slice(0, 4).forEach((item, index) => {
- const cardLeft = left + index * (cardWidth + gap)
- elements.push(
- shapeElement({
- left: cardLeft,
- top,
- width: cardWidth,
- height: 190,
- fill: index % 2 === 0 ? palette.soft : palette.surface,
- radius: 8,
- shadow: lightShadow()
- })
- )
- elements.push(
- textElement({
- left: cardLeft + 24,
- top: top + 24,
- width: 64,
- height: 38,
- content: `0${index + 1}`,
- fontSize: 26,
- color: index % 2 === 0 ? palette.primary : palette.secondary,
- bold: true
- })
- )
- elements.push(
- textElement({
- left: cardLeft + 24,
- top: top + 82,
- width: cardWidth - 48,
- height: 78,
- content: item,
- fontSize: fitFont(item, cardWidth - 48, 78, 27, 21),
- color: palette.text,
- bold: true,
- lineHeight: 1.3
- })
- )
- })
- return elements
- }
- function knowledgeList(items: string[], palette: Palette, left: number, top: number, width: number): PPTElement[] {
- const elements: PPTElement[] = []
- items.forEach((item, index) => {
- const itemTop = top + index * 108
- elements.push(
- shapeElement({
- left,
- top: itemTop,
- width,
- height: 82,
- fill: index % 2 === 0 ? palette.surface : palette.soft,
- radius: 8,
- shadow: lightShadow()
- })
- )
- elements.push(shapeElement({ left, top: itemTop, width: 14, height: 82, fill: index % 2 === 0 ? palette.primary : palette.secondary, radius: 0 }))
- elements.push(
- textElement({
- left: left + 42,
- top: itemTop + 18,
- width: width - 74,
- height: 48,
- content: item,
- fontSize: fitFont(item, width - 74, 48, 29, 22),
- color: palette.text,
- bold: true
- })
- )
- })
- return elements
- }
- function addClassroomCue(elements: PPTElement[], page: AiCoursewarePage, palette: Palette) {
- const cue = page.interaction?.question || activityTask(page)
- if (!cue) return
- const cueKey = normalizeVisibleText(cue)
- const alreadyVisible = elements.some(element => {
- if (element.type === "text") return normalizeVisibleText(stripHtml(element.content)) === cueKey
- if (element.type === "shape" && element.text) return normalizeVisibleText(stripHtml(element.text.content)) === cueKey
- return false
- })
- if (alreadyVisible) return
- const bar = { left: 92, top: 818, width: 1416, height: 54 }
- const fontSize = fitFont(cue, bar.width - 40, 28, 20, 17)
- const visibleCue = fitVisibleText(cue, bar.width - 40, fontSize * 1.2, fontSize, 1.2)
- elements.push(
- shapeElement({
- ...bar,
- fill: palette.soft,
- radius: 8,
- text: {
- content: `<div style="font-family:${FONT_NAME};font-size:${fontSize}px;font-weight:700;">${escapeHtml(visibleCue)}</div>`,
- defaultFontName: FONT_NAME,
- defaultColor: palette.text,
- align: "middle"
- }
- })
- )
- }
- function dedicatedResourceElement(page: AiCoursewarePage): PPTElement | undefined {
- const binding = (page.resourceBindings || []).find(item => item.verified !== false && item.dedicatedPage)
- if (!binding || binding.resourceType !== "MUSIC") return undefined
- const base = { id: nanoid(10), left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT, rotate: 0 }
- return { ...base, type: "elf", subtype: "elf-sing-play", sid: binding.resourceId, title: binding.name || page.title } as PPTCloudCoachElement
- }
- function inlineResourceElements(page: AiCoursewarePage): PPTElement[] {
- const binding = (page.resourceBindings || []).find(item => item.verified !== false && item.resourceType === "SONG" && item.contentUrl)
- if (!binding) return []
- return [
- {
- id: nanoid(10),
- type: "elf",
- subtype: "elf-enjoy",
- left: 110,
- top: 700,
- width: 1380,
- height: 100,
- rotate: 0,
- sid: binding.resourceId,
- title: binding.name || page.title,
- src: binding.contentUrl || "",
- enjoyList: [{ id: binding.resourceId, title: binding.name || page.title, src: binding.contentUrl || "" }]
- } as PPTEnjoyElement
- ]
- }
- function videoBinding(page: AiCoursewarePage) {
- return (page.resourceBindings || []).find(item => item.verified !== false && item.resourceType === "VIDEO" && item.contentUrl)
- }
- function videoResourceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
- const binding = videoBinding(page)!
- const elements = slideHeader(page, outline, palette, page.teachingStage || "观看与发现")
- elements.push({
- id: nanoid(10),
- type: "elf",
- subtype: "elf-video",
- left: 190,
- top: 190,
- width: 1220,
- height: 570,
- rotate: 0,
- src: binding.contentUrl || "",
- poster: binding.coverUrl,
- autoplay: false
- } as PPTVideoElement)
- addClassroomCue(elements, page, palette)
- return elements
- }
- function normalizeOutline(outline: AiCoursewareOutline): AiCoursewareOutline {
- const chapter = cleanText(outline.chapterName || outline.title || "音乐课件")
- return {
- ...outline,
- title: GENERIC_PAGE_TITLES.has(cleanText(outline.title).toLowerCase()) ? chapter : cleanText(outline.title || chapter),
- pages: (outline.pages || []).map((page, index) => ({
- ...page,
- pageNo: index + 1,
- title: index === 0 && GENERIC_PAGE_TITLES.has(cleanText(page.title).toLowerCase()) ? chapter : cleanText(page.title || chapter),
- bullets: (page.bullets || []).map(cleanText).filter(Boolean),
- imageKeywords: (page.imageKeywords || []).map(cleanText).filter(Boolean)
- }))
- }
- }
- 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("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("phrase_sing") || kind.includes("sing_focus")) return "lyric"
- if (kind.includes("rhythm") || kind.includes("skill")) return "rhythm"
- 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") || 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"
- }
- function displayPageTitle(page: AiCoursewarePage, outline: AiCoursewareOutline) {
- const title = cleanText(page.title)
- return !title || GENERIC_PAGE_TITLES.has(title.toLowerCase()) ? chapterTitle(outline, page) : title
- }
- function chapterTitle(outline: AiCoursewareOutline, page: AiCoursewarePage) {
- return cleanText(outline.chapterName || outline.title || page.title || "音乐课件")
- }
- function conciseTextbook(value?: string) {
- return cleanText(value).replace(/^.*?·/, "")
- }
- function hasImage(page: AiCoursewarePage) {
- return Boolean(page.image?.url)
- }
- function studentHeadline(page: AiCoursewarePage) {
- const headline = cleanText(page.studentContent?.headline || page.subtitle || "")
- return sameVisibleText(headline, page.title) ? "" : headline
- }
- function studentPoints(page: AiCoursewarePage, limit: number) {
- const structured = (page.studentContent?.points || []).map(cleanText).filter(Boolean)
- const legacy = (page.bullets || []).map(cleanText).filter(Boolean)
- const points = structured.length ? structured : legacy
- const excluded = [page.title, studentHeadline(page)].map(normalizeVisibleText).filter(Boolean)
- const unique = uniqueVisibleTexts(points).filter(item => !excluded.includes(normalizeVisibleText(item)))
- return (unique.length ? unique : [studentHeadline(page) || cleanText(page.title) || "课堂内容"]).slice(0, limit)
- }
- function activityTask(page: AiCoursewarePage) {
- return cleanText(page.classroomAction?.studentTask || page.activity?.task || "")
- }
- function activitySteps(page: AiCoursewarePage, limit: number) {
- const steps = (page.activity?.steps || []).map(cleanText).filter(Boolean)
- const task = activityTask(page)
- const points = studentPoints(page, limit).filter(item => !sameVisibleText(item, task))
- const values = steps.length ? steps : points
- return uniqueVisibleTexts(values).slice(0, limit)
- }
- function slideRemark(page: AiCoursewarePage) {
- const lessonMeta = [page.teachingStage, page.durationMinutes ? `建议用时:${page.durationMinutes}分钟` : ""].filter(Boolean).join(" · ")
- const evidence = page.learningEvidence ? `学习表现:${page.learningEvidence}` : ""
- return [lessonMeta, page.teachingPurpose, page.speakerNotes, page.teacherTips, page.classroomAction?.teacherInstruction, evidence]
- .map(cleanText)
- .filter(Boolean)
- .filter((item, index, all) => all.indexOf(item) === index)
- .join("\n")
- }
- function buildPalette(outline: AiCoursewareOutline, variant: number, templateId?: AiCoursewareTemplateId): Palette {
- const fallbackTemplate = outline.style === "cartoon" ? "playful-handdrawn" : "fresh-education"
- return { ...getAiCoursewarePalette(templateId || fallbackTemplate, variant) }
- }
- function backgroundConfig(_role: PageRole, palette: Palette, options: AiPptOptions): SlideBackground {
- if (options.backgroundImage) return { type: "image", image: options.backgroundImage, imageSize: "cover" }
- return { type: "solid", color: palette.background }
- }
- function fitFont(content: string, width: number, height: number, preferred: number, minimum: number) {
- const text = cleanText(content)
- if (!text) return preferred
- let size = preferred
- while (size > minimum) {
- const charsPerLine = Math.max(1, Math.floor(width / size))
- const lines = Math.ceil(text.length / charsPerLine)
- if (lines * size * 1.35 <= height) break
- size -= 2
- }
- return size
- }
- 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="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 ""
- // 编号、选项和勾选符号属于不可截断的原子文本。
- if (/^(?:0?\d{1,3}|[A-Z]|[✓√])(?:[.)、])?$/.test(text)) return text
- const safeWidth = Math.max(1, width)
- const safeHeight = Math.max(1, height)
- const maxLines = Math.max(1, Math.floor(safeHeight / (fontSize * lineHeight)))
- // 保留小数容量,避免宽度足够容纳“01”时因向下取整被误判为溢出。
- const maxUnits = Math.max(1, (safeWidth / fontSize) * maxLines)
- if (textUnits(text) <= maxUnits) return text
- let units = 0
- let result = ""
- for (const char of text) {
- const charUnits = char.charCodeAt(0) <= 0xff ? 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 + (char.charCodeAt(0) <= 0xff ? 0.55 : 1), 0)
- }
- function imageElement(page: AiCoursewarePage, rect: ImageRect): PPTImageElement {
- const width = page.image?.width || 0
- const height = page.image?.height || 0
- return {
- id: nanoid(10),
- type: "image",
- left: rect.left,
- top: rect.top,
- width: rect.width,
- height: rect.height,
- rotate: 0,
- src: page.image?.url || "",
- fixedRatio: false,
- radius: rect.radius || 0,
- shadow: rect.radius ? softShadow() : undefined,
- clip: width > 0 && height > 0 ? { shape: "rect", range: coverRange(width, height, rect.width, rect.height) } : undefined
- }
- }
- function coverRange(sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number): [[number, number], [number, number]] {
- const sourceRatio = sourceWidth / sourceHeight
- const targetRatio = targetWidth / targetHeight
- if (sourceRatio > targetRatio) {
- const visible = (targetRatio / sourceRatio) * 100
- const start = (100 - visible) / 2
- return [
- [start, 0],
- [100 - start, 100]
- ]
- }
- const visible = (sourceRatio / targetRatio) * 100
- const start = (100 - visible) / 2
- return [
- [0, start],
- [100, 100 - start]
- ]
- }
- function shapeElement(params: {
- left: number
- top: number
- width: number
- height: number
- fill: string
- opacity?: number
- radius?: number
- shadow?: PPTElementShadow
- gradient?: Gradient
- text?: PPTShapeElement["text"]
- }): PPTShapeElement {
- return {
- id: nanoid(10),
- type: "shape",
- left: params.left,
- top: params.top,
- width: params.width,
- height: params.height,
- rotate: 0,
- viewBox: [200, 200],
- path: roundedRectPath(params.radius || 0),
- fixedRatio: false,
- fill: params.fill,
- opacity: params.opacity ?? 1,
- shadow: params.shadow,
- gradient: params.gradient,
- text: params.text
- }
- }
- function lineElement(x1: number, y1: number, x2: number, y2: number, color: string, width = 2): PPTLineElement {
- const left = Math.min(x1, x2)
- const top = Math.min(y1, y2)
- const lineWidth = Math.abs(x2 - x1)
- const lineHeight = Math.abs(y2 - y1)
- return {
- id: nanoid(10),
- type: "line",
- left,
- top,
- width,
- start: [x1 === left ? 0 : lineWidth, y1 === top ? 0 : lineHeight],
- end: [x2 === left ? 0 : lineWidth, y2 === top ? 0 : lineHeight],
- style: "solid",
- color,
- points: ["", ""]
- }
- }
- function keepInCanvas(elements: PPTElement[]) {
- return elements.map(element => {
- const left = Math.max(0, Math.min(element.left, SLIDE_WIDTH - 1))
- const top = Math.max(0, Math.min(element.top, SLIDE_HEIGHT - 1))
- if (element.type === "line") return { ...element, left, top, width: Math.max(1, Math.min(element.width, 20)) }
- return {
- ...element,
- left,
- top,
- width: Math.max(1, Math.min(element.width, SLIDE_WIDTH - left)),
- height: Math.max(1, Math.min(element.height, SLIDE_HEIGHT - top))
- }
- }) as PPTElement[]
- }
- function scaleElementsToViewport(elements: PPTElement[]): PPTElement[] {
- return elements.map(element => {
- if (element.type === "line") {
- return {
- ...element,
- left: element.left * VIEWPORT_SCALE,
- top: element.top * VIEWPORT_SCALE,
- width: element.width * VIEWPORT_SCALE,
- start: [element.start[0] * VIEWPORT_SCALE, element.start[1] * VIEWPORT_SCALE],
- end: [element.end[0] * VIEWPORT_SCALE, element.end[1] * VIEWPORT_SCALE]
- }
- }
- const scaled = {
- ...element,
- left: element.left * VIEWPORT_SCALE,
- top: element.top * VIEWPORT_SCALE,
- width: element.width * VIEWPORT_SCALE,
- height: element.height * VIEWPORT_SCALE
- } as PPTElement
- if (scaled.type === "text") {
- scaled.content = scaleInlineFontSizes(scaled.content)
- }
- if (scaled.type === "shape" && scaled.text) {
- scaled.text = { ...scaled.text, content: scaleInlineFontSizes(scaled.text.content) }
- }
- if ("radius" in scaled && typeof scaled.radius === "number") {
- scaled.radius *= VIEWPORT_SCALE
- }
- if ("shadow" in scaled && scaled.shadow) {
- scaled.shadow = {
- ...scaled.shadow,
- h: scaled.shadow.h * VIEWPORT_SCALE,
- v: scaled.shadow.v * VIEWPORT_SCALE,
- blur: scaled.shadow.blur * VIEWPORT_SCALE
- }
- }
- return scaled
- })
- }
- function scaleInlineFontSizes(content: string) {
- return content.replace(/font-size:\s*([\d.]+)px/g, (_match, size) => `font-size:${Number(size) * VIEWPORT_SCALE}px`)
- }
- function runPreflight(slides: Slide[]) {
- slides.forEach((slide, index) => {
- const invalid = slide.elements.filter(
- element =>
- element.left < 0 ||
- element.top < 0 ||
- element.left + element.width > VIEWPORT_WIDTH + 1 ||
- (element.type !== "line" && element.top + element.height > VIEWPORT_HEIGHT + 1)
- )
- const emptyText = slide.elements.filter(element => element.type === "text" && !stripHtml(element.content))
- if (invalid.length || emptyText.length)
- console.warn("AI PPT preflight issue", { pageNo: index + 1, invalid: invalid.length, emptyText: emptyText.length })
- })
- }
- function roundedRectPath(radius: number) {
- const r = Math.max(0, Math.min(radius, 100))
- return `M ${r} 0 L ${200 - r} 0 Q 200 0 200 ${r} L 200 ${200 - r} Q 200 200 ${200 - r} 200 L ${r} 200 Q 0 200 0 ${200 - r} L 0 ${r} Q 0 0 ${r} 0 Z`
- }
- function softShadow(): PPTElementShadow {
- return { h: 0, v: 8, blur: 22, color: "rgba(23, 34, 46, 0.14)" }
- }
- function lightShadow(): PPTElementShadow {
- return { h: 0, v: 4, blur: 14, color: "rgba(23, 34, 46, 0.1)" }
- }
- function cleanText(value?: string) {
- return String(value || "")
- .replace(/\s+/g, " ")
- .trim()
- }
- function escapeHtml(value: string) {
- return cleanText(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
- }
- function stripHtml(value: string) {
- return value.replace(/<[^>]+>/g, "").trim()
- }
- function normalizeVisibleText(value?: string) {
- return cleanText(value)
- .replace(/[,。!?、;:,.!?;:\s]+/g, "")
- .toLowerCase()
- }
- function sameVisibleText(left?: string, right?: string) {
- const normalizedLeft = normalizeVisibleText(left)
- return Boolean(normalizedLeft) && normalizedLeft === normalizeVisibleText(right)
- }
- function uniqueVisibleTexts(values: string[]) {
- const seen = new Set<string>()
- return values.filter(value => {
- const key = normalizeVisibleText(value)
- if (!key || seen.has(key)) return false
- seen.add(key)
- return true
- })
- }
|