|
|
@@ -133,7 +133,8 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
|
|
|
}
|
|
|
const elements: PPTElement[] = []
|
|
|
const title = masterPage.layout === "cover" ? chapterTitle(outline, page) : displayPageTitle(page, outline)
|
|
|
- const points = studentPoints(page, visiblePointLimit(page))
|
|
|
+ // 学生可见内容必须与编辑区保持一致,不能因模板容量静默丢弃条目。
|
|
|
+ const points = studentPoints(page)
|
|
|
const headline = activityTask(page) || studentHeadline(page)
|
|
|
|
|
|
addSlotText(elements, slotOf(masterPage, "title"), title, masterPage.layout === "cover" ? master.coverTitleColor : master.titleColor, true)
|
|
|
@@ -185,7 +186,7 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
|
|
|
|
|
|
if (masterPage.layout === "diagram") {
|
|
|
if (role === "notation") addNotationVisual(elements, slotOf(masterPage, "image"), page, master, palette, false)
|
|
|
- else if (role === "rhythm") addStepGrid(elements, slotOf(masterPage, "image"), activitySteps(page, 4), master, palette)
|
|
|
+ else if (role === "rhythm") addStepGrid(elements, slotOf(masterPage, "image"), activitySteps(page), master, palette)
|
|
|
else addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, true)
|
|
|
if (role !== "rhythm") addSlotList(elements, slotOf(masterPage, "body"), points, master, palette)
|
|
|
addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
|
|
|
@@ -204,7 +205,7 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
|
|
|
|
|
|
if (masterPage.layout === "activity") {
|
|
|
addSlotText(elements, slotOf(masterPage, "headline"), headline || "一起完成音乐任务", palette.primary, true)
|
|
|
- addStepGrid(elements, slotOf(masterPage, "steps"), activitySteps(page, 4), master, palette)
|
|
|
+ addStepGrid(elements, slotOf(masterPage, "steps"), activitySteps(page), master, palette)
|
|
|
addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
|
|
|
return elements
|
|
|
}
|
|
|
@@ -309,33 +310,53 @@ function addSlotText(elements: PPTElement[], slot: TemplateSlot | undefined, con
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
-function addSlotList(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette, cards = true, textColor = master.textColor) {
|
|
|
+function addSlotList(
|
|
|
+ elements: PPTElement[],
|
|
|
+ slot: TemplateSlot | undefined,
|
|
|
+ sourceItems: string[],
|
|
|
+ master: TemplateMaster,
|
|
|
+ palette: Palette,
|
|
|
+ cards = true,
|
|
|
+ textColor = master.textColor
|
|
|
+) {
|
|
|
if (!slot) return
|
|
|
- const items = fitItemsToSlot(uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean)), slot.maxItems || 4)
|
|
|
+ const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
|
|
|
if (!items.length) return
|
|
|
- const gap = Math.min(16, Math.max(8, slot.height / Math.max(1, items.length * 6)))
|
|
|
+ const gap = collectionGap(slot.height, items.length, 16)
|
|
|
const rowHeight = (slot.height - gap * (items.length - 1)) / items.length
|
|
|
items.forEach((item, index) => {
|
|
|
const top = slot.top + index * (rowHeight + gap)
|
|
|
+ const inset = cards ? Math.min(34, Math.max(12, rowHeight * 0.22)) : 0
|
|
|
+ const textTopInset = Math.min(6, Math.max(1, rowHeight * 0.08))
|
|
|
+ const textHeight = Math.max(12, rowHeight - textTopInset * 2)
|
|
|
+ const textWidth = Math.max(40, slot.width - (cards ? inset + 24 : 0))
|
|
|
+ const minimumFontSize = adaptiveMinimumFont(slot, textHeight)
|
|
|
if (cards) {
|
|
|
addCardFrame(elements, slot.left, top, slot.width, rowHeight, index, master, palette)
|
|
|
}
|
|
|
- elements.push(textElement({
|
|
|
- left: slot.left + (cards ? 34 : 0),
|
|
|
- top: top + 6,
|
|
|
- width: slot.width - (cards ? 58 : 0),
|
|
|
- height: Math.max(28, rowHeight - 12),
|
|
|
- content: item,
|
|
|
- fontSize: fitFont(item, slot.width - 58, Math.max(28, rowHeight - 12), slot.preferredFontSize || 30, slot.minimumFontSize || 22),
|
|
|
- minimumFontSize: slot.minimumFontSize,
|
|
|
- color: textColor,
|
|
|
- bold: index === 0 && items.length === 1
|
|
|
- }))
|
|
|
+ elements.push(
|
|
|
+ textElement({
|
|
|
+ left: slot.left + inset,
|
|
|
+ top: top + textTopInset,
|
|
|
+ width: textWidth,
|
|
|
+ height: textHeight,
|
|
|
+ content: item,
|
|
|
+ fontSize: fitFont(item, textWidth, textHeight, slot.preferredFontSize || 30, minimumFontSize),
|
|
|
+ minimumFontSize,
|
|
|
+ color: textColor,
|
|
|
+ bold: index === 0 && items.length === 1
|
|
|
+ })
|
|
|
+ )
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-function fitItemsToSlot(items: string[], maxItems: number) {
|
|
|
- return items.slice(0, maxItems)
|
|
|
+function collectionGap(height: number, count: number, preferred: number) {
|
|
|
+ if (count <= 1) return 0
|
|
|
+ return Math.max(1, Math.min(preferred, height / Math.max(8, count * 8)))
|
|
|
+}
|
|
|
+
|
|
|
+function adaptiveMinimumFont(slot: TemplateSlot, availableHeight: number) {
|
|
|
+ return Math.min(slot.minimumFontSize || 22, Math.max(12, Math.floor(availableHeight / 1.35)))
|
|
|
}
|
|
|
|
|
|
function addCardFrame(elements: PPTElement[], left: number, top: number, width: number, height: number, index: number, master: TemplateMaster, palette: Palette) {
|
|
|
@@ -373,30 +394,62 @@ function addCardFrame(elements: PPTElement[], left: number, top: number, width:
|
|
|
|
|
|
function addCardGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
|
|
|
if (!slot) return
|
|
|
- const items = fitItemsToSlot(uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean)), slot.maxItems || 4)
|
|
|
+ const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
|
|
|
if (!items.length) return
|
|
|
const columns = items.length === 1 ? 1 : Math.min(3, items.length)
|
|
|
const rows = Math.ceil(items.length / columns)
|
|
|
- const gap = 22
|
|
|
+ const gap = collectionGap(slot.height, rows, 22)
|
|
|
const width = (slot.width - gap * (columns - 1)) / columns
|
|
|
const height = (slot.height - gap * (rows - 1)) / rows
|
|
|
items.forEach((item, index) => {
|
|
|
const left = slot.left + (index % columns) * (width + gap)
|
|
|
const top = slot.top + Math.floor(index / columns) * (height + gap)
|
|
|
+ const inset = Math.min(34, Math.max(10, Math.min(width, height) * 0.1))
|
|
|
+ const textWidth = Math.max(36, width - inset * 2)
|
|
|
+ const textHeight = Math.max(12, height - inset * 2)
|
|
|
+ const minimumFontSize = adaptiveMinimumFont(slot, textHeight)
|
|
|
addCardFrame(elements, left, top, width, height, index, master, palette)
|
|
|
- elements.push(textElement({ left: left + 34, top: top + 34, width: width - 68, height: height - 68, content: item, fontSize: slot.preferredFontSize || 32, minimumFontSize: slot.minimumFontSize, color: master.textColor, bold: true }))
|
|
|
+ elements.push(
|
|
|
+ textElement({
|
|
|
+ left: left + inset,
|
|
|
+ top: top + inset,
|
|
|
+ width: textWidth,
|
|
|
+ height: textHeight,
|
|
|
+ content: item,
|
|
|
+ fontSize: fitFont(item, textWidth, textHeight, slot.preferredFontSize || 32, minimumFontSize),
|
|
|
+ minimumFontSize,
|
|
|
+ color: master.textColor,
|
|
|
+ bold: true
|
|
|
+ })
|
|
|
+ )
|
|
|
})
|
|
|
}
|
|
|
|
|
|
function addLyricLines(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
|
|
|
if (!slot) return
|
|
|
- const items = fitItemsToSlot(uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean)), slot.maxItems || 5)
|
|
|
- const gap = 12
|
|
|
- const height = Math.max(56, (slot.height - gap * Math.max(0, items.length - 1)) / Math.max(1, items.length))
|
|
|
+ const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
|
|
|
+ const gap = collectionGap(slot.height, items.length, 12)
|
|
|
+ const height = (slot.height - gap * Math.max(0, items.length - 1)) / Math.max(1, items.length)
|
|
|
items.forEach((item, index) => {
|
|
|
const top = slot.top + index * (height + gap)
|
|
|
- if (index % 2 === 0) elements.push(shapeElement({ left: slot.left, top, width: slot.width, height, fill: palette.soft, opacity: 0.55, radius: 18 }))
|
|
|
- elements.push(textElement({ left: slot.left + 45, top: top + 8, width: slot.width - 90, height: height - 16, content: `♪ ${item}`, fontSize: slot.preferredFontSize || 38, minimumFontSize: slot.minimumFontSize, color: master.textColor, bold: index === 0 }))
|
|
|
+ const inset = Math.min(8, Math.max(1, height * 0.08))
|
|
|
+ const textHeight = Math.max(12, height - inset * 2)
|
|
|
+ const minimumFontSize = adaptiveMinimumFont(slot, textHeight)
|
|
|
+ if (index % 2 === 0)
|
|
|
+ elements.push(shapeElement({ left: slot.left, top, width: slot.width, height, fill: palette.soft, opacity: 0.55, radius: 18 }))
|
|
|
+ elements.push(
|
|
|
+ textElement({
|
|
|
+ left: slot.left + 45,
|
|
|
+ top: top + inset,
|
|
|
+ width: slot.width - 90,
|
|
|
+ height: textHeight,
|
|
|
+ content: `♪ ${item}`,
|
|
|
+ fontSize: fitFont(item, slot.width - 90, textHeight, slot.preferredFontSize || 38, minimumFontSize),
|
|
|
+ minimumFontSize,
|
|
|
+ color: master.textColor,
|
|
|
+ bold: index === 0
|
|
|
+ })
|
|
|
+ )
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -423,28 +476,111 @@ function addNotationVisual(elements: PPTElement[], slot: TemplateSlot | undefine
|
|
|
|
|
|
function addStepGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
|
|
|
if (!slot) return
|
|
|
- const items = fitItemsToSlot(uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean)), slot.maxItems || 4)
|
|
|
+ const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
|
|
|
if (!items.length) return
|
|
|
- const gap = 22
|
|
|
- const width = (slot.width - gap * (items.length - 1)) / items.length
|
|
|
+ const columns = Math.min(4, items.length)
|
|
|
+ const rows = Math.ceil(items.length / columns)
|
|
|
+ const gap = collectionGap(slot.height, rows, 22)
|
|
|
+ const width = (slot.width - gap * (columns - 1)) / columns
|
|
|
+ const height = (slot.height - gap * (rows - 1)) / rows
|
|
|
items.forEach((item, index) => {
|
|
|
- addCardFrame(elements, slot.left + index * (width + gap), slot.top + 42, width, slot.height - 42, index, master, palette)
|
|
|
- elements.push(shapeElement({ left: slot.left + index * (width + gap) + 28, top: slot.top, width: 72, height: 72, fill: index % 2 ? palette.secondary : palette.primary, radius: 36 }))
|
|
|
- elements.push(textElement({ left: slot.left + index * (width + gap) + 49, top: slot.top + 13, width: 34, height: 42, content: String(index + 1), fontSize: 28, color: "#FFFFFF", bold: true }))
|
|
|
- elements.push(textElement({ left: slot.left + index * (width + gap) + 30, top: slot.top + 112, width: width - 60, height: slot.height - 150, content: item, fontSize: 29, minimumFontSize: slot.minimumFontSize, color: master.textColor, bold: true }))
|
|
|
+ const left = slot.left + (index % columns) * (width + gap)
|
|
|
+ const top = slot.top + Math.floor(index / columns) * (height + gap)
|
|
|
+ const badgeSize = Math.min(72, Math.max(24, height * 0.2))
|
|
|
+ const cardTop = top + badgeSize * 0.55
|
|
|
+ const cardHeight = Math.max(12, height - badgeSize * 0.55)
|
|
|
+ const textInset = Math.min(30, Math.max(8, width * 0.08))
|
|
|
+ const textTop = cardTop + Math.min(70, Math.max(18, cardHeight * 0.22))
|
|
|
+ const textHeight = Math.max(12, top + height - textTop - 8)
|
|
|
+ const minimumFontSize = adaptiveMinimumFont(slot, textHeight)
|
|
|
+ addCardFrame(elements, left, cardTop, width, cardHeight, index, master, palette)
|
|
|
+ elements.push(
|
|
|
+ shapeElement({
|
|
|
+ left: left + textInset,
|
|
|
+ top,
|
|
|
+ width: badgeSize,
|
|
|
+ height: badgeSize,
|
|
|
+ fill: index % 2 ? palette.secondary : palette.primary,
|
|
|
+ radius: badgeSize / 2
|
|
|
+ })
|
|
|
+ )
|
|
|
+ elements.push(
|
|
|
+ textElement({
|
|
|
+ left: left + textInset,
|
|
|
+ top: top + badgeSize * 0.15,
|
|
|
+ width: badgeSize,
|
|
|
+ height: badgeSize * 0.62,
|
|
|
+ content: String(index + 1),
|
|
|
+ fontSize: Math.min(28, Math.max(14, badgeSize * 0.4)),
|
|
|
+ minimumFontSize: 12,
|
|
|
+ color: "#FFFFFF",
|
|
|
+ bold: true
|
|
|
+ })
|
|
|
+ )
|
|
|
+ elements.push(
|
|
|
+ textElement({
|
|
|
+ left: left + textInset,
|
|
|
+ top: textTop,
|
|
|
+ width: Math.max(36, width - textInset * 2),
|
|
|
+ height: textHeight,
|
|
|
+ content: item,
|
|
|
+ fontSize: fitFont(item, width - textInset * 2, textHeight, 29, minimumFontSize),
|
|
|
+ minimumFontSize,
|
|
|
+ color: master.textColor,
|
|
|
+ bold: true
|
|
|
+ })
|
|
|
+ )
|
|
|
})
|
|
|
}
|
|
|
|
|
|
function addChecklist(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
|
|
|
if (!slot) return
|
|
|
- const items = fitItemsToSlot(uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean)), slot.maxItems || 5)
|
|
|
- const gap = 16
|
|
|
+ const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
|
|
|
+ const gap = collectionGap(slot.height, items.length, 16)
|
|
|
const height = (slot.height - gap * Math.max(0, items.length - 1)) / Math.max(1, items.length)
|
|
|
items.forEach((item, index) => {
|
|
|
const top = slot.top + index * (height + gap)
|
|
|
- elements.push(shapeElement({ left: slot.left, top: top + 8, width: 42, height: 42, fill: index % 2 ? palette.secondary : palette.primary, radius: 21 }))
|
|
|
- elements.push(textElement({ left: slot.left + 12, top: top + 10, width: 28, height: 34, content: "✓", fontSize: 24, color: "#FFFFFF", bold: true }))
|
|
|
- elements.push(textElement({ left: slot.left + 72, top, width: slot.width - 72, height, content: item, fontSize: slot.preferredFontSize || 32, minimumFontSize: slot.minimumFontSize, color: master.textColor, bold: index === 0 }))
|
|
|
+ const iconSize = Math.min(42, Math.max(16, height * 0.58))
|
|
|
+ const iconTop = top + Math.max(0, (height - iconSize) / 2)
|
|
|
+ const textLeft = slot.left + iconSize + Math.min(30, Math.max(8, iconSize * 0.7))
|
|
|
+ const textWidth = Math.max(40, slot.left + slot.width - textLeft)
|
|
|
+ const minimumFontSize = adaptiveMinimumFont(slot, height)
|
|
|
+ elements.push(
|
|
|
+ shapeElement({
|
|
|
+ left: slot.left,
|
|
|
+ top: iconTop,
|
|
|
+ width: iconSize,
|
|
|
+ height: iconSize,
|
|
|
+ fill: index % 2 ? palette.secondary : palette.primary,
|
|
|
+ radius: iconSize / 2
|
|
|
+ })
|
|
|
+ )
|
|
|
+ elements.push(
|
|
|
+ textElement({
|
|
|
+ left: slot.left + iconSize * 0.2,
|
|
|
+ top: iconTop + iconSize * 0.12,
|
|
|
+ width: iconSize * 0.6,
|
|
|
+ height: iconSize * 0.7,
|
|
|
+ content: "✓",
|
|
|
+ fontSize: Math.min(24, Math.max(10, iconSize * 0.55)),
|
|
|
+ minimumFontSize: 10,
|
|
|
+ color: "#FFFFFF",
|
|
|
+ bold: true
|
|
|
+ })
|
|
|
+ )
|
|
|
+ elements.push(
|
|
|
+ textElement({
|
|
|
+ left: textLeft,
|
|
|
+ top,
|
|
|
+ width: textWidth,
|
|
|
+ height,
|
|
|
+ content: item,
|
|
|
+ fontSize: fitFont(item, textWidth, height, slot.preferredFontSize || 32, minimumFontSize),
|
|
|
+ minimumFontSize,
|
|
|
+ color: master.textColor,
|
|
|
+ bold: index === 0
|
|
|
+ })
|
|
|
+ )
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -612,13 +748,13 @@ function studentHeadline(page: AiCoursewarePage) {
|
|
|
return sameVisibleText(headline, page.title) ? "" : headline
|
|
|
}
|
|
|
|
|
|
-function studentPoints(page: AiCoursewarePage, limit: number) {
|
|
|
+function studentPoints(page: AiCoursewarePage) {
|
|
|
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)
|
|
|
+ return unique.length ? unique : [studentHeadline(page) || cleanText(page.title) || "课堂内容"]
|
|
|
}
|
|
|
|
|
|
function activityTask(page: AiCoursewarePage) {
|
|
|
@@ -626,17 +762,10 @@ function activityTask(page: AiCoursewarePage) {
|
|
|
return cleanText(page.classroomAction?.studentTask || studentHeadline(page) || "")
|
|
|
}
|
|
|
|
|
|
-function visiblePointLimit(page: AiCoursewarePage) {
|
|
|
- const density = page.visualPlan?.visualDensity
|
|
|
- if (density === "low") return 3
|
|
|
- if (density === "high") return 5
|
|
|
- return 4
|
|
|
-}
|
|
|
-
|
|
|
-function activitySteps(page: AiCoursewarePage, limit: number) {
|
|
|
+function activitySteps(page: AiCoursewarePage) {
|
|
|
const task = activityTask(page)
|
|
|
- const points = studentPoints(page, limit).filter(item => !sameVisibleText(item, task))
|
|
|
- return uniqueVisibleTexts(points).slice(0, limit)
|
|
|
+ const points = studentPoints(page).filter(item => !sameVisibleText(item, task))
|
|
|
+ return uniqueVisibleTexts(points)
|
|
|
}
|
|
|
|
|
|
function slideRemark(page: AiCoursewarePage) {
|