jvs-ppt-ui 0.0.11 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,10 +13,11 @@ import { parseColorString, extractTextAlignFromHtml, updateTextAlignInHtml, norm
13
13
 
14
14
  import { shapeFormulas, shapeList as getPptxShapeList, ensurePptxShapes } from '../utils/pptxShapesLazy'
15
15
  import { inferElementTextType, type SlideTextType } from '../utils/textTypes'
16
+ import { applyGroupGeometryFlip } from '../utils/groupFlip'
16
17
  // 快捷键弹层异步化;mock DSL 模板(~77KB)改为触发时动态加载
17
18
  const ShortcutsDialog = defineAsyncComponent(() => import('./ShortcutsDialog.vue'))
18
19
 
19
- const { presentationTitle, exportSlides, importSlides, currentZipMeta, startPresentation, activeIndex, slides, notify, renderLatexToSvg, globalFontFamily, globalFontColor, globalThemeColor, themeSchemeFillMap, themeClrScheme, logicalWidth, logicalHeight, dslInputDialogVisible, aiPptDialogVisible, shortcutsDialogVisible, registerPPTParser, isMasterEditingView, enterMasterView, exitMasterView, zoomRatio, resetZoom, normalizeSlideBackground, getEffectiveMasterElementsForSlide, getEffectiveSlideBackground, uploadMediaResourcesInSlides, getCustomUploadHandler } = useSlides()
20
+ const { presentationTitle, exportSlides, importSlides, currentZipMeta, startPresentation, activeIndex, slides, notify, renderLatexToSvg, globalFontFamily, globalFontColor, globalThemeColor, themeSchemeFillMap, themeClrScheme, logicalWidth, logicalHeight, dslInputDialogVisible, aiPptDialogVisible, shortcutsDialogVisible, registerPPTParser, isMasterEditingView, enterMasterView, exitMasterView, zoomRatio, resetZoom, normalizeSlideBackground, getEffectiveMasterElementsForSlide, getEffectiveSlideBackground, uploadMediaResourcesInSlides, getCustomUploadHandler, isReadonly } = useSlides()
20
21
  import { sanitizeNbspInHtml, checkIsVertical } from '../composables/useSlides'
21
22
 
22
23
  // 快捷键弹层挂载闩锁:首次打开后保持挂载,异步 chunk 只加载一次
@@ -42,6 +43,7 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
42
43
  const isEditingTitle = ref(false)
43
44
  const titleInputRef = ref<HTMLInputElement | null>(null)
44
45
  const startEditingTitle = () => {
46
+ if (isReadonly.value) return
45
47
  isEditingTitle.value = true
46
48
  nextTick(() => {
47
49
  titleInputRef.value?.focus()
@@ -957,7 +959,7 @@ function parseHTMLTable(htmlStr: string, totalWidth?: number): { tableData: any[
957
959
  const colgroupCols = tableEl.querySelectorAll('colgroup col')
958
960
  const firstRowCells = tableEl.querySelectorAll('tr:first-child > td, tr:first-child > th')
959
961
  const targetEls = colgroupCols.length >= totalCols ? colgroupCols : (firstRowCells.length > 0 ? firstRowCells : colgroupCols)
960
-
962
+
961
963
  let colCounter = 0
962
964
  targetEls.forEach((cEl) => {
963
965
  if (colCounter >= totalCols) return
@@ -1785,960 +1787,960 @@ const generatePptxBlob = async (): Promise<Blob | null> => {
1785
1787
 
1786
1788
  // 2. Elements
1787
1789
  const processElement = async (el: any, parentPixels: { left: number; top: number; width: number; height: number; rotate: number; flipH: boolean; flipV: boolean } | null = null) => {
1788
- if (el.visible === false) return
1790
+ if (el.visible === false) return
1789
1791
 
1790
- let pixelWidth = 0
1791
- let pixelHeight = 0
1792
+ let pixelWidth = 0
1793
+ let pixelHeight = 0
1792
1794
 
1793
- const rawWidth = el.style?.width !== undefined ? el.style.width : el.width
1794
- const rawHeight = el.style?.height !== undefined ? el.style.height : el.height
1795
- const rawLeft = el.style?.left !== undefined ? el.style.left : el.left
1796
- const rawTop = el.style?.top !== undefined ? el.style.top : el.top
1795
+ const rawWidth = el.style?.width !== undefined ? el.style.width : el.width
1796
+ const rawHeight = el.style?.height !== undefined ? el.style.height : el.height
1797
+ const rawLeft = el.style?.left !== undefined ? el.style.left : el.left
1798
+ const rawTop = el.style?.top !== undefined ? el.style.top : el.top
1797
1799
 
1798
- if (parentPixels) {
1799
- pixelWidth = parseValToPx(rawWidth, parentPixels.width, 100)
1800
- pixelHeight = parseValToPx(rawHeight, parentPixels.height, 40)
1801
- } else {
1802
- pixelWidth = parseCoordinate(rawWidth, logicalWidth.value)
1803
- pixelHeight = parseCoordinate(rawHeight, logicalHeight.value)
1804
- }
1800
+ if (parentPixels) {
1801
+ pixelWidth = parseValToPx(rawWidth, parentPixels.width, 100)
1802
+ pixelHeight = parseValToPx(rawHeight, parentPixels.height, 40)
1803
+ } else {
1804
+ pixelWidth = parseCoordinate(rawWidth, logicalWidth.value)
1805
+ pixelHeight = parseCoordinate(rawHeight, logicalHeight.value)
1806
+ }
1805
1807
 
1806
- if (el.type === 'text') {
1807
- const domEl = document.getElementById(`canvas-el-${el.id}`)
1808
- if (domEl && domEl.offsetHeight > 0) {
1809
- if (!pixelHeight || el.style?.height === 'auto') {
1810
- pixelHeight = domEl.offsetHeight
1811
- }
1812
- if (!pixelWidth || el.style?.width === 'auto') {
1813
- pixelWidth = domEl.offsetWidth
1814
- }
1815
- } else {
1816
- const defaultFontSize = parseInt(el.style?.fontSize || '') || 18
1817
- if (!pixelWidth || el.style?.width === 'auto') {
1818
- pixelWidth = 400
1819
- }
1820
- if (!pixelHeight || el.style?.height === 'auto') {
1821
- let topPadding = 4.8
1822
- let bottomPadding = 4.8
1823
- if (el.style?.padding) {
1824
- const paddingParts = el.style.padding.trim().split(/\s+/)
1825
- let parsedPadding = [4.8, 9.6, 4.8, 9.6]
1826
- if (paddingParts.length === 4) {
1827
- parsedPadding = paddingParts.map((p: string) => parseFloat(p) || 0)
1828
- } else if (paddingParts.length === 1) {
1829
- const val = parseFloat(paddingParts[0]) || 0
1830
- parsedPadding = [val, val, val, val]
1831
- }
1832
- topPadding = parsedPadding[0]
1833
- bottomPadding = parsedPadding[2]
1834
- }
1835
- pixelHeight = estimateTextHeight(el.content || '', pixelWidth, defaultFontSize) + topPadding + bottomPadding
1836
- }
1808
+ if (el.type === 'text') {
1809
+ const domEl = document.getElementById(`canvas-el-${el.id}`)
1810
+ if (domEl && domEl.offsetHeight > 0) {
1811
+ if (!pixelHeight || el.style?.height === 'auto') {
1812
+ pixelHeight = domEl.offsetHeight
1837
1813
  }
1838
- }
1839
-
1840
- const elRotate = typeof el.style?.rotate === 'number'
1841
- ? el.style.rotate
1842
- : (parseFloat(el.style?.rotate || el.rotate || '0') || 0)
1843
- const elFlipH = Boolean(el.style?.flipH || el.flipH || el.isFlipH)
1844
- const elFlipV = Boolean(el.style?.flipV || el.flipV || el.isFlipV)
1845
-
1846
- let pixelLeft = 0
1847
- let pixelTop = 0
1848
- let rotate = 0
1849
- let flipH = elFlipH
1850
- let flipV = elFlipV
1851
-
1852
- if (parentPixels) {
1853
- const rawChildLeft = parseValToPx(rawLeft, parentPixels.width, 0)
1854
- const rawChildTop = parseValToPx(rawTop, parentPixels.height, 0)
1855
-
1856
- const pW = parentPixels.width
1857
- const pH = parentPixels.height
1858
- const cCenterRelX = (rawChildLeft + pixelWidth / 2) - pW / 2
1859
- const cCenterRelY = (rawChildTop + pixelHeight / 2) - pH / 2
1860
-
1861
- let flippedRelX = parentPixels.flipH ? -cCenterRelX : cCenterRelX
1862
- let flippedRelY = parentPixels.flipV ? -cCenterRelY : cCenterRelY
1863
-
1864
- let rotRelX = flippedRelX
1865
- let rotRelY = flippedRelY
1866
- if (parentPixels.rotate) {
1867
- const rad = (parentPixels.rotate * Math.PI) / 180
1868
- rotRelX = flippedRelX * Math.cos(rad) - flippedRelY * Math.sin(rad)
1869
- rotRelY = flippedRelX * Math.sin(rad) + flippedRelY * Math.cos(rad)
1814
+ if (!pixelWidth || el.style?.width === 'auto') {
1815
+ pixelWidth = domEl.offsetWidth
1870
1816
  }
1871
-
1872
- const newCenterX = pW / 2 + rotRelX
1873
- const newCenterY = pH / 2 + rotRelY
1874
-
1875
- pixelLeft = parentPixels.left + (newCenterX - pixelWidth / 2)
1876
- pixelTop = parentPixels.top + (newCenterY - pixelHeight / 2)
1877
-
1878
- const parentOddFlips = (parentPixels.flipH !== parentPixels.flipV)
1879
- const childEffectiveRot = parentOddFlips ? -elRotate : elRotate
1880
- rotate = ((parentPixels.rotate + childEffectiveRot) % 360 + 360) % 360
1881
-
1882
- flipH = parentPixels.flipH ? !elFlipH : elFlipH
1883
- flipV = parentPixels.flipV ? !elFlipV : elFlipV
1884
1817
  } else {
1885
- pixelLeft = parseCoordinate(rawLeft, logicalWidth.value)
1886
- pixelTop = parseCoordinate(rawTop, logicalHeight.value)
1887
- rotate = (elRotate % 360 + 360) % 360
1888
- }
1889
-
1890
- if (el.type === 'group' && Array.isArray(el.elements)) {
1891
- const curGroupPixels = {
1892
- left: pixelLeft,
1893
- top: pixelTop,
1894
- width: pixelWidth,
1895
- height: pixelHeight,
1896
- rotate,
1897
- flipH,
1898
- flipV
1899
- }
1900
- for (const child of el.elements) {
1901
- await processElement(child, curGroupPixels)
1902
- }
1903
- return
1904
- }
1905
-
1906
- const x = (pixelLeft / logicalWidth.value) * baseWidth
1907
- const y = (pixelTop / logicalHeight.value) * baseHeight
1908
- const w = (pixelWidth / logicalWidth.value) * baseWidth
1909
- const h = (pixelHeight / logicalHeight.value) * baseHeight
1910
-
1911
- if (el.type === 'text') {
1912
- if (el.latex) {
1913
- const svgString = renderLatexToSvg(el.latex)
1914
- const base64SVG = 'data:image/svg+xml;base64,' + window.btoa(unescape(encodeURIComponent(svgString)))
1915
- pptxSlide.addImage({
1916
- data: base64SVG,
1917
- x, y, w, h,
1918
- rotate,
1919
- objectName: el.id
1920
- })
1921
- }
1922
- else if ((el.content && (el.content.includes('<table') || el.content.includes('&lt;table'))) || (el.data && Array.isArray(el.data) && el.data.length > 0)) {
1923
- const res = (el.content && (el.content.includes('<table') || el.content.includes('&lt;table')))
1924
- ? parseHTMLTable(el.content, w)
1925
- : parseTableElementData(el, w)
1926
- if (res.tableData && res.tableData.length > 0) {
1927
- const tableOpts: any = { x, y, w, h, objectName: el.id }
1928
- if (res.colW && res.colW.length > 0) {
1929
- tableOpts.colW = res.colW
1930
- }
1931
- pptxSlide.addTable(res.tableData, tableOpts)
1932
- }
1818
+ const defaultFontSize = parseInt(el.style?.fontSize || '') || 18
1819
+ if (!pixelWidth || el.style?.width === 'auto') {
1820
+ pixelWidth = 400
1933
1821
  }
1934
- else {
1935
- const defaultFontSize = Math.max(1, Math.round((parseInt(el.style?.fontSize || '') || 18) * fontSizeFactor))
1936
- const defaultColor = el.style?.color || globalFontColor.value || '#475569'
1937
- const defaultFontFamily = (el.style?.fontFamily && el.style.fontFamily !== 'inherit' && el.style.fontFamily !== '默认字体')
1938
- ? el.style.fontFamily
1939
- : (globalFontFamily.value && globalFontFamily.value !== '默认字体' ? globalFontFamily.value : 'SourceHanSansCN')
1940
-
1941
- const isBold = el.style?.fontWeight === 'bold' || el.style?.fontWeight === '700' || (parseInt(el.style?.fontWeight as string) || 0) > 500
1942
- const isItalic = (el.style as any)?.fontStyle === 'italic'
1943
- const isUnderline = !!(el.style?.textDecoration && el.style.textDecoration.includes('underline'))
1944
- const isStrike = !!(el.style?.textDecoration && el.style.textDecoration.includes('line-through'))
1945
-
1946
- const hasFillOrBorder = (el.style?.textBackground && el.style.textBackground !== 'transparent' && el.style.textBackground !== 'none') || (parseFloat(el.style?.borderWidth || '') || 0) > 0
1947
-
1948
- const textShadowObj = (el as any).textShadow || (el.style && (el.style as any).textShadow) || (!hasFillOrBorder ? (el.shadow || (el.style && (el.style as any).shadow)) : undefined)
1949
- let parsedTextShadow: any = undefined
1950
- if (textShadowObj) {
1951
- parsedTextShadow = convertElShadowToPptx(textShadowObj, ratioPx2Pt)
1952
- }
1953
- if (!parsedTextShadow) {
1954
- let textShadowStr = el.style?.boxShadow
1955
- if (!textShadowStr && el.content) {
1956
- const match = el.content.match(/(?:text-shadow)\s*:\s*([^;"]+)/i)
1957
- if (match) {
1958
- textShadowStr = match[1].trim()
1959
- }
1960
- }
1961
- if (textShadowStr) {
1962
- parsedTextShadow = parseCssShadow(textShadowStr, ratioPx2Pt)
1963
- }
1964
- }
1965
-
1966
- const shapeShadowObj = hasFillOrBorder ? (el.shadow || (el.style && (el.style as any).shadow)) : undefined
1967
- let parsedShapeShadow: any = undefined
1968
- if (shapeShadowObj) {
1969
- parsedShapeShadow = convertElShadowToPptx(shapeShadowObj, ratioPx2Pt)
1970
- }
1971
-
1972
- const textRefl = (el as any).textReflection || (el.style as any)?.textReflection || (!hasFillOrBorder ? (el.reflection || (el.style && el.style.reflection)) : undefined)
1973
- let parsedTextReflection: any = undefined
1974
- if (textRefl) {
1975
- parsedTextReflection = convertElReflectionToPptx(textRefl, ratioPx2Pt)
1976
- }
1977
-
1978
- const shapeRefl = hasFillOrBorder ? (el.reflection || (el.style && el.style.reflection)) : undefined
1979
- let parsedShapeReflection: any = undefined
1980
- if (shapeRefl) {
1981
- parsedShapeReflection = convertElReflectionToPptx(shapeRefl, ratioPx2Pt)
1982
- }
1983
-
1984
- const textProps = formatHTMLToPPTX(el.content || '', {
1985
- fontSize: defaultFontSize,
1986
- color: defaultColor,
1987
- fontFamily: defaultFontFamily,
1988
- bold: isBold,
1989
- italic: isItalic,
1990
- underline: isUnderline,
1991
- strike: isStrike,
1992
- align: el.style?.textAlign || 'left',
1993
- shadow: parsedTextShadow,
1994
- reflection: parsedTextReflection
1995
- })
1996
-
1997
- let finalLineHeight: number | undefined = undefined
1998
- if (el.style?.lineHeight !== undefined) {
1999
- const val = typeof el.style.lineHeight === 'number'
2000
- ? el.style.lineHeight
2001
- : parseFloat(el.style.lineHeight as string || '')
2002
- if (!isNaN(val)) {
2003
- finalLineHeight = val <= 1.2 ? 1.0 : val
2004
- }
2005
- }
2006
- let textValign = 'top'
2007
- const currentValign = el.style?.verticalAlign || (el as any).vAlign
2008
- if (currentValign === 'middle' || currentValign === 'mid') {
2009
- textValign = 'middle'
2010
- } else if (currentValign === 'bottom' || currentValign === 'down') {
2011
- textValign = 'bottom'
2012
- }
2013
-
2014
- let textMargin: [number, number, number, number] = [7.2, 7.2, 3.6, 3.6]
1822
+ if (!pixelHeight || el.style?.height === 'auto') {
1823
+ let topPadding = 4.8
1824
+ let bottomPadding = 4.8
2015
1825
  if (el.style?.padding) {
2016
1826
  const paddingParts = el.style.padding.trim().split(/\s+/)
2017
- let parsedPadding = [0, 0, 0, 0]
1827
+ let parsedPadding = [4.8, 9.6, 4.8, 9.6]
2018
1828
  if (paddingParts.length === 4) {
2019
1829
  parsedPadding = paddingParts.map((p: string) => parseFloat(p) || 0)
2020
1830
  } else if (paddingParts.length === 1) {
2021
1831
  const val = parseFloat(paddingParts[0]) || 0
2022
1832
  parsedPadding = [val, val, val, val]
2023
1833
  }
2024
- textMargin = [
2025
- parsedPadding[3] / ratioPx2Pt, // left
2026
- parsedPadding[1] / ratioPx2Pt, // right
2027
- parsedPadding[2] / ratioPx2Pt, // bottom
2028
- parsedPadding[0] / ratioPx2Pt // top
2029
- ]
2030
- }
2031
-
2032
- const textOpts: any = {
2033
- x, y, w, h,
2034
- valign: textValign as any,
2035
- align: (el.style?.textAlign || 'left') as any,
2036
- margin: textMargin,
2037
- rotate, flipH, flipV,
2038
- objectName: el.id
2039
- }
2040
- if (finalLineHeight !== undefined) {
2041
- textOpts.lineSpacingMultiple = finalLineHeight
1834
+ topPadding = parsedPadding[0]
1835
+ bottomPadding = parsedPadding[2]
2042
1836
  }
1837
+ pixelHeight = estimateTextHeight(el.content || '', pixelWidth, defaultFontSize) + topPadding + bottomPadding
1838
+ }
1839
+ }
1840
+ }
2043
1841
 
2044
- if (parsedShapeShadow) {
2045
- textOpts.shadow = parsedShapeShadow
2046
- }
2047
- if (parsedShapeReflection) {
2048
- textOpts.reflection = parsedShapeReflection
2049
- }
1842
+ const elRotate = typeof el.style?.rotate === 'number'
1843
+ ? el.style.rotate
1844
+ : (parseFloat(el.style?.rotate || el.rotate || '0') || 0)
1845
+ const elFlipH = Boolean(el.style?.flipH || el.flipH || el.isFlipH)
1846
+ const elFlipV = Boolean(el.style?.flipV || el.flipV || el.isFlipV)
1847
+
1848
+ let pixelLeft = 0
1849
+ let pixelTop = 0
1850
+ let rotate = 0
1851
+ let flipH = elFlipH
1852
+ let flipV = elFlipV
1853
+
1854
+ if (parentPixels) {
1855
+ const rawChildLeft = parseValToPx(rawLeft, parentPixels.width, 0)
1856
+ const rawChildTop = parseValToPx(rawTop, parentPixels.height, 0)
1857
+
1858
+ const pW = parentPixels.width
1859
+ const pH = parentPixels.height
1860
+ const cCenterRelX = (rawChildLeft + pixelWidth / 2) - pW / 2
1861
+ const cCenterRelY = (rawChildTop + pixelHeight / 2) - pH / 2
1862
+
1863
+ let flippedRelX = parentPixels.flipH ? -cCenterRelX : cCenterRelX
1864
+ let flippedRelY = parentPixels.flipV ? -cCenterRelY : cCenterRelY
1865
+
1866
+ let rotRelX = flippedRelX
1867
+ let rotRelY = flippedRelY
1868
+ if (parentPixels.rotate) {
1869
+ const rad = (parentPixels.rotate * Math.PI) / 180
1870
+ rotRelX = flippedRelX * Math.cos(rad) - flippedRelY * Math.sin(rad)
1871
+ rotRelY = flippedRelX * Math.sin(rad) + flippedRelY * Math.cos(rad)
1872
+ }
2050
1873
 
2051
- // Apply background color to text element
2052
- if (el.style?.textBackground && el.style.textBackground !== 'transparent' && el.style.textBackground !== 'none') {
2053
- const fillParsed = parseColor(el.style.textBackground)
2054
- textOpts.fill = { color: fillParsed.color, transparency: fillParsed.transparency }
2055
- }
1874
+ const newCenterX = pW / 2 + rotRelX
1875
+ const newCenterY = pH / 2 + rotRelY
2056
1876
 
2057
- // Apply border line style to text element
2058
- const textBorderWidth = parseFloat(el.style?.borderWidth || '') || 0
2059
- const textBorderColorStr = el.style?.borderColor
2060
- if (textBorderWidth > 0 && textBorderColorStr && textBorderColorStr !== 'transparent' && textBorderColorStr !== 'none') {
2061
- const borderParsed = parseColor(textBorderColorStr)
2062
- textOpts.line = {
2063
- color: borderParsed.color,
2064
- width: textBorderWidth / ratioPx2Pt,
2065
- dashType: el.style?.borderStyle === 'dashed' ? 'dash' : 'solid'
2066
- }
2067
- }
1877
+ pixelLeft = parentPixels.left + (newCenterX - pixelWidth / 2)
1878
+ pixelTop = parentPixels.top + (newCenterY - pixelHeight / 2)
2068
1879
 
2069
- if ((el.style as any)?.whiteSpace === 'nowrap') {
2070
- textOpts.wrap = false
2071
- }
1880
+ const parentOddFlips = (parentPixels.flipH !== parentPixels.flipV)
1881
+ const childEffectiveRot = parentOddFlips ? -elRotate : elRotate
1882
+ rotate = ((parentPixels.rotate + childEffectiveRot) % 360 + 360) % 360
2072
1883
 
2073
- const elAny = el as any
2074
- if (elAny.autoFit) {
2075
- if (elAny.autoFit.type === 'shape') {
2076
- textOpts.fit = 'resize'
2077
- } else if (elAny.autoFit.type === 'text') {
2078
- textOpts.fit = 'shrink'
2079
- }
2080
- }
1884
+ flipH = parentPixels.flipH ? !elFlipH : elFlipH
1885
+ flipV = parentPixels.flipV ? !elFlipV : elFlipV
1886
+ } else {
1887
+ pixelLeft = parseCoordinate(rawLeft, logicalWidth.value)
1888
+ pixelTop = parseCoordinate(rawTop, logicalHeight.value)
1889
+ rotate = (elRotate % 360 + 360) % 360
1890
+ }
2081
1891
 
2082
- pptxSlide.addText(textProps, textOpts)
2083
- }
1892
+ if (el.type === 'group' && Array.isArray(el.elements)) {
1893
+ const curGroupPixels = {
1894
+ left: pixelLeft,
1895
+ top: pixelTop,
1896
+ width: pixelWidth,
1897
+ height: pixelHeight,
1898
+ rotate,
1899
+ flipH,
1900
+ flipV
2084
1901
  }
2085
- else if (el.type === 'image') {
2086
- let imgPath = await resolveMediaSrc(el.content || (el as any).src || '', 'image')
2087
- if (!imgPath) {
2088
- imgPath = createFallbackImageBase64('图片缺失')
2089
- }
1902
+ for (const child of el.elements) {
1903
+ await processElement(child, curGroupPixels)
1904
+ }
1905
+ return
1906
+ }
1907
+
1908
+ const x = (pixelLeft / logicalWidth.value) * baseWidth
1909
+ const y = (pixelTop / logicalHeight.value) * baseHeight
1910
+ const w = (pixelWidth / logicalWidth.value) * baseWidth
1911
+ const h = (pixelHeight / logicalHeight.value) * baseHeight
2090
1912
 
2091
- const imgOpts: pptxgen.ImageProps = {
1913
+ if (el.type === 'text') {
1914
+ if (el.latex) {
1915
+ const svgString = renderLatexToSvg(el.latex)
1916
+ const base64SVG = 'data:image/svg+xml;base64,' + window.btoa(unescape(encodeURIComponent(svgString)))
1917
+ pptxSlide.addImage({
1918
+ data: base64SVG,
2092
1919
  x, y, w, h,
2093
- rotate, flipH, flipV,
1920
+ rotate,
2094
1921
  objectName: el.id
2095
- }
2096
- if (imgPath.startsWith('data:')) {
2097
- imgOpts.data = imgPath
2098
- } else {
2099
- imgOpts.path = imgPath
2100
- }
2101
-
2102
- const imgShadow = el.shadow || (el.style && (el.style as any).shadow)
2103
- let parsedImgShadow = convertElShadowToPptx(imgShadow, ratioPx2Pt)
2104
- if (!parsedImgShadow && el.style?.boxShadow) {
2105
- parsedImgShadow = parseCssShadow(el.style.boxShadow, ratioPx2Pt)
2106
- }
2107
- if (parsedImgShadow) {
2108
- imgOpts.shadow = parsedImgShadow
2109
- }
2110
- const imgRefl = el.reflection || (el.style && el.style.reflection)
2111
- if (imgRefl) {
2112
- const parsedReflection = convertElReflectionToPptx(imgRefl, ratioPx2Pt)
2113
- if (parsedReflection) {
2114
- imgOpts.reflection = parsedReflection
1922
+ })
1923
+ }
1924
+ else if ((el.content && (el.content.includes('<table') || el.content.includes('&lt;table'))) || (el.data && Array.isArray(el.data) && el.data.length > 0)) {
1925
+ const res = (el.content && (el.content.includes('<table') || el.content.includes('&lt;table')))
1926
+ ? parseHTMLTable(el.content, w)
1927
+ : parseTableElementData(el, w)
1928
+ if (res.tableData && res.tableData.length > 0) {
1929
+ const tableOpts: any = { x, y, w, h, objectName: el.id }
1930
+ if (res.colW && res.colW.length > 0) {
1931
+ tableOpts.colW = res.colW
2115
1932
  }
1933
+ pptxSlide.addTable(res.tableData, tableOpts)
2116
1934
  }
2117
-
2118
- if (el.clip && Array.isArray(el.clip.range) && el.clip.range.length >= 2) {
2119
- const [start, end] = el.clip.range
2120
- if (Array.isArray(start) && start.length >= 2 && Array.isArray(end) && end.length >= 2) {
2121
- const [startX, startY] = start
2122
- const [endX, endY] = end
2123
-
2124
- const fractionX = (endX - startX) / 100
2125
- const fractionY = (endY - startY) / 100
2126
- if (fractionX > 0 && fractionY > 0) {
2127
- const originW = w / fractionX
2128
- const originH = h / fractionY
2129
-
2130
- imgOpts.w = originW
2131
- imgOpts.h = originH
2132
- imgOpts.sizing = {
2133
- type: 'crop',
2134
- x: (startX / 100) * originW,
2135
- y: (startY / 100) * originH,
2136
- w: w,
2137
- h: h,
2138
- }
1935
+ }
1936
+ else {
1937
+ const defaultFontSize = Math.max(1, Math.round((parseInt(el.style?.fontSize || '') || 18) * fontSizeFactor))
1938
+ const defaultColor = el.style?.color || globalFontColor.value || '#475569'
1939
+ const defaultFontFamily = (el.style?.fontFamily && el.style.fontFamily !== 'inherit' && el.style.fontFamily !== '默认字体')
1940
+ ? el.style.fontFamily
1941
+ : (globalFontFamily.value && globalFontFamily.value !== '默认字体' ? globalFontFamily.value : 'SourceHanSansCN')
1942
+
1943
+ const isBold = el.style?.fontWeight === 'bold' || el.style?.fontWeight === '700' || (parseInt(el.style?.fontWeight as string) || 0) > 500
1944
+ const isItalic = (el.style as any)?.fontStyle === 'italic'
1945
+ const isUnderline = !!(el.style?.textDecoration && el.style.textDecoration.includes('underline'))
1946
+ const isStrike = !!(el.style?.textDecoration && el.style.textDecoration.includes('line-through'))
1947
+
1948
+ const hasFillOrBorder = (el.style?.textBackground && el.style.textBackground !== 'transparent' && el.style.textBackground !== 'none') || (parseFloat(el.style?.borderWidth || '') || 0) > 0
1949
+
1950
+ const textShadowObj = (el as any).textShadow || (el.style && (el.style as any).textShadow) || (!hasFillOrBorder ? (el.shadow || (el.style && (el.style as any).shadow)) : undefined)
1951
+ let parsedTextShadow: any = undefined
1952
+ if (textShadowObj) {
1953
+ parsedTextShadow = convertElShadowToPptx(textShadowObj, ratioPx2Pt)
1954
+ }
1955
+ if (!parsedTextShadow) {
1956
+ let textShadowStr = el.style?.boxShadow
1957
+ if (!textShadowStr && el.content) {
1958
+ const match = el.content.match(/(?:text-shadow)\s*:\s*([^;"]+)/i)
1959
+ if (match) {
1960
+ textShadowStr = match[1].trim()
2139
1961
  }
2140
1962
  }
1963
+ if (textShadowStr) {
1964
+ parsedTextShadow = parseCssShadow(textShadowStr, ratioPx2Pt)
1965
+ }
2141
1966
  }
2142
1967
 
2143
- pptxSlide.addImage(imgOpts)
2144
- }
2145
- else if (el.type === 'shape') {
2146
- const shapeType = getPredefinedShapeType(el.content, el.pathFormula, (el as any).pptxShapeType)
2147
-
2148
- // Set defaults based on shape types to match CanvasArea.vue styles
2149
- let defaultFill = '#3b82f6'
2150
- let defaultBorderColor = '#2563eb'
2151
- let defaultBorderWidth = '2px'
2152
-
2153
- if (el.content === 'triangle') {
2154
- defaultFill = '#f59e0b'
2155
- defaultBorderColor = '#d97706'
2156
- defaultBorderWidth = '3px'
2157
- } else if (el.content === 'circle') {
2158
- defaultFill = '#3b82f6'
2159
- defaultBorderColor = '#2563eb'
2160
- defaultBorderWidth = '2px'
2161
- } else if (typeof el.content === 'string' && (el.content.startsWith('M') || el.content.startsWith('m'))) {
2162
- defaultFill = '#3b82f6'
2163
- defaultBorderColor = '#2563eb'
2164
- defaultBorderWidth = '2px'
2165
- } else { // rect or default
2166
- defaultFill = '#10b981'
2167
- defaultBorderColor = '#059669'
2168
- defaultBorderWidth = '2px'
2169
- }
2170
-
2171
- const style: any = el.style || {}
2172
- let shapeFillOpt: any = undefined
2173
-
2174
- // Helper function to extract element gradient configuration for PPTX export
2175
- const getElementGradientConfig = (item: any): any => {
2176
- if (!item) return undefined
2177
- let gradient = item.gradient || item.style?.gradient
2178
- if (!gradient) {
2179
- const rawFill = (item.style && item.style.fill !== undefined) ? item.style.fill : item.fill
2180
- if (rawFill && typeof rawFill === 'object' && rawFill.type === 'gradient') {
2181
- const val = rawFill.value || {}
2182
- gradient = {
2183
- path: val.path || 'line',
2184
- type: val.path === 'line' || !val.path ? 'linear' : 'radial',
2185
- rotate: val.rot !== undefined ? val.rot : (val.rotate !== undefined ? val.rotate : 90),
2186
- colors: (val.colors || []).map((c: any) => ({
2187
- color: c.color || c.value || '#3b82f6',
2188
- pos: c.pos !== undefined ? (typeof c.pos === 'string' ? parseInt(c.pos) : c.pos) : 0
2189
- }))
2190
- }
2191
- }
2192
- }
2193
- return gradient
1968
+ const shapeShadowObj = hasFillOrBorder ? (el.shadow || (el.style && (el.style as any).shadow)) : undefined
1969
+ let parsedShapeShadow: any = undefined
1970
+ if (shapeShadowObj) {
1971
+ parsedShapeShadow = convertElShadowToPptx(shapeShadowObj, ratioPx2Pt)
2194
1972
  }
2195
1973
 
2196
- const getElementPatternUrl = (item: any): string | undefined => {
2197
- if (!item) return undefined
2198
- if (item.pattern || item.style?.pattern) return item.pattern || item.style?.pattern
2199
- const rawFill = (item.style && item.style.fill !== undefined) ? item.style.fill : item.fill
2200
- if (rawFill && typeof rawFill === 'object' && (rawFill.type === 'image' || rawFill.type === 'pattern')) {
2201
- return rawFill.value?.src || rawFill.value?.url || rawFill.value
2202
- }
2203
- return undefined
2204
- }
2205
-
2206
- const patternUrl = getElementPatternUrl(el)
2207
- const g = getElementGradientConfig(el)
2208
-
2209
- // SVG 图片填充 PowerPoint 无法可靠渲染 a:blipFill,先按形状显示尺寸栅格化为 PNG
2210
- // (画布上图案填充本身是 preserveAspectRatio="none" 拉伸铺满,与 a:stretch 语义一致)
2211
- const rasterizeSvgFill = (svgUrl: string, widthPx: number, heightPx: number): Promise<string | undefined> => {
2212
- return new Promise((resolve) => {
2213
- const img = new Image()
2214
- img.onload = () => {
2215
- try {
2216
- const canvas = document.createElement('canvas')
2217
- canvas.width = Math.max(1, Math.min(4096, Math.round(widthPx)))
2218
- canvas.height = Math.max(1, Math.min(4096, Math.round(heightPx)))
2219
- const ctx = canvas.getContext('2d')
2220
- if (!ctx) return resolve(undefined)
2221
- ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
2222
- resolve(canvas.toDataURL('image/png'))
2223
- } catch {
2224
- resolve(undefined)
2225
- }
2226
- }
2227
- img.onerror = () => resolve(undefined)
2228
- img.src = svgUrl
2229
- })
1974
+ const textRefl = (el as any).textReflection || (el.style as any)?.textReflection || (!hasFillOrBorder ? (el.reflection || (el.style && el.style.reflection)) : undefined)
1975
+ let parsedTextReflection: any = undefined
1976
+ if (textRefl) {
1977
+ parsedTextReflection = convertElReflectionToPptx(textRefl, ratioPx2Pt)
2230
1978
  }
2231
1979
 
2232
- const buildNativeShapeFill = async (): Promise<any> => {
2233
- // 图片填充 → 原生 a:blipFill
2234
- if (patternUrl) {
2235
- const pUrl = await resolveMediaSrc(patternUrl, 'image')
2236
- if (pUrl && /^data:image\/svg\+xml/i.test(pUrl)) {
2237
- const pngUrl = await rasterizeSvgFill(pUrl, pixelWidth, pixelHeight)
2238
- return pngUrl ? { type: 'image', data: pngUrl } : undefined
2239
- }
2240
- if (pUrl && pUrl.startsWith('data:')) {
2241
- return { type: 'image', data: pUrl }
2242
- }
2243
- if (pUrl && /^https?:\/\//.test(pUrl)) {
2244
- return { type: 'image', path: pUrl }
2245
- }
2246
- return undefined // 图片解析失败,回落纯色
2247
- }
2248
- // 渐变填充 → 原生 a:gradFill
2249
- if (g && Array.isArray(g.colors) && g.colors.length >= 2) {
2250
- return {
2251
- type: 'gradient',
2252
- stops: g.colors.map((c: any) => ({
2253
- color: String(c.color || '#3b82f6').replace('#', ''),
2254
- pos: typeof c.pos === 'number' && !isNaN(c.pos) ? c.pos : 0
2255
- })),
2256
- angle: g.rotate !== undefined ? g.rotate : (g.rot !== undefined ? g.rot : 90),
2257
- radial: g.type === 'radial' || g.path === 'circle'
2258
- }
1980
+ const shapeRefl = hasFillOrBorder ? (el.reflection || (el.style && el.style.reflection)) : undefined
1981
+ let parsedShapeReflection: any = undefined
1982
+ if (shapeRefl) {
1983
+ parsedShapeReflection = convertElReflectionToPptx(shapeRefl, ratioPx2Pt)
1984
+ }
1985
+
1986
+ const textProps = formatHTMLToPPTX(el.content || '', {
1987
+ fontSize: defaultFontSize,
1988
+ color: defaultColor,
1989
+ fontFamily: defaultFontFamily,
1990
+ bold: isBold,
1991
+ italic: isItalic,
1992
+ underline: isUnderline,
1993
+ strike: isStrike,
1994
+ align: el.style?.textAlign || 'left',
1995
+ shadow: parsedTextShadow,
1996
+ reflection: parsedTextReflection
1997
+ })
1998
+
1999
+ let finalLineHeight: number | undefined = undefined
2000
+ if (el.style?.lineHeight !== undefined) {
2001
+ const val = typeof el.style.lineHeight === 'number'
2002
+ ? el.style.lineHeight
2003
+ : parseFloat(el.style.lineHeight as string || '')
2004
+ if (!isNaN(val)) {
2005
+ finalLineHeight = val <= 1.2 ? 1.0 : val
2259
2006
  }
2260
- return undefined
2261
2007
  }
2262
- const nativeFillOpt = await buildNativeShapeFill()
2263
-
2264
- const rawFill = style.fill !== undefined ? style.fill : (el.fill !== undefined ? el.fill : defaultFill)
2265
- if (rawFill === 'transparent' || rawFill === 'none') {
2266
- shapeFillOpt = { type: 'none' }
2267
- } else {
2268
- const fillParsed = parseColor(rawFill)
2269
- shapeFillOpt = { color: fillParsed.color, transparency: fillParsed.transparency }
2008
+ let textValign = 'top'
2009
+ const currentValign = el.style?.verticalAlign || (el as any).vAlign
2010
+ if (currentValign === 'middle' || currentValign === 'mid') {
2011
+ textValign = 'middle'
2012
+ } else if (currentValign === 'bottom' || currentValign === 'down') {
2013
+ textValign = 'bottom'
2014
+ }
2015
+
2016
+ let textMargin: [number, number, number, number] = [7.2, 7.2, 3.6, 3.6]
2017
+ if (el.style?.padding) {
2018
+ const paddingParts = el.style.padding.trim().split(/\s+/)
2019
+ let parsedPadding = [0, 0, 0, 0]
2020
+ if (paddingParts.length === 4) {
2021
+ parsedPadding = paddingParts.map((p: string) => parseFloat(p) || 0)
2022
+ } else if (paddingParts.length === 1) {
2023
+ const val = parseFloat(paddingParts[0]) || 0
2024
+ parsedPadding = [val, val, val, val]
2025
+ }
2026
+ textMargin = [
2027
+ parsedPadding[3] / ratioPx2Pt, // left
2028
+ parsedPadding[1] / ratioPx2Pt, // right
2029
+ parsedPadding[2] / ratioPx2Pt, // bottom
2030
+ parsedPadding[0] / ratioPx2Pt // top
2031
+ ]
2270
2032
  }
2271
2033
 
2272
- const shapeOpts: pptxgen.ShapeProps = {
2034
+ const textOpts: any = {
2273
2035
  x, y, w, h,
2274
- fill: nativeFillOpt || shapeFillOpt,
2036
+ valign: textValign as any,
2037
+ align: (el.style?.textAlign || 'left') as any,
2038
+ margin: textMargin,
2275
2039
  rotate, flipH, flipV,
2276
2040
  objectName: el.id
2277
2041
  }
2278
-
2279
- const borderWidth = style.borderWidth ? (parseFloat(style.borderWidth) || 0) : 0
2280
- const borderColorStr = style.borderColor
2281
- if (borderWidth > 0 && borderColorStr && borderColorStr !== 'transparent' && borderColorStr !== 'none') {
2282
- const borderParsed = parseColor(borderColorStr)
2283
- shapeOpts.line = {
2284
- color: borderParsed.color,
2285
- width: borderWidth / ratioPx2Pt,
2286
- dashType: style.borderStyle === 'dashed' ? 'dash' : 'solid'
2287
- }
2042
+ if (finalLineHeight !== undefined) {
2043
+ textOpts.lineSpacingMultiple = finalLineHeight
2288
2044
  }
2289
2045
 
2290
- const shapeShadow = el.shadow || style.shadow
2291
- let parsedShadow: any = convertElShadowToPptx(shapeShadow, ratioPx2Pt)
2292
- if (!parsedShadow && style.boxShadow) {
2293
- parsedShadow = parseCssShadow(style.boxShadow, ratioPx2Pt)
2046
+ if (parsedShapeShadow) {
2047
+ textOpts.shadow = parsedShapeShadow
2294
2048
  }
2295
- if (parsedShadow) {
2296
- shapeOpts.shadow = parsedShadow
2049
+ if (parsedShapeReflection) {
2050
+ textOpts.reflection = parsedShapeReflection
2297
2051
  }
2298
2052
 
2299
- const shapeRefl = el.reflection || style.reflection
2300
- if (shapeRefl) {
2301
- const parsedReflection = convertElReflectionToPptx(shapeRefl, ratioPx2Pt)
2302
- if (parsedReflection) {
2303
- shapeOpts.reflection = parsedReflection
2053
+ // Apply background color to text element
2054
+ if (el.style?.textBackground && el.style.textBackground !== 'transparent' && el.style.textBackground !== 'none') {
2055
+ const fillParsed = parseColor(el.style.textBackground)
2056
+ textOpts.fill = { color: fillParsed.color, transparency: fillParsed.transparency }
2057
+ }
2058
+
2059
+ // Apply border line style to text element
2060
+ const textBorderWidth = parseFloat(el.style?.borderWidth || '') || 0
2061
+ const textBorderColorStr = el.style?.borderColor
2062
+ if (textBorderWidth > 0 && textBorderColorStr && textBorderColorStr !== 'transparent' && textBorderColorStr !== 'none') {
2063
+ const borderParsed = parseColor(textBorderColorStr)
2064
+ textOpts.line = {
2065
+ color: borderParsed.color,
2066
+ width: textBorderWidth / ratioPx2Pt,
2067
+ dashType: el.style?.borderStyle === 'dashed' ? 'dash' : 'solid'
2304
2068
  }
2305
2069
  }
2306
2070
 
2307
- if (shapeType === 'custGeom') {
2308
- const rawPoints = toPoints(el.content || '')
2309
- const viewBoxWidth = (Array.isArray(el.viewBox) && el.viewBox.length >= 2) ? el.viewBox[0] : pixelWidth
2310
- const viewBoxHeight = (Array.isArray(el.viewBox) && el.viewBox.length >= 2) ? el.viewBox[1] : pixelHeight
2311
- const scale = {
2312
- x: pixelWidth / viewBoxWidth,
2313
- y: viewBoxHeight > 0 ? pixelHeight / viewBoxHeight : 1
2071
+ if ((el.style as any)?.whiteSpace === 'nowrap') {
2072
+ textOpts.wrap = false
2073
+ }
2074
+
2075
+ const elAny = el as any
2076
+ if (elAny.autoFit) {
2077
+ if (elAny.autoFit.type === 'shape') {
2078
+ textOpts.fit = 'resize'
2079
+ } else if (elAny.autoFit.type === 'text') {
2080
+ textOpts.fit = 'shrink'
2314
2081
  }
2315
- shapeOpts.points = formatPoints(rawPoints, scale, pixelToInch) as any
2316
2082
  }
2317
2083
 
2318
- const shapeText = typeof el.text === 'string' ? el.text : ((el as any).text?.content || (typeof el.content === 'string' && !el.content.startsWith('M') && !el.content.startsWith('m') && el.content !== 'circle' && el.content !== 'triangle' && el.content.replace(/<[^>]+>/g, '').trim().length > 0 ? el.content : ''))
2084
+ pptxSlide.addText(textProps, textOpts)
2085
+ }
2086
+ }
2087
+ else if (el.type === 'image') {
2088
+ let imgPath = await resolveMediaSrc(el.content || (el as any).src || '', 'image')
2089
+ if (!imgPath) {
2090
+ imgPath = createFallbackImageBase64('图片缺失')
2091
+ }
2319
2092
 
2320
- if (shapeText) {
2321
- const shapeTextDefaultColor = style.textColor || style.color || (style.fill === '#ffffff' || style.fill === '#FFFFFF' ? '#475569' : '#ffffff')
2322
- const shapeTextDefaultFont = style.fontFamily || (globalFontFamily && globalFontFamily.value) || 'Arial'
2323
- const resolvedShapeFont = (shapeTextDefaultFont !== 'inherit' && shapeTextDefaultFont !== '默认字体') ? shapeTextDefaultFont : 'Microsoft YaHei'
2324
- const shapeTextDefaultSize = Math.max(1, Math.round((parseFloat(style.fontSize || '') || 16) * fontSizeFactor))
2093
+ const imgOpts: pptxgen.ImageProps = {
2094
+ x, y, w, h,
2095
+ rotate, flipH, flipV,
2096
+ objectName: el.id
2097
+ }
2098
+ if (imgPath.startsWith('data:')) {
2099
+ imgOpts.data = imgPath
2100
+ } else {
2101
+ imgOpts.path = imgPath
2102
+ }
2325
2103
 
2326
- const isShapeTextBold = style.fontWeight === 'bold' || style.fontWeight === '700' || (parseInt(style.fontWeight as string) || 0) > 500
2327
- const isShapeTextItalic = style.fontStyle === 'italic'
2328
- const isShapeTextUnderline = !!(style.textDecoration && style.textDecoration.includes('underline'))
2329
- const isShapeTextStrike = !!(style.textDecoration && style.textDecoration.includes('line-through'))
2104
+ const imgShadow = el.shadow || (el.style && (el.style as any).shadow)
2105
+ let parsedImgShadow = convertElShadowToPptx(imgShadow, ratioPx2Pt)
2106
+ if (!parsedImgShadow && el.style?.boxShadow) {
2107
+ parsedImgShadow = parseCssShadow(el.style.boxShadow, ratioPx2Pt)
2108
+ }
2109
+ if (parsedImgShadow) {
2110
+ imgOpts.shadow = parsedImgShadow
2111
+ }
2112
+ const imgRefl = el.reflection || (el.style && el.style.reflection)
2113
+ if (imgRefl) {
2114
+ const parsedReflection = convertElReflectionToPptx(imgRefl, ratioPx2Pt)
2115
+ if (parsedReflection) {
2116
+ imgOpts.reflection = parsedReflection
2117
+ }
2118
+ }
2330
2119
 
2331
- let shapeMargin: [number, number, number, number] = [7.2, 7.2, 3.6, 3.6]
2332
- if (style.padding) {
2333
- const paddingParts = style.padding.trim().split(/\s+/)
2334
- let parsedPadding = [0, 0, 0, 0]
2335
- if (paddingParts.length === 4) {
2336
- parsedPadding = paddingParts.map((p: string) => parseFloat(p) || 0)
2337
- } else if (paddingParts.length === 1) {
2338
- const val = parseFloat(paddingParts[0]) || 0
2339
- parsedPadding = [val, val, val, val]
2120
+ if (el.clip && Array.isArray(el.clip.range) && el.clip.range.length >= 2) {
2121
+ const [start, end] = el.clip.range
2122
+ if (Array.isArray(start) && start.length >= 2 && Array.isArray(end) && end.length >= 2) {
2123
+ const [startX, startY] = start
2124
+ const [endX, endY] = end
2125
+
2126
+ const fractionX = (endX - startX) / 100
2127
+ const fractionY = (endY - startY) / 100
2128
+ if (fractionX > 0 && fractionY > 0) {
2129
+ const originW = w / fractionX
2130
+ const originH = h / fractionY
2131
+
2132
+ imgOpts.w = originW
2133
+ imgOpts.h = originH
2134
+ imgOpts.sizing = {
2135
+ type: 'crop',
2136
+ x: (startX / 100) * originW,
2137
+ y: (startY / 100) * originH,
2138
+ w: w,
2139
+ h: h,
2340
2140
  }
2341
- shapeMargin = [
2342
- parsedPadding[3] / ratioPx2Pt, // left
2343
- parsedPadding[1] / ratioPx2Pt, // right
2344
- parsedPadding[2] / ratioPx2Pt, // bottom
2345
- parsedPadding[0] / ratioPx2Pt // top
2346
- ]
2347
2141
  }
2142
+ }
2143
+ }
2144
+
2145
+ pptxSlide.addImage(imgOpts)
2146
+ }
2147
+ else if (el.type === 'shape') {
2148
+ const shapeType = getPredefinedShapeType(el.content, el.pathFormula, (el as any).pptxShapeType)
2149
+
2150
+ // Set defaults based on shape types to match CanvasArea.vue styles
2151
+ let defaultFill = '#3b82f6'
2152
+ let defaultBorderColor = '#2563eb'
2153
+ let defaultBorderWidth = '2px'
2154
+
2155
+ if (el.content === 'triangle') {
2156
+ defaultFill = '#f59e0b'
2157
+ defaultBorderColor = '#d97706'
2158
+ defaultBorderWidth = '3px'
2159
+ } else if (el.content === 'circle') {
2160
+ defaultFill = '#3b82f6'
2161
+ defaultBorderColor = '#2563eb'
2162
+ defaultBorderWidth = '2px'
2163
+ } else if (typeof el.content === 'string' && (el.content.startsWith('M') || el.content.startsWith('m'))) {
2164
+ defaultFill = '#3b82f6'
2165
+ defaultBorderColor = '#2563eb'
2166
+ defaultBorderWidth = '2px'
2167
+ } else { // rect or default
2168
+ defaultFill = '#10b981'
2169
+ defaultBorderColor = '#059669'
2170
+ defaultBorderWidth = '2px'
2171
+ }
2348
2172
 
2349
- let shapeValign = 'middle'
2350
- const currentShapeValign = el.style?.verticalAlign || (el as any).vAlign
2351
- if (currentShapeValign === 'top' || currentShapeValign === 'up') shapeValign = 'top'
2352
- else if (currentShapeValign === 'bottom' || currentShapeValign === 'down') shapeValign = 'bottom'
2353
- else if (currentShapeValign === 'middle' || currentShapeValign === 'mid') shapeValign = 'middle'
2354
-
2355
- let shapeAlign = style.textAlign || 'center'
2356
-
2357
- let finalLineHeight: number | undefined = undefined
2358
- if (style.lineHeight !== undefined) {
2359
- const val = typeof style.lineHeight === 'number'
2360
- ? style.lineHeight
2361
- : parseFloat(style.lineHeight as string || '')
2362
- if (!isNaN(val)) {
2363
- finalLineHeight = val <= 1.2 ? 1.0 : val
2173
+ const style: any = el.style || {}
2174
+ let shapeFillOpt: any = undefined
2175
+
2176
+ // Helper function to extract element gradient configuration for PPTX export
2177
+ const getElementGradientConfig = (item: any): any => {
2178
+ if (!item) return undefined
2179
+ let gradient = item.gradient || item.style?.gradient
2180
+ if (!gradient) {
2181
+ const rawFill = (item.style && item.style.fill !== undefined) ? item.style.fill : item.fill
2182
+ if (rawFill && typeof rawFill === 'object' && rawFill.type === 'gradient') {
2183
+ const val = rawFill.value || {}
2184
+ gradient = {
2185
+ path: val.path || 'line',
2186
+ type: val.path === 'line' || !val.path ? 'linear' : 'radial',
2187
+ rotate: val.rot !== undefined ? val.rot : (val.rotate !== undefined ? val.rotate : 90),
2188
+ colors: (val.colors || []).map((c: any) => ({
2189
+ color: c.color || c.value || '#3b82f6',
2190
+ pos: c.pos !== undefined ? (typeof c.pos === 'string' ? parseInt(c.pos) : c.pos) : 0
2191
+ }))
2364
2192
  }
2365
2193
  }
2194
+ }
2195
+ return gradient
2196
+ }
2366
2197
 
2367
- let shapeTextShadowObj = (el as any).textShadow || (el.style && (el.style as any).textShadow)
2368
- let parsedShapeTextShadow: any = undefined
2369
- if (shapeTextShadowObj) {
2370
- parsedShapeTextShadow = convertElShadowToPptx(shapeTextShadowObj, ratioPx2Pt)
2371
- }
2198
+ const getElementPatternUrl = (item: any): string | undefined => {
2199
+ if (!item) return undefined
2200
+ if (item.pattern || item.style?.pattern) return item.pattern || item.style?.pattern
2201
+ const rawFill = (item.style && item.style.fill !== undefined) ? item.style.fill : item.fill
2202
+ if (rawFill && typeof rawFill === 'object' && (rawFill.type === 'image' || rawFill.type === 'pattern')) {
2203
+ return rawFill.value?.src || rawFill.value?.url || rawFill.value
2204
+ }
2205
+ return undefined
2206
+ }
2372
2207
 
2373
- let shapeTextReflObj = (el as any).textReflection || (el.style as any)?.textReflection
2374
- let parsedShapeTextReflection: any = undefined
2375
- if (shapeTextReflObj && shapeTextReflObj.enabled !== false) {
2376
- parsedShapeTextReflection = convertElReflectionToPptx(shapeTextReflObj, ratioPx2Pt)
2208
+ const patternUrl = getElementPatternUrl(el)
2209
+ const g = getElementGradientConfig(el)
2210
+
2211
+ // SVG 图片填充 PowerPoint 无法可靠渲染 a:blipFill,先按形状显示尺寸栅格化为 PNG
2212
+ // (画布上图案填充本身是 preserveAspectRatio="none" 拉伸铺满,与 a:stretch 语义一致)
2213
+ const rasterizeSvgFill = (svgUrl: string, widthPx: number, heightPx: number): Promise<string | undefined> => {
2214
+ return new Promise((resolve) => {
2215
+ const img = new Image()
2216
+ img.onload = () => {
2217
+ try {
2218
+ const canvas = document.createElement('canvas')
2219
+ canvas.width = Math.max(1, Math.min(4096, Math.round(widthPx)))
2220
+ canvas.height = Math.max(1, Math.min(4096, Math.round(heightPx)))
2221
+ const ctx = canvas.getContext('2d')
2222
+ if (!ctx) return resolve(undefined)
2223
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
2224
+ resolve(canvas.toDataURL('image/png'))
2225
+ } catch {
2226
+ resolve(undefined)
2227
+ }
2377
2228
  }
2229
+ img.onerror = () => resolve(undefined)
2230
+ img.src = svgUrl
2231
+ })
2232
+ }
2378
2233
 
2379
- const textProps = formatHTMLToPPTX(shapeText, {
2380
- fontSize: shapeTextDefaultSize,
2381
- color: shapeTextDefaultColor,
2382
- fontFamily: resolvedShapeFont,
2383
- bold: isShapeTextBold,
2384
- italic: isShapeTextItalic,
2385
- underline: isShapeTextUnderline,
2386
- strike: isShapeTextStrike,
2387
- align: shapeAlign,
2388
- shadow: parsedShapeTextShadow,
2389
- reflection: parsedShapeTextReflection
2390
- })
2391
- const shapeTextOpts: any = {
2392
- x, y, w, h,
2393
- shape: shapeType,
2394
- valign: shapeValign as any,
2395
- align: shapeAlign as any,
2396
- margin: shapeMargin,
2397
- rotate, flipH, flipV,
2398
- objectName: el.id,
2399
- fill: nativeFillOpt || shapeFillOpt
2400
- }
2401
- if (shapeOpts.points) {
2402
- shapeTextOpts.points = shapeOpts.points
2403
- }
2404
- if (shapeOpts.line) {
2405
- shapeTextOpts.line = shapeOpts.line
2406
- }
2407
- if (shapeOpts.shadow) {
2408
- shapeTextOpts.shadow = shapeOpts.shadow
2409
- }
2410
- if (shapeOpts.reflection) {
2411
- shapeTextOpts.reflection = shapeOpts.reflection
2412
- }
2413
- if (finalLineHeight !== undefined) {
2414
- shapeTextOpts.lineSpacingMultiple = finalLineHeight
2234
+ const buildNativeShapeFill = async (): Promise<any> => {
2235
+ // 图片填充 → 原生 a:blipFill
2236
+ if (patternUrl) {
2237
+ const pUrl = await resolveMediaSrc(patternUrl, 'image')
2238
+ if (pUrl && /^data:image\/svg\+xml/i.test(pUrl)) {
2239
+ const pngUrl = await rasterizeSvgFill(pUrl, pixelWidth, pixelHeight)
2240
+ return pngUrl ? { type: 'image', data: pngUrl } : undefined
2415
2241
  }
2416
- if (style.whiteSpace === 'nowrap') {
2417
- shapeTextOpts.wrap = false
2242
+ if (pUrl && pUrl.startsWith('data:')) {
2243
+ return { type: 'image', data: pUrl }
2418
2244
  }
2419
-
2420
- const elAny = el as any
2421
- if (elAny.autoFit) {
2422
- if (elAny.autoFit.type === 'shape') {
2423
- shapeTextOpts.fit = 'resize'
2424
- } else if (elAny.autoFit.type === 'text') {
2425
- shapeTextOpts.fit = 'shrink'
2426
- }
2245
+ if (pUrl && /^https?:\/\//.test(pUrl)) {
2246
+ return { type: 'image', path: pUrl }
2427
2247
  }
2428
-
2429
- pptxSlide.addText(textProps, shapeTextOpts)
2430
- } else {
2431
- pptxSlide.addShape(shapeType as any, shapeOpts)
2248
+ return undefined // 图片解析失败,回落纯色
2432
2249
  }
2433
- }
2434
- else if (el.type === 'chart') {
2435
- const chartData = el.data?.datasets.map((ds: any, idx: number) => ({
2436
- name: ds.label || `Series ${idx + 1}`,
2437
- labels: el.data?.labels || [],
2438
- values: ds.data || []
2439
- })) || []
2440
-
2441
- let rawType = (el.chartType as string) || 'bar'
2442
- if (rawType === 'doughnutChart' || rawType === 'ring') rawType = 'donut'
2443
- if (rawType === 'pieChart' || rawType === 'pie3DChart') rawType = 'pie'
2444
- if (rawType === 'barChart' || rawType === 'bar3DChart') rawType = 'bar'
2445
- if (rawType === 'lineChart' || rawType === 'line3DChart') rawType = 'line'
2446
- if (rawType === 'areaChart' || rawType === 'area3DChart') rawType = 'area'
2447
-
2448
- let chartType = pptx.ChartType.bar
2449
- if (rawType === 'line') {
2450
- chartType = pptx.ChartType.line
2451
- } else if (rawType === 'area') {
2452
- chartType = pptx.ChartType.area
2453
- } else if (rawType === 'scatter') {
2454
- chartType = pptx.ChartType.scatter
2455
- } else if (rawType === 'pie') {
2456
- chartType = pptx.ChartType.pie
2457
- } else if (rawType === 'donut') {
2458
- chartType = pptx.ChartType.doughnut
2459
- } else if (rawType === 'radar') {
2460
- chartType = pptx.ChartType.radar
2461
- }
2462
-
2463
- // 1. Resolve colors
2464
- let colorsList: string[] = []
2465
- if (Array.isArray(el.style?.chartColors) && el.style.chartColors.length > 0) {
2466
- colorsList = el.style.chartColors
2467
- } else if (Array.isArray((el as any).themeColors) && (el as any).themeColors.length > 0) {
2468
- colorsList = (el as any).themeColors
2469
- } else if (Array.isArray((el as any).colors) && (el as any).colors.length > 0) {
2470
- colorsList = (el as any).colors
2471
- } else if (Array.isArray(el.data?.datasets?.[0]?.backgroundColor) && el.data.datasets[0].backgroundColor.length > 0) {
2472
- colorsList = el.data.datasets[0].backgroundColor
2473
- } else if (Array.isArray(el.data?.datasets?.[0]?.colors) && el.data.datasets[0].colors.length > 0) {
2474
- colorsList = el.data.datasets[0].colors
2475
- } else {
2476
- const dsColors = (el.data?.datasets || []).map((ds: any) => ds.color || ds.backgroundColor).filter((c: any) => c && typeof c === 'string')
2477
- if (dsColors.length > 0) {
2478
- colorsList = dsColors
2479
- } else {
2480
- colorsList = (globalThemeColor && globalThemeColor.value && globalThemeColor.value.length > 0)
2481
- ? globalThemeColor.value
2482
- : ['#4f46e5', '#06b6d4', '#f59e0b', '#10b981', '#ec4899', '#8b5cf6', '#ef4444', '#14b8a6']
2250
+ // 渐变填充 → 原生 a:gradFill
2251
+ if (g && Array.isArray(g.colors) && g.colors.length >= 2) {
2252
+ return {
2253
+ type: 'gradient',
2254
+ stops: g.colors.map((c: any) => ({
2255
+ color: String(c.color || '#3b82f6').replace('#', ''),
2256
+ pos: typeof c.pos === 'number' && !isNaN(c.pos) ? c.pos : 0
2257
+ })),
2258
+ angle: g.rotate !== undefined ? g.rotate : (g.rot !== undefined ? g.rot : 90),
2259
+ radial: g.type === 'radial' || g.path === 'circle'
2483
2260
  }
2484
2261
  }
2485
- const cleanHexColors = colorsList
2486
- .filter((c: any) => c && typeof c === 'string')
2487
- .map((c: string) => parseColor(c).color)
2488
-
2489
- // 2. Legend visibility and position
2490
- const isPieOrDonut = chartType === pptx.ChartType.doughnut || chartType === pptx.ChartType.pie
2491
- let showLegend = false
2492
- if (el.showLegend !== undefined) {
2493
- showLegend = Boolean(el.showLegend)
2494
- } else if ((el as any).options?.showLegend !== undefined) {
2495
- showLegend = Boolean((el as any).options.showLegend)
2496
- } else {
2497
- showLegend = !isPieOrDonut
2498
- }
2262
+ return undefined
2263
+ }
2264
+ const nativeFillOpt = await buildNativeShapeFill()
2499
2265
 
2500
- const chartFont = el.style?.fontFamily || el.fontFamily || (globalFontFamily && globalFontFamily.value) || 'Microsoft YaHei'
2501
- const textColorHex = parseColor(el.style?.color || el.style?.textColor || el.textColor || '#64748b').color
2266
+ const rawFill = style.fill !== undefined ? style.fill : (el.fill !== undefined ? el.fill : defaultFill)
2267
+ if (rawFill === 'transparent' || rawFill === 'none') {
2268
+ shapeFillOpt = { type: 'none' }
2269
+ } else {
2270
+ const fillParsed = parseColor(rawFill)
2271
+ shapeFillOpt = { color: fillParsed.color, transparency: fillParsed.transparency }
2272
+ }
2502
2273
 
2503
- const chartOpts: pptxgen.IChartOpts = {
2504
- x, y, w, h,
2505
- showLegend,
2506
- chartColors: cleanHexColors.length > 0 ? cleanHexColors : undefined,
2507
- objectName: el.id
2508
- }
2274
+ const shapeOpts: pptxgen.ShapeProps = {
2275
+ x, y, w, h,
2276
+ fill: nativeFillOpt || shapeFillOpt,
2277
+ rotate, flipH, flipV,
2278
+ objectName: el.id
2279
+ }
2509
2280
 
2510
- if (showLegend) {
2511
- let legendPos: 'b' | 'l' | 'r' | 't' = 'b'
2512
- const rawPos = (el.legendPosition || (el as any).options?.legendPosition || 'bottom').toLowerCase()
2513
- if (rawPos.startsWith('top')) legendPos = 't'
2514
- else if (rawPos.startsWith('left')) legendPos = 'l'
2515
- else if (rawPos.startsWith('right')) legendPos = 'r'
2516
- else if (rawPos.startsWith('bottom')) legendPos = 'b'
2517
- chartOpts.legendPos = legendPos
2518
- chartOpts.legendColor = textColorHex
2519
- chartOpts.legendFontSize = 10
2520
- chartOpts.legendFontFace = chartFont
2281
+ const borderWidth = style.borderWidth ? (parseFloat(style.borderWidth) || 0) : 0
2282
+ const borderColorStr = style.borderColor
2283
+ if (borderWidth > 0 && borderColorStr && borderColorStr !== 'transparent' && borderColorStr !== 'none') {
2284
+ const borderParsed = parseColor(borderColorStr)
2285
+ shapeOpts.line = {
2286
+ color: borderParsed.color,
2287
+ width: borderWidth / ratioPx2Pt,
2288
+ dashType: style.borderStyle === 'dashed' ? 'dash' : 'solid'
2521
2289
  }
2290
+ }
2522
2291
 
2523
- // 3. Bar direction
2524
- if (rawType === 'horizontal-bar') {
2525
- chartOpts.barDir = 'bar'
2526
- } else if (rawType === 'bar') {
2527
- chartOpts.barDir = 'col'
2528
- }
2292
+ const shapeShadow = el.shadow || style.shadow
2293
+ let parsedShadow: any = convertElShadowToPptx(shapeShadow, ratioPx2Pt)
2294
+ if (!parsedShadow && style.boxShadow) {
2295
+ parsedShadow = parseCssShadow(style.boxShadow, ratioPx2Pt)
2296
+ }
2297
+ if (parsedShadow) {
2298
+ shapeOpts.shadow = parsedShadow
2299
+ }
2529
2300
 
2530
- // 4. Donut specific options (hole size)
2531
- if (chartType === pptx.ChartType.doughnut) {
2532
- const rawHole = parseFloat((el as any).holeSize || (el as any).options?.holeSize)
2533
- const holeSize = (!isNaN(rawHole) && rawHole > 0 && rawHole < 100) ? Math.round(rawHole) : 72
2534
- chartOpts.holeSize = holeSize
2301
+ const shapeRefl = el.reflection || style.reflection
2302
+ if (shapeRefl) {
2303
+ const parsedReflection = convertElReflectionToPptx(shapeRefl, ratioPx2Pt)
2304
+ if (parsedReflection) {
2305
+ shapeOpts.reflection = parsedReflection
2535
2306
  }
2307
+ }
2536
2308
 
2537
- // 5. Slices border
2538
- if (isPieOrDonut) {
2539
- chartOpts.dataBorder = {
2540
- pt: 1.5,
2541
- color: 'FFFFFF'
2542
- }
2309
+ if (shapeType === 'custGeom') {
2310
+ const rawPoints = toPoints(el.content || '')
2311
+ const viewBoxWidth = (Array.isArray(el.viewBox) && el.viewBox.length >= 2) ? el.viewBox[0] : pixelWidth
2312
+ const viewBoxHeight = (Array.isArray(el.viewBox) && el.viewBox.length >= 2) ? el.viewBox[1] : pixelHeight
2313
+ const scale = {
2314
+ x: pixelWidth / viewBoxWidth,
2315
+ y: viewBoxHeight > 0 ? pixelHeight / viewBoxHeight : 1
2543
2316
  }
2317
+ shapeOpts.points = formatPoints(rawPoints, scale, pixelToInch) as any
2318
+ }
2544
2319
 
2545
- // 6. Data labels & Percent
2546
- const hasPercentOpt = (el as any).showPercent !== undefined
2547
- ? Boolean((el as any).showPercent)
2548
- : (el as any).options?.showPercent !== undefined
2549
- ? Boolean((el as any).options.showPercent)
2550
- : isPieOrDonut
2551
-
2552
- const showDataLabels = el.showDataLabels !== undefined
2553
- ? Boolean(el.showDataLabels)
2554
- : (el as any).options?.showDataLabels !== undefined
2555
- ? Boolean((el as any).options.showDataLabels)
2556
- : isPieOrDonut
2557
-
2558
- if (isPieOrDonut) {
2559
- if (hasPercentOpt || showDataLabels) {
2560
- chartOpts.showPercent = true
2561
- chartOpts.showValue = false
2562
- chartOpts.dataLabelColor = 'FFFFFF'
2563
- chartOpts.dataLabelFontSize = 11
2564
- chartOpts.dataLabelFontBold = true
2565
- chartOpts.dataLabelFormatCode = '0%'
2566
- chartOpts.dataLabelFontFace = chartFont
2567
- } else {
2568
- chartOpts.showPercent = false
2569
- chartOpts.showValue = false
2570
- }
2571
- } else {
2572
- if (showDataLabels || (el as any).showValue) {
2573
- chartOpts.showValue = true
2574
- chartOpts.dataLabelColor = textColorHex
2575
- chartOpts.dataLabelFontSize = 10
2576
- chartOpts.dataLabelFontFace = chartFont
2320
+ const shapeText = typeof el.text === 'string' ? el.text : ((el as any).text?.content || (typeof el.content === 'string' && !el.content.startsWith('M') && !el.content.startsWith('m') && el.content !== 'circle' && el.content !== 'triangle' && el.content.replace(/<[^>]+>/g, '').trim().length > 0 ? el.content : ''))
2321
+
2322
+ if (shapeText) {
2323
+ const shapeTextDefaultColor = style.textColor || style.color || (style.fill === '#ffffff' || style.fill === '#FFFFFF' ? '#475569' : '#ffffff')
2324
+ const shapeTextDefaultFont = style.fontFamily || (globalFontFamily && globalFontFamily.value) || 'Arial'
2325
+ const resolvedShapeFont = (shapeTextDefaultFont !== 'inherit' && shapeTextDefaultFont !== '默认字体') ? shapeTextDefaultFont : 'Microsoft YaHei'
2326
+ const shapeTextDefaultSize = Math.max(1, Math.round((parseFloat(style.fontSize || '') || 16) * fontSizeFactor))
2327
+
2328
+ const isShapeTextBold = style.fontWeight === 'bold' || style.fontWeight === '700' || (parseInt(style.fontWeight as string) || 0) > 500
2329
+ const isShapeTextItalic = style.fontStyle === 'italic'
2330
+ const isShapeTextUnderline = !!(style.textDecoration && style.textDecoration.includes('underline'))
2331
+ const isShapeTextStrike = !!(style.textDecoration && style.textDecoration.includes('line-through'))
2332
+
2333
+ let shapeMargin: [number, number, number, number] = [7.2, 7.2, 3.6, 3.6]
2334
+ if (style.padding) {
2335
+ const paddingParts = style.padding.trim().split(/\s+/)
2336
+ let parsedPadding = [0, 0, 0, 0]
2337
+ if (paddingParts.length === 4) {
2338
+ parsedPadding = paddingParts.map((p: string) => parseFloat(p) || 0)
2339
+ } else if (paddingParts.length === 1) {
2340
+ const val = parseFloat(paddingParts[0]) || 0
2341
+ parsedPadding = [val, val, val, val]
2577
2342
  }
2343
+ shapeMargin = [
2344
+ parsedPadding[3] / ratioPx2Pt, // left
2345
+ parsedPadding[1] / ratioPx2Pt, // right
2346
+ parsedPadding[2] / ratioPx2Pt, // bottom
2347
+ parsedPadding[0] / ratioPx2Pt // top
2348
+ ]
2578
2349
  }
2579
2350
 
2580
- // 7. Cartesian axes & grid lines
2581
- if (!isPieOrDonut && chartType !== pptx.ChartType.radar) {
2582
- const gridHex = parseColor(el.style?.gridColor || el.gridColor || '#e2e8f0').color
2583
- const showGrid = el.showGridlines !== false
2584
- const showAxes = el.showAxes !== false
2351
+ let shapeValign = 'middle'
2352
+ const currentShapeValign = el.style?.verticalAlign || (el as any).vAlign
2353
+ if (currentShapeValign === 'top' || currentShapeValign === 'up') shapeValign = 'top'
2354
+ else if (currentShapeValign === 'bottom' || currentShapeValign === 'down') shapeValign = 'bottom'
2355
+ else if (currentShapeValign === 'middle' || currentShapeValign === 'mid') shapeValign = 'middle'
2585
2356
 
2586
- chartOpts.valGridLine = showGrid ? { color: gridHex, size: 0.75, style: 'dash' } : { style: 'none' }
2587
- chartOpts.catGridLine = { style: 'none' }
2357
+ let shapeAlign = style.textAlign || 'center'
2588
2358
 
2589
- chartOpts.catAxisLabelColor = textColorHex
2590
- chartOpts.valAxisLabelColor = textColorHex
2591
- chartOpts.catAxisHidden = !showAxes
2592
- chartOpts.valAxisHidden = !showAxes
2359
+ let finalLineHeight: number | undefined = undefined
2360
+ if (style.lineHeight !== undefined) {
2361
+ const val = typeof style.lineHeight === 'number'
2362
+ ? style.lineHeight
2363
+ : parseFloat(style.lineHeight as string || '')
2364
+ if (!isNaN(val)) {
2365
+ finalLineHeight = val <= 1.2 ? 1.0 : val
2366
+ }
2593
2367
  }
2594
2368
 
2595
- // 8. Line chart specific
2596
- if (chartType === pptx.ChartType.line) {
2597
- chartOpts.lineSize = 2.5
2598
- chartOpts.lineSmooth = true
2369
+ let shapeTextShadowObj = (el as any).textShadow || (el.style && (el.style as any).textShadow)
2370
+ let parsedShapeTextShadow: any = undefined
2371
+ if (shapeTextShadowObj) {
2372
+ parsedShapeTextShadow = convertElShadowToPptx(shapeTextShadowObj, ratioPx2Pt)
2599
2373
  }
2600
2374
 
2601
- pptxSlide.addChart(chartType, chartData, chartOpts)
2602
- }
2603
- else if (el.type === 'line') {
2604
- const mapArrowToPPTX = (uiType: string | undefined) => {
2605
- if (!uiType) return 'none'
2606
- const t = uiType.toLowerCase()
2607
- if (t === 'arrow') return 'triangle'
2608
- if (t === 'open-arrow') return 'arrow'
2609
- if (t === 'stealth-arrow') return 'stealth'
2610
- if (t === 'dot') return 'oval'
2611
- if (t === 'diamond') return 'diamond'
2612
- return 'none'
2613
- }
2614
- const beginArrow = mapArrowToPPTX(el.points?.[0])
2615
- const endArrow = mapArrowToPPTX(el.points?.[1])
2616
-
2617
- const path = getLineElementPath(el)
2618
- const rawPoints = toPoints(path)
2619
- const points = formatPoints(rawPoints, { x: 1, y: 1 }, pixelToInch) as any
2620
-
2621
- const lineOpts: any = {
2375
+ let shapeTextReflObj = (el as any).textReflection || (el.style as any)?.textReflection
2376
+ let parsedShapeTextReflection: any = undefined
2377
+ if (shapeTextReflObj && shapeTextReflObj.enabled !== false) {
2378
+ parsedShapeTextReflection = convertElReflectionToPptx(shapeTextReflObj, ratioPx2Pt)
2379
+ }
2380
+
2381
+ const textProps = formatHTMLToPPTX(shapeText, {
2382
+ fontSize: shapeTextDefaultSize,
2383
+ color: shapeTextDefaultColor,
2384
+ fontFamily: resolvedShapeFont,
2385
+ bold: isShapeTextBold,
2386
+ italic: isShapeTextItalic,
2387
+ underline: isShapeTextUnderline,
2388
+ strike: isShapeTextStrike,
2389
+ align: shapeAlign,
2390
+ shadow: parsedShapeTextShadow,
2391
+ reflection: parsedShapeTextReflection
2392
+ })
2393
+ const shapeTextOpts: any = {
2622
2394
  x, y, w, h,
2623
- line: {
2624
- color: parseColor(el.style?.color || '#475569').color,
2625
- width: ((el.style?.strokeWidth || 1) as number) / ratioPx2Pt,
2626
- dashType: el.lineStyle === 'dashed' ? 'dash' : 'solid',
2627
- beginArrowType: beginArrow as any,
2628
- endArrowType: endArrow as any
2629
- },
2630
- points,
2395
+ shape: shapeType,
2396
+ valign: shapeValign as any,
2397
+ align: shapeAlign as any,
2398
+ margin: shapeMargin,
2631
2399
  rotate, flipH, flipV,
2632
- objectName: el.id
2400
+ objectName: el.id,
2401
+ fill: nativeFillOpt || shapeFillOpt
2633
2402
  }
2634
- const lineShadow = el.shadow || (el.style as any)?.shadow
2635
- let parsedLineShadow = convertElShadowToPptx(lineShadow, ratioPx2Pt)
2636
- if (!parsedLineShadow && el.style?.boxShadow) {
2637
- parsedLineShadow = parseCssShadow(el.style.boxShadow, ratioPx2Pt)
2403
+ if (shapeOpts.points) {
2404
+ shapeTextOpts.points = shapeOpts.points
2638
2405
  }
2639
- if (parsedLineShadow) lineOpts.shadow = parsedLineShadow
2640
- const lineRefl = el.reflection || el.style?.reflection
2641
- if (lineRefl) {
2642
- const parsedReflection = convertElReflectionToPptx(lineRefl, ratioPx2Pt)
2643
- if (parsedReflection) lineOpts.reflection = parsedReflection
2406
+ if (shapeOpts.line) {
2407
+ shapeTextOpts.line = shapeOpts.line
2644
2408
  }
2645
- pptxSlide.addShape('custGeom' as any, lineOpts)
2646
- }
2647
- else if (el.type === 'audio' || el.type === 'video') {
2648
- const mediaInput = (el as any).blob || (el as any).src || el.content || (el as any).value || ''
2649
- let mediaPath = ''
2650
- if (typeof Blob !== 'undefined' && mediaInput instanceof Blob) {
2651
- try {
2652
- mediaPath = await blobToDataUrl(mediaInput)
2653
- } catch (e) {
2654
- console.warn('Converting media Blob to DataURL failed:', e)
2655
- }
2409
+ if (shapeOpts.shadow) {
2410
+ shapeTextOpts.shadow = shapeOpts.shadow
2411
+ }
2412
+ if (shapeOpts.reflection) {
2413
+ shapeTextOpts.reflection = shapeOpts.reflection
2414
+ }
2415
+ if (finalLineHeight !== undefined) {
2416
+ shapeTextOpts.lineSpacingMultiple = finalLineHeight
2417
+ }
2418
+ if (style.whiteSpace === 'nowrap') {
2419
+ shapeTextOpts.wrap = false
2656
2420
  }
2657
- if (!mediaPath) {
2658
- mediaPath = await resolveMediaSrc(mediaInput, el.type as 'video' | 'audio')
2659
- }
2660
- if (mediaPath && mediaPath.startsWith('data:')) {
2661
- let extn = el.type === 'audio' ? 'mp3' : 'mp4'
2662
- if (mediaPath.startsWith('data:audio/')) {
2663
- if (mediaPath.includes('audio/wav')) extn = 'wav'
2664
- else if (mediaPath.includes('audio/m4a') || mediaPath.includes('audio/x-m4a') || mediaPath.includes('audio/mp4')) extn = 'm4a'
2665
- else if (mediaPath.includes('audio/ogg')) extn = 'ogg'
2666
- else extn = 'mp3'
2667
- } else if (mediaPath.startsWith('data:video/')) {
2668
- if (mediaPath.includes('video/webm')) extn = 'webm'
2669
- else if (mediaPath.includes('video/ogg')) extn = 'ogg'
2670
- else extn = 'mp4'
2421
+
2422
+ const elAny = el as any
2423
+ if (elAny.autoFit) {
2424
+ if (elAny.autoFit.type === 'shape') {
2425
+ shapeTextOpts.fit = 'resize'
2426
+ } else if (elAny.autoFit.type === 'text') {
2427
+ shapeTextOpts.fit = 'shrink'
2671
2428
  }
2429
+ }
2672
2430
 
2673
- pptxSlide.addMedia({
2674
- type: el.type,
2675
- data: mediaPath,
2676
- extn,
2677
- x, y, w, h,
2678
- objectName: el.id
2679
- })
2431
+ pptxSlide.addText(textProps, shapeTextOpts)
2432
+ } else {
2433
+ pptxSlide.addShape(shapeType as any, shapeOpts)
2434
+ }
2435
+ }
2436
+ else if (el.type === 'chart') {
2437
+ const chartData = el.data?.datasets.map((ds: any, idx: number) => ({
2438
+ name: ds.label || `Series ${idx + 1}`,
2439
+ labels: el.data?.labels || [],
2440
+ values: ds.data || []
2441
+ })) || []
2442
+
2443
+ let rawType = (el.chartType as string) || 'bar'
2444
+ if (rawType === 'doughnutChart' || rawType === 'ring') rawType = 'donut'
2445
+ if (rawType === 'pieChart' || rawType === 'pie3DChart') rawType = 'pie'
2446
+ if (rawType === 'barChart' || rawType === 'bar3DChart') rawType = 'bar'
2447
+ if (rawType === 'lineChart' || rawType === 'line3DChart') rawType = 'line'
2448
+ if (rawType === 'areaChart' || rawType === 'area3DChart') rawType = 'area'
2449
+
2450
+ let chartType = pptx.ChartType.bar
2451
+ if (rawType === 'line') {
2452
+ chartType = pptx.ChartType.line
2453
+ } else if (rawType === 'area') {
2454
+ chartType = pptx.ChartType.area
2455
+ } else if (rawType === 'scatter') {
2456
+ chartType = pptx.ChartType.scatter
2457
+ } else if (rawType === 'pie') {
2458
+ chartType = pptx.ChartType.pie
2459
+ } else if (rawType === 'donut') {
2460
+ chartType = pptx.ChartType.doughnut
2461
+ } else if (rawType === 'radar') {
2462
+ chartType = pptx.ChartType.radar
2463
+ }
2464
+
2465
+ // 1. Resolve colors
2466
+ let colorsList: string[] = []
2467
+ if (Array.isArray(el.style?.chartColors) && el.style.chartColors.length > 0) {
2468
+ colorsList = el.style.chartColors
2469
+ } else if (Array.isArray((el as any).themeColors) && (el as any).themeColors.length > 0) {
2470
+ colorsList = (el as any).themeColors
2471
+ } else if (Array.isArray((el as any).colors) && (el as any).colors.length > 0) {
2472
+ colorsList = (el as any).colors
2473
+ } else if (Array.isArray(el.data?.datasets?.[0]?.backgroundColor) && el.data.datasets[0].backgroundColor.length > 0) {
2474
+ colorsList = el.data.datasets[0].backgroundColor
2475
+ } else if (Array.isArray(el.data?.datasets?.[0]?.colors) && el.data.datasets[0].colors.length > 0) {
2476
+ colorsList = el.data.datasets[0].colors
2477
+ } else {
2478
+ const dsColors = (el.data?.datasets || []).map((ds: any) => ds.color || ds.backgroundColor).filter((c: any) => c && typeof c === 'string')
2479
+ if (dsColors.length > 0) {
2480
+ colorsList = dsColors
2680
2481
  } else {
2681
- // 无法获取或跨域拦截二进制数据时的防护兜底:
2682
- // 严禁调用带 path 的 addMedia,否则 pptxgenjs 会因为 XHR 失败向媒体文件写入损坏的图片数据,导致导出的 PPTX 无法打开!
2683
- // 生成规范的优雅多媒体占位卡片并挂载在线超链接,确保 PPT 100% 正常打开且可点击跳转播放
2684
- const isVideo = el.type === 'video'
2685
- const mediaTitle = isVideo ? '视频' : '音频'
2686
- const displayUrl = typeof mediaInput === 'string' ? mediaInput : ''
2687
-
2688
- pptxSlide.addShape('rect' as any, {
2689
- x, y, w, h,
2690
- fill: { color: isVideo ? '1E293B' : '0F172A' },
2691
- line: { color: '3B82F6', width: 1.5, dashType: 'dash' },
2692
- rectRadius: 0.08,
2693
- hyperlink: displayUrl ? { url: displayUrl, tooltip: `点击在线打开${mediaTitle}` } : undefined,
2694
- objectName: el.id
2695
- })
2482
+ colorsList = (globalThemeColor && globalThemeColor.value && globalThemeColor.value.length > 0)
2483
+ ? globalThemeColor.value
2484
+ : ['#4f46e5', '#06b6d4', '#f59e0b', '#10b981', '#ec4899', '#8b5cf6', '#ef4444', '#14b8a6']
2485
+ }
2486
+ }
2487
+ const cleanHexColors = colorsList
2488
+ .filter((c: any) => c && typeof c === 'string')
2489
+ .map((c: string) => parseColor(c).color)
2490
+
2491
+ // 2. Legend visibility and position
2492
+ const isPieOrDonut = chartType === pptx.ChartType.doughnut || chartType === pptx.ChartType.pie
2493
+ let showLegend = false
2494
+ if (el.showLegend !== undefined) {
2495
+ showLegend = Boolean(el.showLegend)
2496
+ } else if ((el as any).options?.showLegend !== undefined) {
2497
+ showLegend = Boolean((el as any).options.showLegend)
2498
+ } else {
2499
+ showLegend = !isPieOrDonut
2500
+ }
2696
2501
 
2697
- pptxSlide.addText([
2698
- { text: isVideo ? '▶ 视频多媒体' : '🎵 音频多媒体', options: { bold: true, fontSize: 13, color: 'FFFFFF' } },
2699
- { text: displayUrl ? `\n(在线链接: ${displayUrl.length > 36 ? displayUrl.slice(0, 36) + '...' : displayUrl})` : '', options: { fontSize: 9, color: '94A3B8' } },
2700
- { text: '\n[点击卡片在浏览器中播放]', options: { fontSize: 9, color: '60A5FA' } }
2701
- ], {
2702
- x, y, w, h,
2703
- align: 'center',
2704
- valign: 'middle',
2705
- hyperlink: displayUrl ? { url: displayUrl, tooltip: `点击在线打开${mediaTitle}` } : undefined
2706
- })
2502
+ const chartFont = el.style?.fontFamily || el.fontFamily || (globalFontFamily && globalFontFamily.value) || 'Microsoft YaHei'
2503
+ const textColorHex = parseColor(el.style?.color || el.style?.textColor || el.textColor || '#64748b').color
2504
+
2505
+ const chartOpts: pptxgen.IChartOpts = {
2506
+ x, y, w, h,
2507
+ showLegend,
2508
+ chartColors: cleanHexColors.length > 0 ? cleanHexColors : undefined,
2509
+ objectName: el.id
2510
+ }
2511
+
2512
+ if (showLegend) {
2513
+ let legendPos: 'b' | 'l' | 'r' | 't' = 'b'
2514
+ const rawPos = (el.legendPosition || (el as any).options?.legendPosition || 'bottom').toLowerCase()
2515
+ if (rawPos.startsWith('top')) legendPos = 't'
2516
+ else if (rawPos.startsWith('left')) legendPos = 'l'
2517
+ else if (rawPos.startsWith('right')) legendPos = 'r'
2518
+ else if (rawPos.startsWith('bottom')) legendPos = 'b'
2519
+ chartOpts.legendPos = legendPos
2520
+ chartOpts.legendColor = textColorHex
2521
+ chartOpts.legendFontSize = 10
2522
+ chartOpts.legendFontFace = chartFont
2523
+ }
2524
+
2525
+ // 3. Bar direction
2526
+ if (rawType === 'horizontal-bar') {
2527
+ chartOpts.barDir = 'bar'
2528
+ } else if (rawType === 'bar') {
2529
+ chartOpts.barDir = 'col'
2530
+ }
2531
+
2532
+ // 4. Donut specific options (hole size)
2533
+ if (chartType === pptx.ChartType.doughnut) {
2534
+ const rawHole = parseFloat((el as any).holeSize || (el as any).options?.holeSize)
2535
+ const holeSize = (!isNaN(rawHole) && rawHole > 0 && rawHole < 100) ? Math.round(rawHole) : 72
2536
+ chartOpts.holeSize = holeSize
2537
+ }
2538
+
2539
+ // 5. Slices border
2540
+ if (isPieOrDonut) {
2541
+ chartOpts.dataBorder = {
2542
+ pt: 1.5,
2543
+ color: 'FFFFFF'
2707
2544
  }
2708
2545
  }
2709
- else if (el.type === 'table') {
2710
- const res = (el.content && (el.content.includes('<table') || el.content.includes('&lt;table')))
2711
- ? parseHTMLTable(el.content, w)
2712
- : parseTableElementData(el, w)
2713
- if (res.tableData && res.tableData.length > 0) {
2714
- const tableOpts: any = { x, y, w, h, objectName: el.id }
2715
- if (res.colW && res.colW.length > 0) {
2716
- tableOpts.colW = res.colW
2717
- }
2718
- pptxSlide.addTable(res.tableData, tableOpts)
2546
+
2547
+ // 6. Data labels & Percent
2548
+ const hasPercentOpt = (el as any).showPercent !== undefined
2549
+ ? Boolean((el as any).showPercent)
2550
+ : (el as any).options?.showPercent !== undefined
2551
+ ? Boolean((el as any).options.showPercent)
2552
+ : isPieOrDonut
2553
+
2554
+ const showDataLabels = el.showDataLabels !== undefined
2555
+ ? Boolean(el.showDataLabels)
2556
+ : (el as any).options?.showDataLabels !== undefined
2557
+ ? Boolean((el as any).options.showDataLabels)
2558
+ : isPieOrDonut
2559
+
2560
+ if (isPieOrDonut) {
2561
+ if (hasPercentOpt || showDataLabels) {
2562
+ chartOpts.showPercent = true
2563
+ chartOpts.showValue = false
2564
+ chartOpts.dataLabelColor = 'FFFFFF'
2565
+ chartOpts.dataLabelFontSize = 11
2566
+ chartOpts.dataLabelFontBold = true
2567
+ chartOpts.dataLabelFormatCode = '0%'
2568
+ chartOpts.dataLabelFontFace = chartFont
2569
+ } else {
2570
+ chartOpts.showPercent = false
2571
+ chartOpts.showValue = false
2572
+ }
2573
+ } else {
2574
+ if (showDataLabels || (el as any).showValue) {
2575
+ chartOpts.showValue = true
2576
+ chartOpts.dataLabelColor = textColorHex
2577
+ chartOpts.dataLabelFontSize = 10
2578
+ chartOpts.dataLabelFontFace = chartFont
2719
2579
  }
2720
2580
  }
2581
+
2582
+ // 7. Cartesian axes & grid lines
2583
+ if (!isPieOrDonut && chartType !== pptx.ChartType.radar) {
2584
+ const gridHex = parseColor(el.style?.gridColor || el.gridColor || '#e2e8f0').color
2585
+ const showGrid = el.showGridlines !== false
2586
+ const showAxes = el.showAxes !== false
2587
+
2588
+ chartOpts.valGridLine = showGrid ? { color: gridHex, size: 0.75, style: 'dash' } : { style: 'none' }
2589
+ chartOpts.catGridLine = { style: 'none' }
2590
+
2591
+ chartOpts.catAxisLabelColor = textColorHex
2592
+ chartOpts.valAxisLabelColor = textColorHex
2593
+ chartOpts.catAxisHidden = !showAxes
2594
+ chartOpts.valAxisHidden = !showAxes
2595
+ }
2596
+
2597
+ // 8. Line chart specific
2598
+ if (chartType === pptx.ChartType.line) {
2599
+ chartOpts.lineSize = 2.5
2600
+ chartOpts.lineSmooth = true
2601
+ }
2602
+
2603
+ pptxSlide.addChart(chartType, chartData, chartOpts)
2604
+ }
2605
+ else if (el.type === 'line') {
2606
+ const mapArrowToPPTX = (uiType: string | undefined) => {
2607
+ if (!uiType) return 'none'
2608
+ const t = uiType.toLowerCase()
2609
+ if (t === 'arrow') return 'triangle'
2610
+ if (t === 'open-arrow') return 'arrow'
2611
+ if (t === 'stealth-arrow') return 'stealth'
2612
+ if (t === 'dot') return 'oval'
2613
+ if (t === 'diamond') return 'diamond'
2614
+ return 'none'
2615
+ }
2616
+ const beginArrow = mapArrowToPPTX(el.points?.[0])
2617
+ const endArrow = mapArrowToPPTX(el.points?.[1])
2618
+
2619
+ const path = getLineElementPath(el)
2620
+ const rawPoints = toPoints(path)
2621
+ const points = formatPoints(rawPoints, { x: 1, y: 1 }, pixelToInch) as any
2622
+
2623
+ const lineOpts: any = {
2624
+ x, y, w, h,
2625
+ line: {
2626
+ color: parseColor(el.style?.color || '#475569').color,
2627
+ width: ((el.style?.strokeWidth || 1) as number) / ratioPx2Pt,
2628
+ dashType: el.lineStyle === 'dashed' ? 'dash' : 'solid',
2629
+ beginArrowType: beginArrow as any,
2630
+ endArrowType: endArrow as any
2631
+ },
2632
+ points,
2633
+ rotate, flipH, flipV,
2634
+ objectName: el.id
2635
+ }
2636
+ const lineShadow = el.shadow || (el.style as any)?.shadow
2637
+ let parsedLineShadow = convertElShadowToPptx(lineShadow, ratioPx2Pt)
2638
+ if (!parsedLineShadow && el.style?.boxShadow) {
2639
+ parsedLineShadow = parseCssShadow(el.style.boxShadow, ratioPx2Pt)
2640
+ }
2641
+ if (parsedLineShadow) lineOpts.shadow = parsedLineShadow
2642
+ const lineRefl = el.reflection || el.style?.reflection
2643
+ if (lineRefl) {
2644
+ const parsedReflection = convertElReflectionToPptx(lineRefl, ratioPx2Pt)
2645
+ if (parsedReflection) lineOpts.reflection = parsedReflection
2646
+ }
2647
+ pptxSlide.addShape('custGeom' as any, lineOpts)
2721
2648
  }
2649
+ else if (el.type === 'audio' || el.type === 'video') {
2650
+ const mediaInput = (el as any).blob || (el as any).src || el.content || (el as any).value || ''
2651
+ let mediaPath = ''
2652
+ if (typeof Blob !== 'undefined' && mediaInput instanceof Blob) {
2653
+ try {
2654
+ mediaPath = await blobToDataUrl(mediaInput)
2655
+ } catch (e) {
2656
+ console.warn('Converting media Blob to DataURL failed:', e)
2657
+ }
2658
+ }
2659
+ if (!mediaPath) {
2660
+ mediaPath = await resolveMediaSrc(mediaInput, el.type as 'video' | 'audio')
2661
+ }
2662
+ if (mediaPath && mediaPath.startsWith('data:')) {
2663
+ let extn = el.type === 'audio' ? 'mp3' : 'mp4'
2664
+ if (mediaPath.startsWith('data:audio/')) {
2665
+ if (mediaPath.includes('audio/wav')) extn = 'wav'
2666
+ else if (mediaPath.includes('audio/m4a') || mediaPath.includes('audio/x-m4a') || mediaPath.includes('audio/mp4')) extn = 'm4a'
2667
+ else if (mediaPath.includes('audio/ogg')) extn = 'ogg'
2668
+ else extn = 'mp3'
2669
+ } else if (mediaPath.startsWith('data:video/')) {
2670
+ if (mediaPath.includes('video/webm')) extn = 'webm'
2671
+ else if (mediaPath.includes('video/ogg')) extn = 'ogg'
2672
+ else extn = 'mp4'
2673
+ }
2674
+
2675
+ pptxSlide.addMedia({
2676
+ type: el.type,
2677
+ data: mediaPath,
2678
+ extn,
2679
+ x, y, w, h,
2680
+ objectName: el.id
2681
+ })
2682
+ } else {
2683
+ // 无法获取或跨域拦截二进制数据时的防护兜底:
2684
+ // 严禁调用带 path 的 addMedia,否则 pptxgenjs 会因为 XHR 失败向媒体文件写入损坏的图片数据,导致导出的 PPTX 无法打开!
2685
+ // 生成规范的优雅多媒体占位卡片并挂载在线超链接,确保 PPT 100% 正常打开且可点击跳转播放
2686
+ const isVideo = el.type === 'video'
2687
+ const mediaTitle = isVideo ? '视频' : '音频'
2688
+ const displayUrl = typeof mediaInput === 'string' ? mediaInput : ''
2689
+
2690
+ pptxSlide.addShape('rect' as any, {
2691
+ x, y, w, h,
2692
+ fill: { color: isVideo ? '1E293B' : '0F172A' },
2693
+ line: { color: '3B82F6', width: 1.5, dashType: 'dash' },
2694
+ rectRadius: 0.08,
2695
+ hyperlink: displayUrl ? { url: displayUrl, tooltip: `点击在线打开${mediaTitle}` } : undefined,
2696
+ objectName: el.id
2697
+ })
2722
2698
 
2723
- const hasNativeMaster = !!(currentZipMeta?.value?.masterFilesMap && Object.keys(currentZipMeta.value.masterFilesMap).length > 0)
2724
- const masterElements = getEffectiveMasterElementsForSlide(slide, true)
2725
- if (!hasNativeMaster && masterElements && masterElements.length > 0) {
2726
- for (const mEl of masterElements) {
2727
- await processElement(mEl, null)
2699
+ pptxSlide.addText([
2700
+ { text: isVideo ? '▶ 视频多媒体' : '🎵 音频多媒体', options: { bold: true, fontSize: 13, color: 'FFFFFF' } },
2701
+ { text: displayUrl ? `\n(在线链接: ${displayUrl.length > 36 ? displayUrl.slice(0, 36) + '...' : displayUrl})` : '', options: { fontSize: 9, color: '94A3B8' } },
2702
+ { text: '\n[点击卡片在浏览器中播放]', options: { fontSize: 9, color: '60A5FA' } }
2703
+ ], {
2704
+ x, y, w, h,
2705
+ align: 'center',
2706
+ valign: 'middle',
2707
+ hyperlink: displayUrl ? { url: displayUrl, tooltip: `点击在线打开${mediaTitle}` } : undefined
2708
+ })
2728
2709
  }
2729
2710
  }
2711
+ else if (el.type === 'table') {
2712
+ const res = (el.content && (el.content.includes('<table') || el.content.includes('&lt;table')))
2713
+ ? parseHTMLTable(el.content, w)
2714
+ : parseTableElementData(el, w)
2715
+ if (res.tableData && res.tableData.length > 0) {
2716
+ const tableOpts: any = { x, y, w, h, objectName: el.id }
2717
+ if (res.colW && res.colW.length > 0) {
2718
+ tableOpts.colW = res.colW
2719
+ }
2720
+ pptxSlide.addTable(res.tableData, tableOpts)
2721
+ }
2722
+ }
2723
+ }
2730
2724
 
2731
- const userElements = (slide.elements || []).filter((e: any) => {
2732
- if (e.isMaster && !e.isPlaceholder && !e.placeholder && !e.id?.startsWith('ph-') && !e.id?.startsWith('ph_') && !e.name?.includes('占位符')) {
2733
- if (masterElements && masterElements.some((m: any) => m.id === e.id)) {
2734
- return false
2735
- }
2725
+ const hasNativeMaster = !!(currentZipMeta?.value?.masterFilesMap && Object.keys(currentZipMeta.value.masterFilesMap).length > 0)
2726
+ const masterElements = getEffectiveMasterElementsForSlide(slide, true)
2727
+ if (!hasNativeMaster && masterElements && masterElements.length > 0) {
2728
+ for (const mEl of masterElements) {
2729
+ await processElement(mEl, null)
2730
+ }
2731
+ }
2732
+
2733
+ const userElements = (slide.elements || []).filter((e: any) => {
2734
+ if (e.isMaster && !e.isPlaceholder && !e.placeholder && !e.id?.startsWith('ph-') && !e.id?.startsWith('ph_') && !e.name?.includes('占位符')) {
2735
+ if (masterElements && masterElements.some((m: any) => m.id === e.id)) {
2736
+ return false
2736
2737
  }
2737
- return true
2738
- })
2739
- for (const el of userElements) {
2740
- await processElement(el, null)
2741
2738
  }
2739
+ return true
2740
+ })
2741
+ for (const el of userElements) {
2742
+ await processElement(el, null)
2743
+ }
2742
2744
  }
2743
2745
  const rawBlob = (await pptx.write({ outputType: 'blob' })) as Blob
2744
2746
  const animatedBlob = await injectAnimationToPptxBlob(
@@ -2791,6 +2793,14 @@ const handleExportCommand = (command: string) => {
2791
2793
  }
2792
2794
  }
2793
2795
 
2796
+ const handleImportCommand = (command: string) => {
2797
+ if (command === 'pptx') {
2798
+ triggerPPTXImport()
2799
+ } else if (command === 'json') {
2800
+ triggerJSONImport()
2801
+ }
2802
+ }
2803
+
2794
2804
  function parseColorToRgb(c: string) {
2795
2805
  c = c.trim().toLowerCase();
2796
2806
  if (c.startsWith('#')) {
@@ -5767,12 +5777,12 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
5767
5777
  points: connectorPoints,
5768
5778
  elements: isNestedGroup
5769
5779
  ? (() => {
5770
- const nestedChildIdMap = new Map<string, any>(siblings)
5771
- for (const ch of _el.elements || []) {
5772
- if (ch && ch.id !== undefined) nestedChildIdMap.set(String(ch.id), ch)
5773
- }
5774
- return [...(_el.elements || [])].sort((a: any, b: any) => (a.order || 0) - (b.order || 0)).map((gChild: any) => convertChildElement(gChild, curW, curH, nestedChildIdMap))
5775
- })()
5780
+ const nestedChildIdMap = new Map<string, any>(siblings)
5781
+ for (const ch of _el.elements || []) {
5782
+ if (ch && ch.id !== undefined) nestedChildIdMap.set(String(ch.id), ch)
5783
+ }
5784
+ return [...(_el.elements || [])].sort((a: any, b: any) => (a.order || 0) - (b.order || 0)).map((gChild: any) => convertChildElement(gChild, curW, curH, nestedChildIdMap))
5785
+ })()
5776
5786
  : undefined
5777
5787
  }
5778
5788
  }
@@ -5820,6 +5830,10 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
5820
5830
  elements: sortedTopGroupChildren.map((child: any) => convertChildElement(child, topW, topH, childIdMap))
5821
5831
  }
5822
5832
 
5833
+ if (grpFlipH || grpFlipV) {
5834
+ applyGroupGeometryFlip(groupElement, grpFlipH, grpFlipV)
5835
+ }
5836
+
5823
5837
  slide.elements.push(groupElement)
5824
5838
  }
5825
5839
  else if (el.type === 'diagram') {
@@ -6761,186 +6775,268 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
6761
6775
  })
6762
6776
  console.log(json)
6763
6777
 
6764
- let zipMeta: any = null
6765
- let loadedZip: JSZip | null = null
6778
+ let zipMeta: any = null
6779
+ let loadedZip: JSZip | null = null
6780
+ try {
6781
+ const zip = await JSZipLib.loadAsync(buffer)
6782
+ loadedZip = zip
6783
+ const layoutFiles = Object.keys(zip.files).filter(k => k.startsWith('ppt/slideLayouts/slideLayout') && !k.includes('_rels') && !zip.files[k].dir)
6784
+ const sortSlideXml = (p1: string, p2: string) => {
6785
+ const m1 = /(\d+)\.xml/.exec(p1)
6786
+ const m2 = /(\d+)\.xml/.exec(p2)
6787
+ const n1 = m1 ? +m1[1] : 0
6788
+ const n2 = m2 ? +m2[1] : 0
6789
+ return n1 - n2
6790
+ }
6791
+ // 1. 提前解析主题配色 clrScheme(dk1/lt1/dk2/lt2/accent1-6),供母版、版式背景与装饰形状还原真实颜色
6792
+ const themeColors: Record<string, string> = {}
6766
6793
  try {
6767
- const zip = await JSZipLib.loadAsync(buffer)
6768
- loadedZip = zip
6769
- const layoutFiles = Object.keys(zip.files).filter(k => k.startsWith('ppt/slideLayouts/slideLayout') && !k.includes('_rels') && !zip.files[k].dir)
6770
- const sortSlideXml = (p1: string, p2: string) => {
6771
- const m1 = /(\d+)\.xml/.exec(p1)
6772
- const m2 = /(\d+)\.xml/.exec(p2)
6773
- const n1 = m1 ? +m1[1] : 0
6774
- const n2 = m2 ? +m2[1] : 0
6775
- return n1 - n2
6776
- }
6777
- // 1. 提前解析主题配色 clrScheme(dk1/lt1/dk2/lt2/accent1-6),供母版、版式背景与装饰形状还原真实颜色
6778
- const themeColors: Record<string, string> = {}
6779
- try {
6780
- const themeFiles = Object.keys(zip.files).filter(k => /^ppt\/theme\/theme\d+\.xml$/.test(k))
6781
- if (themeFiles.length > 0) {
6782
- const tTxt = await zip.file(themeFiles[0])?.async('text') || ''
6783
- const schemeM = tTxt.match(/<a:clrScheme[\s\S]*?<\/a:clrScheme>/)
6784
- if (schemeM) {
6785
- const slotRe = /<a:(dk1|lt1|dk2|lt2|accent1|accent2|accent3|accent4|accent5|accent6|hlink|folHlink)>([\s\S]*?)<\/a:\1>/g
6786
- let slotMatch: RegExpExecArray | null
6787
- while ((slotMatch = slotRe.exec(schemeM[0]))) {
6788
- const hexM = slotMatch[2].match(/<a:srgbClr val="([0-9A-Fa-f]{6})"/) || slotMatch[2].match(/<a:sysClr[^>]*lastClr="([0-9A-Fa-f]{6})"/)
6789
- if (hexM) themeColors[slotMatch[1]] = '#' + hexM[1].toUpperCase()
6790
- }
6794
+ const themeFiles = Object.keys(zip.files).filter(k => /^ppt\/theme\/theme\d+\.xml$/.test(k))
6795
+ if (themeFiles.length > 0) {
6796
+ const tTxt = await zip.file(themeFiles[0])?.async('text') || ''
6797
+ const schemeM = tTxt.match(/<a:clrScheme[\s\S]*?<\/a:clrScheme>/)
6798
+ if (schemeM) {
6799
+ const slotRe = /<a:(dk1|lt1|dk2|lt2|accent1|accent2|accent3|accent4|accent5|accent6|hlink|folHlink)>([\s\S]*?)<\/a:\1>/g
6800
+ let slotMatch: RegExpExecArray | null
6801
+ while ((slotMatch = slotRe.exec(schemeM[0]))) {
6802
+ const hexM = slotMatch[2].match(/<a:srgbClr val="([0-9A-Fa-f]{6})"/) || slotMatch[2].match(/<a:sysClr[^>]*lastClr="([0-9A-Fa-f]{6})"/)
6803
+ if (hexM) themeColors[slotMatch[1]] = '#' + hexM[1].toUpperCase()
6791
6804
  }
6792
6805
  }
6793
- } catch (e) {}
6794
-
6795
- // 通用背景 XML (<p:bg>) 解析器:全面支持 <a:blip> 图片、<a:gradFill> 渐变(含 circle/radial 径向渐变)、<a:srgbClr> 与 <a:schemeClr> 单色
6796
- const parsePptxBgFromXml = async (bgTxt: string, xmlPath: string): Promise<string | null> => {
6797
- if (!bgTxt) return null
6798
- const mimeByExt: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', tif: 'image/tiff', tiff: 'image/tiff' }
6799
-
6800
- // 1. 图片填充 <a:blip>
6801
- const blipMatch = bgTxt.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)
6802
- if (blipMatch) {
6803
- const rId = blipMatch[1]
6804
- const relsPath = xmlPath.replace(/(slideMasters|slideLayouts)\/([^/]+)\.xml$/, '$1/_rels/$2.xml.rels')
6805
- const relsTxt = await zip.file(relsPath)?.async('text') || ''
6806
- const relM = relsTxt.match(new RegExp(`Id="${rId}"[^>]*Target="([^"]+)"`)) || relsTxt.match(new RegExp(`Target="([^"]+)"[^>]*Id="${rId}"`))
6807
- if (relM) {
6808
- const stack = xmlPath.split('/').slice(0, -1)
6809
- for (const part of relM[1].split('/')) {
6810
- if (part === '..') stack.pop()
6811
- else if (part && part !== '.') stack.push(part)
6812
- }
6813
- const mediaPath = stack.join('/')
6814
- const ext = ((mediaPath.match(/\.(\w+)$/) || [])[1] || '').toLowerCase()
6815
- const blob = await zip.file(mediaPath)?.async('blob')
6816
- if (blob) {
6817
- const blobUrl = URL.createObjectURL(blob)
6818
- return `#ffffff url("${blobUrl}") center center / 100% 100% no-repeat`
6819
- }
6806
+ }
6807
+ } catch (e) { }
6808
+
6809
+ // 通用背景 XML (<p:bg>) 解析器:全面支持 <a:blip> 图片、<a:gradFill> 渐变(含 circle/radial 径向渐变)、<a:srgbClr> 与 <a:schemeClr> 单色
6810
+ const parsePptxBgFromXml = async (bgTxt: string, xmlPath: string): Promise<string | null> => {
6811
+ if (!bgTxt) return null
6812
+ const mimeByExt: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', tif: 'image/tiff', tiff: 'image/tiff' }
6813
+
6814
+ // 1. 图片填充 <a:blip>
6815
+ const blipMatch = bgTxt.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)
6816
+ if (blipMatch) {
6817
+ const rId = blipMatch[1]
6818
+ const relsPath = xmlPath.replace(/(slideMasters|slideLayouts)\/([^/]+)\.xml$/, '$1/_rels/$2.xml.rels')
6819
+ const relsTxt = await zip.file(relsPath)?.async('text') || ''
6820
+ const relM = relsTxt.match(new RegExp(`Id="${rId}"[^>]*Target="([^"]+)"`)) || relsTxt.match(new RegExp(`Target="([^"]+)"[^>]*Id="${rId}"`))
6821
+ if (relM) {
6822
+ const stack = xmlPath.split('/').slice(0, -1)
6823
+ for (const part of relM[1].split('/')) {
6824
+ if (part === '..') stack.pop()
6825
+ else if (part && part !== '.') stack.push(part)
6826
+ }
6827
+ const mediaPath = stack.join('/')
6828
+ const ext = ((mediaPath.match(/\.(\w+)$/) || [])[1] || '').toLowerCase()
6829
+ const blob = await zip.file(mediaPath)?.async('blob')
6830
+ if (blob) {
6831
+ const blobUrl = URL.createObjectURL(blob)
6832
+ return `#ffffff url("${blobUrl}") center center / 100% 100% no-repeat`
6820
6833
  }
6821
6834
  }
6835
+ }
6822
6836
 
6823
- // 2. 渐变填充 <a:gradFill>
6824
- const gradMatch = bgTxt.match(/<a:gradFill\b[\s\S]*?<\/a:gradFill>/)
6825
- if (gradMatch) {
6826
- const gradTxt = gradMatch[0]
6827
- const gsList = gradTxt.match(/<a:gs\b[\s\S]*?<\/a:gs>/g) || []
6828
- if (gsList.length > 0) {
6829
- const stops: string[] = []
6830
- for (const gs of gsList) {
6831
- const posM = gs.match(/pos="(\d+)"/)
6832
- const posVal = posM ? Math.round(parseInt(posM[1], 10) / 1000) : 0
6833
- let col = '#FFFFFF'
6834
- const srgbM = gs.match(/<a:srgbClr\s+val="([0-9A-Fa-f]{6})"/)
6835
- if (srgbM) {
6836
- col = '#' + srgbM[1].toUpperCase()
6837
- } else {
6838
- const schemeM = gs.match(/<a:schemeClr\s+val="([^"]+)"([\s\S]*?)<\/a:schemeClr>/) || gs.match(/<a:schemeClr\s+val="([^"]+)"\s*\/>/)
6839
- if (schemeM) {
6840
- const val = schemeM[1]
6841
- const inner = schemeM[2] || ''
6842
- const lumModM = inner.match(/<a:lumMod val="(\d+)"/)
6843
- const lumOffM = inner.match(/<a:lumOff val="(\d+)"/)
6844
- const lumMod = lumModM ? parseInt(lumModM[1], 10) / 100000 : 1
6845
- const lumOff = lumOffM ? parseInt(lumOffM[1], 10) / 100000 : 0
6846
- const slot = val === 'tx1' ? 'dk1' : val === 'bg1' ? 'lt1' : val === 'tx2' ? 'dk2' : val === 'bg2' ? 'lt2' : val
6847
- const baseHex = themeColors[slot] || themeColors[val] || (slot === 'dk1' || slot === 'tx1' ? '#000000' : '#FFFFFF')
6848
- col = (lumMod !== 1 || lumOff !== 0) ? applyLumAdjust(baseHex, lumMod, lumOff) : baseHex
6849
- }
6837
+ // 2. 渐变填充 <a:gradFill>
6838
+ const gradMatch = bgTxt.match(/<a:gradFill\b[\s\S]*?<\/a:gradFill>/)
6839
+ if (gradMatch) {
6840
+ const gradTxt = gradMatch[0]
6841
+ const gsList = gradTxt.match(/<a:gs\b[\s\S]*?<\/a:gs>/g) || []
6842
+ if (gsList.length > 0) {
6843
+ const stops: string[] = []
6844
+ for (const gs of gsList) {
6845
+ const posM = gs.match(/pos="(\d+)"/)
6846
+ const posVal = posM ? Math.round(parseInt(posM[1], 10) / 1000) : 0
6847
+ let col = '#FFFFFF'
6848
+ const srgbM = gs.match(/<a:srgbClr\s+val="([0-9A-Fa-f]{6})"/)
6849
+ if (srgbM) {
6850
+ col = '#' + srgbM[1].toUpperCase()
6851
+ } else {
6852
+ const schemeM = gs.match(/<a:schemeClr\s+val="([^"]+)"([\s\S]*?)<\/a:schemeClr>/) || gs.match(/<a:schemeClr\s+val="([^"]+)"\s*\/>/)
6853
+ if (schemeM) {
6854
+ const val = schemeM[1]
6855
+ const inner = schemeM[2] || ''
6856
+ const lumModM = inner.match(/<a:lumMod val="(\d+)"/)
6857
+ const lumOffM = inner.match(/<a:lumOff val="(\d+)"/)
6858
+ const lumMod = lumModM ? parseInt(lumModM[1], 10) / 100000 : 1
6859
+ const lumOff = lumOffM ? parseInt(lumOffM[1], 10) / 100000 : 0
6860
+ const slot = val === 'tx1' ? 'dk1' : val === 'bg1' ? 'lt1' : val === 'tx2' ? 'dk2' : val === 'bg2' ? 'lt2' : val
6861
+ const baseHex = themeColors[slot] || themeColors[val] || (slot === 'dk1' || slot === 'tx1' ? '#000000' : '#FFFFFF')
6862
+ col = (lumMod !== 1 || lumOff !== 0) ? applyLumAdjust(baseHex, lumMod, lumOff) : baseHex
6850
6863
  }
6851
- stops.push(`${col} ${posVal}%`)
6852
6864
  }
6853
- const pathMatch = gradTxt.match(/<a:path\b[^>]*path="([^"]+)"/i)
6854
- const pathKind = pathMatch ? pathMatch[1].toLowerCase() : ''
6855
- const isPath = pathKind === 'shape'
6856
- const isCircle = pathKind === 'circle' || pathKind === 'radial'
6857
- const isRect = pathKind === 'rect'
6858
- const isRadialOrRect = isCircle || isRect || /<a:fillToRect/i.test(gradTxt)
6859
-
6860
- if (isRadialOrRect) {
6861
- const fillToRectM = gradTxt.match(/<a:fillToRect\b([^>]*)\/?>/)
6862
- let cx = 50
6863
- let cy = 50
6864
- if (fillToRectM) {
6865
- const attrs = fillToRectM[1]
6866
- const lM = attrs.match(/l="(-?\d+)"/)
6867
- const tM = attrs.match(/t="(-?\d+)"/)
6868
- const rM = attrs.match(/r="(-?\d+)"/)
6869
- const bM = attrs.match(/b="(-?\d+)"/)
6870
- const l = lM ? parseInt(lM[1], 10) / 100000 : 0.5
6871
- const t = tM ? parseInt(tM[1], 10) / 100000 : 0.5
6872
- const r = rM ? parseInt(rM[1], 10) / 100000 : 0.5
6873
- const b = bM ? parseInt(bM[1], 10) / 100000 : 0.5
6874
- cx = Math.round(((l + (1 - r)) / 2) * 100)
6875
- cy = Math.round(((t + (1 - b)) / 2) * 100)
6876
- }
6877
- if (isRect) {
6878
- return `radial-gradient(closest-side at ${cx}% ${cy}%, ${stops.join(', ')})`
6879
- }
6880
- return `radial-gradient(circle farthest-corner at ${cx}% ${cy}%, ${stops.join(', ')})`
6865
+ stops.push(`${col} ${posVal}%`)
6866
+ }
6867
+ const pathMatch = gradTxt.match(/<a:path\b[^>]*path="([^"]+)"/i)
6868
+ const pathKind = pathMatch ? pathMatch[1].toLowerCase() : ''
6869
+ const isPath = pathKind === 'shape'
6870
+ const isCircle = pathKind === 'circle' || pathKind === 'radial'
6871
+ const isRect = pathKind === 'rect'
6872
+ const isRadialOrRect = isCircle || isRect || /<a:fillToRect/i.test(gradTxt)
6873
+
6874
+ if (isRadialOrRect) {
6875
+ const fillToRectM = gradTxt.match(/<a:fillToRect\b([^>]*)\/?>/)
6876
+ let cx = 50
6877
+ let cy = 50
6878
+ if (fillToRectM) {
6879
+ const attrs = fillToRectM[1]
6880
+ const lM = attrs.match(/l="(-?\d+)"/)
6881
+ const tM = attrs.match(/t="(-?\d+)"/)
6882
+ const rM = attrs.match(/r="(-?\d+)"/)
6883
+ const bM = attrs.match(/b="(-?\d+)"/)
6884
+ const l = lM ? parseInt(lM[1], 10) / 100000 : 0.5
6885
+ const t = tM ? parseInt(tM[1], 10) / 100000 : 0.5
6886
+ const r = rM ? parseInt(rM[1], 10) / 100000 : 0.5
6887
+ const b = bM ? parseInt(bM[1], 10) / 100000 : 0.5
6888
+ cx = Math.round(((l + (1 - r)) / 2) * 100)
6889
+ cy = Math.round(((t + (1 - b)) / 2) * 100)
6881
6890
  }
6882
- if (isPath) {
6883
- return `radial-gradient(ellipse farthest-corner at 50% 50%, ${stops.join(', ')})`
6891
+ if (isRect) {
6892
+ return `radial-gradient(closest-side at ${cx}% ${cy}%, ${stops.join(', ')})`
6884
6893
  }
6885
- const linM = gradTxt.match(/<a:lin\b[^>]*ang="(-?\d+)"/)
6886
- const angVal = linM ? parseInt(linM[1], 10) / 60000 : 0
6887
- const cssAng = (Math.round(angVal) + 90) % 360
6888
- return `linear-gradient(${cssAng}deg, ${stops.join(', ')})`
6894
+ return `radial-gradient(circle farthest-corner at ${cx}% ${cy}%, ${stops.join(', ')})`
6889
6895
  }
6896
+ if (isPath) {
6897
+ return `radial-gradient(ellipse farthest-corner at 50% 50%, ${stops.join(', ')})`
6898
+ }
6899
+ const linM = gradTxt.match(/<a:lin\b[^>]*ang="(-?\d+)"/)
6900
+ const angVal = linM ? parseInt(linM[1], 10) / 60000 : 0
6901
+ const cssAng = (Math.round(angVal) + 90) % 360
6902
+ return `linear-gradient(${cssAng}deg, ${stops.join(', ')})`
6890
6903
  }
6904
+ }
6891
6905
 
6892
- // 3. 纯色填充 <a:srgbClr> 或 <a:schemeClr>
6893
- const srgbMatch = bgTxt.match(/<a:srgbClr\s+val="([0-9A-Fa-f]{6})"/)
6894
- if (srgbMatch) {
6895
- return '#' + srgbMatch[1].toUpperCase()
6896
- }
6897
- const schemeMatch = bgTxt.match(/<a:schemeClr\s+val="([^"]+)"([\s\S]*?)<\/a:schemeClr>/) || bgTxt.match(/<a:schemeClr\s+val="([^"]+)"\s*\/>/)
6898
- if (schemeMatch) {
6899
- const val = schemeMatch[1]
6900
- const inner = schemeMatch[2] || ''
6901
- const lumModM = inner.match(/<a:lumMod val="(\d+)"/)
6902
- const lumOffM = inner.match(/<a:lumOff val="(\d+)"/)
6903
- const lumMod = lumModM ? parseInt(lumModM[1], 10) / 100000 : 1
6904
- const lumOff = lumOffM ? parseInt(lumOffM[1], 10) / 100000 : 0
6905
- const slot = val === 'tx1' ? 'dk1' : val === 'bg1' ? 'lt1' : val === 'tx2' ? 'dk2' : val === 'bg2' ? 'lt2' : val
6906
- const baseHex = themeColors[slot] || themeColors[val] || (slot === 'dk1' || slot === 'tx1' ? '#000000' : '#FFFFFF')
6907
- return (lumMod !== 1 || lumOff !== 0) ? applyLumAdjust(baseHex, lumMod, lumOff) : baseHex
6908
- }
6909
-
6910
- return null
6906
+ // 3. 纯色填充 <a:srgbClr> 或 <a:schemeClr>
6907
+ const srgbMatch = bgTxt.match(/<a:srgbClr\s+val="([0-9A-Fa-f]{6})"/)
6908
+ if (srgbMatch) {
6909
+ return '#' + srgbMatch[1].toUpperCase()
6911
6910
  }
6911
+ const schemeMatch = bgTxt.match(/<a:schemeClr\s+val="([^"]+)"([\s\S]*?)<\/a:schemeClr>/) || bgTxt.match(/<a:schemeClr\s+val="([^"]+)"\s*\/>/)
6912
+ if (schemeMatch) {
6913
+ const val = schemeMatch[1]
6914
+ const inner = schemeMatch[2] || ''
6915
+ const lumModM = inner.match(/<a:lumMod val="(\d+)"/)
6916
+ const lumOffM = inner.match(/<a:lumOff val="(\d+)"/)
6917
+ const lumMod = lumModM ? parseInt(lumModM[1], 10) / 100000 : 1
6918
+ const lumOff = lumOffM ? parseInt(lumOffM[1], 10) / 100000 : 0
6919
+ const slot = val === 'tx1' ? 'dk1' : val === 'bg1' ? 'lt1' : val === 'tx2' ? 'dk2' : val === 'bg2' ? 'lt2' : val
6920
+ const baseHex = themeColors[slot] || themeColors[val] || (slot === 'dk1' || slot === 'tx1' ? '#000000' : '#FFFFFF')
6921
+ return (lumMod !== 1 || lumOff !== 0) ? applyLumAdjust(baseHex, lumMod, lumOff) : baseHex
6922
+ }
6923
+
6924
+ return null
6925
+ }
6926
+
6927
+ layoutFiles.sort(sortSlideXml)
6928
+
6929
+ const allLayouts: any[] = []
6930
+ const layoutByFile = new Map<string, any>()
6931
+
6932
+ for (let i = 0; i < layoutFiles.length; i++) {
6933
+ const lf = layoutFiles[i]
6934
+ const txt = await zip.file(lf)?.async('text') || ''
6935
+ const mimeByExt: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', tif: 'image/tiff', tiff: 'image/tiff' }
6912
6936
 
6913
- layoutFiles.sort(sortSlideXml)
6937
+ // 解析版式背景 (<p:bg>) 图片、渐变或颜色填充
6938
+ let layoutBg: string | null = null
6939
+ const bgMatch = txt.match(/<p:bg\b[\s\S]*?<\/p:bg>/)
6940
+ if (bgMatch) {
6941
+ layoutBg = await parsePptxBgFromXml(bgMatch[0], lf)
6942
+ }
6914
6943
 
6915
- const allLayouts: any[] = []
6916
- const layoutByFile = new Map<string, any>()
6944
+ // 版式装饰图片(userDrawn <p:pic>,如整幅世界地图):未被任何页使用的版式走
6945
+ // XML 解析分支(parseLayoutShapesFromXml),sp 解析管不到 pic,需经 rels 把
6946
+ // media 转 base64 一并传入,否则该版式在母版编辑视图/缩略图整片空白
6947
+ const picSrcMap: Record<string, string> = {}
6948
+ const layoutPicMatches = txt.match(/<p:pic\b[\s\S]*?<\/p:pic>/g) || []
6949
+ if (layoutPicMatches.length > 0) {
6950
+ const relsTxt = await zip.file(lf.replace(/(slideLayouts\/)(slideLayout\d+\.xml)$/, '$1_rels/$2.rels'))?.async('text') || ''
6951
+ for (const pic of layoutPicMatches) {
6952
+ const rId = (pic.match(/r:embed="([^"]+)"/) || [])[1]
6953
+ if (!rId || picSrcMap[rId]) continue
6954
+ const relM = relsTxt.match(new RegExp(`Id="${rId}"[^>]*Target="([^"]+)"`)) || relsTxt.match(new RegExp(`Target="([^"]+)"[^>]*Id="${rId}"`))
6955
+ if (!relM) continue
6956
+ // target 相对 slideLayouts 目录(如 ../media/image2.jpeg)→ zip 内绝对路径
6957
+ const stack = lf.split('/').slice(0, -1)
6958
+ for (const part of relM[1].split('/')) {
6959
+ if (part === '..') stack.pop()
6960
+ else if (part && part !== '.') stack.push(part)
6961
+ }
6962
+ const mediaPath = stack.join('/')
6963
+ const ext = ((mediaPath.match(/\.(\w+)$/) || [])[1] || '').toLowerCase()
6964
+ const b64 = await zip.file(mediaPath)?.async('base64')
6965
+ if (b64) picSrcMap[rId] = `data:${mimeByExt[ext] || 'application/octet-stream'};base64,${b64}`
6966
+ }
6967
+ }
6968
+ const nameM = txt.match(/<p:cSld[^>]*name="([^"]+)"/)
6969
+ const typeM = txt.match(/<p:sldLayout[^>]*type="([^"]+)"/)
6970
+ const isTitle = (typeM && typeM[1] === 'title') || txt.includes('type="ctrTitle"') || (nameM && (nameM[1].includes('Title') || nameM[1].includes('标题')))
6971
+ const isBlank = (typeM && typeM[1] === 'blank') || (nameM && (nameM[1].includes('Blank') || nameM[1].includes('空白')))
6972
+ const picPhCount = (txt.match(/type="pic"/g) || []).length
6973
+
6974
+ const lObj = {
6975
+ file: lf,
6976
+ id: `layout-${i + 1}`,
6977
+ name: nameM ? nameM[1] : `版式 ${i + 1}`,
6978
+ type: typeM ? typeM[1] : (isTitle ? 'title' : (isBlank ? 'blank' : 'content')),
6979
+ background: layoutBg || undefined,
6980
+ picPhCount,
6981
+ picSrcMap,
6982
+ xml: txt
6983
+ }
6984
+ allLayouts.push(lObj)
6985
+ layoutByFile.set(lf, lObj)
6986
+ const shortName = lf.replace('ppt/slideLayouts/', '')
6987
+ layoutByFile.set(shortName, lObj)
6988
+ layoutByFile.set(`../slideLayouts/${shortName}`, lObj)
6989
+ }
6917
6990
 
6918
- for (let i = 0; i < layoutFiles.length; i++) {
6919
- const lf = layoutFiles[i]
6920
- const txt = await zip.file(lf)?.async('text') || ''
6921
- const mimeByExt: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', tif: 'image/tiff', tiff: 'image/tiff' }
6991
+ const slideRelsFiles = Object.keys(zip.files).filter(k => k.startsWith('ppt/slides/_rels/slide') && k.endsWith('.rels'))
6992
+ slideRelsFiles.sort(sortSlideXml)
6993
+
6994
+ const slideLayoutMap: any[] = []
6995
+ const slideLayoutTargetList: string[] = []
6996
+ for (let i = 0; i < slideRelsFiles.length; i++) {
6997
+ const srf = slideRelsFiles[i]
6998
+ const txt = await zip.file(srf)?.async('text') || ''
6999
+ const layoutMatch = txt.match(/Type="[^"]*\/slideLayout"[^>]*Target="([^"]+)"/) || txt.match(/Target="([^"]+)"[^>]*Type="[^"]*\/slideLayout"/)
7000
+ const target = layoutMatch ? layoutMatch[1] : ''
7001
+ slideLayoutTargetList.push(target)
7002
+ const lObj = layoutByFile.get(target)
7003
+ slideLayoutMap.push(lObj || null)
7004
+ }
6922
7005
 
6923
- // 解析版式背景 (<p:bg>) 图片、渐变或颜色填充
6924
- let layoutBg: string | null = null
6925
- const bgMatch = txt.match(/<p:bg\b[\s\S]*?<\/p:bg>/)
6926
- if (bgMatch) {
6927
- layoutBg = await parsePptxBgFromXml(bgMatch[0], lf)
7006
+ let masterName = nameWithoutExt
7007
+ const masterShapesInfo: Array<{ name: string; text?: string; isPic?: boolean }> = []
7008
+ // 母版占位符 xfrm 位置表(idx/type → px 盒子):版式占位符常省略自身 xfrm(继承母版),
7009
+ // 解析版式提示框时按此回退,否则这类版式解析不出占位符、缩略图整片空白
7010
+ const masterPhXfrm: Record<string, { left: number; top: number; width: number; height: number }> = {}
7011
+ let primaryMasterXml = ''
7012
+ let masterXmlBg: string | null = null
7013
+ const masterPicSrcMap: Record<string, string> = {}
7014
+ try {
7015
+ const masterFiles = Object.keys(zip.files).filter(k => k.startsWith('ppt/slideMasters/slideMaster') && !k.includes('_rels') && !zip.files[k].dir)
7016
+ masterFiles.sort(sortSlideXml)
7017
+ for (const mf of masterFiles) {
7018
+ const mTxt = await zip.file(mf)?.async('text') || ''
7019
+ if (!primaryMasterXml) primaryMasterXml = mTxt
7020
+
7021
+ // 提取母版背景 (<p:bg>)
7022
+ if (!masterXmlBg) {
7023
+ const mBgMatch = mTxt.match(/<p:bg\b[\s\S]*?<\/p:bg>/)
7024
+ if (mBgMatch) {
7025
+ masterXmlBg = await parsePptxBgFromXml(mBgMatch[0], mf)
7026
+ }
6928
7027
  }
6929
7028
 
6930
- // 版式装饰图片(userDrawn <p:pic>,如整幅世界地图):未被任何页使用的版式走
6931
- // XML 解析分支(parseLayoutShapesFromXml),sp 解析管不到 pic,需经 rels 把
6932
- // media 转 base64 一并传入,否则该版式在母版编辑视图/缩略图整片空白
6933
- const picSrcMap: Record<string, string> = {}
6934
- const layoutPicMatches = txt.match(/<p:pic\b[\s\S]*?<\/p:pic>/g) || []
6935
- if (layoutPicMatches.length > 0) {
6936
- const relsTxt = await zip.file(lf.replace(/(slideLayouts\/)(slideLayout\d+\.xml)$/, '$1_rels/$2.rels'))?.async('text') || ''
6937
- for (const pic of layoutPicMatches) {
7029
+ // 防御策略 3:从 slideMasters/_rels/slideMaster*.xml.rels 提取母版图片引用,解析为 Base64 供 Logo/背景图显影
7030
+ const masterPicMatches = mTxt.match(/<p:pic\b[\s\S]*?<\/p:pic>/g) || []
7031
+ if (masterPicMatches.length > 0) {
7032
+ const relsTxt = await zip.file(mf.replace(/(slideMasters\/)(slideMaster\d+\.xml)$/, '$1_rels/$2.rels'))?.async('text') || ''
7033
+ const mimeByExt: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', tif: 'image/tiff', tiff: 'image/tiff' }
7034
+ for (const pic of masterPicMatches) {
6938
7035
  const rId = (pic.match(/r:embed="([^"]+)"/) || [])[1]
6939
- if (!rId || picSrcMap[rId]) continue
7036
+ if (!rId || masterPicSrcMap[rId]) continue
6940
7037
  const relM = relsTxt.match(new RegExp(`Id="${rId}"[^>]*Target="([^"]+)"`)) || relsTxt.match(new RegExp(`Target="([^"]+)"[^>]*Id="${rId}"`))
6941
7038
  if (!relM) continue
6942
- // target 相对 slideLayouts 目录(如 ../media/image2.jpeg)→ zip 内绝对路径
6943
- const stack = lf.split('/').slice(0, -1)
7039
+ const stack = mf.split('/').slice(0, -1)
6944
7040
  for (const part of relM[1].split('/')) {
6945
7041
  if (part === '..') stack.pop()
6946
7042
  else if (part && part !== '.') stack.push(part)
@@ -6948,481 +7044,399 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
6948
7044
  const mediaPath = stack.join('/')
6949
7045
  const ext = ((mediaPath.match(/\.(\w+)$/) || [])[1] || '').toLowerCase()
6950
7046
  const b64 = await zip.file(mediaPath)?.async('base64')
6951
- if (b64) picSrcMap[rId] = `data:${mimeByExt[ext] || 'application/octet-stream'};base64,${b64}`
7047
+ if (b64) masterPicSrcMap[rId] = `data:${mimeByExt[ext] || 'application/octet-stream'};base64,${b64}`
6952
7048
  }
6953
7049
  }
6954
- const nameM = txt.match(/<p:cSld[^>]*name="([^"]+)"/)
6955
- const typeM = txt.match(/<p:sldLayout[^>]*type="([^"]+)"/)
6956
- const isTitle = (typeM && typeM[1] === 'title') || txt.includes('type="ctrTitle"') || (nameM && (nameM[1].includes('Title') || nameM[1].includes('标题')))
6957
- const isBlank = (typeM && typeM[1] === 'blank') || (nameM && (nameM[1].includes('Blank') || nameM[1].includes('空白')))
6958
- const picPhCount = (txt.match(/type="pic"/g) || []).length
6959
-
6960
- const lObj = {
6961
- file: lf,
6962
- id: `layout-${i + 1}`,
6963
- name: nameM ? nameM[1] : `版式 ${i + 1}`,
6964
- type: typeM ? typeM[1] : (isTitle ? 'title' : (isBlank ? 'blank' : 'content')),
6965
- background: layoutBg || undefined,
6966
- picPhCount,
6967
- picSrcMap,
6968
- xml: txt
6969
- }
6970
- allLayouts.push(lObj)
6971
- layoutByFile.set(lf, lObj)
6972
- const shortName = lf.replace('ppt/slideLayouts/', '')
6973
- layoutByFile.set(shortName, lObj)
6974
- layoutByFile.set(`../slideLayouts/${shortName}`, lObj)
6975
- }
6976
7050
 
6977
- const slideRelsFiles = Object.keys(zip.files).filter(k => k.startsWith('ppt/slides/_rels/slide') && k.endsWith('.rels'))
6978
- slideRelsFiles.sort(sortSlideXml)
6979
-
6980
- const slideLayoutMap: any[] = []
6981
- const slideLayoutTargetList: string[] = []
6982
- for (let i = 0; i < slideRelsFiles.length; i++) {
6983
- const srf = slideRelsFiles[i]
6984
- const txt = await zip.file(srf)?.async('text') || ''
6985
- const layoutMatch = txt.match(/Type="[^"]*\/slideLayout"[^>]*Target="([^"]+)"/) || txt.match(/Target="([^"]+)"[^>]*Type="[^"]*\/slideLayout"/)
6986
- const target = layoutMatch ? layoutMatch[1] : ''
6987
- slideLayoutTargetList.push(target)
6988
- const lObj = layoutByFile.get(target)
6989
- slideLayoutMap.push(lObj || null)
6990
- }
6991
-
6992
- let masterName = nameWithoutExt
6993
- const masterShapesInfo: Array<{ name: string; text?: string; isPic?: boolean }> = []
6994
- // 母版占位符 xfrm 位置表(idx/type → px 盒子):版式占位符常省略自身 xfrm(继承母版),
6995
- // 解析版式提示框时按此回退,否则这类版式解析不出占位符、缩略图整片空白
6996
- const masterPhXfrm: Record<string, { left: number; top: number; width: number; height: number }> = {}
6997
- let primaryMasterXml = ''
6998
- let masterXmlBg: string | null = null
6999
- const masterPicSrcMap: Record<string, string> = {}
7000
- try {
7001
- const masterFiles = Object.keys(zip.files).filter(k => k.startsWith('ppt/slideMasters/slideMaster') && !k.includes('_rels') && !zip.files[k].dir)
7002
- masterFiles.sort(sortSlideXml)
7003
- for (const mf of masterFiles) {
7004
- const mTxt = await zip.file(mf)?.async('text') || ''
7005
- if (!primaryMasterXml) primaryMasterXml = mTxt
7006
-
7007
- // 提取母版背景 (<p:bg>)
7008
- if (!masterXmlBg) {
7009
- const mBgMatch = mTxt.match(/<p:bg\b[\s\S]*?<\/p:bg>/)
7010
- if (mBgMatch) {
7011
- masterXmlBg = await parsePptxBgFromXml(mBgMatch[0], mf)
7012
- }
7013
- }
7014
-
7015
- // 防御策略 3:从 slideMasters/_rels/slideMaster*.xml.rels 提取母版图片引用,解析为 Base64 供 Logo/背景图显影
7016
- const masterPicMatches = mTxt.match(/<p:pic\b[\s\S]*?<\/p:pic>/g) || []
7017
- if (masterPicMatches.length > 0) {
7018
- const relsTxt = await zip.file(mf.replace(/(slideMasters\/)(slideMaster\d+\.xml)$/, '$1_rels/$2.rels'))?.async('text') || ''
7019
- const mimeByExt: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', tif: 'image/tiff', tiff: 'image/tiff' }
7020
- for (const pic of masterPicMatches) {
7021
- const rId = (pic.match(/r:embed="([^"]+)"/) || [])[1]
7022
- if (!rId || masterPicSrcMap[rId]) continue
7023
- const relM = relsTxt.match(new RegExp(`Id="${rId}"[^>]*Target="([^"]+)"`)) || relsTxt.match(new RegExp(`Target="([^"]+)"[^>]*Id="${rId}"`))
7024
- if (!relM) continue
7025
- const stack = mf.split('/').slice(0, -1)
7026
- for (const part of relM[1].split('/')) {
7027
- if (part === '..') stack.pop()
7028
- else if (part && part !== '.') stack.push(part)
7029
- }
7030
- const mediaPath = stack.join('/')
7031
- const ext = ((mediaPath.match(/\.(\w+)$/) || [])[1] || '').toLowerCase()
7032
- const b64 = await zip.file(mediaPath)?.async('base64')
7033
- if (b64) masterPicSrcMap[rId] = `data:${mimeByExt[ext] || 'application/octet-stream'};base64,${b64}`
7034
- }
7035
- }
7036
-
7037
- const spMatches = mTxt.match(/<p:sp\b[\s\S]*?<\/p:sp>/g) || []
7038
- for (const sp of spMatches) {
7039
- const name = sp.match(/<p:cNvPr[^>]*name="([^"]+)"/)?.[1] || sp.match(/name="([^"]+)"/)?.[1] || ''
7040
- const text = (sp.match(/<a:t>([^<]+)<\/a:t>/g) || []).map(t => t.replace(/<\/?a:t>/g, '')).join('').trim()
7041
- masterShapesInfo.push({ name, text })
7042
- const phAttrsM = sp.match(/<p:ph\b([^>]*)\/?>/)
7043
- if (phAttrsM) {
7044
- const phAttrs = phAttrsM[1]
7045
- const idxAttr = (phAttrs.match(/idx="(\d+)"/) || [])[1]
7046
- const typeAttr = (phAttrs.match(/type="(\w+)"/) || [])[1]
7047
- const off = sp.match(/<a:off\s+x="(-?\d+)"\s+y="(-?\d+)"/)
7048
- const ext = sp.match(/<a:ext\s+cx="(\d+)"\s+cy="(\d+)"/)
7049
- if (off && ext) {
7050
- const box = { left: Math.round(parseInt(off[1]) / 9525), top: Math.round(parseInt(off[2]) / 9525), width: Math.round(parseInt(ext[1]) / 9525), height: Math.round(parseInt(ext[2]) / 9525) }
7051
- if (idxAttr !== undefined && !masterPhXfrm['idx_' + idxAttr]) masterPhXfrm['idx_' + idxAttr] = box
7052
- if (typeAttr && !masterPhXfrm['type_' + typeAttr]) masterPhXfrm['type_' + typeAttr] = box
7053
- }
7051
+ const spMatches = mTxt.match(/<p:sp\b[\s\S]*?<\/p:sp>/g) || []
7052
+ for (const sp of spMatches) {
7053
+ const name = sp.match(/<p:cNvPr[^>]*name="([^"]+)"/)?.[1] || sp.match(/name="([^"]+)"/)?.[1] || ''
7054
+ const text = (sp.match(/<a:t>([^<]+)<\/a:t>/g) || []).map(t => t.replace(/<\/?a:t>/g, '')).join('').trim()
7055
+ masterShapesInfo.push({ name, text })
7056
+ const phAttrsM = sp.match(/<p:ph\b([^>]*)\/?>/)
7057
+ if (phAttrsM) {
7058
+ const phAttrs = phAttrsM[1]
7059
+ const idxAttr = (phAttrs.match(/idx="(\d+)"/) || [])[1]
7060
+ const typeAttr = (phAttrs.match(/type="(\w+)"/) || [])[1]
7061
+ const off = sp.match(/<a:off\s+x="(-?\d+)"\s+y="(-?\d+)"/)
7062
+ const ext = sp.match(/<a:ext\s+cx="(\d+)"\s+cy="(\d+)"/)
7063
+ if (off && ext) {
7064
+ const box = { left: Math.round(parseInt(off[1]) / 9525), top: Math.round(parseInt(off[2]) / 9525), width: Math.round(parseInt(ext[1]) / 9525), height: Math.round(parseInt(ext[2]) / 9525) }
7065
+ if (idxAttr !== undefined && !masterPhXfrm['idx_' + idxAttr]) masterPhXfrm['idx_' + idxAttr] = box
7066
+ if (typeAttr && !masterPhXfrm['type_' + typeAttr]) masterPhXfrm['type_' + typeAttr] = box
7054
7067
  }
7055
7068
  }
7056
- const picMatches = mTxt.match(/<p:pic\b[\s\S]*?<\/p:pic>/g) || []
7057
- for (const pic of picMatches) {
7058
- const name = pic.match(/<p:cNvPr[^>]*name="([^"]+)"/)?.[1] || pic.match(/name="([^"]+)"/)?.[1] || ''
7059
- masterShapesInfo.push({ name, isPic: true })
7060
- }
7061
7069
  }
7062
- if (masterFiles.length > 0) {
7063
- const mTxt = await zip.file(masterFiles[0])?.async('text') || ''
7064
- const mNameM = mTxt.match(/<p:cSld[^>]*name="([^"]+)"/)
7065
- if (mNameM && mNameM[1]) masterName = mNameM[1]
7070
+ const picMatches = mTxt.match(/<p:pic\b[\s\S]*?<\/p:pic>/g) || []
7071
+ for (const pic of picMatches) {
7072
+ const name = pic.match(/<p:cNvPr[^>]*name="([^"]+)"/)?.[1] || pic.match(/name="([^"]+)"/)?.[1] || ''
7073
+ masterShapesInfo.push({ name, isPic: true })
7066
7074
  }
7067
- if (!masterName || masterName === nameWithoutExt) {
7068
- const themeFiles = Object.keys(zip.files).filter(k => k.startsWith('ppt/theme/theme') && !k.includes('_rels') && !zip.files[k].dir)
7069
- if (themeFiles.length > 0) {
7070
- const tTxt = await zip.file(themeFiles[0])?.async('text') || ''
7071
- const tNameM = tTxt.match(/<a:theme[^>]*name="([^"]+)"/)
7072
- if (tNameM && tNameM[1] && tNameM[1] !== 'Office 主题' && tNameM[1] !== 'Office Theme') {
7073
- masterName = tNameM[1]
7074
- }
7075
+ }
7076
+ if (masterFiles.length > 0) {
7077
+ const mTxt = await zip.file(masterFiles[0])?.async('text') || ''
7078
+ const mNameM = mTxt.match(/<p:cSld[^>]*name="([^"]+)"/)
7079
+ if (mNameM && mNameM[1]) masterName = mNameM[1]
7080
+ }
7081
+ if (!masterName || masterName === nameWithoutExt) {
7082
+ const themeFiles = Object.keys(zip.files).filter(k => k.startsWith('ppt/theme/theme') && !k.includes('_rels') && !zip.files[k].dir)
7083
+ if (themeFiles.length > 0) {
7084
+ const tTxt = await zip.file(themeFiles[0])?.async('text') || ''
7085
+ const tNameM = tTxt.match(/<a:theme[^>]*name="([^"]+)"/)
7086
+ if (tNameM && tNameM[1] && tNameM[1] !== 'Office 主题' && tNameM[1] !== 'Office Theme') {
7087
+ masterName = tNameM[1]
7075
7088
  }
7076
7089
  }
7077
- } catch (e) {}
7078
-
7079
-
7080
- // 从母版 primaryMasterXml 解析标准文本色(txStyles: titleStyle/bodyStyle),支持 lumMod/lumOff 调制
7081
- let masterTextColor = ''
7082
- try {
7083
- if (primaryMasterXml) {
7084
- const titleMatch = primaryMasterXml.match(/<p:titleStyle>[\s\S]*?<a:schemeClr val="([^"]+)">([\s\S]*?)<\/a:schemeClr>/)
7085
- const bodyMatch = primaryMasterXml.match(/<p:bodyStyle>[\s\S]*?<a:schemeClr val="([^"]+)">([\s\S]*?)<\/a:schemeClr>/)
7086
- const m = bodyMatch || titleMatch
7087
- if (m) {
7088
- const val = m[1]
7089
- const lumModM = m[2].match(/<a:lumMod val="(\d+)"/)
7090
- const lumOffM = m[2].match(/<a:lumOff val="(\d+)"/)
7090
+ }
7091
+ } catch (e) { }
7092
+
7093
+
7094
+ // 从母版 primaryMasterXml 解析标准文本色(txStyles: titleStyle/bodyStyle),支持 lumMod/lumOff 调制
7095
+ let masterTextColor = ''
7096
+ try {
7097
+ if (primaryMasterXml) {
7098
+ const titleMatch = primaryMasterXml.match(/<p:titleStyle>[\s\S]*?<a:schemeClr val="([^"]+)">([\s\S]*?)<\/a:schemeClr>/)
7099
+ const bodyMatch = primaryMasterXml.match(/<p:bodyStyle>[\s\S]*?<a:schemeClr val="([^"]+)">([\s\S]*?)<\/a:schemeClr>/)
7100
+ const m = bodyMatch || titleMatch
7101
+ if (m) {
7102
+ const val = m[1]
7103
+ const lumModM = m[2].match(/<a:lumMod val="(\d+)"/)
7104
+ const lumOffM = m[2].match(/<a:lumOff val="(\d+)"/)
7105
+ const lumMod = lumModM ? parseInt(lumModM[1], 10) / 100000 : 1
7106
+ const lumOff = lumOffM ? parseInt(lumOffM[1], 10) / 100000 : 0
7107
+ const slot = val === 'tx1' ? 'dk1' : val === 'bg1' ? 'lt1' : val === 'tx2' ? 'dk2' : val === 'bg2' ? 'lt2' : val
7108
+ const baseHex = themeColors[slot] || themeColors[val] || (slot === 'dk1' || slot === 'tx1' ? '#000000' : '#FFFFFF')
7109
+ masterTextColor = (lumMod !== 1 || lumOff !== 0) ? applyLumAdjust(baseHex, lumMod, lumOff) : baseHex
7110
+ }
7111
+ }
7112
+ } catch (mErr) { }
7113
+
7114
+ // 提取并扫描全局 schemeClr 表达片段与计算颜色映射(仅针对幻灯片、母版、版式与主题核心XML)
7115
+ const schemeFillMap: Record<string, string> = {}
7116
+ try {
7117
+ for (const [name, file] of Object.entries(zip.files)) {
7118
+ if (name.endsWith('.xml') && !name.includes('_rels') && (name.startsWith('ppt/slides/') || name.startsWith('ppt/slideMasters/') || name.startsWith('ppt/slideLayouts/') || name.startsWith('ppt/theme/'))) {
7119
+ const txt = await file.async('text')
7120
+ const regex = /<a:schemeClr\b[^>]*\/>|<a:schemeClr\b[^>]*>(?:(?!<\/a:schemeClr>)[\s\S])*<\/a:schemeClr>/g
7121
+ const matches = txt.match(regex) || []
7122
+ for (const m of matches) {
7123
+ const clean = m.replace(/\s+/g, ' ').trim()
7124
+ const valM = clean.match(/val="([^"]+)"/)
7125
+ if (!valM) continue
7126
+ const val = valM[1]
7127
+ if (val === 'phClr') continue
7128
+ const lumModM = clean.match(/<a:lumMod val="(\d+)"/)
7129
+ const lumOffM = clean.match(/<a:lumOff val="(\d+)"/)
7091
7130
  const lumMod = lumModM ? parseInt(lumModM[1], 10) / 100000 : 1
7092
7131
  const lumOff = lumOffM ? parseInt(lumOffM[1], 10) / 100000 : 0
7093
7132
  const slot = val === 'tx1' ? 'dk1' : val === 'bg1' ? 'lt1' : val === 'tx2' ? 'dk2' : val === 'bg2' ? 'lt2' : val
7094
- const baseHex = themeColors[slot] || themeColors[val] || (slot === 'dk1' || slot === 'tx1' ? '#000000' : '#FFFFFF')
7095
- masterTextColor = (lumMod !== 1 || lumOff !== 0) ? applyLumAdjust(baseHex, lumMod, lumOff) : baseHex
7096
- }
7097
- }
7098
- } catch (mErr) {}
7099
-
7100
- // 提取并扫描全局 schemeClr 表达片段与计算颜色映射(仅针对幻灯片、母版、版式与主题核心XML)
7101
- const schemeFillMap: Record<string, string> = {}
7102
- try {
7103
- for (const [name, file] of Object.entries(zip.files)) {
7104
- if (name.endsWith('.xml') && !name.includes('_rels') && (name.startsWith('ppt/slides/') || name.startsWith('ppt/slideMasters/') || name.startsWith('ppt/slideLayouts/') || name.startsWith('ppt/theme/'))) {
7105
- const txt = await file.async('text')
7106
- const regex = /<a:schemeClr\b[^>]*\/>|<a:schemeClr\b[^>]*>(?:(?!<\/a:schemeClr>)[\s\S])*<\/a:schemeClr>/g
7107
- const matches = txt.match(regex) || []
7108
- for (const m of matches) {
7109
- const clean = m.replace(/\s+/g, ' ').trim()
7110
- const valM = clean.match(/val="([^"]+)"/)
7111
- if (!valM) continue
7112
- const val = valM[1]
7113
- if (val === 'phClr') continue
7114
- const lumModM = clean.match(/<a:lumMod val="(\d+)"/)
7115
- const lumOffM = clean.match(/<a:lumOff val="(\d+)"/)
7116
- const lumMod = lumModM ? parseInt(lumModM[1], 10) / 100000 : 1
7117
- const lumOff = lumOffM ? parseInt(lumOffM[1], 10) / 100000 : 0
7118
- const slot = val === 'tx1' ? 'dk1' : val === 'bg1' ? 'lt1' : val === 'tx2' ? 'dk2' : val === 'bg2' ? 'lt2' : val
7119
- const baseHex = themeColors[slot] || themeColors[val] || ''
7120
- if (baseHex) {
7121
- const calcHex = (lumMod !== 1 || lumOff !== 0) ? applyLumAdjust(baseHex, lumMod, lumOff) : baseHex
7122
- const cleanHex = calcHex.replace('#', '').toUpperCase()
7123
- schemeFillMap[cleanHex] = clean
7124
-
7125
- // 处理 RGB 整数四舍五入 ±1 的容差 (如 8496B0 vs 8497B0)
7126
- const r = parseInt(cleanHex.slice(0, 2), 16)
7127
- const g = parseInt(cleanHex.slice(2, 4), 16)
7128
- const b = parseInt(cleanHex.slice(4, 6), 16)
7129
- const toHex = (num: number) => num.toString(16).padStart(2, '0').toUpperCase()
7130
- for (let dg = -1; dg <= 1; dg++) {
7131
- for (let db = -1; db <= 1; db++) {
7132
- if (dg === 0 && db === 0) continue
7133
- const nearHex = `${toHex(r)}${toHex(Math.min(255, Math.max(0, g + dg)))}${toHex(Math.min(255, Math.max(0, b + db)))}`
7134
- if (!schemeFillMap[nearHex]) {
7135
- schemeFillMap[nearHex] = clean
7136
- }
7133
+ const baseHex = themeColors[slot] || themeColors[val] || ''
7134
+ if (baseHex) {
7135
+ const calcHex = (lumMod !== 1 || lumOff !== 0) ? applyLumAdjust(baseHex, lumMod, lumOff) : baseHex
7136
+ const cleanHex = calcHex.replace('#', '').toUpperCase()
7137
+ schemeFillMap[cleanHex] = clean
7138
+
7139
+ // 处理 RGB 整数四舍五入 ±1 的容差 (如 8496B0 vs 8497B0)
7140
+ const r = parseInt(cleanHex.slice(0, 2), 16)
7141
+ const g = parseInt(cleanHex.slice(2, 4), 16)
7142
+ const b = parseInt(cleanHex.slice(4, 6), 16)
7143
+ const toHex = (num: number) => num.toString(16).padStart(2, '0').toUpperCase()
7144
+ for (let dg = -1; dg <= 1; dg++) {
7145
+ for (let db = -1; db <= 1; db++) {
7146
+ if (dg === 0 && db === 0) continue
7147
+ const nearHex = `${toHex(r)}${toHex(Math.min(255, Math.max(0, g + dg)))}${toHex(Math.min(255, Math.max(0, b + db)))}`
7148
+ if (!schemeFillMap[nearHex]) {
7149
+ schemeFillMap[nearHex] = clean
7137
7150
  }
7138
7151
  }
7139
7152
  }
7140
7153
  }
7141
7154
  }
7142
7155
  }
7143
- } catch (e) {}
7144
-
7145
- // 提取版式占位符几何字典与每页占位符图片的几何形状映射
7146
- const slidePicGeomMap: Array<Record<string, { geom?: string; polygonPoints?: string }>> = []
7147
- const slideSpLstStyleAlignMap: Array<Record<string, string>> = []
7148
- try {
7149
- const layoutGeomMap = new Map<string, Map<string, { geom?: string; polygonPoints?: string; type?: string }>>()
7150
- for (const layout of allLayouts) {
7151
- const phMap = new Map<string, { geom?: string; polygonPoints?: string; type?: string }>()
7152
- const spMatches = (layout.xml || '').match(/<p:(?:sp|pic)\b[\s\S]*?<\/p:(?:sp|pic)>/g) || []
7153
- for (const sp of spMatches) {
7154
- const phMatch = sp.match(/<p:ph\b[^>]*\/?>/)
7155
- if (!phMatch) continue
7156
- const phAttrs = phMatch[0]
7157
- const idxM = phAttrs.match(/idx="(\d+)"/)
7158
- const typeM = phAttrs.match(/type="(\w+)"/)
7159
- const prstM = sp.match(/<a:prstGeom\b[^>]*prst="([^"]+)"/)
7160
- const custM = sp.match(/<a:custGeom\b[\s\S]*?<\/a:custGeom>/)
7161
- let geom = prstM ? prstM[1] : undefined
7162
- let polygonPoints: string | undefined = undefined
7163
- if (custM) {
7164
- const poly = parseCustGeomToPolygon(custM[0])
7165
- if (poly) {
7166
- polygonPoints = poly
7167
- geom = 'custom'
7168
- }
7169
- }
7170
- const geomInfo = { geom, polygonPoints, type: typeM ? typeM[1] : undefined }
7171
- if (idxM) phMap.set('idx_' + idxM[1], geomInfo)
7172
- if (typeM) phMap.set('type_' + typeM[1], geomInfo)
7173
- }
7174
- layoutGeomMap.set(layout.file, phMap)
7175
- const shortName = layout.file.replace('ppt/slideLayouts/', '')
7176
- layoutGeomMap.set(shortName, phMap)
7177
- layoutGeomMap.set(`../slideLayouts/${shortName}`, phMap)
7178
- }
7156
+ }
7157
+ } catch (e) { }
7179
7158
 
7180
- const slideFiles = Object.keys(zip.files).filter(k => k.startsWith('ppt/slides/slide') && !k.includes('_rels') && !zip.files[k].dir)
7181
- slideFiles.sort(sortSlideXml)
7182
-
7183
- for (let i = 0; i < slideFiles.length; i++) {
7184
- const sXml = await zip.file(slideFiles[i])?.async('text') || ''
7185
- const lObj = slideLayoutMap[i]
7186
- const phMap = lObj ? (layoutGeomMap.get(lObj.file) || layoutGeomMap.get(lObj.id)) : null
7187
- const picGeomById: Record<string, { geom?: string; polygonPoints?: string }> = {}
7188
-
7189
- // 提取各文本框在 a:lstStyle 中定义的继承对齐方式(当段落自身无显式 a:pPr algn 时兜底生效)
7190
- const spLstStyleAlignById: Record<string, string> = {}
7191
- const spMatches = sXml.match(/<p:sp\b[\s\S]*?<\/p:sp>/g) || []
7192
- for (const sp of spMatches) {
7193
- const idM = sp.match(/<p:cNvPr\b[^>]*id="(\d+)"/)
7194
- if (!idM) continue
7195
- const id = idM[1]
7196
- const hasDirectPAlign = /<a:pPr\b[^>]*\balgn="/.test(sp)
7197
- const lstStyleM = sp.match(/<a:lstStyle\b[\s\S]*?<\/a:lstStyle>/)
7198
- if (!hasDirectPAlign && lstStyleM) {
7199
- const algnM = lstStyleM[0].match(/algn="(ctr|center|r|right|l|left|just|dist)"/)
7200
- if (algnM) {
7201
- const a = algnM[1]
7202
- const standardAlign = (a === 'ctr' || a === 'center') ? 'center' : (a === 'r' || a === 'right') ? 'right' : (a === 'dist') ? 'distribute' : (a === 'just') ? 'justify' : 'left'
7203
- spLstStyleAlignById[id] = standardAlign
7204
- }
7205
- }
7206
- }
7207
- slideSpLstStyleAlignMap.push(spLstStyleAlignById)
7208
-
7209
- const picMatches = sXml.match(/<p:pic\b[\s\S]*?<\/p:pic>/g) || []
7210
- for (const pic of picMatches) {
7211
- const idM = pic.match(/<p:cNvPr\b[^>]*id="(\d+)"/)
7212
- if (!idM) continue
7213
- const id = idM[1]
7214
- const phMatch = pic.match(/<p:ph\b[^>]*\/?>/)
7215
- if (phMatch && phMap) {
7216
- const phAttrs = phMatch[0]
7217
- const idxM = phAttrs.match(/idx="(\d+)"/)
7218
- const typeM = phAttrs.match(/type="(\w+)"/)
7219
- let geomInfo: { geom?: string; polygonPoints?: string } | undefined
7220
- if (idxM && phMap.has('idx_' + idxM[1])) {
7221
- geomInfo = phMap.get('idx_' + idxM[1])
7222
- } else if (typeM && phMap.has('type_' + typeM[1])) {
7223
- geomInfo = phMap.get('type_' + typeM[1])
7224
- }
7225
- if (geomInfo) {
7226
- picGeomById[id] = geomInfo
7227
- }
7159
+ // 提取版式占位符几何字典与每页占位符图片的几何形状映射
7160
+ const slidePicGeomMap: Array<Record<string, { geom?: string; polygonPoints?: string }>> = []
7161
+ const slideSpLstStyleAlignMap: Array<Record<string, string>> = []
7162
+ try {
7163
+ const layoutGeomMap = new Map<string, Map<string, { geom?: string; polygonPoints?: string; type?: string }>>()
7164
+ for (const layout of allLayouts) {
7165
+ const phMap = new Map<string, { geom?: string; polygonPoints?: string; type?: string }>()
7166
+ const spMatches = (layout.xml || '').match(/<p:(?:sp|pic)\b[\s\S]*?<\/p:(?:sp|pic)>/g) || []
7167
+ for (const sp of spMatches) {
7168
+ const phMatch = sp.match(/<p:ph\b[^>]*\/?>/)
7169
+ if (!phMatch) continue
7170
+ const phAttrs = phMatch[0]
7171
+ const idxM = phAttrs.match(/idx="(\d+)"/)
7172
+ const typeM = phAttrs.match(/type="(\w+)"/)
7173
+ const prstM = sp.match(/<a:prstGeom\b[^>]*prst="([^"]+)"/)
7174
+ const custM = sp.match(/<a:custGeom\b[\s\S]*?<\/a:custGeom>/)
7175
+ let geom = prstM ? prstM[1] : undefined
7176
+ let polygonPoints: string | undefined = undefined
7177
+ if (custM) {
7178
+ const poly = parseCustGeomToPolygon(custM[0])
7179
+ if (poly) {
7180
+ polygonPoints = poly
7181
+ geom = 'custom'
7228
7182
  }
7229
7183
  }
7230
- slidePicGeomMap.push(picGeomById)
7231
- }
7232
- } catch (geomErr) {
7233
- console.warn('提取占位符几何失败:', geomErr)
7234
- }
7235
-
7236
- // 捕获完整原生母版与版式文件及底层依赖媒体,供导出时无损注入原生 PPT 母版架构体系
7237
- const masterFilesMap: Record<string, string> = {}
7238
- for (const k of Object.keys(zip.files)) {
7239
- if (k.startsWith('ppt/slideMasters/') && (k.endsWith('.xml') || k.endsWith('.rels')) && !zip.files[k].dir) {
7240
- masterFilesMap[k] = await zip.file(k)?.async('text') || ''
7184
+ const geomInfo = { geom, polygonPoints, type: typeM ? typeM[1] : undefined }
7185
+ if (idxM) phMap.set('idx_' + idxM[1], geomInfo)
7186
+ if (typeM) phMap.set('type_' + typeM[1], geomInfo)
7241
7187
  }
7188
+ layoutGeomMap.set(layout.file, phMap)
7189
+ const shortName = layout.file.replace('ppt/slideLayouts/', '')
7190
+ layoutGeomMap.set(shortName, phMap)
7191
+ layoutGeomMap.set(`../slideLayouts/${shortName}`, phMap)
7242
7192
  }
7243
7193
 
7244
- const layoutFilesMap: Record<string, string> = {}
7245
- for (const k of Object.keys(zip.files)) {
7246
- if (k.startsWith('ppt/slideLayouts/') && (k.endsWith('.xml') || k.endsWith('.rels')) && !zip.files[k].dir) {
7247
- layoutFilesMap[k] = await zip.file(k)?.async('text') || ''
7194
+ const slideFiles = Object.keys(zip.files).filter(k => k.startsWith('ppt/slides/slide') && !k.includes('_rels') && !zip.files[k].dir)
7195
+ slideFiles.sort(sortSlideXml)
7196
+
7197
+ for (let i = 0; i < slideFiles.length; i++) {
7198
+ const sXml = await zip.file(slideFiles[i])?.async('text') || ''
7199
+ const lObj = slideLayoutMap[i]
7200
+ const phMap = lObj ? (layoutGeomMap.get(lObj.file) || layoutGeomMap.get(lObj.id)) : null
7201
+ const picGeomById: Record<string, { geom?: string; polygonPoints?: string }> = {}
7202
+
7203
+ // 提取各文本框在 a:lstStyle 中定义的继承对齐方式(当段落自身无显式 a:pPr algn 时兜底生效)
7204
+ const spLstStyleAlignById: Record<string, string> = {}
7205
+ const spMatches = sXml.match(/<p:sp\b[\s\S]*?<\/p:sp>/g) || []
7206
+ for (const sp of spMatches) {
7207
+ const idM = sp.match(/<p:cNvPr\b[^>]*id="(\d+)"/)
7208
+ if (!idM) continue
7209
+ const id = idM[1]
7210
+ const hasDirectPAlign = /<a:pPr\b[^>]*\balgn="/.test(sp)
7211
+ const lstStyleM = sp.match(/<a:lstStyle\b[\s\S]*?<\/a:lstStyle>/)
7212
+ if (!hasDirectPAlign && lstStyleM) {
7213
+ const algnM = lstStyleM[0].match(/algn="(ctr|center|r|right|l|left|just|dist)"/)
7214
+ if (algnM) {
7215
+ const a = algnM[1]
7216
+ const standardAlign = (a === 'ctr' || a === 'center') ? 'center' : (a === 'r' || a === 'right') ? 'right' : (a === 'dist') ? 'distribute' : (a === 'just') ? 'justify' : 'left'
7217
+ spLstStyleAlignById[id] = standardAlign
7218
+ }
7219
+ }
7248
7220
  }
7249
- }
7250
-
7251
- const masterMediaMap: Record<string, string> = {}
7252
- const relsFiles = [...Object.keys(masterFilesMap), ...Object.keys(layoutFilesMap)].filter(k => k.includes('_rels/'))
7253
- for (const rf of relsFiles) {
7254
- const rTxt = masterFilesMap[rf] || layoutFilesMap[rf] || ''
7255
- const targetMatches = rTxt.match(/Target="([^"]+)"/g) || []
7256
- for (const tm of targetMatches) {
7257
- const rawTarget = tm.replace(/^Target="/, '').replace(/"$/, '')
7258
- if (rawTarget.includes('media/') || rawTarget.includes('../media/')) {
7259
- const sourcePart = rf.replace('/_rels/', '/').replace(/\.rels$/, '')
7260
- const stack = sourcePart.split('/').slice(0, -1)
7261
- for (const part of rawTarget.split('/')) {
7262
- if (part === '..') stack.pop()
7263
- else if (part && part !== '.') stack.push(part)
7221
+ slideSpLstStyleAlignMap.push(spLstStyleAlignById)
7222
+
7223
+ const picMatches = sXml.match(/<p:pic\b[\s\S]*?<\/p:pic>/g) || []
7224
+ for (const pic of picMatches) {
7225
+ const idM = pic.match(/<p:cNvPr\b[^>]*id="(\d+)"/)
7226
+ if (!idM) continue
7227
+ const id = idM[1]
7228
+ const phMatch = pic.match(/<p:ph\b[^>]*\/?>/)
7229
+ if (phMatch && phMap) {
7230
+ const phAttrs = phMatch[0]
7231
+ const idxM = phAttrs.match(/idx="(\d+)"/)
7232
+ const typeM = phAttrs.match(/type="(\w+)"/)
7233
+ let geomInfo: { geom?: string; polygonPoints?: string } | undefined
7234
+ if (idxM && phMap.has('idx_' + idxM[1])) {
7235
+ geomInfo = phMap.get('idx_' + idxM[1])
7236
+ } else if (typeM && phMap.has('type_' + typeM[1])) {
7237
+ geomInfo = phMap.get('type_' + typeM[1])
7264
7238
  }
7265
- const mediaPath = stack.join('/')
7266
- if (!masterMediaMap[mediaPath] && zip.files[mediaPath]) {
7267
- const b64 = await zip.file(mediaPath)?.async('base64')
7268
- if (b64) masterMediaMap[mediaPath] = b64
7239
+ if (geomInfo) {
7240
+ picGeomById[id] = geomInfo
7269
7241
  }
7270
7242
  }
7271
7243
  }
7244
+ slidePicGeomMap.push(picGeomById)
7272
7245
  }
7246
+ } catch (geomErr) {
7247
+ console.warn('提取占位符几何失败:', geomErr)
7248
+ }
7273
7249
 
7274
- const tagFilesMap: Record<string, string> = {}
7275
- for (const k of Object.keys(zip.files)) {
7276
- if (k.startsWith('ppt/tags/') && k.endsWith('.xml') && !zip.files[k].dir) {
7277
- tagFilesMap[k] = await zip.file(k)?.async('text') || ''
7278
- }
7250
+ // 捕获完整原生母版与版式文件及底层依赖媒体,供导出时无损注入原生 PPT 母版架构体系
7251
+ const masterFilesMap: Record<string, string> = {}
7252
+ for (const k of Object.keys(zip.files)) {
7253
+ if (k.startsWith('ppt/slideMasters/') && (k.endsWith('.xml') || k.endsWith('.rels')) && !zip.files[k].dir) {
7254
+ masterFilesMap[k] = await zip.file(k)?.async('text') || ''
7279
7255
  }
7256
+ }
7280
7257
 
7281
- zipMeta = {
7282
- allLayouts,
7283
- slideLayoutMap,
7284
- masterName: masterName || nameWithoutExt,
7285
- masterShapesInfo,
7286
- themeColors,
7287
- schemeFillMap,
7288
- masterPhXfrm,
7289
- primaryMasterXml,
7290
- masterPicSrcMap,
7291
- masterXmlBg,
7292
- slidePicGeomMap,
7293
- slideSpLstStyleAlignMap,
7294
- masterTextColor,
7295
- masterFilesMap,
7296
- layoutFilesMap,
7297
- masterMediaMap,
7298
- tagFilesMap,
7299
- slideLayoutTargetList
7258
+ const layoutFilesMap: Record<string, string> = {}
7259
+ for (const k of Object.keys(zip.files)) {
7260
+ if (k.startsWith('ppt/slideLayouts/') && (k.endsWith('.xml') || k.endsWith('.rels')) && !zip.files[k].dir) {
7261
+ layoutFilesMap[k] = await zip.file(k)?.async('text') || ''
7300
7262
  }
7301
- } catch (e) {
7302
- console.warn('Failed to extract zipMeta:', e)
7303
7263
  }
7304
7264
 
7305
- const parsedData = parsePPTXJSONData(json, zipMeta)
7306
- let animsPerSlide: any[] = []
7307
- try {
7308
- animsPerSlide = await extractAnimationsFromPptxArrayBuffer(loadedZip || buffer, parsedData?.slides || [])
7309
- } catch (animErr) {
7310
- console.warn('提取 PPTX 动画失败:', animErr)
7311
- }
7312
- for (let i = 0; i < (parsedData?.slides?.length || 0); i++) {
7313
- if (!parsedData.slides[i].animations || !parsedData.slides[i].animations.length) {
7314
- if (animsPerSlide && animsPerSlide[i] && animsPerSlide[i].length) {
7315
- parsedData.slides[i].animations = animsPerSlide[i]
7265
+ const masterMediaMap: Record<string, string> = {}
7266
+ const relsFiles = [...Object.keys(masterFilesMap), ...Object.keys(layoutFilesMap)].filter(k => k.includes('_rels/'))
7267
+ for (const rf of relsFiles) {
7268
+ const rTxt = masterFilesMap[rf] || layoutFilesMap[rf] || ''
7269
+ const targetMatches = rTxt.match(/Target="([^"]+)"/g) || []
7270
+ for (const tm of targetMatches) {
7271
+ const rawTarget = tm.replace(/^Target="/, '').replace(/"$/, '')
7272
+ if (rawTarget.includes('media/') || rawTarget.includes('../media/')) {
7273
+ const sourcePart = rf.replace('/_rels/', '/').replace(/\.rels$/, '')
7274
+ const stack = sourcePart.split('/').slice(0, -1)
7275
+ for (const part of rawTarget.split('/')) {
7276
+ if (part === '..') stack.pop()
7277
+ else if (part && part !== '.') stack.push(part)
7278
+ }
7279
+ const mediaPath = stack.join('/')
7280
+ if (!masterMediaMap[mediaPath] && zip.files[mediaPath]) {
7281
+ const b64 = await zip.file(mediaPath)?.async('base64')
7282
+ if (b64) masterMediaMap[mediaPath] = b64
7283
+ }
7316
7284
  }
7317
7285
  }
7318
7286
  }
7319
7287
 
7320
- if (parsedData.slides.length > 0) {
7321
- if (typeof getCustomUploadHandler === 'function' && getCustomUploadHandler()) {
7322
- loadingText.value = '正在上传媒体资源并导入...'
7323
- await uploadMediaResourcesInSlides(parsedData)
7288
+ const tagFilesMap: Record<string, string> = {}
7289
+ for (const k of Object.keys(zip.files)) {
7290
+ if (k.startsWith('ppt/tags/') && k.endsWith('.xml') && !zip.files[k].dir) {
7291
+ tagFilesMap[k] = await zip.file(k)?.async('text') || ''
7324
7292
  }
7325
- presentationTitle.value = nameWithoutExt
7326
- importSlides(parsedData)
7327
- return true
7328
- } else {
7329
- notify('导入失败:文件中未发现有效幻灯片页')
7330
- return false
7331
7293
  }
7332
- } catch (err: any) {
7333
- console.error(err)
7334
- notify('导入 PPTX 失败: ' + err.message)
7335
- return false
7336
- } finally {
7337
- isExporting.value = false
7294
+
7295
+ zipMeta = {
7296
+ allLayouts,
7297
+ slideLayoutMap,
7298
+ masterName: masterName || nameWithoutExt,
7299
+ masterShapesInfo,
7300
+ themeColors,
7301
+ schemeFillMap,
7302
+ masterPhXfrm,
7303
+ primaryMasterXml,
7304
+ masterPicSrcMap,
7305
+ masterXmlBg,
7306
+ slidePicGeomMap,
7307
+ slideSpLstStyleAlignMap,
7308
+ masterTextColor,
7309
+ masterFilesMap,
7310
+ layoutFilesMap,
7311
+ masterMediaMap,
7312
+ tagFilesMap,
7313
+ slideLayoutTargetList
7314
+ }
7315
+ } catch (e) {
7316
+ console.warn('Failed to extract zipMeta:', e)
7338
7317
  }
7339
- }
7340
7318
 
7341
- const onFileChange = async (e: Event) => {
7342
- const target = e.target as HTMLInputElement
7343
- const files = target.files
7344
- if (!files || files.length === 0) return
7319
+ const parsedData = parsePPTXJSONData(json, zipMeta)
7320
+ let animsPerSlide: any[] = []
7321
+ try {
7322
+ animsPerSlide = await extractAnimationsFromPptxArrayBuffer(loadedZip || buffer, parsedData?.slides || [])
7323
+ } catch (animErr) {
7324
+ console.warn('提取 PPTX 动画失败:', animErr)
7325
+ }
7326
+ for (let i = 0; i < (parsedData?.slides?.length || 0); i++) {
7327
+ if (!parsedData.slides[i].animations || !parsedData.slides[i].animations.length) {
7328
+ if (animsPerSlide && animsPerSlide[i] && animsPerSlide[i].length) {
7329
+ parsedData.slides[i].animations = animsPerSlide[i]
7330
+ }
7331
+ }
7332
+ }
7345
7333
 
7346
- const file = files[0]
7347
- const reader = new FileReader()
7348
- reader.onload = async (event) => {
7349
- try {
7350
- const buffer = event.target?.result as ArrayBuffer
7351
- await importPptxArrayBuffer(buffer, file.name)
7352
- } finally {
7353
- target.value = ''
7334
+ if (parsedData.slides.length > 0) {
7335
+ if (typeof getCustomUploadHandler === 'function' && getCustomUploadHandler()) {
7336
+ loadingText.value = '正在上传媒体资源并导入...'
7337
+ await uploadMediaResourcesInSlides(parsedData)
7354
7338
  }
7339
+ presentationTitle.value = nameWithoutExt
7340
+ importSlides(parsedData)
7341
+ return true
7342
+ } else {
7343
+ notify('导入失败:文件中未发现有效幻灯片页')
7344
+ return false
7355
7345
  }
7356
- reader.readAsArrayBuffer(file)
7346
+ } catch (err: any) {
7347
+ console.error(err)
7348
+ notify('导入 PPTX 失败: ' + err.message)
7349
+ return false
7350
+ } finally {
7351
+ isExporting.value = false
7357
7352
  }
7353
+ }
7358
7354
 
7359
- const props = withDefaults(defineProps<{
7360
- slotProps?: any
7361
- showMenu?: boolean
7362
- toolbarMode?: 'auto' | 'more' | 'full'
7363
- visible?: boolean
7364
- }>(), {
7365
- showMenu: true,
7366
- toolbarMode: 'auto',
7367
- visible: true
7368
- })
7355
+ const onFileChange = async (e: Event) => {
7356
+ const target = e.target as HTMLInputElement
7357
+ const files = target.files
7358
+ if (!files || files.length === 0) return
7369
7359
 
7370
- // 不能整体展开 slotProps:其 deck 是惰性 getter,展开会立刻执行 getDeck() 整册深拷贝
7371
- // (110 页实测 ~500ms/次),且展开发生在本 computed 内会把本 computed 订阅到全册,
7372
- // 拖拽/打字每步都失效重算。改为逐键拷贝(跳过 deck),deck 仍以 getter 形式透传,
7373
- // 只在消费者真正读取时才现算
7374
- const headerContext = computed(() => {
7375
- const sp: any = props.slotProps || {}
7376
- const merged: any = {
7377
- showMenu: props.showMenu,
7378
- presentationTitle: presentationTitle.value,
7379
- isEditingTitle: isEditingTitle.value,
7380
- startEditingTitle,
7381
- stopEditingTitle,
7382
- zoomRatio: zoomRatio.value,
7383
- setZoomRatio: (val: number) => { zoomRatio.value = val },
7384
- resetZoom,
7385
- isMasterEditingView: isMasterEditingView.value,
7386
- enterMasterView,
7387
- exitMasterView,
7388
- startPresentation,
7389
- startFromBeginning,
7390
- triggerPPTXImport,
7391
- triggerJSONImport
7392
- }
7393
- for (const k of Object.keys(sp)) {
7394
- if (k === 'deck' || k in merged) continue
7395
- merged[k] = sp[k]
7396
- }
7397
- if ('deck' in sp) {
7398
- Object.defineProperty(merged, 'deck', {
7399
- get: () => sp.deck,
7400
- enumerable: true,
7401
- configurable: true
7402
- })
7360
+ const file = files[0]
7361
+ const reader = new FileReader()
7362
+ reader.onload = async (event) => {
7363
+ try {
7364
+ const buffer = event.target?.result as ArrayBuffer
7365
+ await importPptxArrayBuffer(buffer, file.name)
7366
+ } finally {
7367
+ target.value = ''
7403
7368
  }
7404
- return merged
7405
- })
7369
+ }
7370
+ reader.readAsArrayBuffer(file)
7371
+ }
7406
7372
 
7407
- function handleToolbarWheel(e: WheelEvent) {
7408
- const el = e.currentTarget as HTMLElement
7409
- if (el && el.scrollWidth > el.clientWidth) {
7410
- if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
7411
- el.scrollLeft += e.deltaY
7412
- }
7373
+ const props = withDefaults(defineProps<{
7374
+ slotProps?: any
7375
+ showMenu?: boolean
7376
+ toolbarMode?: 'auto' | 'more' | 'full'
7377
+ visible?: boolean
7378
+ }>(), {
7379
+ showMenu: true,
7380
+ toolbarMode: 'auto',
7381
+ visible: true
7382
+ })
7383
+
7384
+ // 不能整体展开 slotProps:其 deck 是惰性 getter,展开会立刻执行 getDeck() 整册深拷贝
7385
+ // (110 页实测 ~500ms/次),且展开发生在本 computed 内会把本 computed 订阅到全册,
7386
+ // 拖拽/打字每步都失效重算。改为逐键拷贝(跳过 deck),deck 仍以 getter 形式透传,
7387
+ // 只在消费者真正读取时才现算
7388
+ const headerContext = computed(() => {
7389
+ const sp: any = props.slotProps || {}
7390
+ const merged: any = {
7391
+ showMenu: props.showMenu,
7392
+ presentationTitle: presentationTitle.value,
7393
+ isEditingTitle: isEditingTitle.value,
7394
+ startEditingTitle,
7395
+ stopEditingTitle,
7396
+ zoomRatio: zoomRatio.value,
7397
+ setZoomRatio: (val: number) => { zoomRatio.value = val },
7398
+ resetZoom,
7399
+ isMasterEditingView: isMasterEditingView.value,
7400
+ enterMasterView,
7401
+ exitMasterView,
7402
+ startPresentation,
7403
+ startFromBeginning,
7404
+ triggerPPTXImport,
7405
+ triggerJSONImport
7406
+ }
7407
+ for (const k of Object.keys(sp)) {
7408
+ if (k === 'deck' || k in merged) continue
7409
+ merged[k] = sp[k]
7410
+ }
7411
+ if ('deck' in sp) {
7412
+ Object.defineProperty(merged, 'deck', {
7413
+ get: () => sp.deck,
7414
+ enumerable: true,
7415
+ configurable: true
7416
+ })
7417
+ }
7418
+ return merged
7419
+ })
7420
+
7421
+ function handleToolbarWheel(e: WheelEvent) {
7422
+ const el = e.currentTarget as HTMLElement
7423
+ if (el && el.scrollWidth > el.clientWidth) {
7424
+ if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
7425
+ el.scrollLeft += e.deltaY
7413
7426
  }
7414
7427
  }
7428
+ }
7415
7429
 
7416
- defineExpose({
7417
- generatePptxBlob,
7418
- generateImagesZipBlob,
7419
- downloadAsPptx,
7420
- downloadAllSlidesAsZip,
7421
- downloadCurrentSlide,
7422
- triggerPPTXImport,
7423
- triggerJSONImport,
7424
- importPptxArrayBuffer
7425
- })
7430
+ defineExpose({
7431
+ generatePptxBlob,
7432
+ generateImagesZipBlob,
7433
+ downloadAsPptx,
7434
+ downloadAllSlidesAsZip,
7435
+ downloadCurrentSlide,
7436
+ triggerPPTXImport,
7437
+ triggerJSONImport,
7438
+ importPptxArrayBuffer
7439
+ })
7426
7440
  </script>
7427
7441
 
7428
7442
  <template>
@@ -7438,167 +7452,178 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
7438
7452
  <!-- 外部左侧主体插槽 (默认呈现内置菜单和标题) -->
7439
7453
  <slot name="left" v-bind="headerContext">
7440
7454
  <el-popover v-if="showMenu" ref="menuPopoverRef" placement="bottom-start" :width="320" trigger="click"
7441
- popper-style="padding: 0; border-radius: 12px; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); border: 1px solid #e2e8f0; background: #fff; overflow: hidden;">
7442
- <template #reference>
7443
- <button class="btn-icon" style="padding: 4px; color: var(--text-primary); flex-shrink: 0;" data-tooltip="菜单">
7444
- <svg class="icon" style="width: 18px; height: 18px" viewBox="0 0 24 24">
7445
- <path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
7446
- </svg>
7447
- </button>
7448
- </template>
7449
-
7450
- <div class="main-menu-popover">
7451
- <!-- AIPPT Banner -->
7452
- <div class="aippt-banner" @click="handleMenuAction(handleAIPPT)" v-if="false">
7453
- <div class="aippt-icon-container">
7454
- <svg class="aippt-arrow-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
7455
- <path d="M4.5 3L4 16.5L8.5 12.5L13.5 20L16.5 18L11.5 10.5L17 10L4.5 3Z" fill="#ffb800" stroke="#fff"
7456
- stroke-width="1.5" stroke-linejoin="round" />
7457
- <path d="M19 4L19.5 2.5L21 2L19.5 1.5L19 0L18.5 1.5L17 2L18.5 2.5L19 4Z" fill="#ffb800" />
7458
- <path d="M21 14L21.5 12.5L23 12L21.5 11.5L21 10L20.5 11.5L19 12L20.5 12.5L21 14Z" fill="#ff9900" />
7459
- <path d="M3 21L3.5 19.5L5 19L3.5 18.5L3 17L2.5 18.5L1 19L2.5 19.5L3 21Z" fill="#ffcc00" />
7455
+ popper-style="padding: 0; border-radius: 12px; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); border: 1px solid #e2e8f0; background: #fff; overflow: hidden;">
7456
+ <template #reference>
7457
+ <button class="btn-icon" style="padding: 4px; color: var(--text-primary); flex-shrink: 0;"
7458
+ data-tooltip="菜单">
7459
+ <svg class="icon" style="width: 18px; height: 18px" viewBox="0 0 24 24">
7460
+ <path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
7460
7461
  </svg>
7461
- </div>
7462
- <div class="aippt-text-container">
7463
- <div class="aippt-title">
7464
- <span style="color: #3b82f6">AIPP</span><span style="color: #d946ef">T</span>
7462
+ </button>
7463
+ </template>
7464
+
7465
+ <div class="main-menu-popover">
7466
+ <!-- AIPPT Banner -->
7467
+ <div class="aippt-banner" @click="handleMenuAction(handleAIPPT)" v-if="false">
7468
+ <div class="aippt-icon-container">
7469
+ <svg class="aippt-arrow-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
7470
+ <path d="M4.5 3L4 16.5L8.5 12.5L13.5 20L16.5 18L11.5 10.5L17 10L4.5 3Z" fill="#ffb800" stroke="#fff"
7471
+ stroke-width="1.5" stroke-linejoin="round" />
7472
+ <path d="M19 4L19.5 2.5L21 2L19.5 1.5L19 0L18.5 1.5L17 2L18.5 2.5L19 4Z" fill="#ffb800" />
7473
+ <path d="M21 14L21.5 12.5L23 12L21.5 11.5L21 10L20.5 11.5L19 12L20.5 12.5L21 14Z" fill="#ff9900" />
7474
+ <path d="M3 21L3.5 19.5L5 19L3.5 18.5L3 17L2.5 18.5L1 19L2.5 19.5L3 21Z" fill="#ffcc00" />
7475
+ </svg>
7476
+ </div>
7477
+ <div class="aippt-text-container">
7478
+ <div class="aippt-title">
7479
+ <span style="color: #3b82f6">AIPP</span><span style="color: #d946ef">T</span>
7480
+ </div>
7481
+ <div class="aippt-desc">输入一句话,智能生成演示文稿</div>
7465
7482
  </div>
7466
- <div class="aippt-desc">输入一句话,智能生成演示文稿</div>
7467
7483
  </div>
7468
- </div>
7469
7484
 
7470
- <div class="menu-body">
7471
- <!-- Import File Section -->
7472
- <div class="menu-section-title">导入文件</div>
7473
- <div class="import-grid">
7474
- <!-- PPTX Import -->
7475
- <div class="import-card" @click="handleMenuAction(triggerPPTXImport)">
7476
- <div class="import-icon-box pptx-icon">
7477
- <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
7478
- <path
7479
- d="M4 4C4 2.89543 4.89543 2 6 2H14L20 8V20C20 21.1046 19.1046 22 18 22H6C4.89543 22 4 21.1046 4 20V4Z"
7480
- fill="#ea4335" />
7481
- <path d="M14 2V8H20L14 2Z" fill="#c5221f" />
7482
- <text x="7" y="17" fill="#fff" font-size="9" font-weight="900" font-family="Arial">P</text>
7483
- </svg>
7485
+ <div class="menu-body">
7486
+ <!-- Import File Section -->
7487
+ <div class="menu-section-title">导入文件</div>
7488
+ <div class="import-grid">
7489
+ <!-- PPTX Import -->
7490
+ <div class="import-card" @click="handleMenuAction(triggerPPTXImport)">
7491
+ <div class="import-icon-box pptx-icon">
7492
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
7493
+ <path
7494
+ d="M4 4C4 2.89543 4.89543 2 6 2H14L20 8V20C20 21.1046 19.1046 22 18 22H6C4.89543 22 4 21.1046 4 20V4Z"
7495
+ fill="#ea4335" />
7496
+ <path d="M14 2V8H20L14 2Z" fill="#c5221f" />
7497
+ <text x="7" y="17" fill="#fff" font-size="9" font-weight="900" font-family="Arial">P</text>
7498
+ </svg>
7499
+ </div>
7500
+ <div class="import-label">PPTX</div>
7501
+ <div class="import-subtext">(仅供测试)</div>
7484
7502
  </div>
7485
- <div class="import-label">PPTX</div>
7486
- <div class="import-subtext">(仅供测试)</div>
7487
- </div>
7488
7503
 
7489
- <!-- JSON Import -->
7490
- <div class="import-card" @click="handleMenuAction(triggerJSONImport)">
7491
- <div class="import-icon-box json-icon">
7492
- <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
7493
- <path
7494
- d="M4 4C4 2.89543 4.89543 2 6 2H14L20 8V20C20 21.1046 19.1046 22 18 22H6C4.89543 22 4 21.1046 4 20V4Z"
7495
- fill="#f2994a" />
7496
- <path d="M14 2V8H20L14 2Z" fill="#e07c24" />
7497
- <text x="7" y="17" fill="#fff" font-size="9" font-weight="900" font-family="Arial">J</text>
7498
- </svg>
7504
+ <!-- JSON Import -->
7505
+ <div class="import-card" @click="handleMenuAction(triggerJSONImport)">
7506
+ <div class="import-icon-box json-icon">
7507
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
7508
+ <path
7509
+ d="M4 4C4 2.89543 4.89543 2 6 2H14L20 8V20C20 21.1046 19.1046 22 18 22H6C4.89543 22 4 21.1046 4 20V4Z"
7510
+ fill="#f2994a" />
7511
+ <path d="M14 2V8H20L14 2Z" fill="#e07c24" />
7512
+ <text x="7" y="17" fill="#fff" font-size="9" font-weight="900" font-family="Arial">J</text>
7513
+ </svg>
7514
+ </div>
7515
+ <div class="import-label">JSON</div>
7516
+ <div class="import-subtext">(仅供测试)</div>
7517
+ </div>
7518
+
7519
+ <!-- JVS-PPT Import -->
7520
+ <div class="import-card" @click="handleMenuAction(handleJVSPPTImport)">
7521
+ <div class="import-icon-box pptist-icon">
7522
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
7523
+ <path
7524
+ d="M4 4C4 2.89543 4.89543 2 6 2H14L20 8V20C20 21.1046 19.1046 22 18 22H6C4.89543 22 4 21.1046 4 20V4Z"
7525
+ fill="#eb5757" />
7526
+ <path d="M14 2V8H20L14 2Z" fill="#c03930" />
7527
+ <rect x="7" y="12" width="10" height="2" rx="1" fill="#fff" />
7528
+ <rect x="7" y="16" width="10" height="2" rx="1" fill="#fff" />
7529
+ </svg>
7530
+ </div>
7531
+ <div class="import-label">JVS-PPT</div>
7532
+ <div class="import-subtext">(专属格式)</div>
7499
7533
  </div>
7500
- <div class="import-label">JSON</div>
7501
- <div class="import-subtext">(仅供测试)</div>
7502
7534
  </div>
7503
7535
 
7504
- <!-- JVS-PPT Import -->
7505
- <div class="import-card" @click="handleMenuAction(handleJVSPPTImport)">
7506
- <div class="import-icon-box pptist-icon">
7507
- <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
7536
+ <!-- Divider -->
7537
+ <div class="menu-divider"></div>
7538
+
7539
+ <!-- Link Items List -->
7540
+ <div class="menu-links">
7541
+ <!-- Export -->
7542
+ <el-dropdown trigger="click" @command="handleExportCommand" :disabled="isExporting"
7543
+ style="width: 100%; display: block; text-align: left;">
7544
+ <div class="menu-link-item">
7545
+ <svg class="link-icon" viewBox="0 0 24 24">
7546
+ <path
7547
+ d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z"
7548
+ fill="currentColor" />
7549
+ </svg>
7550
+ <span class="link-text">{{ isExporting ? '正在导出...' : '导出文件' }}</span>
7551
+ </div>
7552
+ <template #dropdown>
7553
+ <el-dropdown-menu>
7554
+ <el-dropdown-item command="png">下载当前页 (PNG)</el-dropdown-item>
7555
+ <el-dropdown-item command="jpeg">下载当前页 (JPEG)</el-dropdown-item>
7556
+ <el-dropdown-item command="zip">下载全部 (ZIP)</el-dropdown-item>
7557
+ <el-dropdown-item command="pptx">下载为 PPTX</el-dropdown-item>
7558
+ <el-dropdown-item command="json" divided>导出完整 JSON 配置</el-dropdown-item>
7559
+ <el-dropdown-item command="json-template">导出纯版式 JSON (排除图片/清空文本)</el-dropdown-item>
7560
+ </el-dropdown-menu>
7561
+ </template>
7562
+ </el-dropdown>
7563
+
7564
+ <!-- Reset -->
7565
+ <div v-if="!isReadonly" class="menu-link-item" @click="handleMenuAction(handleResetSlides)">
7566
+ <svg class="link-icon" viewBox="0 0 24 24">
7508
7567
  <path
7509
- d="M4 4C4 2.89543 4.89543 2 6 2H14L20 8V20C20 21.1046 19.1046 22 18 22H6C4.89543 22 4 21.1046 4 20V4Z"
7510
- fill="#eb5757" />
7511
- <path d="M14 2V8H20L14 2Z" fill="#c03930" />
7512
- <rect x="7" y="12" width="10" height="2" rx="1" fill="#fff" />
7513
- <rect x="7" y="16" width="10" height="2" rx="1" fill="#fff" />
7568
+ d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"
7569
+ fill="currentColor" />
7514
7570
  </svg>
7571
+ <span class="link-text">重置幻灯片</span>
7515
7572
  </div>
7516
- <div class="import-label">JVS-PPT</div>
7517
- <div class="import-subtext">(专属格式)</div>
7518
- </div>
7519
- </div>
7520
7573
 
7521
- <!-- Divider -->
7522
- <div class="menu-divider"></div>
7574
+ <!-- Label -->
7575
+ <div class="menu-link-item" @click="handleMenuAction(handleLabelSlides)">
7576
+ <svg class="link-icon" viewBox="0 0 24 24">
7577
+ <path d="M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6z" fill="currentColor" />
7578
+ </svg>
7579
+ <span class="link-text">幻灯片类型标注</span>
7580
+ </div>
7523
7581
 
7524
- <!-- Link Items List -->
7525
- <div class="menu-links">
7526
- <!-- Export -->
7527
- <el-dropdown trigger="click" @command="handleExportCommand" :disabled="isExporting"
7528
- style="width: 100%; display: block; text-align: left;">
7529
- <div class="menu-link-item">
7582
+ <!-- Shortcuts -->
7583
+ <div class="menu-link-item" @click="handleMenuAction(handleShortcuts)">
7530
7584
  <svg class="link-icon" viewBox="0 0 24 24">
7531
7585
  <path
7532
- d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z"
7586
+ d="M4 15h2v3H4v-3zm0-4h2v3H4v-3zm0-4h2v3H4V7zm4 8h8v3H8v-3zm0-4h8v3H8v-3zm0-4h8v3H8V7zm12 8h2v3h-2v-3zm0-4h2v3h-2v-3zm0-4h2v3h-2V7z"
7533
7587
  fill="currentColor" />
7534
7588
  </svg>
7535
- <span class="link-text">{{ isExporting ? '正在导出...' : '导出文件' }}</span>
7589
+ <span class="link-text">快捷操作</span>
7536
7590
  </div>
7537
- <template #dropdown>
7538
- <el-dropdown-menu>
7539
- <el-dropdown-item command="png">下载当前页 (PNG)</el-dropdown-item>
7540
- <el-dropdown-item command="jpeg">下载当前页 (JPEG)</el-dropdown-item>
7541
- <el-dropdown-item command="zip">下载全部 (ZIP)</el-dropdown-item>
7542
- <el-dropdown-item command="pptx">下载为 PPTX</el-dropdown-item>
7543
- <el-dropdown-item command="json" divided>导出完整 JSON 配置</el-dropdown-item>
7544
- <el-dropdown-item command="json-template">导出纯版式 JSON (排除图片/清空文本)</el-dropdown-item>
7545
- </el-dropdown-menu>
7546
- </template>
7547
- </el-dropdown>
7548
-
7549
- <!-- Reset -->
7550
- <div class="menu-link-item" @click="handleMenuAction(handleResetSlides)">
7551
- <svg class="link-icon" viewBox="0 0 24 24">
7552
- <path
7553
- d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"
7554
- fill="currentColor" />
7555
- </svg>
7556
- <span class="link-text">重置幻灯片</span>
7557
- </div>
7558
7591
 
7559
- <!-- Label -->
7560
- <div class="menu-link-item" @click="handleMenuAction(handleLabelSlides)">
7561
- <svg class="link-icon" viewBox="0 0 24 24">
7562
- <path d="M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6z" fill="currentColor" />
7563
- </svg>
7564
- <span class="link-text">幻灯片类型标注</span>
7565
7592
  </div>
7566
7593
 
7567
- <!-- Shortcuts -->
7568
- <div class="menu-link-item" @click="handleMenuAction(handleShortcuts)">
7569
- <svg class="link-icon" viewBox="0 0 24 24">
7570
- <path
7571
- d="M4 15h2v3H4v-3zm0-4h2v3H4v-3zm0-4h2v3H4V7zm4 8h8v3H8v-3zm0-4h8v3H8v-3zm0-4h8v3H8V7zm12 8h2v3h-2v-3zm0-4h2v3h-2v-3zm0-4h2v3h-2V7z"
7572
- fill="currentColor" />
7573
- </svg>
7574
- <span class="link-text">快捷操作</span>
7594
+ <!-- Note Disclaimer -->
7595
+ <div class="menu-disclaimer">
7596
+ 注:本站仅作测试/演示,不提供任何形式的服务
7575
7597
  </div>
7576
-
7577
- </div>
7578
-
7579
- <!-- Note Disclaimer -->
7580
- <div class="menu-disclaimer">
7581
- 注:本站仅作测试/演示,不提供任何形式的服务
7582
7598
  </div>
7583
7599
  </div>
7600
+ </el-popover>
7601
+ <div class="presentation-title-container"
7602
+ style="display: flex; align-items: center; gap: 6px; margin-left: 8px; flex-shrink: 0; min-width: 0; max-width: 280px;">
7603
+ <span v-if="!isEditingTitle || isReadonly" @click="startEditingTitle" class="presentation-title-text" :style="{
7604
+ fontSize: '15px',
7605
+ fontWeight: 600,
7606
+ color: '#0f172a',
7607
+ cursor: isReadonly ? 'default' : 'pointer',
7608
+ transition: 'color 0.15s ease',
7609
+ whiteSpace: 'nowrap',
7610
+ overflow: 'hidden',
7611
+ textOverflow: 'ellipsis',
7612
+ display: 'inline-block',
7613
+ maxWidth: '240px'
7614
+ }" :title="presentationTitle || '未命名演示文稿'">
7615
+ {{ presentationTitle || '未命名演示文稿' }}
7616
+ </span>
7617
+ <input v-else ref="titleInputRef" class="presentation-title-input-edit" v-model="presentationTitle"
7618
+ @blur="stopEditingTitle" @keyup.enter="stopEditingTitle"
7619
+ style="font-size: 15px; font-weight: 600; color: #0f172a; border: 1px solid #3b82f6; border-radius: 6px; padding: 4px 8px; outline: none; background: #fff; box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); width: 200px;" />
7620
+ <svg v-if="!isEditingTitle && !isReadonly" @click="startEditingTitle" viewBox="0 0 24 24" fill="none"
7621
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
7622
+ style="width: 14px; height: 14px; color: #3b82f6; cursor: pointer; margin-left: 2px; flex-shrink: 0;">
7623
+ <path d="M12 20h9" />
7624
+ <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
7625
+ </svg>
7584
7626
  </div>
7585
- </el-popover>
7586
- <div class="presentation-title-container" style="display: flex; align-items: center; gap: 6px; margin-left: 8px; flex-shrink: 0; min-width: 0; max-width: 280px;">
7587
- <span v-if="!isEditingTitle" @click="startEditingTitle" class="presentation-title-text"
7588
- style="font-size: 15px; font-weight: 600; color: #0f172a; cursor: pointer; transition: color 0.15s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: inline-block; max-width: 240px;"
7589
- :title="presentationTitle || '未命名演示文稿'">
7590
- {{ presentationTitle || '未命名演示文稿' }}
7591
- </span>
7592
- <input v-else ref="titleInputRef" class="presentation-title-input-edit" v-model="presentationTitle"
7593
- @blur="stopEditingTitle" @keyup.enter="stopEditingTitle"
7594
- style="font-size: 15px; font-weight: 600; color: #0f172a; border: 1px solid #3b82f6; border-radius: 6px; padding: 4px 8px; outline: none; background: #fff; box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); width: 200px;" />
7595
- <svg v-if="!isEditingTitle" @click="startEditingTitle" viewBox="0 0 24 24" fill="none" stroke="currentColor"
7596
- stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
7597
- style="width: 14px; height: 14px; color: #3b82f6; cursor: pointer; margin-left: 2px; flex-shrink: 0;">
7598
- <path d="M12 20h9" />
7599
- <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
7600
- </svg>
7601
- </div>
7602
7627
  </slot>
7603
7628
 
7604
7629
  <!-- 外部左侧后置插槽 (例如文档状态标签、副标题) -->
@@ -7606,11 +7631,13 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
7606
7631
  </div>
7607
7632
 
7608
7633
  <!-- Center Section: Integrated Top Toolbar -->
7609
- <div class="header-center-tools" @wheel.passive="handleToolbarWheel" style="flex: 1; display: flex; align-items: center; justify-content: flex-start; overflow-x: auto; overflow-y: hidden; min-width: 0; position: relative; z-index: 10; margin: 0 8px; scrollbar-width: none; -ms-overflow-style: none;">
7634
+ <div v-if="!isReadonly" class="header-center-tools" @wheel.passive="handleToolbarWheel"
7635
+ style="flex: 1; display: flex; align-items: center; justify-content: flex-start; overflow-x: auto; overflow-y: hidden; min-width: 0; position: relative; z-index: 10; margin: 0 8px; scrollbar-width: none; -ms-overflow-style: none;">
7610
7636
  <!-- 外部中间工具栏插槽 (默认呈现内置 Toolbar,支持前置/后置插槽扩展或整栏替换) -->
7611
7637
  <slot name="center" v-bind="headerContext">
7612
7638
  <slot name="toolbar" v-bind="headerContext">
7613
- <div :style="{ margin: isMasterEditingView ? '0' : '0 auto' }" style="display: flex; align-items: center; flex-shrink: 0;">
7639
+ <div :style="{ margin: isMasterEditingView ? '0' : '0 auto' }"
7640
+ style="display: flex; align-items: center; flex-shrink: 0;">
7614
7641
  <Toolbar :toolbar-mode="toolbarMode">
7615
7642
  <template v-if="$slots['toolbar-prefix']" #prefix>
7616
7643
  <slot name="toolbar-prefix" v-bind="headerContext"></slot>
@@ -7668,7 +7695,7 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
7668
7695
  </button>
7669
7696
 
7670
7697
  <!-- Slide Master Mode Toggle Button -->
7671
- <button v-if="!isMasterEditingView" class="btn-master-toggle" @click="enterMasterView()"
7698
+ <button v-if="!isMasterEditingView && !isReadonly" class="btn-master-toggle" @click="enterMasterView()"
7672
7699
  style="height: 32px; padding: 0 12px; border: none; border-radius: 6px; background: #f1f5f9; color: #334155; font-size: 13px; font-weight: 500; display: flex; align-items: center; gap: 6px; cursor: pointer; white-space: nowrap; flex-shrink: 0;">
7673
7700
  <svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor" style="flex-shrink: 0;">
7674
7701
  <use xlink:href="#icon-muban"></use>
@@ -7696,7 +7723,8 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
7696
7723
  <div class="presentation-menu-item-content">
7697
7724
  <div class="presentation-menu-item-left">
7698
7725
  <svg viewBox="0 0 1024 1024" width="14" height="14" fill="currentColor">
7699
- <path d="M213.333 170.667h85.334v682.666h-85.334V170.667zm170.667 0l426.667 341.333-426.667 341.333V170.667z" />
7726
+ <path
7727
+ d="M213.333 170.667h85.334v682.666h-85.334V170.667zm170.667 0l426.667 341.333-426.667 341.333V170.667z" />
7700
7728
  </svg>
7701
7729
  <span>从头开始</span>
7702
7730
  </div>