<!--
 * @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>
