| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060 |
- import { nanoid } from "nanoid"
- import type {
- Gradient,
- PPTElement,
- PPTElementShadow,
- PPTImageElement,
- PPTVideoElement,
- PPTAudioElement,
- PPTCloudCoachElement,
- PPTLineElement,
- PPTAnimation,
- PPTShapeElement,
- PPTTextElement,
- Slide,
- SlideBackground,
- SlideTheme
- } from "@/types/slides"
- import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
- import { audioBindings, scoreBinding, isScorePage, usableResourceUrl } from "@/utils/aiPptContent"
- import {
- AI_COURSEWARE_TEMPLATES,
- getAiCoursewarePalette,
- getAiCoursewareTemplate,
- recommendAiCoursewareTemplate,
- type AiCoursewareTemplateId,
- type AiCoursewareThemePalette
- } from "@/config/aiCoursewareTemplates"
- import {
- getAiCoursewareTemplateMaster,
- getTemplateMasterPage,
- selectTemplateMasterPageVariant,
- validateTemplateMasterRegistry,
- type TemplateMaster,
- type TemplateMasterElement,
- type TemplateMasterPage,
- type TemplatePageRole,
- type TemplateSlot
- } from "@/config/aiCoursewareTemplateMasters"
- interface AiPptOptions {
- backgroundImage?: string
- enableAnimations?: boolean
- paletteVariant?: number
- templateId?: AiCoursewareTemplateId
- }
- type PageRole = TemplatePageRole
- type Palette = AiCoursewareThemePalette
- interface ImageRect {
- left: number
- top: number
- width: number
- height: number
- radius?: number
- }
- interface PageRenderIntent {
- role: PageRole
- density: "low" | "medium" | "high"
- visualKind: "image" | "notation" | "rhythm" | "compare" | "activity" | "text"
- showMedia: boolean
- }
- 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)
- validateTemplateMasterRegistry(AI_COURSEWARE_TEMPLATES.map(template => template.id))
- const requestedTemplateId = options.templateId || normalized.templateId
- const templateId = requestedTemplateId
- ? getAiCoursewareTemplate(requestedTemplateId).id
- : recommendAiCoursewareTemplate(normalized).id
- const palette = buildPalette(normalized, options.paletteVariant || 0, templateId)
- const resolvedOptions = { ...options, templateId }
- const layoutOccurrences = new Map<string, number>()
- const slides = normalized.pages.map((page, index) => buildSlide(page, normalized, palette, resolvedOptions, index, layoutOccurrences))
- 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, layoutOccurrences: Map<string, number>): Slide {
- const role = pageRole(page, index)
- const intent = pageRenderIntent(page, role)
- const master = getAiCoursewareTemplateMaster(options.templateId!)
- const baseMasterPage = getTemplateMasterPage(master, role, isScorePage(page) ? "diagram_focus" : videoBinding(page) || page.requiredVisual ? "split_content" : page.interaction?.question ? "text_focus" : preferredLayout(page, intent))
- const occurrence = layoutOccurrences.get(baseMasterPage.id) || 0
- layoutOccurrences.set(baseMasterPage.id, occurrence + 1)
- const masterPage = adaptMasterPage(selectTemplateMasterPageVariant(baseMasterPage, occurrence), intent)
- const contentElements = fillTemplatePage(master, masterPage, page, outline, palette, role, intent)
- assertContentContract(master, masterPage, contentElements)
- const elements = [
- ...renderTemplateMasterElements(masterPage.base),
- ...contentElements,
- ...renderTemplateMasterElements(masterPage.chrome)
- ]
- return {
- id: nanoid(10),
- elements: scaleElementsToViewport(keepInCanvas(elements)),
- background: backgroundConfig(masterPage, options),
- remark: slideRemark(page),
- animations: options.enableAnimations ? entranceAnimations(contentElements) : undefined,
- turningMode: options.enableAnimations ? "fade" : "no"
- }
- }
- function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, role: PageRole = masterPage.roles[0], intent = pageRenderIntent(page, role)): PPTElement[] {
- master = {
- ...master,
- titleColor: masterPage.titleColor || master.titleColor,
- coverTitleColor: masterPage.coverTitleColor || master.coverTitleColor,
- textColor: masterPage.textColor || master.textColor,
- mutedColor: masterPage.mutedColor || master.mutedColor
- }
- const elements: PPTElement[] = []
- const title = masterPage.layout === "cover" ? chapterTitle(outline, page) : displayPageTitle(page, outline)
- // 学生可见内容必须与编辑区保持一致,不能因模板容量静默丢弃条目。
- const headline = activityTask(page) || studentHeadline(page)
- const points = studentPoints(page).filter(point => !sameVisibleText(point, headline))
- addSlotText(elements, slotOf(masterPage, "title"), title, masterPage.layout === "cover" ? master.coverTitleColor : master.titleColor, true)
- if (role !== "cover" && isScorePage(page)) {
- addNotationVisual(elements, slotOf(masterPage, "image"), page, master, palette)
- addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points, page.interaction?.question || "", ...(page.interaction?.options || [])]), master, palette, false)
- addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
- return elements
- }
- if (role !== "cover" && page.interaction?.question) {
- addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, false)
- addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([page.interaction.question, headline, ...points, ...(page.interaction.options || [])]), master, palette, false)
- addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
- return elements
- }
- if (masterPage.layout === "cover") {
- 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)
- addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual)
- return elements
- }
- if (masterPage.layout === "hero") {
- addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual)
- const body = uniqueVisibleTexts([page.interaction?.question || "", headline, ...points])
- addSlotList(elements, slotOf(masterPage, "body"), body, master, palette)
- addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
- return elements
- }
- if (masterPage.layout === "objectives") {
- addCardGrid(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
- return elements
- }
- if (masterPage.layout === "media") {
- addSlotText(elements, slotOf(masterPage, "headline"), headline || page.interaction?.question || "带着问题听音乐", palette.primary, true)
- addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual)
- addSlotList(elements, slotOf(masterPage, "body"), points, master, palette)
- addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
- return elements
- }
- if (masterPage.layout === "split") {
- addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual)
- addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
- addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
- return elements
- }
- if (masterPage.layout === "text") {
- addSlotText(elements, slotOf(masterPage, "headline"), headline || (role === "lyric" ? "读一读,再唱一唱" : "抓住这一页的关键信息"), palette.primary, true)
- if (role === "lyric") addLyricLines(elements, slotOf(masterPage, "body"), points, master, palette)
- else addSlotList(elements, slotOf(masterPage, "body"), points, master, palette, false)
- addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
- return elements
- }
- 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), master, palette)
- else addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, true)
- if (role !== "rhythm") addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
- else addSlotText(elements, slotOf(masterPage, "body"), headline, master.textColor)
- addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
- return elements
- }
- if (masterPage.layout === "compare") {
- const values = uniqueVisibleTexts([...(page.interaction?.options || []), ...points])
- const midpoint = Math.max(1, Math.ceil(values.length / 2))
- addSlotText(elements, slotOf(masterPage, "headline"), headline || page.interaction?.question || "听一听,它们有什么不同?", palette.primary, true)
- addSlotList(elements, slotOf(masterPage, "left"), values.slice(0, midpoint), master, palette)
- addSlotList(elements, slotOf(masterPage, "right"), values.slice(midpoint), master, palette)
- addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
- return elements
- }
- if (masterPage.layout === "activity") {
- addSlotText(elements, slotOf(masterPage, "headline"), headline || "一起完成音乐任务", palette.primary, true)
- addStepGrid(elements, slotOf(masterPage, "steps"), activitySteps(page), master, palette)
- addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
- return elements
- }
- addChecklist(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
- addSlotText(elements, slotOf(masterPage, "headline"), page.learningEvidence || "说一说今天的收获", palette.primary, true)
- return elements
- }
- function slotOf(page: TemplateMasterPage, id: string) {
- return page.slots.find(item => item.id === id && item.enabled !== false)
- }
- function pageRenderIntent(page: AiCoursewarePage, role: PageRole): PageRenderIntent {
- const densityValue = String(page.visualPlan?.visualDensity || "").toLowerCase()
- const pointCount = Math.max(page.studentContent?.points?.length || 0, page.bullets?.length || 0, page.activity?.steps?.length || 0)
- const density: PageRenderIntent["density"] = densityValue === "low" || densityValue === "high"
- ? densityValue
- : pointCount <= 2 ? "low" : pointCount >= 5 ? "high" : "medium"
- const hasBoundMedia = (page.resourceBindings || []).some(binding =>
- binding.verified !== false && ["SONG", "VIDEO"].includes(binding.resourceType) && Boolean(binding.contentUrl)
- )
- const expectsMedia = role === "listen" || (page.resourceBindings || []).some(binding =>
- ["SONG", "VIDEO"].includes(binding.resourceType) && binding.required === true
- )
- const visualKind: PageRenderIntent["visualKind"] = role === "notation"
- ? "notation"
- : role === "rhythm"
- ? "rhythm"
- : role === "compare"
- ? "compare"
- : ["activity", "performance"].includes(role)
- ? "activity"
- : ["goal", "summary", "homework", "practice", "lyric"].includes(role)
- ? "text"
- : "image"
- return { role, density, visualKind, showMedia: hasBoundMedia || expectsMedia }
- }
- function preferredLayout(page: AiCoursewarePage, intent: PageRenderIntent) {
- const requested = page.visualPlan?.layout || page.visual?.layout
- if (requested) return requested
- if (intent.visualKind === "notation" || intent.visualKind === "rhythm") return "diagram_focus"
- if (intent.visualKind === "compare") return "compare"
- if (intent.visualKind === "activity") return "activity"
- if (!hasImage(page) && intent.visualKind === "text" && !intent.showMedia) {
- if (intent.role === "goal") return "objectives"
- if (intent.role === "summary" || intent.role === "practice") return "summary"
- return "text_focus"
- }
- return requested
- }
- function adaptMasterPage(page: TemplateMasterPage, intent: PageRenderIntent): TemplateMasterPage {
- if (intent.showMedia) return page
- const media = page.slots.find(slot => slot.kind === "media" && slot.enabled !== false)
- if (!media) return page
- const availableBottom = media.top + media.height
- const expandable = new Set(["body", "image", "steps", "left", "right"])
- return {
- ...page,
- slots: page.slots
- .filter(slot => slot.kind !== "media")
- .map(slot => {
- if (!expandable.has(slot.kind) || slot.top + slot.height > media.top + 24) return { ...slot }
- return { ...slot, height: Math.max(slot.height, availableBottom - slot.top) }
- })
- }
- }
- function assertContentContract(master: TemplateMaster, page: TemplateMasterPage, elements: PPTElement[]) {
- if (!page.safeArea || !page.reservedZones) return
- type ElementBounds = { left: number; top: number; width: number; height: number }
- const boundedElements = elements.filter((element): element is PPTElement & ElementBounds =>
- "height" in element && typeof element.height === "number"
- )
- const overlaps = (left: ElementBounds, right: ElementBounds) =>
- left.left < right.left + right.width && left.left + left.width > right.left && left.top < right.top + right.height && left.top + left.height > right.top
- const outsideSafeArea = boundedElements.find(element =>
- element.left < page.safeArea!.left ||
- element.top < page.safeArea!.top ||
- element.left + element.width > page.safeArea!.left + page.safeArea!.width ||
- element.top + element.height > page.safeArea!.top + page.safeArea!.height
- )
- if (outsideSafeArea) throw new Error(`模板 ${master.id}/${page.id} 生成内容超出投屏安全区,请调整该页面内容槽。`)
- const collision = page.reservedZones.find(zone => boundedElements.some(element => overlaps(element, zone)))
- if (collision) throw new Error(`模板 ${master.id}/${page.id} 生成内容侵入“${collision.purpose}”保留区,请更换版式或缩减内容。`)
- }
- function addSlotText(elements: PPTElement[], slot: TemplateSlot | undefined, content: string, color: string, bold = false) {
- if (!slot || !cleanText(content)) return
- elements.push(textElement({
- left: slot.left,
- top: slot.top,
- width: slot.width,
- height: slot.height,
- content,
- fontSize: fitFont(content, slot.width, slot.height, slot.preferredFontSize || 30, slot.minimumFontSize || 22),
- minimumFontSize: slot.minimumFontSize,
- color,
- bold
- }))
- }
- function addSlotList(
- elements: PPTElement[],
- slot: TemplateSlot | undefined,
- sourceItems: string[],
- master: TemplateMaster,
- palette: Palette,
- cards = true,
- textColor = master.textColor
- ) {
- if (!slot) return
- const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
- if (!items.length) return
- 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 + 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 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.max(24, slot.minimumFontSize || 24)
- }
- function addCardFrame(elements: PPTElement[], left: number, top: number, width: number, height: number, index: number, master: TemplateMaster, palette: Palette) {
- const accent = index % 2 ? palette.secondary : palette.primary
- if (master.cardStyle === "ink") {
- elements.push(shapeElement({ left, top: top + height - 3, width, height: 3, fill: master.borderColor, opacity: 0.52 }))
- elements.push(shapeElement({ left, top: top + 12, width: 14, height: 14, fill: accent, radius: 7 }))
- return
- }
- if (master.cardStyle === "sticker") {
- const fills = [palette.warm, palette.soft, master.surfaceColor]
- elements.push(shapeElement({ left, top, width, height, fill: fills[index % fills.length], radius: 22, shadow: lightShadow() }))
- elements.push(shapeElement({ left: left + 12, top: top + 12, width: 38, height: 38, fill: accent, radius: 19 }))
- return
- }
- if (master.cardStyle === "bubble") {
- elements.push(shapeElement({ left, top, width, height, fill: index % 2 ? palette.soft : master.surfaceColor, radius: Math.min(28, height / 2), shadow: lightShadow() }))
- elements.push(shapeElement({ left: left + 14, top: top + height / 2 - 8, width: 16, height: 16, fill: accent, radius: 8 }))
- return
- }
- if (master.cardStyle === "grid") {
- elements.push(shapeElement({ left, top, width, height, fill: master.surfaceColor, radius: 4 }))
- elements.push(shapeElement({ left, top, width: 14, height: 14, fill: accent, radius: 0 }))
- elements.push(shapeElement({ left: left + width - 26, top: top + height - 10, width: 26, height: 10, fill: accent, opacity: 0.5 }))
- return
- }
- if (master.cardStyle === "cinematic") {
- elements.push(shapeElement({ left, top, width, height, fill: master.surfaceColor, radius: 6, shadow: lightShadow() }))
- elements.push(shapeElement({ left, top, width: 8, height, fill: accent }))
- return
- }
- elements.push(shapeElement({ left, top, width, height, fill: master.surfaceColor, radius: 14, shadow: lightShadow() }))
- elements.push(shapeElement({ left, top, width: 10, height, fill: accent }))
- }
- function addCardGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
- if (!slot) return
- 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 = 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 + 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 = 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)
- 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
- })
- )
- })
- }
- function addNotationVisual(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage, master: TemplateMaster, palette: Palette, backgroundVisual = false) {
- if (!slot) return
- const binding = scoreBinding(page)
- if (!binding) return // 缺谱在教师质量面板中提示,绝不绘制虚构谱例。
- if (usableResourceUrl(binding.scoreImageUrl)) {
- // 保持整张曲谱,不使用 cover 裁切,防止丢失谱号、小节或歌词。
- elements.push({ id: nanoid(10), type: "image", src: binding.scoreImageUrl!, left: slot.left, top: slot.top,
- width: slot.width, height: slot.height, fixedRatio: true, rotate: 0 } as PPTImageElement)
- } else {
- elements.push({ id: nanoid(10), type: "elf", subtype: "elf-sing-play", sid: binding.resourceId,
- title: binding.name || page.title, left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0 } as PPTCloudCoachElement)
- }
- }
- function addStepGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
- if (!slot) return
- const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
- if (!items.length) return
- 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) => {
- 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 = 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)
- 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
- })
- )
- })
- }
- function addVisualOrResource(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage, master: TemplateMaster, palette: Palette, allowPlaceholder = true) {
- if (!slot) return
- const dedicated = (page.resourceBindings || []).find(item => item.verified !== false && item.dedicatedPage)
- if (dedicated?.resourceType === "MUSIC") {
- elements.push({ id: nanoid(10), type: "elf", subtype: "elf-sing-play", sid: dedicated.resourceId, title: dedicated.name || page.title, left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0 } as PPTCloudCoachElement)
- return
- }
- const video = videoBinding(page)
- if (video) {
- elements.push({ id: nanoid(10), type: "elf", subtype: "elf-video", left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0, src: video.contentUrl || "", poster: video.coverUrl, autoplay: false } as PPTVideoElement)
- return
- }
- if (hasImage(page)) {
- elements.push(imageElement(page, { left: slot.left, top: slot.top, width: slot.width, height: slot.height, radius: 18 }))
- return
- }
- // 无教学图片时留给文字排版;通用装饰不能充当观察对象。
- }
- function addSemanticVisual(elements: PPTElement[], slot: TemplateSlot, page: AiCoursewarePage, master: TemplateMaster, palette: Palette) {
- elements.push(shapeElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, fill: master.surfaceColor, opacity: 0.86, radius: 18, shadow: lightShadow() }))
- const landscapeTop = slot.top + slot.height * 0.48
- elements.push(shapeElement({ left: slot.left + slot.width * 0.08, top: landscapeTop, width: slot.width * 0.84, height: slot.height * 0.35, fill: palette.soft, opacity: 0.9, radius: 28 }))
- elements.push(shapeElement({ left: slot.left + slot.width * 0.68, top: slot.top + slot.height * 0.12, width: Math.min(84, slot.width * 0.16), height: Math.min(84, slot.width * 0.16), fill: palette.warm, opacity: 0.85, radius: 42 }))
- elements.push(shapeElement({ left: slot.left + slot.width * 0.12, top: landscapeTop + slot.height * 0.12, width: slot.width * 0.46, height: slot.height * 0.2, fill: palette.secondary, opacity: 0.55, radius: 40 }))
- elements.push(shapeElement({ left: slot.left + slot.width * 0.42, top: landscapeTop + slot.height * 0.08, width: slot.width * 0.42, height: slot.height * 0.24, fill: palette.primary, opacity: 0.45, radius: 44 }))
- const requestedCue = cleanText(page.visualPlan?.focalContent || page.visualPlan?.mainVisual || page.visual?.mainVisual || page.imageKeywords?.[0])
- const cue = !requestedCue || sameVisibleText(requestedCue, page.title)
- ? `画面线索:${cleanText(page.title) || "观察音乐变化"}`
- : requestedCue
- elements.push(textElement({
- left: slot.left + slot.width * 0.1,
- top: slot.top + slot.height * 0.13,
- width: slot.width * 0.54,
- height: slot.height * 0.25,
- content: cue,
- fontSize: fitFont(cue, slot.width * 0.54, slot.height * 0.25, 30, 22),
- minimumFontSize: 22,
- color: master.titleColor,
- bold: true
- }))
- }
- function addInlineMedia(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage, master: TemplateMaster, palette: Palette, showMedia: boolean) {
- if (!slot || !showMedia) return
- const bindings = audioBindings(page)
- const width = slot.width / Math.max(1, bindings.length)
- bindings.forEach((binding, index) => {
- elements.push({ id: nanoid(10), type: "elf", subtype: "elf-audio", left: slot.left + index * width,
- top: slot.top + (slot.height - 60) / 2, width: 60, height: 60, rotate: 0, fixedRatio: true,
- color: palette.primary, autoplay: false, loop: false, src: binding.contentUrl! } as PPTAudioElement)
- addSlotText(elements, { ...slot, left: slot.left + index * width + 76, width: width - 92 },
- `${bindings.length > 1 ? String.fromCharCode(65 + index) + " · " : ""}${binding.name || "课堂音频"}`, master.textColor)
- })
- }
- function renderTemplateMasterElements(elements: TemplateMasterElement[]): PPTElement[] {
- return elements.map(element =>
- element.type === "text"
- ? textElement({
- left: element.left,
- top: element.top,
- width: element.width,
- height: element.height,
- content: element.content || "",
- fontSize: element.fontSize || 14,
- color: element.color || "#20313A",
- bold: element.bold
- })
- : shapeElement({
- left: element.left,
- top: element.top,
- width: element.width,
- height: element.height,
- fill: element.fill || "#FFFFFF",
- opacity: element.opacity,
- radius: element.radius
- })
- )
- }
- function entranceAnimations(elements: PPTElement[]): PPTAnimation[] {
- return elements
- .filter(
- element => element.type === "text" && 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 videoBinding(page: AiCoursewarePage) {
- return (page.resourceBindings || []).find(item => item.verified === true && item.resourceType === "VIDEO" && usableResourceUrl(item.contentUrl))
- }
- 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(page.title || outline.title || outline.chapterName || "音乐课件")
- }
- 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) {
- const structured = (page.studentContent?.points || []).map(cleanText).filter(Boolean)
- const legacy = (page.bullets || []).map(cleanText).filter(Boolean)
- const points = [...(structured.length ? structured : legacy), studentHeadline(page)].filter(Boolean)
- const excluded = [page.title].map(normalizeVisibleText).filter(Boolean)
- const unique = uniqueVisibleTexts(points).filter(item => !excluded.includes(normalizeVisibleText(item)))
- return unique.length ? unique : [studentHeadline(page) || cleanText(page.title) || "课堂内容"]
- }
- function activityTask(page: AiCoursewarePage) {
- // activity 属于教师组织信息,不能回退到学生投屏画面。
- const task = cleanText(page.classroomAction?.studentTask || studentHeadline(page) || "")
- return sameVisibleText(task, page.title) ? "" : task
- }
- function activitySteps(page: AiCoursewarePage) {
- const task = activityTask(page)
- const points = studentPoints(page).filter(item => !sameVisibleText(item, task))
- return uniqueVisibleTexts(points)
- }
- function slideRemark(page: AiCoursewarePage) {
- const lessonMeta = page.teachingStage || ""
- const evidence = page.learningEvidence ? `学习表现:${page.learningEvidence}` : ""
- const activity = [page.activity?.task, ...(page.activity?.steps || [])].map(cleanText).filter(Boolean).join("\n")
- const fallbackInstruction = cleanText(page.classroomAction?.teacherInstruction || page.teacherTips || page.speakerNotes)
- || `引导学生完成“${activityTask(page) || page.title}”,观察学生是否能用语言、动作或声音表达本页学习结果。`
- const answer = page.interaction?.answer ? `参考答案:${page.interaction.answer}` : ""
- const explanation = page.interaction?.explanation ? `解析:${page.interaction.explanation}` : ""
- const resources = (page.resourceBindings || []).map(item => `${item.name || item.resourceType}:${item.purpose || "课堂资源"}`).join("\n")
- return [lessonMeta, page.teachingPurpose, activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources]
- .map(cleanText)
- .filter(Boolean)
- .filter((item, index, all) => all.indexOf(item) === index)
- .join("\n")
- }
- function buildPalette(outline: AiCoursewareOutline, variant: number, templateId?: AiCoursewareTemplateId): Palette {
- const resolvedTemplateId = templateId || recommendAiCoursewareTemplate(outline).id
- return { ...getAiCoursewarePalette(resolvedTemplateId, variant) }
- }
- function backgroundConfig(masterPage: TemplateMasterPage, options: AiPptOptions): SlideBackground {
- if (options.backgroundImage) return { type: "image", image: options.backgroundImage, imageSize: "cover" }
- if (masterPage.backgroundImage) return { type: "image", image: masterPage.backgroundImage, imageSize: "cover" }
- return { type: "solid", color: masterPage.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
- minimumFontSize?: number
- color: string
- bold?: boolean
- lineHeight?: number
- }): PPTTextElement {
- const lineHeight = params.lineHeight || 1.25
- const minimum = Math.min(params.fontSize, params.minimumFontSize ?? (params.fontSize >= 30 ? 22 : 18))
- const resolvedFontSize = fitFont(params.content, params.width - 20, params.height, params.fontSize, minimum)
- 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:${resolvedFontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(cleanText(params.content))}</div>`,
- defaultFontName: FONT_NAME,
- defaultColor: params.color,
- lineHeight,
- paragraphSpace: 0,
- wordSpace: 0,
- opacity: 1
- }
- }
- 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
- })
- }
|