| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923 |
- <!--
- * @Author: LiZhiWei
- * @Date: 2025-12-26 14:41:26
- * @LastEditors: LiZhiWei
- * @LastEditTime: 2025-12-26 17:49:03
- * @Description:
- -->
- <template>
- <div ref="rootRef" class="pptx-preview">
- <div v-if="!slides.length" class="pptx-empty">暂无可渲染的幻灯片</div>
- <div v-else class="pptx-slides">
- <div
- v-for="(slide, slideIndex) in slides"
- :key="slideIndex"
- class="pptx-slide-shell"
- :style="getSlideShellStyle()"
- >
- <div
- class="pptx-slide"
- :style="getSlideStyle(slide)"
- >
- <div
- v-for="(el, elementIndex) in slide.elements"
- :key="getElementKey(el, elementIndex)"
- class="pptx-element"
- :style="getElementStyle(el, elementIndex)"
- >
- <template v-if="isImageElement(el)">
- <div class="pptx-img-wrap" :style="getImageWrapStyle(el)">
- <img
- class="pptx-img"
- :src="getMediaSrc(el)"
- :style="getImageStyle(el)"
- alt=""
- draggable="false"
- />
- </div>
- </template>
- <template v-else-if="isVideoElement(el)">
- <video class="pptx-media" :src="getMediaSrc(el)" controls preload="metadata" />
- </template>
- <template v-else-if="isAudioElement(el)">
- <audio class="pptx-media" :src="getMediaSrc(el)" controls preload="metadata" />
- </template>
- <template v-else-if="isMathElement(el)">
- <img class="pptx-img" :src="getMediaSrc(el)" alt="" draggable="false" />
- </template>
- <template v-else-if="isChartElement(el)">
- <svg
- class="pptx-chart"
- :viewBox="`0 0 ${getNumber(el.width)} ${getNumber(el.height)}`"
- preserveAspectRatio="none"
- >
- <template v-if="getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).kind === 'bar'">
- <rect
- v-for="(b, i) in getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).bars"
- :key="i"
- :x="b.x"
- :y="b.y"
- :width="b.w"
- :height="b.h"
- :fill="b.fill"
- :fill-opacity="b.opacity"
- />
- </template>
- <template v-else-if="getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).kind === 'line'">
- <path
- v-for="(p, i) in getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).paths"
- :key="i"
- :d="p.d"
- fill="none"
- :stroke="p.stroke"
- :stroke-width="p.strokeWidth"
- stroke-linejoin="round"
- stroke-linecap="round"
- />
- <circle
- v-for="(pt, i) in getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).points"
- :key="i"
- :cx="pt.cx"
- :cy="pt.cy"
- :r="pt.r"
- :fill="pt.fill"
- :fill-opacity="pt.opacity"
- />
- </template>
- <template v-else-if="getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).kind === 'pie'">
- <path
- v-for="(s, i) in getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).slices"
- :key="i"
- :d="s.d"
- :fill="s.fill"
- :fill-opacity="s.opacity"
- />
- <circle
- v-if="getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).holeR"
- :cx="getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).cx"
- :cy="getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).cy"
- :r="getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).holeR"
- fill="#fff"
- />
- </template>
- <template v-else-if="getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).kind === 'scatter'">
- <circle
- v-for="(pt, i) in getChartModel(el, slideIndex, getElementCacheId(el, elementIndex)).points"
- :key="i"
- :cx="pt.cx"
- :cy="pt.cy"
- :r="pt.r"
- :fill="pt.fill"
- :fill-opacity="pt.opacity"
- />
- </template>
- </svg>
- </template>
- <template v-else-if="isGroupElement(el) || isDiagramElement(el)">
- <PptxGroup
- :el="el"
- :slide-index="slideIndex"
- :cache-id="getElementCacheId(el, elementIndex)"
- :abs-left="getNumber(el.left)"
- :abs-top="getNumber(el.top)"
- />
- </template>
- <template v-else-if="isTableElement(el)">
- <div class="pptx-table-wrap">
- <table class="pptx-table">
- <colgroup v-if="getTableModel(el, slideIndex, getElementCacheId(el, elementIndex)).colWidths.length">
- <col
- v-for="(w, i) in getTableModel(el, slideIndex, getElementCacheId(el, elementIndex)).colWidths"
- :key="i"
- :style="{ width: w ? `${w}px` : undefined }"
- />
- </colgroup>
- <tbody>
- <tr
- v-for="(row, ri) in getTableModel(el, slideIndex, getElementCacheId(el, elementIndex)).rows"
- :key="ri"
- :style="getTableRowStyle(el, slideIndex, getElementCacheId(el, elementIndex), ri)"
- >
- <template v-for="(cell, ci) in row" :key="`${ri}-${ci}`">
- <td
- v-if="!cell.skip"
- :colspan="cell.colspan"
- :rowspan="cell.rowspan"
- :style="cell.style"
- >
- <div class="pptx-table-html" v-html="sanitizeHtml(cell.html)" />
- </td>
- </template>
- </tr>
- </tbody>
- </table>
- </div>
- </template>
- <template v-else-if="isShapeElement(el)">
- <svg
- class="pptx-shape-svg"
- :viewBox="`0 0 ${getNumber(el.width)} ${getNumber(el.height)}`"
- preserveAspectRatio="none"
- >
- <defs v-if="hasShapeDefs(el, slideIndex, getElementCacheId(el, elementIndex))">
- <linearGradient
- v-if="getShapeGradient(el, slideIndex, getElementCacheId(el, elementIndex))?.kind === 'linear'"
- :id="getShapeGradient(el, slideIndex, getElementCacheId(el, elementIndex))!.id"
- :x1="getShapeGradient(el, slideIndex, getElementCacheId(el, elementIndex))!.x1"
- :y1="getShapeGradient(el, slideIndex, getElementCacheId(el, elementIndex))!.y1"
- :x2="getShapeGradient(el, slideIndex, getElementCacheId(el, elementIndex))!.x2"
- :y2="getShapeGradient(el, slideIndex, getElementCacheId(el, elementIndex))!.y2"
- >
- <stop
- v-for="(s, i) in getShapeGradient(el, slideIndex, getElementCacheId(el, elementIndex))!.stops"
- :key="i"
- :offset="s.offset"
- :stop-color="s.color"
- :stop-opacity="s.opacity"
- />
- </linearGradient>
- <radialGradient
- v-else-if="getShapeGradient(el, slideIndex, getElementCacheId(el, elementIndex))?.kind === 'radial'"
- :id="getShapeGradient(el, slideIndex, getElementCacheId(el, elementIndex))!.id"
- cx="50%"
- cy="50%"
- r="50%"
- >
- <stop
- v-for="(s, i) in getShapeGradient(el, slideIndex, getElementCacheId(el, elementIndex))!.stops"
- :key="i"
- :offset="s.offset"
- :stop-color="s.color"
- :stop-opacity="s.opacity"
- />
- </radialGradient>
- <pattern
- v-if="getShapePattern(el, slideIndex, getElementCacheId(el, elementIndex))"
- :id="getShapePattern(el, slideIndex, getElementCacheId(el, elementIndex))!.id"
- patternUnits="userSpaceOnUse"
- :width="getShapePattern(el, slideIndex, getElementCacheId(el, elementIndex))!.width"
- :height="getShapePattern(el, slideIndex, getElementCacheId(el, elementIndex))!.height"
- >
- <rect
- x="0"
- y="0"
- :width="getShapePattern(el, slideIndex, getElementCacheId(el, elementIndex))!.width"
- :height="getShapePattern(el, slideIndex, getElementCacheId(el, elementIndex))!.height"
- :fill="getShapePattern(el, slideIndex, getElementCacheId(el, elementIndex))!.background"
- />
- <template
- v-for="(shape, i) in getShapePattern(el, slideIndex, getElementCacheId(el, elementIndex))!.shapes"
- :key="i"
- >
- <circle
- v-if="shape.kind === 'circle'"
- :cx="shape.cx"
- :cy="shape.cy"
- :r="shape.r"
- :fill="shape.fill"
- />
- <polygon
- v-else-if="shape.kind === 'polygon'"
- :points="shape.points"
- :fill="shape.fill"
- />
- <rect
- v-else-if="shape.kind === 'rect'"
- :x="shape.x"
- :y="shape.y"
- :width="shape.width"
- :height="shape.height"
- :fill="shape.fill"
- :transform="shape.transform"
- />
- </template>
- </pattern>
- <pattern
- v-if="getShapeImagePaint(el, slideIndex, getElementCacheId(el, elementIndex))"
- :id="getShapeImagePaint(el, slideIndex, getElementCacheId(el, elementIndex))!.id"
- patternUnits="objectBoundingBox"
- width="1"
- height="1"
- >
- <image
- x="0"
- y="0"
- width="1"
- height="1"
- preserveAspectRatio="none"
- :href="getShapeImagePaint(el, slideIndex, getElementCacheId(el, elementIndex))!.href"
- />
- </pattern>
- </defs>
- <path
- :d="getShapePath(el)"
- :fill="getShapeFill(el, slideIndex, getElementCacheId(el, elementIndex))"
- :stroke="getShapeStroke(el)"
- :stroke-width="getShapeStrokeWidth(el)"
- :stroke-dasharray="getShapeDasharray(el)"
- />
- </svg>
- <div
- v-if="typeof el.content === 'string' && el.content.trim()"
- class="pptx-html"
- :style="getHtmlBoxStyle(el)"
- >
- <div
- class="pptx-html-inner"
- :style="getHtmlInnerStyle(el)"
- v-html="sanitizeHtml(el.content)"
- />
- </div>
- </template>
- <template v-else>
- <div
- v-if="typeof el.content === 'string' && el.content.trim()"
- class="pptx-html"
- :style="getHtmlBoxStyle(el)"
- >
- <div
- class="pptx-html-inner"
- :style="getHtmlInnerStyle(el)"
- v-html="sanitizeHtml(el.content)"
- />
- </div>
- </template>
- </div>
- </div>
- </div>
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref } from "vue"
- import type { CSSProperties } from "vue"
- type PptxSize = {
- width: number
- height: number
- }
- type PptxFill = {
- type?: string
- value?: any
- }
- type PptxElement = {
- type?: string
- left?: number
- top?: number
- width?: number
- height?: number
- rotate?: number
- order?: number
- opacity?: number
- isFlipH?: boolean
- isFlipV?: boolean
- vAlign?: "up" | "mid" | "down" | string
- content?: string
- fill?: PptxFill | string
- path?: string
- borderColor?: string
- borderWidth?: number
- borderStrokeDasharray?: string
- __source?: "layout" | "slide"
- [key: string]: any
- }
- type PptxSlide = {
- fill?: PptxFill
- elements?: PptxElement[]
- layoutElements?: PptxElement[]
- [key: string]: any
- }
- type PptxJson = {
- size?: PptxSize
- slides?: PptxSlide[]
- [key: string]: any
- }
- const props = defineProps<{ pptxJson: PptxJson }>()
- const rootRef = ref<HTMLDivElement | null>(null)
- const containerWidth = ref(0)
- let resizeObserver: ResizeObserver | null = null
- const size = computed<PptxSize>(() => {
- const w = (props.pptxJson?.size?.width ?? 960) as number
- const h = (props.pptxJson?.size?.height ?? 540) as number
- return {
- width: Number.isFinite(w) ? w : 960,
- height: Number.isFinite(h) ? h : 540,
- }
- })
- const scale = computed(() => {
- const w = containerWidth.value
- if (!w) return 1
- const raw = w / size.value.width
- return Math.min(1, Math.max(0.1, raw))
- })
- const slides = computed(() => {
- const rawSlides = Array.isArray(props.pptxJson?.slides) ? props.pptxJson.slides : []
- return rawSlides.map((slide) => {
- const layoutElements = (Array.isArray(slide.layoutElements) ? slide.layoutElements : []).map((el) => ({
- ...(el as any),
- __source: "layout" as const,
- }))
- const slideElements = (Array.isArray(slide.elements) ? slide.elements : []).map((el) => ({
- ...(el as any),
- __source: "slide" as const,
- }))
- const merged = [...layoutElements, ...slideElements]
- const elements = merged
- .filter((el) => el && typeof el === "object")
- .slice()
- .sort((a, b) => (getNumber(a.order) || 0) - (getNumber(b.order) || 0))
- return {
- ...slide,
- elements,
- }
- })
- })
- onMounted(() => {
- if (!rootRef.value) return
- console.log('props.pptxJson', props.pptxJson)
- const updateWidth = () => {
- if (!rootRef.value) return
- containerWidth.value = rootRef.value.clientWidth
- }
- updateWidth()
- resizeObserver = new ResizeObserver(() => updateWidth())
- resizeObserver.observe(rootRef.value)
- })
- onBeforeUnmount(() => {
- resizeObserver?.disconnect()
- resizeObserver = null
- })
- function getNumber(value: unknown) {
- const n = Number(value)
- return Number.isFinite(n) ? n : 0
- }
- function sanitizeHtml(html: string) {
- if (!html) return ""
- try {
- const parser = new DOMParser()
- const doc = parser.parseFromString(html, "text/html")
- const forbidden = ["script", "style", "iframe", "object", "embed", "link", "meta"]
- forbidden.forEach((tag) => {
- doc.querySelectorAll(tag).forEach((node) => node.remove())
- })
- doc.querySelectorAll("*").forEach((node) => {
- Array.from(node.attributes).forEach((attr) => {
- const name = attr.name.toLowerCase()
- const value = String(attr.value || "").trim().toLowerCase()
- if (name.startsWith("on")) node.removeAttribute(attr.name)
- if ((name === "href" || name === "src") && value.startsWith("javascript:")) {
- node.removeAttribute(attr.name)
- }
- })
- const style = node.getAttribute("style")
- if (style) {
- const replaced = style.replace(/font-size\s*:\s*([\d.]+)pt/gi, (_m, v) => {
- const n = Number(v)
- if (!Number.isFinite(n)) return _m
- const px = (n * 96) / 72
- return `font-size: ${px.toFixed(3).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1")}px`
- })
- if (replaced !== style) node.setAttribute("style", replaced)
- }
- })
- return doc.body.innerHTML
- } catch {
- return html
- }
- }
- function getElementKey(el: PptxElement, elementIndex: number) {
- const stable = getNumber(el.order) || elementIndex
- return `${el.type || "el"}-${stable}`
- }
- function getElementCacheId(el: PptxElement, elementIndex: number, parentCacheId?: string) {
- const stable = getNumber(el.order) || elementIndex
- const t = String(el.type || "el")
- const self = `${t}-${stable}`
- return parentCacheId ? `${parentCacheId}/${self}` : self
- }
- function getSlideShellStyle() {
- return {
- width: `${Math.round(size.value.width * scale.value)}px`,
- height: `${Math.round(size.value.height * scale.value)}px`,
- } as CSSProperties
- }
- function getSlideStyle(slide: PptxSlide) {
- const background = getSlideBackground(slide.fill)
- return {
- width: `${size.value.width}px`,
- height: `${size.value.height}px`,
- transform: `scale(${scale.value})`,
- background,
- } as CSSProperties
- }
- function getSlideBackground(fill?: PptxFill) {
- if (!fill || !fill.type) return "#fff"
- if (fill.type === "color") return String(fill.value || "#fff")
- if (fill.type === "gradient") {
- const colors = Array.isArray(fill.value?.colors) ? fill.value.colors : []
- const rot = getNumber(fill.value?.rot)
- const path = String(fill.value?.path || "rect")
- const stops = colors
- .map((c: any) => {
- const pos = String(c?.pos ?? "")
- const color = String(c?.color ?? "")
- if (!color) return ""
- return pos ? `${color} ${pos}` : color
- })
- .filter(Boolean)
- .join(", ")
- if (!stops) return "#fff"
- if (path === "rect") {
- const angle = (90 - rot + 360) % 360
- return `linear-gradient(${angle}deg, ${stops})`
- }
- return `radial-gradient(circle, ${stops})`
- }
- if (fill.type === "image") {
- const src = String(fill.value?.picBase64 || fill.value?.src || "")
- if (!src) return "#fff"
- return `center / cover no-repeat url(${src})`
- }
- return "#fff"
- }
- function getElementStyle(el: PptxElement, elementIndex: number) {
- const left = getNumber(el.left)
- const top = getNumber(el.top)
- const width = Math.max(0, getNumber(el.width))
- const height = Math.max(0, getNumber(el.height))
- const rotate = getNumber(el.rotate)
- const flipH = !!el.isFlipH
- const flipV = !!el.isFlipV
- const zIndex = getElementZIndex(el, elementIndex)
- const opacity = el.opacity == null ? 1 : Math.max(0, Math.min(1, getNumber(el.opacity)))
- const transforms: string[] = []
- if (rotate) transforms.push(`rotate(${rotate}deg)`)
- if (flipH) transforms.push(`scaleX(-1)`)
- if (flipV) transforms.push(`scaleY(-1)`)
- const background = getElementBackground(el)
- const border = getElementBorderCss(el)
- const radius = getElementBorderRadius(el)
- const shadowFilter = getElementShadowFilter(el)
- return {
- position: "absolute" as const,
- left: `${left}px`,
- top: `${top}px`,
- width: `${width}px`,
- height: `${height}px`,
- zIndex,
- opacity,
- background,
- border,
- borderRadius: radius,
- filter: shadowFilter,
- transformOrigin: "center center",
- transform: transforms.length ? transforms.join(" ") : undefined,
- } as CSSProperties
- }
- function getElementZIndex(el: PptxElement, elementIndex: number) {
- const base = getNumber(el.order) || elementIndex
- const source = (el as any)?.__source
- const offset = source === "slide" ? 100000 : 0
- return base + offset
- }
- function getElementShadowFilter(el: PptxElement) {
- const shadow = (el as any)?.shadow
- if (!shadow || typeof shadow !== "object") return undefined
- const h = getNumber((shadow as any).h)
- const v = getNumber((shadow as any).v)
- const blur = Math.max(0, getNumber((shadow as any).blur))
- const color = String((shadow as any).color || "").trim()
- if (!color || (!h && !v && !blur)) return undefined
- return `drop-shadow(${h}px ${v}px ${blur}px ${color})`
- }
- function getElementBorderRadius(el: PptxElement) {
- const candidates = [el.radius, el.cornerRadius, el.rx]
- const r = candidates.map(getNumber).find((n) => n > 0) || 0
- return r ? `${r}px` : undefined
- }
- function getElementBorderCss(el: PptxElement) {
- if (isShapeElement(el)) return undefined
- const border = getBorderInfo(el)
- if (!border.color || !border.width) return undefined
- return `${border.width}px solid ${border.color}`
- }
- function getElementBackground(el: PptxElement) {
- if (isShapeElement(el) || isImageElement(el) || isVideoElement(el) || isAudioElement(el)) return undefined
- if (typeof el.fill === "string") {
- const raw = el.fill.trim()
- return raw ? raw : undefined
- }
- const fill = typeof el.fill === "object" && el.fill ? (el.fill as PptxFill) : null
- if (!fill || !fill.type) return undefined
- if (fill.type === "color") return String(fill.value || "") || undefined
- if (fill.type === "gradient") {
- const colors = Array.isArray(fill.value?.colors) ? fill.value.colors : []
- const rot = getNumber(fill.value?.rot)
- const path = String(fill.value?.path || "rect")
- const stops = colors
- .map((c: any) => {
- const pos = String(c?.pos ?? "")
- const color = String(c?.color ?? "")
- if (!color) return ""
- return pos ? `${color} ${pos}` : color
- })
- .filter(Boolean)
- .join(", ")
- if (!stops) return undefined
- if (path === "rect") {
- const angle = (90 - rot + 360) % 360
- return `linear-gradient(${angle}deg, ${stops})`
- }
- return `radial-gradient(circle, ${stops})`
- }
- return undefined
- }
- function getHtmlBoxStyle(el: PptxElement) {
- const vAlign = String(el.vAlign || "up")
- const justifyContent = vAlign === "down" ? "flex-end" : vAlign === "mid" ? "center" : "flex-start"
- return {
- width: "100%",
- height: "100%",
- display: "flex" as const,
- flexDirection: "column" as const,
- justifyContent,
- } as CSSProperties
- }
- function getHtmlInnerStyle(el: PptxElement) {
- const autoFit = (el as any)?.autoFit
- const rawScale = autoFit && typeof autoFit === "object" ? getNumber(autoFit.fontScale) : 1
- const fontScale = rawScale && rawScale !== 1 ? Math.max(0.1, Math.min(5, rawScale)) : 1
- const isVertical = !!(el as any)?.isVertical
- const writingMode = isVertical ? ("vertical-rl" as const) : undefined
- const textOrientation = isVertical ? ("mixed" as const) : undefined
- if (fontScale === 1) {
- return {
- width: "100%",
- height: "100%",
- writingMode,
- textOrientation,
- } as CSSProperties
- }
- const inv = 100 / fontScale
- return {
- width: `${inv}%`,
- height: `${inv}%`,
- transformOrigin: "top left",
- transform: `scale(${fontScale})`,
- writingMode,
- textOrientation,
- } as CSSProperties
- }
- function isShapeElement(el: PptxElement) {
- return String(el.type || "").toLowerCase() === "shape"
- }
- function isImageElement(el: PptxElement) {
- const t = String(el.type || "").toLowerCase()
- if (t === "image" || t === "pic" || t === "picture") return true
- const src = getMediaSrc(el)
- return !!src && /^data:image\//i.test(src)
- }
- function isVideoElement(el: PptxElement) {
- const t = String(el.type || "").toLowerCase()
- if (t === "video") return true
- const src = getMediaSrc(el)
- return !!src && /^data:video\//i.test(src)
- }
- function isAudioElement(el: PptxElement) {
- const t = String(el.type || "").toLowerCase()
- if (t === "audio") return true
- const src = getMediaSrc(el)
- return !!src && /^data:audio\//i.test(src)
- }
- function isTableElement(el: PptxElement) {
- return String(el.type || "").toLowerCase() === "table"
- }
- function isChartElement(el: PptxElement) {
- const t = String(el.type || "").toLowerCase()
- return t === "chart" || t === "charts"
- }
- function isGroupElement(el: PptxElement) {
- const t = String(el.type || "").toLowerCase()
- if (t === "group" || t === "groupshape" || t === "grpsp" || t === "grp") return true
- const hasChildren = Array.isArray((el as any)?.elements) && (el as any).elements.length
- return hasChildren && t.includes("group")
- }
- function isDiagramElement(el: PptxElement) {
- const t = String(el.type || "").toLowerCase()
- return t === "diagram" || t === "smartart" || t === "smart_art"
- }
- function isMathElement(el: PptxElement) {
- return String(el.type || "").toLowerCase() === "math"
- }
- function getGroupChildren(el: PptxElement) {
- const raw = (el as any)?.elements
- return Array.isArray(raw) ? (raw as PptxElement[]) : ([] as PptxElement[])
- }
- function getGroupChildStyle(parent: PptxElement, child: PptxElement, childIndex: number, parentAbsLeft: number, parentAbsTop: number) {
- const parentW = Math.max(0, getNumber(parent.width))
- const parentH = Math.max(0, getNumber(parent.height))
- const rawLeft = getNumber(child.left)
- const rawTop = getNumber(child.top)
- const childW = Math.max(0, getNumber(child.width))
- const childH = Math.max(0, getNumber(child.height))
- const isRelative =
- rawLeft >= -0.5 &&
- rawTop >= -0.5 &&
- rawLeft + childW <= parentW + 0.5 &&
- rawTop + childH <= parentH + 0.5
- const left = isRelative ? rawLeft : rawLeft - parentAbsLeft
- const top = isRelative ? rawTop : rawTop - parentAbsTop
- const width = childW
- const height = childH
- const rotate = getNumber(child.rotate)
- const flipH = !!child.isFlipH
- const flipV = !!child.isFlipV
- const zIndex = getNumber(child.order) || childIndex
- const opacity = child.opacity == null ? 1 : Math.max(0, Math.min(1, getNumber(child.opacity)))
- const transforms: string[] = []
- if (rotate) transforms.push(`rotate(${rotate}deg)`)
- if (flipH) transforms.push(`scaleX(-1)`)
- if (flipV) transforms.push(`scaleY(-1)`)
- const background = getElementBackground(child)
- const border = getElementBorderCss(child)
- const radius = getElementBorderRadius(child)
- const shadowFilter = getElementShadowFilter(child)
- return {
- position: "absolute" as const,
- left: `${left}px`,
- top: `${top}px`,
- width: `${width}px`,
- height: `${height}px`,
- zIndex,
- opacity,
- background,
- border,
- borderRadius: radius,
- filter: shadowFilter,
- transformOrigin: "center center",
- transform: transforms.length ? transforms.join(" ") : undefined,
- } as CSSProperties
- }
- type GroupRenderProps = {
- el: PptxElement
- slideIndex: number
- cacheId: string
- absLeft: number
- absTop: number
- }
- const PptxGroup = defineComponent<GroupRenderProps>({
- name: "PptxGroup",
- props: {
- el: { type: Object as any, required: true },
- slideIndex: { type: Number, required: true },
- cacheId: { type: String, required: true },
- absLeft: { type: Number, required: true },
- absTop: { type: Number, required: true },
- },
- setup(groupProps) {
- const renderShapeDefs = (el: PptxElement, slideIndex: number, cacheId: string) => {
- if (!hasShapeDefs(el, slideIndex, cacheId)) return null
- const grad = getShapeGradient(el, slideIndex, cacheId)
- const pattern = getShapePattern(el, slideIndex, cacheId)
- const paint = getShapeImagePaint(el, slideIndex, cacheId)
- const nodes: any[] = []
- if (grad?.kind === "linear") {
- nodes.push(
- h(
- "linearGradient",
- { id: grad.id, x1: grad.x1, y1: grad.y1, x2: grad.x2, y2: grad.y2 },
- grad.stops.map((s, i) => h("stop", { key: i, offset: s.offset, "stop-color": s.color, "stop-opacity": s.opacity }))
- )
- )
- } else if (grad?.kind === "radial") {
- nodes.push(
- h(
- "radialGradient",
- { id: grad.id, cx: "50%", cy: "50%", r: "50%" },
- grad.stops.map((s, i) => h("stop", { key: i, offset: s.offset, "stop-color": s.color, "stop-opacity": s.opacity }))
- )
- )
- }
- if (pattern) {
- nodes.push(
- h(
- "pattern",
- { id: pattern.id, patternUnits: "userSpaceOnUse", width: pattern.width, height: pattern.height },
- [
- h("rect", { x: 0, y: 0, width: pattern.width, height: pattern.height, fill: pattern.background }),
- ...pattern.shapes.map((shape, i) => {
- if (shape.kind === "circle") return h("circle", { key: i, cx: shape.cx, cy: shape.cy, r: shape.r, fill: shape.fill })
- if (shape.kind === "polygon") return h("polygon", { key: i, points: shape.points, fill: shape.fill })
- return h("rect", {
- key: i,
- x: (shape as any).x,
- y: (shape as any).y,
- width: (shape as any).width,
- height: (shape as any).height,
- fill: (shape as any).fill,
- transform: (shape as any).transform,
- })
- }),
- ]
- )
- )
- }
- if (paint) {
- nodes.push(
- h(
- "pattern",
- { id: paint.id, patternUnits: "objectBoundingBox", width: 1, height: 1 },
- [
- h("image", { x: 0, y: 0, width: 1, height: 1, preserveAspectRatio: "none", href: paint.href }),
- ]
- )
- )
- }
- return nodes.length ? h("defs", null, nodes) : null
- }
- const renderElement = (child: PptxElement, childIndex: number, parentEl: PptxElement, parentAbsLeft: number, parentAbsTop: number, parentCacheId: string) => {
- const style = getGroupChildStyle(parentEl, child, childIndex, parentAbsLeft, parentAbsTop)
- const cacheId = getElementCacheId(child, childIndex, parentCacheId)
- const left = getNumber((style as any)?.left)
- const top = getNumber((style as any)?.top)
- const absLeft = parentAbsLeft + left
- const absTop = parentAbsTop + top
- if (isImageElement(child)) {
- return h(
- "div",
- { key: cacheId, class: "pptx-element", style },
- [
- h(
- "div",
- { class: "pptx-img-wrap", style: getImageWrapStyle(child) },
- [h("img", { class: "pptx-img", src: getMediaSrc(child), style: getImageStyle(child), alt: "", draggable: false })]
- ),
- ]
- )
- }
- if (isVideoElement(child)) {
- return h("div", { key: cacheId, class: "pptx-element", style }, [h("video", { class: "pptx-media", src: getMediaSrc(child), controls: true, preload: "metadata" })])
- }
- if (isAudioElement(child)) {
- return h("div", { key: cacheId, class: "pptx-element", style }, [h("audio", { class: "pptx-media", src: getMediaSrc(child), controls: true, preload: "metadata" })])
- }
- if (isMathElement(child)) {
- return h("div", { key: cacheId, class: "pptx-element", style }, [h("img", { class: "pptx-img", src: getMediaSrc(child), alt: "", draggable: false })])
- }
- if (isChartElement(child)) {
- const model = getChartModel(child, groupProps.slideIndex, cacheId)
- const nodes: any[] = []
- if (model.kind === "bar") {
- nodes.push(...model.bars.map((b, i) => h("rect", { key: i, x: b.x, y: b.y, width: b.w, height: b.h, fill: b.fill, "fill-opacity": b.opacity })))
- } else if (model.kind === "line") {
- nodes.push(
- ...model.paths.map((p, i) =>
- h("path", { key: i, d: p.d, fill: "none", stroke: p.stroke, "stroke-width": p.strokeWidth, "stroke-linejoin": "round", "stroke-linecap": "round" })
- )
- )
- nodes.push(...model.points.map((pt, i) => h("circle", { key: i, cx: pt.cx, cy: pt.cy, r: pt.r, fill: pt.fill, "fill-opacity": pt.opacity })))
- } else if (model.kind === "pie") {
- nodes.push(...model.slices.map((s, i) => h("path", { key: i, d: s.d, fill: s.fill, "fill-opacity": s.opacity })))
- if (model.holeR) nodes.push(h("circle", { cx: model.cx, cy: model.cy, r: model.holeR, fill: "#fff" }))
- } else if (model.kind === "scatter") {
- nodes.push(...model.points.map((pt, i) => h("circle", { key: i, cx: pt.cx, cy: pt.cy, r: pt.r, fill: pt.fill, "fill-opacity": pt.opacity })))
- }
- return h(
- "div",
- { key: cacheId, class: "pptx-element", style },
- [h("svg", { class: "pptx-chart", viewBox: `0 0 ${getNumber(child.width)} ${getNumber(child.height)}`, preserveAspectRatio: "none" }, nodes)]
- )
- }
- if (isTableElement(child)) {
- const model = getTableModel(child, groupProps.slideIndex, cacheId)
- return h(
- "div",
- { key: cacheId, class: "pptx-element", style },
- [
- h(
- "div",
- { class: "pptx-table-wrap" },
- [
- h(
- "table",
- { class: "pptx-table" },
- [
- model.colWidths.length
- ? h(
- "colgroup",
- null,
- model.colWidths.map((w, i) => h("col", { key: i, style: { width: w ? `${w}px` : undefined } }))
- )
- : null,
- h(
- "tbody",
- null,
- model.rows.map((row, ri) =>
- h(
- "tr",
- { key: ri, style: getTableRowStyle(child, groupProps.slideIndex, cacheId, ri) },
- row
- .map((cell, ci) => {
- if (cell.skip) return null
- return h(
- "td",
- { key: `${ri}-${ci}`, colspan: cell.colspan, rowspan: cell.rowspan, style: cell.style },
- [h("div", { class: "pptx-table-html", innerHTML: sanitizeHtml(cell.html) })]
- )
- })
- .filter(Boolean)
- )
- )
- ),
- ].filter(Boolean) as any
- ),
- ]
- ),
- ]
- )
- }
- if (isShapeElement(child)) {
- const defs = renderShapeDefs(child, groupProps.slideIndex, cacheId)
- const svg = h(
- "svg",
- { class: "pptx-shape-svg", viewBox: `0 0 ${getNumber(child.width)} ${getNumber(child.height)}`, preserveAspectRatio: "none" },
- [
- defs,
- h("path", {
- d: getShapePath(child),
- fill: getShapeFill(child, groupProps.slideIndex, cacheId),
- stroke: getShapeStroke(child),
- "stroke-width": getShapeStrokeWidth(child),
- "stroke-dasharray": getShapeDasharray(child),
- }),
- ].filter(Boolean) as any
- )
- const html = typeof child.content === "string" && child.content.trim()
- const overlay = html
- ? h(
- "div",
- { class: "pptx-html", style: getHtmlBoxStyle(child) },
- [h("div", { class: "pptx-html-inner", style: getHtmlInnerStyle(child), innerHTML: sanitizeHtml(child.content as string) })]
- )
- : null
- return h("div", { key: cacheId, class: "pptx-element", style }, [svg, overlay].filter(Boolean) as any)
- }
- if (isGroupElement(child) || isDiagramElement(child)) {
- return h(
- "div",
- { key: cacheId, class: "pptx-element", style },
- [h(PptxGroup as any, { el: child, slideIndex: groupProps.slideIndex, cacheId, absLeft, absTop })]
- )
- }
- const html = typeof child.content === "string" && child.content.trim()
- return h(
- "div",
- { key: cacheId, class: "pptx-element", style },
- html
- ? [
- h(
- "div",
- { class: "pptx-html", style: getHtmlBoxStyle(child) },
- [h("div", { class: "pptx-html-inner", style: getHtmlInnerStyle(child), innerHTML: sanitizeHtml(child.content as string) })]
- ),
- ]
- : []
- )
- }
- return () => {
- const children = getGroupChildren(groupProps.el)
- return h(
- "div",
- { class: "pptx-group-inner" },
- children.map((child, idx) => renderElement(child, idx, groupProps.el, groupProps.absLeft, groupProps.absTop, groupProps.cacheId))
- )
- }
- },
- })
- function normalizeRatio(n: unknown) {
- const v = getNumber(n)
- if (!v) return 0
- if (v > 1) return Math.max(0, Math.min(1, v / 100))
- return Math.max(0, Math.min(1, v))
- }
- function getImageCropRect(el: PptxElement) {
- const rect = (el as any)?.rect
- const crop = (el as any)?.crop
- const raw = rect && typeof rect === "object" ? rect : crop && typeof crop === "object" ? crop : null
- if (!raw) return null
- const t = normalizeRatio(raw.t)
- const b = normalizeRatio(raw.b)
- const l = normalizeRatio(raw.l)
- const r = normalizeRatio(raw.r)
- if (!t && !b && !l && !r) return null
- const safeL = Math.max(0, Math.min(0.9, l))
- const safeR = Math.max(0, Math.min(0.9, r))
- const safeT = Math.max(0, Math.min(0.9, t))
- const safeB = Math.max(0, Math.min(0.9, b))
- if (safeL + safeR >= 0.98 || safeT + safeB >= 0.98) return null
- return { t: safeT, b: safeB, l: safeL, r: safeR }
- }
- function getImageGeom(el: PptxElement) {
- const g = String((el as any)?.geom || (el as any)?.shapeType || "").toLowerCase()
- return g
- }
- function getImageWrapStyle(el: PptxElement) {
- const geom = getImageGeom(el)
- const isEllipse = geom === "ellipse" || geom === "circle"
- const borderRadius = isEllipse ? "50%" : getElementBorderRadius(el)
- return {
- width: "100%",
- height: "100%",
- position: "relative" as const,
- overflow: "hidden",
- borderRadius,
- } as CSSProperties
- }
- function normalizeFilterFactor(value: unknown, base = 1) {
- if (value == null) return base
- const n = getNumber(value)
- if (!n) return base
- const v = n > 10 ? n / 100 : n
- return Math.max(0, Math.min(3, v))
- }
- function buildImageFilter(el: PptxElement) {
- const filters = (el as any)?.filters
- if (!filters || typeof filters !== "object") return undefined
- const brightness = normalizeFilterFactor(filters.brightness, 1)
- const contrast = normalizeFilterFactor(filters.contrast, 1)
- const saturation = normalizeFilterFactor(filters.saturation, 1)
- const colorTemperature = getNumber(filters.colorTemperature)
- const parts: string[] = []
- if (brightness !== 1) parts.push(`brightness(${brightness})`)
- if (contrast !== 1) parts.push(`contrast(${contrast})`)
- if (saturation !== 1) parts.push(`saturate(${saturation})`)
- if (colorTemperature) {
- const t = Math.max(-100, Math.min(100, colorTemperature))
- const hue = t * 0.6
- const sepia = Math.min(1, Math.abs(t) / 200)
- parts.push(`hue-rotate(${hue}deg)`)
- if (t > 0 && sepia) parts.push(`sepia(${sepia})`)
- }
- return parts.length ? parts.join(" ") : undefined
- }
- function getImageStyle(el: PptxElement) {
- const crop = getImageCropRect(el)
- const filter = buildImageFilter(el)
- if (!crop) {
- return {
- width: "100%",
- height: "100%",
- objectFit: "contain",
- filter,
- display: "block",
- } as CSSProperties
- }
- const scaleX = 1 / (1 - crop.l - crop.r)
- const scaleY = 1 / (1 - crop.t - crop.b)
- const translateX = -crop.l * 100
- const translateY = -crop.t * 100
- return {
- width: "100%",
- height: "100%",
- objectFit: "fill",
- display: "block",
- transformOrigin: "top left",
- transform: `translate(${translateX}%, ${translateY}%) scale(${scaleX}, ${scaleY})`,
- filter,
- } as CSSProperties
- }
- function getMediaSrc(el: PptxElement) {
- const fillObj = typeof el.fill === "object" && el.fill ? (el.fill as PptxFill) : null
- const candidates = [
- el.src,
- el.url,
- el.picBase64,
- el.mediaBase64,
- el.base64,
- el.blob,
- el.blobUrl,
- el.value?.picBase64,
- el.value?.src,
- fillObj?.value?.picBase64,
- fillObj?.value?.src,
- ]
- const found = candidates.find((v) => typeof v === "string" && v.trim())
- return found ? String(found) : ""
- }
- type ChartBar = { x: number; y: number; w: number; h: number; fill: string; opacity?: number }
- type ChartPath = { d: string; stroke: string; strokeWidth: number }
- type ChartPoint = { cx: number; cy: number; r: number; fill: string; opacity?: number }
- type ChartSlice = { d: string; fill: string; opacity?: number }
- type ChartModel = {
- kind: "bar" | "line" | "pie" | "scatter"
- bars: ChartBar[]
- paths: ChartPath[]
- points: ChartPoint[]
- slices: ChartSlice[]
- cx: number
- cy: number
- holeR: number
- }
- const chartCache = new Map<string, ChartModel>()
- function getChartModel(el: PptxElement, slideIndex: number, cacheId: string): ChartModel {
- const key = `c-${getCacheKey(slideIndex, cacheId)}`
- const cached = chartCache.get(key)
- if (cached) return cached
- const w = Math.max(1, getNumber(el.width))
- const h = Math.max(1, getNumber(el.height))
- const chartType = String((el as any)?.chartType || "").toLowerCase()
- const colorsRaw = Array.isArray((el as any)?.colors) ? ((el as any).colors as string[]) : []
- const colors = colorsRaw.length ? colorsRaw.map((c) => String(c || "").trim()).filter(Boolean) : []
- const getColor = (i: number) => colors[i % Math.max(1, colors.length)] || "#4e79a7"
- const opacity = el.opacity == null ? 1 : Math.max(0, Math.min(1, getNumber(el.opacity)))
- const padding = Math.max(4, Math.min(24, Math.round(Math.min(w, h) * 0.08)))
- const isPie = chartType.includes("pie") || chartType.includes("doughnut")
- const isBar = chartType.includes("bar") || chartType.includes("col")
- const isScatter = chartType.includes("scatter") || chartType.includes("bubble")
- if (isScatter) {
- const data = (el as any)?.data
- const xs = Array.isArray(data?.[0]) ? (data[0] as number[]) : []
- const ys = Array.isArray(data?.[1]) ? (data[1] as number[]) : []
- const n = Math.min(xs.length, ys.length)
- const minX = n ? Math.min(...xs.slice(0, n)) : 0
- const maxX = n ? Math.max(...xs.slice(0, n)) : 1
- const minY = n ? Math.min(...ys.slice(0, n)) : 0
- const maxY = n ? Math.max(...ys.slice(0, n)) : 1
- const spanX = maxX - minX || 1
- const spanY = maxY - minY || 1
- const innerW = Math.max(1, w - padding * 2)
- const innerH = Math.max(1, h - padding * 2)
- const points: ChartPoint[] = []
- for (let i = 0; i < n; i++) {
- const x = padding + ((xs[i] - minX) / spanX) * innerW
- const y = padding + (1 - (ys[i] - minY) / spanY) * innerH
- points.push({ cx: x, cy: y, r: 3, fill: getColor(0), opacity })
- }
- const model: ChartModel = { kind: "scatter", bars: [], paths: [], points, slices: [], cx: 0, cy: 0, holeR: 0 }
- chartCache.set(key, model)
- return model
- }
- const rawData = (el as any)?.data
- if (isPie) {
- const series = Array.isArray(rawData) ? (rawData as any[]) : []
- const valuesRaw = Array.isArray(series?.[0]?.values) ? (series[0].values as any[]) : []
- const values = valuesRaw
- .map((v) => ({ y: getNumber(v?.y), x: String(v?.x ?? "") }))
- .filter((v) => Number.isFinite(v.y) && v.y > 0)
- const sum = values.reduce((acc, v) => acc + v.y, 0) || 1
- const cx = w / 2
- const cy = h / 2
- const r = Math.max(2, Math.min(w, h) / 2 - padding)
- const holeSize = String((el as any)?.holeSize || "").trim()
- const holeRatio = holeSize.endsWith("%") ? getNumber(holeSize.replace("%", "")) / 100 : getNumber(holeSize)
- const holeR = Math.max(0, Math.min(r - 1, r * Math.max(0, Math.min(0.9, holeRatio || 0))))
- let start = -Math.PI / 2
- const slices: ChartSlice[] = values.map((v, i) => {
- const a = (v.y / sum) * Math.PI * 2
- const end = start + a
- const x1 = cx + r * Math.cos(start)
- const y1 = cy + r * Math.sin(start)
- const x2 = cx + r * Math.cos(end)
- const y2 = cy + r * Math.sin(end)
- const large = a > Math.PI ? 1 : 0
- const d = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z`
- start = end
- return { d, fill: getColor(i), opacity }
- })
- const model: ChartModel = { kind: "pie", bars: [], paths: [], points: [], slices, cx, cy, holeR }
- chartCache.set(key, model)
- return model
- }
- const series = Array.isArray(rawData) ? (rawData as any[]) : []
- const items = series
- .map((s) => {
- const values = Array.isArray(s?.values) ? (s.values as any[]) : []
- return {
- key: String(s?.key || ""),
- values: values.map((v) => ({ x: String(v?.x ?? ""), y: getNumber(v?.y) })),
- }
- })
- .filter((s) => s.values.length)
- const categories = Array.from(
- new Set(
- items
- .flatMap((s) => s.values.map((v) => v.x))
- .filter((x) => x != null)
- .map((x) => String(x))
- )
- )
- const catCount = Math.max(1, categories.length)
- const seriesCount = Math.max(1, items.length)
- const maxY = Math.max(
- 1,
- ...items.flatMap((s) => s.values.map((v) => v.y)).filter((v) => Number.isFinite(v))
- )
- const innerW = Math.max(1, w - padding * 2)
- const innerH = Math.max(1, h - padding * 2)
- const barDir = String((el as any)?.barDir || "col").toLowerCase()
- if (isBar) {
- const bars: ChartBar[] = []
- if (barDir === "bar") {
- const bandH = innerH / catCount
- const barH = bandH * 0.8
- const gapY = (bandH - barH) / 2
- const eachH = barH / seriesCount
- for (let ci = 0; ci < catCount; ci++) {
- for (let si = 0; si < items.length; si++) {
- const v = items[si].values.find((vv) => vv.x === categories[ci])
- const value = v ? v.y : 0
- const bw = (value / maxY) * innerW
- const x = padding
- const y = padding + ci * bandH + gapY + si * eachH
- bars.push({ x, y, w: Math.max(0, bw), h: Math.max(0, eachH * 0.9), fill: getColor(si), opacity })
- }
- }
- } else {
- const bandW = innerW / catCount
- const barW = bandW * 0.8
- const gapX = (bandW - barW) / 2
- const eachW = barW / seriesCount
- for (let ci = 0; ci < catCount; ci++) {
- for (let si = 0; si < items.length; si++) {
- const v = items[si].values.find((vv) => vv.x === categories[ci])
- const value = v ? v.y : 0
- const bh = (value / maxY) * innerH
- const x = padding + ci * bandW + gapX + si * eachW
- const y = padding + (innerH - bh)
- bars.push({ x, y, w: Math.max(0, eachW * 0.9), h: Math.max(0, bh), fill: getColor(si), opacity })
- }
- }
- }
- const model: ChartModel = { kind: "bar", bars, paths: [], points: [], slices: [], cx: 0, cy: 0, holeR: 0 }
- chartCache.set(key, model)
- return model
- }
- const paths: ChartPath[] = []
- const points: ChartPoint[] = []
- const strokeWidth = Math.max(1, Math.round(Math.min(w, h) * 0.01))
- const marker = !!(el as any)?.marker
- const xAt = (i: number) => padding + (catCount === 1 ? innerW / 2 : (i / (catCount - 1)) * innerW)
- const yAt = (value: number) => padding + (1 - value / maxY) * innerH
- items.forEach((s, si) => {
- let d = ""
- categories.forEach((cat, ci) => {
- const v = s.values.find((vv) => vv.x === cat)
- const value = v ? v.y : 0
- const x = xAt(ci)
- const y = yAt(value)
- d += ci === 0 ? `M ${x} ${y}` : ` L ${x} ${y}`
- if (marker) points.push({ cx: x, cy: y, r: strokeWidth * 0.9 + 1, fill: getColor(si), opacity })
- })
- paths.push({ d, stroke: getColor(si), strokeWidth })
- })
- const model: ChartModel = { kind: "line", bars: [], paths, points, slices: [], cx: 0, cy: 0, holeR: 0 }
- chartCache.set(key, model)
- return model
- }
- type TableBorderSide = {
- borderColor?: string
- borderWidth?: number
- borderType?: string
- strokeDasharray?: string
- }
- type TableCellRaw = {
- text?: string
- content?: string
- html?: string
- colSpan?: number
- rowSpan?: number
- hMerge?: number
- vMerge?: number
- fillColor?: string
- fontColor?: string
- fontBold?: boolean
- fontItalic?: boolean
- borders?: {
- top?: TableBorderSide
- right?: TableBorderSide
- bottom?: TableBorderSide
- left?: TableBorderSide
- }
- [key: string]: any
- }
- type TableCellView = {
- html: string
- colspan: number
- rowspan: number
- skip: boolean
- style: CSSProperties
- }
- type TableModel = {
- colWidths: number[]
- rowHeights: number[]
- rows: TableCellView[][]
- }
- const tableCache = new Map<string, TableModel>()
- function getTableModel(el: PptxElement, slideIndex: number, cacheId: string): TableModel {
- const key = `t-${getCacheKey(slideIndex, cacheId)}`
- const cached = tableCache.get(key)
- if (cached) return cached
- const rawData = (el as any)?.data
- const data: TableCellRaw[][] = Array.isArray(rawData) ? rawData : []
- const colWidthsRaw = (el as any)?.colWidths
- const rowHeightsRaw = (el as any)?.rowHeights
- const colWidths = Array.isArray(colWidthsRaw) ? colWidthsRaw.map(getNumber) : []
- const rowHeights = Array.isArray(rowHeightsRaw) ? rowHeightsRaw.map(getNumber) : []
- const rows: TableCellView[][] = data.map((row) => {
- const r = Array.isArray(row) ? row : []
- return r.map((cellRaw) => normalizeTableCell(el, cellRaw))
- })
- const model: TableModel = {
- colWidths,
- rowHeights,
- rows,
- }
- tableCache.set(key, model)
- return model
- }
- function getTableRowStyle(el: PptxElement, slideIndex: number, cacheId: string, rowIndex: number) {
- const model = getTableModel(el, slideIndex, cacheId)
- const h = model.rowHeights[rowIndex]
- if (!h) return undefined
- return {
- height: `${h}px`,
- } as CSSProperties
- }
- function extractTextAlign(html: string) {
- const m = html.match(/text-align\s*:\s*(left|right|center|justify)/i)
- return m ? m[1].toLowerCase() : "left"
- }
- function getTableBorderStyle(side?: TableBorderSide) {
- if (!side) return ""
- const color = String(side.borderColor || "")
- const width = Math.max(0, getNumber(side.borderWidth))
- const borderType = String(side.borderType || "solid")
- if (!color || !width) return ""
- const style = borderType === "dashed" || borderType === "dash" ? "dashed" : "solid"
- return `${width}px ${style} ${color}`
- }
- function normalizeTableCell(tableEl: PptxElement, cellRaw: TableCellRaw): TableCellView {
- const raw = cellRaw && typeof cellRaw === "object" ? cellRaw : ({} as TableCellRaw)
- const html = String(raw.text ?? raw.content ?? raw.html ?? "")
- const colspan = Math.max(1, getNumber(raw.colSpan ?? raw.colspan ?? raw.gridSpan) || 1)
- const rowspan = Math.max(1, getNumber(raw.rowSpan ?? raw.rowspan) || 1)
- const skip = getNumber(raw.hMerge) === 1 || getNumber(raw.vMerge) === 1
- const background = String(raw.fillColor || "") || undefined
- const color = String(raw.fontColor || "") || undefined
- const fontWeight = raw.fontBold ? 700 : undefined
- const fontStyle = raw.fontItalic ? "italic" : undefined
- const textAlign = html ? extractTextAlign(html) : undefined
- const borders = raw.borders || (tableEl as any)?.borders || undefined
- const borderTop = getTableBorderStyle(borders?.top)
- const borderRight = getTableBorderStyle(borders?.right)
- const borderBottom = getTableBorderStyle(borders?.bottom)
- const borderLeft = getTableBorderStyle(borders?.left)
- const style: CSSProperties = {
- padding: "2px 4px",
- verticalAlign: "middle",
- background,
- color,
- fontWeight,
- fontStyle,
- textAlign: textAlign as any,
- borderTop: borderTop || undefined,
- borderRight: borderRight || undefined,
- borderBottom: borderBottom || undefined,
- borderLeft: borderLeft || undefined,
- overflow: "hidden",
- }
- return {
- html,
- colspan,
- rowspan,
- skip,
- style,
- }
- }
- type BorderInfo = {
- color: string
- width: number
- dasharray?: string
- }
- function getBorderInfo(el: PptxElement): BorderInfo {
- const directColor = String(el.borderColor ?? el.stroke ?? el.lineColor ?? "")
- const directWidth = getNumber(el.borderWidth ?? el.strokeWidth ?? el.lineWidth)
- const directDash = String(el.borderStrokeDasharray ?? el.strokeDasharray ?? el.dasharray ?? "")
- const borderObj = el.border && typeof el.border === "object" ? el.border : null
- const lineObj = el.line && typeof el.line === "object" ? el.line : null
- const fallbackColor = String(borderObj?.color ?? lineObj?.color ?? "")
- const fallbackWidth = getNumber(borderObj?.width ?? lineObj?.width)
- const fallbackDash = String(borderObj?.dasharray ?? lineObj?.dasharray ?? "")
- const color = directColor || fallbackColor
- const width = directWidth || fallbackWidth
- const dasharray = (directDash && directDash !== "0" ? directDash : "") || (fallbackDash && fallbackDash !== "0" ? fallbackDash : "")
- return {
- color: color || "transparent",
- width: Math.max(0, width),
- dasharray: dasharray || undefined,
- }
- }
- type PatternShape =
- | { kind: "circle"; cx: number; cy: number; r: number; fill: string }
- | { kind: "polygon"; points: string; fill: string }
- | { kind: "rect"; x: number; y: number; width: number; height: number; fill: string; transform?: string }
- type ShapePattern = {
- id: string
- width: number
- height: number
- background: string
- shapes: PatternShape[]
- }
- type GradientStop = {
- offset: string
- color: string
- opacity?: number
- }
- type ShapeGradient = {
- id: string
- kind: "linear" | "radial"
- x1?: string
- y1?: string
- x2?: string
- y2?: string
- stops: GradientStop[]
- }
- type ShapeImagePaint = {
- id: string
- href: string
- }
- function toDomId(raw: string) {
- return raw.replace(/[^a-zA-Z0-9_-]/g, "_")
- }
- function getShapePattern(el: PptxElement, slideIndex: number, cacheId: string): ShapePattern | null {
- const fill = typeof el.fill === "object" ? (el.fill as PptxFill) : null
- if (!fill || fill.type !== "pattern" || !fill.value) return null
- const type = String(fill.value?.type || "")
- const foreground = String(fill.value?.foregroundColor || "#000")
- const background = String(fill.value?.backgroundColor || "#fff")
- const id = `pptx-pattern-${slideIndex}-${toDomId(cacheId)}`
- if (type === "pct5") {
- return {
- id,
- width: 10,
- height: 10,
- background,
- shapes: [{ kind: "circle", cx: 5, cy: 5, r: 1.2, fill: foreground }],
- }
- }
- if (type === "solidDmnd") {
- return {
- id,
- width: 12,
- height: 12,
- background,
- shapes: [
- {
- kind: "polygon",
- points: "6,0 12,6 6,12 0,6",
- fill: foreground,
- },
- ],
- }
- }
- return {
- id,
- width: 10,
- height: 10,
- background,
- shapes: [{ kind: "circle", cx: 5, cy: 5, r: 1, fill: foreground }],
- }
- }
- function getShapeImagePaint(el: PptxElement, slideIndex: number, cacheId: string): ShapeImagePaint | null {
- const fill = typeof el.fill === "object" ? (el.fill as PptxFill) : null
- if (!fill || fill.type !== "image") return null
- const href = String(fill.value?.picBase64 || fill.value?.src || "")
- if (!href) return null
- return {
- id: `pptx-shape-img-${slideIndex}-${toDomId(cacheId)}`,
- href,
- }
- }
- const shapeCache = {
- pattern: new Map<string, ShapePattern | null>(),
- image: new Map<string, ShapeImagePaint | null>(),
- gradient: new Map<string, ShapeGradient | null>(),
- }
- function getCacheKey(slideIndex: number, cacheId: string) {
- return `${slideIndex}-${cacheId}`
- }
- function getShapePatternCached(el: PptxElement, slideIndex: number, cacheId: string) {
- const key = `p-${getCacheKey(slideIndex, cacheId)}`
- if (shapeCache.pattern.has(key)) return shapeCache.pattern.get(key) as ShapePattern | null
- const value = getShapePattern(el, slideIndex, cacheId)
- shapeCache.pattern.set(key, value)
- return value
- }
- function getShapeImagePaintCached(el: PptxElement, slideIndex: number, cacheId: string) {
- const key = `i-${getCacheKey(slideIndex, cacheId)}`
- if (shapeCache.image.has(key)) return shapeCache.image.get(key) as ShapeImagePaint | null
- const value = getShapeImagePaint(el, slideIndex, cacheId)
- shapeCache.image.set(key, value)
- return value
- }
- function normalizeOffset(pos: unknown) {
- const s = String(pos ?? "").trim()
- if (!s) return ""
- if (s.endsWith("%")) return s
- const n = Number(s)
- if (Number.isFinite(n)) return `${n}%`
- return s
- }
- function getShapeGradient(el: PptxElement, slideIndex: number, cacheId: string): ShapeGradient | null {
- const key = `g-${getCacheKey(slideIndex, cacheId)}`
- if (shapeCache.gradient.has(key)) return shapeCache.gradient.get(key) as ShapeGradient | null
- const fill = typeof el.fill === "object" ? (el.fill as PptxFill) : null
- if (!fill || fill.type !== "gradient" || !fill.value) {
- shapeCache.gradient.set(key, null)
- return null
- }
- const colors = Array.isArray(fill.value?.colors) ? fill.value.colors : []
- const rot = getNumber(fill.value?.rot)
- const path = String(fill.value?.path || "rect")
- const stops: GradientStop[] = colors
- .map((c: any) => {
- const offset = normalizeOffset(c?.pos)
- const color = String(c?.color ?? "").trim()
- const opacityRaw = c?.alpha ?? c?.opacity
- const opacity = opacityRaw == null ? undefined : Math.max(0, Math.min(1, getNumber(opacityRaw)))
- if (!color) return null
- return {
- offset: offset || undefined,
- color,
- opacity,
- }
- })
- .filter(Boolean) as any
- const id = `pptx-grad-${slideIndex}-${toDomId(cacheId)}`
- if (!stops.length) {
- shapeCache.gradient.set(key, null)
- return null
- }
- if (path !== "rect") {
- const result: ShapeGradient = {
- id,
- kind: "radial",
- stops: stops.map((s) => ({ ...s, offset: s.offset || "0%" })),
- }
- shapeCache.gradient.set(key, result)
- return result
- }
- const angle = ((90 - rot + 360) % 360) * (Math.PI / 180)
- const dx = Math.cos(angle)
- const dy = Math.sin(angle)
- const x1 = 0.5 - dx / 2
- const y1 = 0.5 + dy / 2
- const x2 = 0.5 + dx / 2
- const y2 = 0.5 - dy / 2
- const result: ShapeGradient = {
- id,
- kind: "linear",
- x1: `${Math.max(0, Math.min(1, x1)) * 100}%`,
- y1: `${Math.max(0, Math.min(1, y1)) * 100}%`,
- x2: `${Math.max(0, Math.min(1, x2)) * 100}%`,
- y2: `${Math.max(0, Math.min(1, y2)) * 100}%`,
- stops: stops.map((s) => ({ ...s, offset: s.offset || "0%" })),
- }
- shapeCache.gradient.set(key, result)
- return result
- }
- function hasShapeDefs(el: PptxElement, slideIndex: number, cacheId: string) {
- return !!(
- getShapeGradient(el, slideIndex, cacheId) ||
- getShapePatternCached(el, slideIndex, cacheId) ||
- getShapeImagePaintCached(el, slideIndex, cacheId)
- )
- }
- function getShapeFill(el: PptxElement, slideIndex: number, cacheId: string) {
- const fill = typeof el.fill === "object" ? (el.fill as PptxFill) : null
- if (!fill || !fill.type) return "transparent"
- if (fill.type === "color") return String(fill.value || "transparent")
- if (fill.type === "gradient") {
- const grad = getShapeGradient(el, slideIndex, cacheId)
- return grad ? `url(#${grad.id})` : "transparent"
- }
- if (fill.type === "pattern") {
- const pattern = getShapePatternCached(el, slideIndex, cacheId)
- return pattern ? `url(#${pattern.id})` : String(fill.value?.foregroundColor || "transparent")
- }
- if (fill.type === "image") {
- const paint = getShapeImagePaintCached(el, slideIndex, cacheId)
- return paint ? `url(#${paint.id})` : "transparent"
- }
- return "transparent"
- }
- function getShapeStroke(el: PptxElement) {
- const border = getBorderInfo(el)
- return border.color || "transparent"
- }
- function getShapeStrokeWidth(el: PptxElement) {
- const border = getBorderInfo(el)
- return Math.max(0, border.width)
- }
- function getShapeDasharray(el: PptxElement) {
- const border = getBorderInfo(el)
- return border.dasharray
- }
- function getShapePath(el: PptxElement) {
- const raw = typeof el.path === "string" ? el.path.trim() : ""
- if (raw) return raw
- const w = Math.max(0, getNumber(el.width))
- const h = Math.max(0, getNumber(el.height))
- if (!w || !h) return ""
- const shapeType = String(el.shapType || el.shapeType || el.geom || "").toLowerCase()
- if (shapeType === "ellipse" || shapeType === "circle") {
- const cx = w / 2
- const cy = h / 2
- const rx = w / 2
- const ry = h / 2
- return `M ${cx - rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy} Z`
- }
- if (shapeType === "triangle" || shapeType === "tri") {
- return `M ${w / 2} 0 L ${w} ${h} L 0 ${h} Z`
- }
- if (shapeType === "diamond" || shapeType === "rhombus") {
- return `M ${w / 2} 0 L ${w} ${h / 2} L ${w / 2} ${h} L 0 ${h / 2} Z`
- }
- const r = Math.max(0, Math.min(Math.min(w, h) / 2, getNumber(el.radius || el.cornerRadius || el.rx)))
- if (r) {
- const rr = Math.min(r, w / 2, h / 2)
- return `M ${rr} 0 H ${w - rr} A ${rr} ${rr} 0 0 1 ${w} ${rr} V ${h - rr} A ${rr} ${rr} 0 0 1 ${w - rr} ${h} H ${rr} A ${rr} ${rr} 0 0 1 0 ${h - rr} V ${rr} A ${rr} ${rr} 0 0 1 ${rr} 0 Z`
- }
- return `M 0 0 H ${w} V ${h} H 0 Z`
- }
- </script>
- <style scoped lang="less">
- .pptx-preview {
- width: 100%;
- }
- .pptx-empty {
- padding: 40px 12px;
- color: #909399;
- text-align: center;
- }
- .pptx-slides {
- display: flex;
- flex-direction: column;
- gap: 16px;
- align-items: center;
- }
- .pptx-slide-shell {
- position: relative;
- display: flex;
- align-items: flex-start;
- justify-content: center;
- }
- .pptx-slide {
- position: absolute;
- left: 0;
- top: 0;
- transform-origin: top left;
- border-radius: 6px;
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
- overflow: hidden;
- }
- .pptx-element {
- overflow: visible;
- }
- .pptx-img,
- .pptx-media {
- width: 100%;
- height: 100%;
- }
- .pptx-media {
- object-fit: contain;
- }
- .pptx-shape-svg {
- width: 100%;
- height: 100%;
- display: block;
- }
- .pptx-chart {
- width: 100%;
- height: 100%;
- display: block;
- }
- .pptx-group-inner {
- width: 100%;
- height: 100%;
- position: relative;
- }
- .pptx-table-wrap {
- width: 100%;
- height: 100%;
- }
- .pptx-table {
- width: 100%;
- height: 100%;
- table-layout: fixed;
- border-collapse: collapse;
- }
- .pptx-table-html {
- width: 100%;
- height: 100%;
- overflow: hidden;
- pointer-events: none;
- }
- .pptx-html {
- width: 100%;
- height: 100%;
- overflow: visible;
- pointer-events: none;
- }
- .pptx-html-inner {
- white-space: pre-wrap;
- overflow-wrap: anywhere;
- word-break: break-word;
- }
- .pptx-html :deep(p) {
- margin: 0;
- padding: 0;
- word-break: break-word;
- }
- .pptx-html :deep(ul),
- .pptx-html :deep(ol) {
- margin: 0;
- padding: 0 0 0 20px;
- word-break: break-word;
- }
- .pptx-table-html :deep(p) {
- margin: 0;
- padding: 0;
- word-break: break-word;
- }
- .pptx-table-html :deep(ul),
- .pptx-table-html :deep(ol) {
- margin: 0;
- padding: 0 0 0 20px;
- word-break: break-word;
- }
- </style>
|