|
|
@@ -1,6 +1,6 @@
|
|
|
import { nanoid } from "nanoid"
|
|
|
import tinycolor from 'tinycolor2'
|
|
|
-import { measureAiPptText, aiPptBlockHeight, aiPptTextGeometry } from "@/utils/aiPptTextMetrics"
|
|
|
+import { AI_PPT_SCALE, measureAiPptText, aiPptBlockHeight, aiPptTextGeometry } from "@/utils/aiPptTextMetrics"
|
|
|
import type {
|
|
|
Gradient,
|
|
|
PPTElement,
|
|
|
@@ -154,7 +154,9 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
|
|
|
assertContentContract(master, masterPage, contentElements)
|
|
|
const elements = [
|
|
|
...renderTemplateMasterElements(masterPage.base),
|
|
|
- ...(designed ? [lineElement(52, slotOf(masterPage, 'title')!.top + 8, 52,
|
|
|
+ ...(designed && role === 'cover' && !hasImage(page) && !intent.showMedia
|
|
|
+ ? [lineElement(80, 70, 1520, 70, palette.primary, 6), lineElement(80, 842, 1520, 842, palette.primary, 2)]
|
|
|
+ : designed ? [lineElement(52, slotOf(masterPage, 'title')!.top + 8, 52,
|
|
|
slotOf(masterPage, 'title')!.top + Math.min(slotOf(masterPage, 'title')!.height - 8, 64), palette.secondary, 6)] : []),
|
|
|
...contentElements,
|
|
|
...renderTemplateMasterElements(masterPage.chrome)
|
|
|
@@ -193,19 +195,28 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
|
|
|
function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number, headline: string, intent: PageRenderIntent): PPTElement[] {
|
|
|
const elements: PPTElement[] = []
|
|
|
const content = modelPageContent(page)
|
|
|
+ const textCover = index === 0 && !hasImage(page) && !intent.showMedia && !content.steps.length && !content.groups.length
|
|
|
+ && !content.task && content.points.length <= 2
|
|
|
const exampleSlot = layout.slots.find(slot => slot.id === 'example')
|
|
|
const examples = exampleSlot ? content.points.filter(text => text.startsWith('例子:')) : []
|
|
|
const points = content.points.filter(text => !examples.includes(text))
|
|
|
if (exampleSlot) addMeasuredFlow(elements, exampleSlot, examples, master.textColor, false, 32)
|
|
|
- addSlotText(elements, slotOf(layout, 'title'), page.title, master.titleColor, true)
|
|
|
- if (index === 0) addSlotText(elements, slotOf(layout, 'meta'), [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(' · '), master.mutedColor)
|
|
|
- addSlotText(elements, slotOf(layout, 'headline'), headline, master.textColor, true)
|
|
|
+ addSlotText(elements, slotOf(layout, 'title'), page.title, master.titleColor, true, textCover ? 'center' : 'left')
|
|
|
+ if (index === 0) addSlotText(elements, slotOf(layout, 'meta'), [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(' · '), master.mutedColor, false, textCover ? 'center' : 'left')
|
|
|
+ addSlotText(elements, slotOf(layout, 'headline'), headline, master.textColor, true, textCover ? 'center' : 'left')
|
|
|
addSlotText(elements, layout.slots.find(slot => slot.id === 'instruction'), content.task, master.mutedColor)
|
|
|
addVisualOrResource(elements, slotOf(layout, 'image'), page, master, palette)
|
|
|
const composition = page.visualPlan!.composition
|
|
|
const presentation = page.visualPlan!.textPresentation || 'plain'
|
|
|
const paintText = (slot: TemplateSlot | undefined, values: string[], format = presentation) => {
|
|
|
if (!slot || !values.length) return
|
|
|
+ if (textCover) {
|
|
|
+ const coverSlot = slot
|
|
|
+ const gap = 18, height = (coverSlot.height - gap * (values.length - 1)) / values.length
|
|
|
+ values.forEach((value, i) => addSlotText(elements, { ...coverSlot, top: coverSlot.top + i * (height + gap), height }, value, master.textColor, false, 'center'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (addEditorialRows(elements, slot, values, format, master, palette)) return
|
|
|
if (!hasImage(page) && !page.diagram && !content.groups.length
|
|
|
&& addDesignedPanels(elements, slot, values, format, master, palette)) return
|
|
|
if (slot?.textFlow === 'columns' && values.length) {
|
|
|
@@ -314,6 +325,39 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
|
|
|
return elements
|
|
|
}
|
|
|
|
|
|
+/** 长步骤采用纵向阅读;明确的“要点:说明”保留原文并突出要点,不自动发明小标题。 */
|
|
|
+function addEditorialRows(elements: PPTElement[], slot: TemplateSlot, values: string[], format: string, master: TemplateMaster, palette: Palette) {
|
|
|
+ if (slot.width < 900 || values.length < 2 || values.length > 5 || values.some(value => value.startsWith('例子:'))) return false
|
|
|
+ const labels = values.map(value => value.match(/^(.{2,14}[::])(.+)$/))
|
|
|
+ const numbered = format === 'steps'
|
|
|
+ if (numbered ? values.length <= 4 && values.every(value => value.length <= 24) : format !== 'plain' || !labels.every(Boolean)) return false
|
|
|
+ const gutter = numbered ? 92 : 0, width = slot.width - gutter
|
|
|
+ const rowHeight = (value: string, i: number, font: number) => !numbered && labels[i]
|
|
|
+ ? measureAiPptText(labels[i]![1], width, font + 6, AI_PPT_SCALE, true) + measureAiPptText(labels[i]![2], width, font) - 20 / AI_PPT_SCALE + 8
|
|
|
+ : Math.max(68, measureAiPptText(value, width, font, AI_PPT_SCALE, true))
|
|
|
+ // 先完整测量,容量不足时不留下部分元素,交回原有排版与质量检查。
|
|
|
+ const font = [38, 36, 34, 32, 30, 28].find(font => values.reduce((sum, value, i) =>
|
|
|
+ sum + rowHeight(value, i, font), 0) + 22 * (values.length - 1) <= slot.height)
|
|
|
+ if (!font) return false
|
|
|
+ const heights = values.map((value, i) => rowHeight(value, i, font))
|
|
|
+ const gap = Math.min(48, (slot.height - heights.reduce((sum, height) => sum + height, 0)) / (values.length - 1))
|
|
|
+ const total = heights.reduce((sum, height) => sum + height, 0) + gap * (values.length - 1)
|
|
|
+ let top = slot.top + Math.min(28, (slot.height - total) / 2)
|
|
|
+ values.forEach((value, i) => {
|
|
|
+ if (numbered) addSlotText(elements, { ...slot, top, width: 68, height: heights[i], preferredFontSize: 40 }, String(i + 1), master.titleColor, true)
|
|
|
+ const text = textElement({ left: slot.left + gutter, top, width, height: heights[i], content: value,
|
|
|
+ fontSize: font, minimumFontSize: font, color: master.textColor })
|
|
|
+ if (labels[i]) text.content = numbered
|
|
|
+ ? text.content.replace(escapeHtml(labels[i]![1]), `<strong>${escapeHtml(labels[i]![1])}</strong>`)
|
|
|
+ : text.content.replace(escapeHtml(value), `<p style="margin:0;font-size:${font + 6}px"><strong>${escapeHtml(labels[i]![1])}</strong></p><p style="margin:8px 0 0">${escapeHtml(labels[i]![2])}</p>`)
|
|
|
+ elements.push(text)
|
|
|
+ if (i < values.length - 1) elements.push(lineElement(slot.left + gutter, top + heights[i] + gap / 2,
|
|
|
+ slot.left + slot.width, top + heights[i] + gap / 2, tinycolor.mix(palette.background, palette.primary, 18).toHexString(), 1))
|
|
|
+ top += heights[i] + gap
|
|
|
+ })
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
/** 短任务用有语义的图标和色块形成阅读顺序,密集正文继续采用测量后的文字流。 */
|
|
|
function addDesignedPanels(elements: PPTElement[], slot: TemplateSlot, values: string[], format: string, master: TemplateMaster, palette: Palette) {
|
|
|
if (values.length < 2 || values.length > 4 || slot.width < 1100 || slot.height < 240
|
|
|
@@ -483,7 +527,8 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
|
|
|
const meta = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
|
|
|
const coverSecondaryColor = master.coverTitleColor.toUpperCase() === "#FFFFFF" ? "#DDE6F4" : master.mutedColor
|
|
|
addSlotText(elements, slotOf(masterPage, "meta"), meta, coverSecondaryColor)
|
|
|
- addSlotList(elements, slotOf(masterPage, "headline"), uniqueVisibleTexts([headline, ...points]), master, palette, false, master.coverTitleColor)
|
|
|
+ // 封面的短导语共用一个文本框,避免每个短句的固定内边距挤占行高。
|
|
|
+ addSlotText(elements, slotOf(masterPage, "headline"), uniqueVisibleTexts([headline, ...points]).join(' · '), master.coverTitleColor)
|
|
|
addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual)
|
|
|
return elements
|
|
|
}
|
|
|
@@ -947,18 +992,10 @@ function addChecklist(elements: PPTElement[], slot: TemplateSlot | undefined, so
|
|
|
radius: iconSize / 2
|
|
|
})
|
|
|
)
|
|
|
+ // 勾号使用原生线条,微小图标不经过带 10px 内边距的富文本组件。
|
|
|
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
|
|
|
- })
|
|
|
+ lineElement(slot.left + iconSize * .25, iconTop + iconSize * .5, slot.left + iconSize * .43, iconTop + iconSize * .68, '#FFFFFF', Math.max(1.5, iconSize * .07)),
|
|
|
+ lineElement(slot.left + iconSize * .43, iconTop + iconSize * .68, slot.left + iconSize * .76, iconTop + iconSize * .32, '#FFFFFF', Math.max(1.5, iconSize * .07))
|
|
|
)
|
|
|
elements.push(
|
|
|
textElement({
|
|
|
@@ -1191,8 +1228,7 @@ function fitFont(content: string, width: number, height: number, preferred: numb
|
|
|
if (!text) return preferred
|
|
|
let size = preferred
|
|
|
while (size > minimum) {
|
|
|
- const estimated = minimum >= 28 ? measureAiPptText(text, width, size)
|
|
|
- : Math.ceil(text.length / Math.max(1, Math.floor(width / size))) * size * 1.35
|
|
|
+ const estimated = measureAiPptText(text, width, size)
|
|
|
if (estimated <= height) break
|
|
|
size = Math.max(minimum, size - 2)
|
|
|
}
|