jvs-ppt-ui 0.0.10 → 0.0.12

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