|
@@ -1,8 +1,16 @@
|
|
|
import { nanoid } from "nanoid"
|
|
import { nanoid } from "nanoid"
|
|
|
-import type { Slide, PPTElement, SlideTheme, PPTTextElement, PPTImageElement } from "@/types/slides"
|
|
|
|
|
|
|
+import type { Slide, PPTElement, SlideTheme, PPTTextElement, PPTImageElement, PPTShapeElement, SlideBackground } from "@/types/slides"
|
|
|
import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
|
|
import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
|
|
|
|
|
|
|
|
-export function aiCoursewareToSlides(outline: AiCoursewareOutline): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
|
|
|
|
|
|
|
+interface AiCoursewareVisualOptions {
|
|
|
|
|
+ backgroundImage?: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const SLIDE_WIDTH = 1600
|
|
|
|
|
+const SLIDE_HEIGHT = 900
|
|
|
|
|
+const FONT_NAME = "Microsoft Yahei"
|
|
|
|
|
+
|
|
|
|
|
+export function aiCoursewareToSlides(outline: AiCoursewareOutline, options: AiCoursewareVisualOptions = {}): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
|
|
|
const primary = outline.style === "clean" ? "#2D6CDF" : "#FF8A3D"
|
|
const primary = outline.style === "clean" ? "#2D6CDF" : "#FF8A3D"
|
|
|
const background = outline.style === "clean" ? "#F7F9FC" : "#FFF8EF"
|
|
const background = outline.style === "clean" ? "#F7F9FC" : "#FFF8EF"
|
|
|
const fontColor = "#263238"
|
|
const fontColor = "#263238"
|
|
@@ -10,29 +18,55 @@ export function aiCoursewareToSlides(outline: AiCoursewareOutline): { title: str
|
|
|
themeColor: primary,
|
|
themeColor: primary,
|
|
|
backgroundColor: background,
|
|
backgroundColor: background,
|
|
|
fontColor,
|
|
fontColor,
|
|
|
- fontName: "Microsoft Yahei",
|
|
|
|
|
|
|
+ fontName: FONT_NAME,
|
|
|
fontSize: "36px"
|
|
fontSize: "36px"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
title: outline.title || outline.chapterName || "AI音乐课件",
|
|
title: outline.title || outline.chapterName || "AI音乐课件",
|
|
|
theme,
|
|
theme,
|
|
|
- slides: outline.pages.map(page => buildSlide(page, outline, primary, background, fontColor))
|
|
|
|
|
|
|
+ slides: outline.pages.map(page => buildSlide(page, outline, primary, background, fontColor, options))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, primary: string, background: string, fontColor: string): Slide {
|
|
|
|
|
|
|
+function buildSlide(
|
|
|
|
|
+ page: AiCoursewarePage,
|
|
|
|
|
+ outline: AiCoursewareOutline,
|
|
|
|
|
+ primary: string,
|
|
|
|
|
+ background: string,
|
|
|
|
|
+ fontColor: string,
|
|
|
|
|
+ options: AiCoursewareVisualOptions
|
|
|
|
|
+): Slide {
|
|
|
const isCover = page.type === "cover" || page.pageNo === 1
|
|
const isCover = page.type === "cover" || page.pageNo === 1
|
|
|
|
|
+ const hasCustomBackground = !!options.backgroundImage
|
|
|
const elements: PPTElement[] = []
|
|
const elements: PPTElement[] = []
|
|
|
|
|
+ const visual = visualConfig(page, primary, outline.style)
|
|
|
|
|
+ const backgroundConfig: SlideBackground = hasCustomBackground
|
|
|
|
|
+ ? {
|
|
|
|
|
+ type: "image",
|
|
|
|
|
+ image: options.backgroundImage,
|
|
|
|
|
+ imageSize: "cover"
|
|
|
|
|
+ }
|
|
|
|
|
+ : {
|
|
|
|
|
+ type: "solid",
|
|
|
|
|
+ color: background
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (hasCustomBackground) {
|
|
|
|
|
+ elements.push(shapeElement({ left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT, fill: "rgba(255, 255, 255, 0.74)", opacity: 0.92 }))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ elements.push(shapeElement({ left: 0, top: 0, width: 18, height: SLIDE_HEIGHT, fill: primary, opacity: 0.95 }))
|
|
|
|
|
+ elements.push(shapeElement({ left: 86, top: isCover ? 142 : 54, width: 70, height: 8, fill: primary, opacity: 0.9 }))
|
|
|
|
|
|
|
|
elements.push(
|
|
elements.push(
|
|
|
textElement({
|
|
textElement({
|
|
|
- left: 110,
|
|
|
|
|
- top: isCover ? 130 : 70,
|
|
|
|
|
- width: isCover ? 980 : 1040,
|
|
|
|
|
- height: isCover ? 160 : 82,
|
|
|
|
|
|
|
+ left: 100,
|
|
|
|
|
+ top: isCover ? 172 : 72,
|
|
|
|
|
+ width: isCover ? 850 : 980,
|
|
|
|
|
+ height: isCover ? 98 : 64,
|
|
|
content: page.title || outline.title,
|
|
content: page.title || outline.title,
|
|
|
- fontSize: isCover ? 58 : 38,
|
|
|
|
|
|
|
+ fontSize: isCover ? 48 : 34,
|
|
|
color: primary,
|
|
color: primary,
|
|
|
bold: true
|
|
bold: true
|
|
|
})
|
|
})
|
|
@@ -42,12 +76,12 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, primar
|
|
|
if (subtitle) {
|
|
if (subtitle) {
|
|
|
elements.push(
|
|
elements.push(
|
|
|
textElement({
|
|
textElement({
|
|
|
- left: 116,
|
|
|
|
|
- top: isCover ? 312 : 150,
|
|
|
|
|
- width: 920,
|
|
|
|
|
- height: 48,
|
|
|
|
|
|
|
+ left: 104,
|
|
|
|
|
+ top: isCover ? 288 : 132,
|
|
|
|
|
+ width: 840,
|
|
|
|
|
+ height: 40,
|
|
|
content: subtitle,
|
|
content: subtitle,
|
|
|
- fontSize: isCover ? 28 : 20,
|
|
|
|
|
|
|
+ fontSize: isCover ? 24 : 18,
|
|
|
color: fontColor
|
|
color: fontColor
|
|
|
})
|
|
})
|
|
|
)
|
|
)
|
|
@@ -56,56 +90,68 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, primar
|
|
|
if (page.image?.url) {
|
|
if (page.image?.url) {
|
|
|
elements.push(
|
|
elements.push(
|
|
|
imageElement({
|
|
imageElement({
|
|
|
- left: isCover ? 1080 : 1180,
|
|
|
|
|
- top: isCover ? 150 : 150,
|
|
|
|
|
- width: isCover ? 650 : 590,
|
|
|
|
|
- height: isCover ? 620 : 480,
|
|
|
|
|
|
|
+ left: isCover ? 980 : 1040,
|
|
|
|
|
+ top: isCover ? 138 : 150,
|
|
|
|
|
+ width: isCover ? 480 : 430,
|
|
|
|
|
+ height: isCover ? 520 : 360,
|
|
|
src: page.image.url
|
|
src: page.image.url
|
|
|
})
|
|
})
|
|
|
)
|
|
)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ elements.push(...fallbackVisualElements(page, visual, isCover))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const bulletTop = isCover ? 430 : 250
|
|
|
|
|
- const bulletWidth = page.image?.url ? 900 : 1320
|
|
|
|
|
|
|
+ const bulletTop = isCover ? 410 : 230
|
|
|
|
|
+ const bulletWidth = page.image?.url ? 820 : 960
|
|
|
const bullets = normalizeBullets(page)
|
|
const bullets = normalizeBullets(page)
|
|
|
elements.push(
|
|
elements.push(
|
|
|
|
|
+ shapeElement({
|
|
|
|
|
+ left: 92,
|
|
|
|
|
+ top: bulletTop - 20,
|
|
|
|
|
+ width: bulletWidth + 38,
|
|
|
|
|
+ height: Math.min(330, 78 + bullets.length * 58),
|
|
|
|
|
+ fill: hasCustomBackground ? "rgba(255, 255, 255, 0.82)" : "#FFFFFF",
|
|
|
|
|
+ opacity: 0.96
|
|
|
|
|
+ })
|
|
|
|
|
+ )
|
|
|
|
|
+ elements.push(
|
|
|
textElement({
|
|
textElement({
|
|
|
left: 120,
|
|
left: 120,
|
|
|
top: bulletTop,
|
|
top: bulletTop,
|
|
|
width: bulletWidth,
|
|
width: bulletWidth,
|
|
|
- height: Math.min(430, 72 + bullets.length * 58),
|
|
|
|
|
- content: bullets.map(item => `• ${escapeHtml(item)}`).join("<br>"),
|
|
|
|
|
- fontSize: isCover ? 30 : 28,
|
|
|
|
|
|
|
+ height: Math.min(290, 66 + bullets.length * 50),
|
|
|
|
|
+ content: bullets.map(item => `<div style="margin-bottom:12px;">${visual.bulletMark} ${escapeHtml(item)}</div>`).join(""),
|
|
|
|
|
+ fontSize: isCover ? 26 : 24,
|
|
|
color: fontColor,
|
|
color: fontColor,
|
|
|
- lineHeight: 1.45
|
|
|
|
|
|
|
+ lineHeight: 1.36
|
|
|
})
|
|
})
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
if (page.interaction?.question) {
|
|
if (page.interaction?.question) {
|
|
|
|
|
+ elements.push(shapeElement({ left: 104, top: 704, width: 1090, height: 88, fill: primary, opacity: 0.92 }))
|
|
|
elements.push(
|
|
elements.push(
|
|
|
textElement({
|
|
textElement({
|
|
|
- left: 120,
|
|
|
|
|
- top: 760,
|
|
|
|
|
- width: 1200,
|
|
|
|
|
- height: 105,
|
|
|
|
|
|
|
+ left: 134,
|
|
|
|
|
+ top: 722,
|
|
|
|
|
+ width: 1020,
|
|
|
|
|
+ height: 56,
|
|
|
content: `互动:${escapeHtml(page.interaction.question)}`,
|
|
content: `互动:${escapeHtml(page.interaction.question)}`,
|
|
|
- fontSize: 28,
|
|
|
|
|
|
|
+ fontSize: 24,
|
|
|
color: "#FFFFFF",
|
|
color: "#FFFFFF",
|
|
|
- fill: primary,
|
|
|
|
|
lineHeight: 1.35
|
|
lineHeight: 1.35
|
|
|
})
|
|
})
|
|
|
)
|
|
)
|
|
|
} else if (page.teacherTips) {
|
|
} else if (page.teacherTips) {
|
|
|
|
|
+ elements.push(shapeElement({ left: 104, top: 724, width: 1090, height: 62, fill: hasCustomBackground ? "rgba(255, 255, 255, 0.78)" : "#FFFFFF", opacity: 0.94 }))
|
|
|
elements.push(
|
|
elements.push(
|
|
|
textElement({
|
|
textElement({
|
|
|
- left: 120,
|
|
|
|
|
- top: 780,
|
|
|
|
|
- width: 1200,
|
|
|
|
|
- height: 86,
|
|
|
|
|
|
|
+ left: 130,
|
|
|
|
|
+ top: 742,
|
|
|
|
|
+ width: 1030,
|
|
|
|
|
+ height: 34,
|
|
|
content: `提示:${escapeHtml(page.teacherTips)}`,
|
|
content: `提示:${escapeHtml(page.teacherTips)}`,
|
|
|
- fontSize: 22,
|
|
|
|
|
|
|
+ fontSize: 18,
|
|
|
color: "#52616B",
|
|
color: "#52616B",
|
|
|
- fill: "#FFFFFF",
|
|
|
|
|
lineHeight: 1.35
|
|
lineHeight: 1.35
|
|
|
})
|
|
})
|
|
|
)
|
|
)
|
|
@@ -113,8 +159,8 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, primar
|
|
|
|
|
|
|
|
elements.push(
|
|
elements.push(
|
|
|
textElement({
|
|
textElement({
|
|
|
- left: 1700,
|
|
|
|
|
- top: 995,
|
|
|
|
|
|
|
+ left: 1450,
|
|
|
|
|
+ top: 838,
|
|
|
width: 120,
|
|
width: 120,
|
|
|
height: 36,
|
|
height: 36,
|
|
|
content: `${page.pageNo || ""}/15`,
|
|
content: `${page.pageNo || ""}/15`,
|
|
@@ -126,10 +172,7 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, primar
|
|
|
return {
|
|
return {
|
|
|
id: nanoid(10),
|
|
id: nanoid(10),
|
|
|
elements,
|
|
elements,
|
|
|
- background: {
|
|
|
|
|
- type: "solid",
|
|
|
|
|
- color: background
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ background: backgroundConfig,
|
|
|
remark: page.teacherTips || ""
|
|
remark: page.teacherTips || ""
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -160,8 +203,8 @@ function textElement(params: {
|
|
|
width: params.width,
|
|
width: params.width,
|
|
|
height: params.height,
|
|
height: params.height,
|
|
|
rotate: 0,
|
|
rotate: 0,
|
|
|
- content: `<div style="font-size:${params.fontSize}px;${params.bold ? "font-weight:700;" : ""}">${params.content}</div>`,
|
|
|
|
|
- defaultFontName: "Microsoft Yahei",
|
|
|
|
|
|
|
+ content: `<div style="font-family:${FONT_NAME};font-size:${params.fontSize}px;${params.bold ? "font-weight:700;" : ""}">${params.content}</div>`,
|
|
|
|
|
+ defaultFontName: FONT_NAME,
|
|
|
defaultColor: params.color,
|
|
defaultColor: params.color,
|
|
|
fill: params.fill,
|
|
fill: params.fill,
|
|
|
lineHeight: params.lineHeight || 1.25,
|
|
lineHeight: params.lineHeight || 1.25,
|
|
@@ -181,7 +224,7 @@ function imageElement(params: { left: number; top: number; width: number; height
|
|
|
rotate: 0,
|
|
rotate: 0,
|
|
|
src: params.src,
|
|
src: params.src,
|
|
|
fixedRatio: false,
|
|
fixedRatio: false,
|
|
|
- radius: 12,
|
|
|
|
|
|
|
+ radius: 8,
|
|
|
shadow: {
|
|
shadow: {
|
|
|
h: 0,
|
|
h: 0,
|
|
|
v: 8,
|
|
v: 8,
|
|
@@ -191,6 +234,77 @@ function imageElement(params: { left: number; top: number; width: number; height
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function shapeElement(params: {
|
|
|
|
|
+ left: number
|
|
|
|
|
+ top: number
|
|
|
|
|
+ width: number
|
|
|
|
|
+ height: number
|
|
|
|
|
+ fill: string
|
|
|
|
|
+ opacity?: number
|
|
|
|
|
+ radius?: number
|
|
|
|
|
+}): 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 || 18),
|
|
|
|
|
+ fixedRatio: false,
|
|
|
|
|
+ fill: params.fill,
|
|
|
|
|
+ opacity: params.opacity ?? 1
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function fallbackVisualElements(page: AiCoursewarePage, visual: ReturnType<typeof visualConfig>, isCover: boolean): PPTElement[] {
|
|
|
|
|
+ const left = isCover ? 1010 : 1080
|
|
|
|
|
+ const top = isCover ? 168 : 180
|
|
|
|
|
+ const size = isCover ? 360 : 280
|
|
|
|
|
+ return [
|
|
|
|
|
+ shapeElement({ left, top, width: size, height: size, fill: visual.light, opacity: 0.94, radius: 80 }),
|
|
|
|
|
+ shapeElement({ left: left + 34, top: top + 34, width: size - 68, height: size - 68, fill: "#FFFFFF", opacity: 0.9, radius: 70 }),
|
|
|
|
|
+ textElement({
|
|
|
|
|
+ left: left + 64,
|
|
|
|
|
+ top: top + (isCover ? 112 : 84),
|
|
|
|
|
+ width: size - 128,
|
|
|
|
|
+ height: 100,
|
|
|
|
|
+ content: visual.icon,
|
|
|
|
|
+ fontSize: isCover ? 76 : 62,
|
|
|
|
|
+ color: visual.color,
|
|
|
|
|
+ bold: true
|
|
|
|
|
+ }),
|
|
|
|
|
+ textElement({
|
|
|
|
|
+ left: left + 54,
|
|
|
|
|
+ top: top + (isCover ? 230 : 186),
|
|
|
|
|
+ width: size - 108,
|
|
|
|
|
+ height: 44,
|
|
|
|
|
+ content: visual.label,
|
|
|
|
|
+ fontSize: isCover ? 24 : 20,
|
|
|
|
|
+ color: visual.color,
|
|
|
|
|
+ bold: true
|
|
|
|
|
+ })
|
|
|
|
|
+ ]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function visualConfig(page: AiCoursewarePage, primary: string, style: string) {
|
|
|
|
|
+ const type = page.type || ""
|
|
|
|
|
+ const cartoonLight = style === "clean" ? "#E8F1FF" : "#FFF0D9"
|
|
|
|
|
+ if (type.includes("interaction") || type.includes("activity")) return { icon: "?", label: "课堂互动", color: "#1E88E5", light: "#EAF4FF", bulletMark: "◆" }
|
|
|
|
|
+ if (type.includes("summary")) return { icon: "✓", label: "课堂小结", color: "#2E7D32", light: "#EAF7EC", bulletMark: "✓" }
|
|
|
|
|
+ if (type.includes("goal")) return { icon: "◎", label: "学习目标", color: "#7B1FA2", light: "#F3E8FF", bulletMark: "◎" }
|
|
|
|
|
+ if (type.includes("listen")) return { icon: "♪", label: "听一听", color: "#00897B", light: "#E5F6F3", bulletMark: "♪" }
|
|
|
|
|
+ if (type.includes("homework")) return { icon: "✎", label: "课后练习", color: "#6D4C41", light: "#F3ECE8", bulletMark: "•" }
|
|
|
|
|
+ return { icon: "♫", label: "音乐课堂", color: primary, light: cartoonLight, bulletMark: "•" }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 escapeHtml(value: string) {
|
|
function escapeHtml(value: string) {
|
|
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
|
}
|
|
}
|