jvs-ppt-ui 0.0.9 → 0.0.11

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.
Files changed (45) hide show
  1. package/README.md +3 -0
  2. package/dist/lib/jvs-ppt-ui.es.js +22611 -21764
  3. package/dist/lib/jvs-ppt-ui.umd.js +102 -101
  4. package/dist/lib/style.css +1 -1
  5. package/package.json +7 -7
  6. package/scripts/check_connectors.cjs +25 -0
  7. package/scripts/check_slide1.cjs +11 -0
  8. package/scripts/check_slide123.cjs +25 -0
  9. package/scripts/convert-fonts-to-woff2.cjs +97 -0
  10. package/scripts/extract-slides.ts +149 -0
  11. package/scripts/find_candidate_pptx.cjs +34 -0
  12. package/scripts/find_orange_slide.cjs +29 -0
  13. package/scripts/generate-complete-ppt-preview.js +385 -0
  14. package/scripts/inspect_315_327.cjs +19 -0
  15. package/scripts/mcp-smoke-test.js +568 -0
  16. package/scripts/patch-pptxtojson.cjs +81 -0
  17. package/scripts/verify-fonts.cjs +20 -0
  18. package/src/App.vue +53 -53
  19. package/src/components/AIPptDialog.vue +2 -0
  20. package/src/components/CanvasArea.vue +124 -62
  21. package/src/components/Header.vue +364 -142
  22. package/src/components/PptEditor.vue +26 -15
  23. package/src/components/PresentationMode.vue +24 -4
  24. package/src/components/SidebarRight.vue +8 -1
  25. package/src/components/SvgChart.vue +5 -2
  26. package/src/components/TemplateGallery.vue +1321 -866
  27. package/src/components/Toolbar.vue +243 -22
  28. package/src/components/canvas/CanvasTransformHandles.vue +5 -1
  29. package/src/components/canvas/elements/CanvasShapeElement.vue +98 -19
  30. package/src/components/canvas/elements/CanvasTextElement.vue +39 -9
  31. package/src/components/canvas/textSelectionOverlay.ts +305 -0
  32. package/src/composables/canvas/useCanvasAlignment.ts +286 -238
  33. package/src/composables/canvas/useCanvasContextMenu.ts +53 -22
  34. package/src/composables/canvas/useCanvasFormatting.ts +35 -12
  35. package/src/composables/canvas/useCanvasTable.ts +52 -24
  36. package/src/composables/canvas/useCanvasText.ts +12 -0
  37. package/src/composables/canvas/useCanvasTransform.ts +2 -2
  38. package/src/composables/sidebar/useSidebarElementTransform.ts +5 -5
  39. package/src/composables/sidebar/useSidebarShapeFormat.ts +2 -2
  40. package/src/composables/useSlides.ts +482 -127
  41. package/src/style.css +88 -0
  42. package/src/types/index.ts +4 -0
  43. package/src/utils/pptxShapes.ts +1 -1
  44. package/src/utils/pptxShapesLazy.ts +39 -0
  45. package/src/utils/svgPathParser.ts +12 -1
@@ -11,7 +11,7 @@ import { toPoints, normalizeSvgPath } from '../utils/svgPathParser'
11
11
  import { getLineElementPath, scaleSvgPath, extractPathStartEnd, isStandardShapeType } from '../utils/line'
12
12
  import { parseColorString, extractTextAlignFromHtml, updateTextAlignInHtml, normalizeGradientConfig, getLinearDirectionByRot, getRadialDirectionByCenter } from '../utils/canvas/elementStyles'
13
13
 
14
- import { SHAPE_LIST as PPTX_SHAPE_LIST, SHAPE_PATH_FORMULAS as PPTX_SHAPE_PATH_FORMULAS } from '../utils/pptxShapes'
14
+ import { shapeFormulas, shapeList as getPptxShapeList, ensurePptxShapes } from '../utils/pptxShapesLazy'
15
15
  import { inferElementTextType, type SlideTextType } from '../utils/textTypes'
16
16
  // 快捷键弹层异步化;mock DSL 模板(~77KB)改为触发时动态加载
17
17
  const ShortcutsDialog = defineAsyncComponent(() => import('./ShortcutsDialog.vue'))
@@ -117,6 +117,8 @@ const onJSONFileChange = (e: Event) => {
117
117
  const reader = new FileReader()
118
118
  reader.onload = async (event) => {
119
119
  try {
120
+ // JSON 装稿可能含 PPTist 原生格式,解析前确保形状预设表就绪(通常已后台预载完成)
121
+ await ensurePptxShapes()
120
122
  const data = JSON.parse(event.target?.result as string)
121
123
  if (Array.isArray(data)) {
122
124
  if (uploadMediaResourcesInSlides) {
@@ -1463,13 +1465,88 @@ const blobToDataUrl = (blob: Blob): Promise<string> => {
1463
1465
  })
1464
1466
  }
1465
1467
 
1466
- const resolveMediaSrc = async (input: any): Promise<string> => {
1468
+ /**
1469
+ * 将图片 DataURL 或位图通过 Canvas 转为高兼容性的 PNG Base64
1470
+ * 解决 PowerPoint / WPS 对 WebP 或部分 SVG 渲染兼容性差的问题
1471
+ */
1472
+ function convertImageToPngBase64(dataUrl: string): Promise<string> {
1473
+ return new Promise((resolve) => {
1474
+ if (typeof Image === 'undefined') {
1475
+ resolve(dataUrl)
1476
+ return
1477
+ }
1478
+ const img = new Image()
1479
+ img.crossOrigin = 'anonymous'
1480
+ img.onload = () => {
1481
+ try {
1482
+ const canvas = document.createElement('canvas')
1483
+ canvas.width = img.naturalWidth || img.width || 400
1484
+ canvas.height = img.naturalHeight || img.height || 300
1485
+ const ctx = canvas.getContext('2d')
1486
+ if (ctx) {
1487
+ ctx.drawImage(img, 0, 0)
1488
+ const pngUrl = canvas.toDataURL('image/png')
1489
+ if (pngUrl && pngUrl.startsWith('data:image/png')) {
1490
+ resolve(pngUrl)
1491
+ return
1492
+ }
1493
+ }
1494
+ } catch (e) {
1495
+ // canvas 污染或导出失败
1496
+ }
1497
+ resolve(dataUrl)
1498
+ }
1499
+ img.onerror = () => resolve(dataUrl)
1500
+ img.src = dataUrl
1501
+ })
1502
+ }
1503
+
1504
+ /**
1505
+ * 创建合法兜底占位图(避免 PPTX 缺少媒体部件关系而直接报文件损坏或无法打开)
1506
+ */
1507
+ function createFallbackImageBase64(label = '图片资源'): string {
1508
+ try {
1509
+ if (typeof document !== 'undefined') {
1510
+ const canvas = document.createElement('canvas')
1511
+ canvas.width = 400
1512
+ canvas.height = 300
1513
+ const ctx = canvas.getContext('2d')
1514
+ if (ctx) {
1515
+ ctx.fillStyle = '#f8fafc'
1516
+ ctx.fillRect(0, 0, 400, 300)
1517
+ ctx.strokeStyle = '#cbd5e1'
1518
+ ctx.lineWidth = 2
1519
+ ctx.strokeRect(4, 4, 392, 292)
1520
+ ctx.fillStyle = '#94a3b8'
1521
+ ctx.font = '14px sans-serif'
1522
+ ctx.textAlign = 'center'
1523
+ ctx.textBaseline = 'middle'
1524
+ ctx.fillText(label, 200, 150)
1525
+ return canvas.toDataURL('image/png')
1526
+ }
1527
+ }
1528
+ } catch (e) { }
1529
+ // 1x1 透明 PNG 兜底
1530
+ return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
1531
+ }
1532
+
1533
+ /**
1534
+ * 全面解析并预载多媒体资源为 Base64 DataURL,
1535
+ * 避免浏览器导出 PPTX 时触发跨域 XHR 失败,导致 PPT 缺少媒体文件或被 pptxgenjs 写入错误损坏二进制包
1536
+ */
1537
+ const resolveMediaSrc = async (input: any, mediaType: 'image' | 'video' | 'audio' | 'auto' = 'auto'): Promise<string> => {
1467
1538
  if (!input) return ''
1468
1539
 
1469
1540
  if (typeof Blob !== 'undefined' && input instanceof Blob) {
1470
1541
  try {
1471
1542
  const dataUrl = await blobToDataUrl(input)
1472
- if (dataUrl && dataUrl.startsWith('data:')) return dataUrl
1543
+ if (dataUrl && dataUrl.startsWith('data:')) {
1544
+ if (dataUrl.startsWith('data:image/webp') || dataUrl.startsWith('data:image/svg+xml')) {
1545
+ const png = await convertImageToPngBase64(dataUrl)
1546
+ if (png) return png
1547
+ }
1548
+ return dataUrl
1549
+ }
1473
1550
  } catch (e) {
1474
1551
  console.warn('Converting Blob to DataURL failed:', e)
1475
1552
  }
@@ -1478,39 +1555,86 @@ const resolveMediaSrc = async (input: any): Promise<string> => {
1478
1555
  const src = typeof input === 'string' ? input : (input.src || input.blob || input.value || input.content || '')
1479
1556
  if (!src || typeof src !== 'string') return ''
1480
1557
 
1481
- if (src.startsWith('blob:')) {
1482
- // 方式 1: 尝试 fetch 读取 blob
1558
+ // 1. 如果是 SVG 代码字符串,转为 data:image/svg+xml;base64
1559
+ if (src.startsWith('<svg') || src.includes('xmlns="http://www.w3.org/2000/svg"')) {
1483
1560
  try {
1484
- const res = await fetch(src)
1561
+ const b64 = typeof window !== 'undefined' ? window.btoa(unescape(encodeURIComponent(src))) : ''
1562
+ if (b64) return `data:image/svg+xml;base64,${b64}`
1563
+ } catch (e) { }
1564
+ }
1565
+
1566
+ // 2. 如果本身已是 DataURL
1567
+ if (src.startsWith('data:')) {
1568
+ if (src.startsWith('data:image/webp') || src.startsWith('data:image/svg+xml')) {
1569
+ const png = await convertImageToPngBase64(src)
1570
+ if (png) return png
1571
+ }
1572
+ return src
1573
+ }
1574
+
1575
+ // 补全相对路径为完整 URL
1576
+ let targetUrl = src
1577
+ if (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://') && !targetUrl.startsWith('blob:')) {
1578
+ if (typeof window !== 'undefined') {
1579
+ const origin = window.location.origin
1580
+ targetUrl = targetUrl.startsWith('/') ? `${origin}${targetUrl}` : `${origin}/${targetUrl}`
1581
+ }
1582
+ }
1583
+
1584
+ // 3. 方式 A: 优先使用 fetch 获取真实媒体二进制流(支持 blob:、相对路径、带 CORS 的 HTTP 资源)
1585
+ try {
1586
+ const res = await fetch(targetUrl, { mode: 'cors' })
1587
+ if (res.ok) {
1485
1588
  const blob = await res.blob()
1486
- const base64 = await blobToDataUrl(blob)
1487
- if (base64 && base64.startsWith('data:')) return base64
1488
- } catch (e) {
1489
- console.warn('Fetch blob URL failed:', src, e)
1589
+ if (blob && blob.size > 0) {
1590
+ const dataUrl = await blobToDataUrl(blob)
1591
+ if (dataUrl && dataUrl.startsWith('data:')) {
1592
+ if (dataUrl.startsWith('data:image/webp') || dataUrl.startsWith('data:image/svg+xml')) {
1593
+ const png = await convertImageToPngBase64(dataUrl)
1594
+ if (png) return png
1595
+ }
1596
+ return dataUrl
1597
+ }
1598
+ }
1490
1599
  }
1600
+ } catch (e) {
1601
+ // fetch 失败(常见于跨域限制、网络异常),转入回退处理
1602
+ }
1491
1603
 
1492
- // 方式 2: 尝试 XMLHttpRequest 读取 blob 对象
1493
- try {
1494
- const blob = await new Promise<Blob>((resolve, reject) => {
1495
- const xhr = new XMLHttpRequest()
1496
- xhr.open('GET', src, true)
1497
- xhr.responseType = 'blob'
1498
- xhr.onload = () => {
1499
- if ((xhr.status === 200 || xhr.status === 0) && xhr.response) resolve(xhr.response)
1500
- else reject(new Error(`XHR status ${xhr.status}`))
1604
+ // 4. 方式 B: XMLHttpRequest 回退
1605
+ try {
1606
+ const blob = await new Promise<Blob>((resolve, reject) => {
1607
+ const xhr = new XMLHttpRequest()
1608
+ xhr.open('GET', targetUrl, true)
1609
+ xhr.responseType = 'blob'
1610
+ xhr.onload = () => {
1611
+ if ((xhr.status === 200 || xhr.status === 0) && xhr.response && xhr.response.size > 0) {
1612
+ resolve(xhr.response)
1613
+ } else {
1614
+ reject(new Error(`XHR status ${xhr.status}`))
1501
1615
  }
1502
- xhr.onerror = reject
1503
- xhr.send()
1504
- })
1505
- const base64 = await blobToDataUrl(blob)
1506
- if (base64 && base64.startsWith('data:')) return base64
1507
- } catch (e) {
1508
- console.warn('XHR blob URL failed:', src, e)
1616
+ }
1617
+ xhr.onerror = reject
1618
+ xhr.ontimeout = reject
1619
+ xhr.timeout = 8000
1620
+ xhr.send()
1621
+ })
1622
+ const dataUrl = await blobToDataUrl(blob)
1623
+ if (dataUrl && dataUrl.startsWith('data:')) {
1624
+ if (dataUrl.startsWith('data:image/webp') || dataUrl.startsWith('data:image/svg+xml')) {
1625
+ const png = await convertImageToPngBase64(dataUrl)
1626
+ if (png) return png
1627
+ }
1628
+ return dataUrl
1509
1629
  }
1630
+ } catch (e) {
1631
+ // XHR 回退失败
1632
+ }
1510
1633
 
1511
- // 方式 3: 仅针对图片做 Canvas 渲染转码
1634
+ // 5. 方式 C: 如果是图片资源,通过 Image + Canvas 获取(浏览器对 img 的跨域加载限制通常比 fetch/xhr 宽松)
1635
+ if (mediaType === 'image' || mediaType === 'auto') {
1512
1636
  try {
1513
- return await new Promise<string>((resolve) => {
1637
+ const canvasData = await new Promise<string>((resolve) => {
1514
1638
  const img = new Image()
1515
1639
  img.crossOrigin = 'anonymous'
1516
1640
  img.onload = () => {
@@ -1522,34 +1646,31 @@ const resolveMediaSrc = async (input: any): Promise<string> => {
1522
1646
  if (ctx) {
1523
1647
  ctx.drawImage(img, 0, 0)
1524
1648
  const dataUrl = canvas.toDataURL('image/png')
1525
- if (dataUrl && dataUrl.startsWith('data:image')) {
1649
+ if (dataUrl && dataUrl.startsWith('data:image/png')) {
1526
1650
  resolve(dataUrl)
1527
1651
  return
1528
1652
  }
1529
1653
  }
1530
1654
  } catch (err) {
1531
- console.warn('Canvas drawImage failed:', err)
1655
+ // Tainted canvas
1532
1656
  }
1533
1657
  resolve('')
1534
1658
  }
1535
1659
  img.onerror = () => resolve('')
1536
- img.src = src
1660
+ img.src = targetUrl
1537
1661
  })
1662
+ if (canvasData) return canvasData
1538
1663
  } catch (e) {
1539
- console.warn('Canvas conversion failed:', e)
1540
- return ''
1664
+ // ignore
1541
1665
  }
1542
1666
  }
1543
1667
 
1544
- if (!src.startsWith('http') && !src.startsWith('data:')) {
1545
- if (src.startsWith('/')) {
1546
- return (typeof window !== 'undefined' ? window.location.origin : '') + src
1547
- } else {
1548
- return (typeof window !== 'undefined' ? window.location.origin : '') + '/' + src
1549
- }
1668
+ // 6. 兜底保护:如果是图片类型,返回有效占位图,确保 pptxgenjs 能写入合法的 media 文件,绝不产生损坏的 pptx
1669
+ if (mediaType === 'image') {
1670
+ return createFallbackImageBase64('图片资源加载失败')
1550
1671
  }
1551
1672
 
1552
- return src
1673
+ return ''
1553
1674
  }
1554
1675
 
1555
1676
  const generatePptxBlob = async (): Promise<Blob | null> => {
@@ -1599,11 +1720,13 @@ const generatePptxBlob = async (): Promise<Blob | null> => {
1599
1720
  pptxBg = { color: parsed.color, transparency: parsed.transparency }
1600
1721
  } else if (bgObj.type === 'image' && bgObj.image) {
1601
1722
  const src = bgObj.image.src || bgObj.image
1602
- const bgUrl = await resolveMediaSrc(src)
1603
- if (bgUrl.startsWith('data:')) {
1723
+ const bgUrl = await resolveMediaSrc(src, 'image')
1724
+ if (bgUrl && bgUrl.startsWith('data:')) {
1604
1725
  pptxBg = { data: bgUrl }
1605
- } else {
1726
+ } else if (bgUrl) {
1606
1727
  pptxBg = { path: bgUrl }
1728
+ } else {
1729
+ pptxBg = { color: 'FFFFFF', transparency: 0 }
1607
1730
  }
1608
1731
  } else if (bgObj.type === 'gradient' && bgObj.gradient) {
1609
1732
  const colors = bgObj.gradient.colors || []
@@ -1960,7 +2083,10 @@ const generatePptxBlob = async (): Promise<Blob | null> => {
1960
2083
  }
1961
2084
  }
1962
2085
  else if (el.type === 'image') {
1963
- let imgPath = await resolveMediaSrc(el.content || (el as any).src || '')
2086
+ let imgPath = await resolveMediaSrc(el.content || (el as any).src || '', 'image')
2087
+ if (!imgPath) {
2088
+ imgPath = createFallbackImageBase64('图片缺失')
2089
+ }
1964
2090
 
1965
2091
  const imgOpts: pptxgen.ImageProps = {
1966
2092
  x, y, w, h,
@@ -2106,7 +2232,7 @@ const generatePptxBlob = async (): Promise<Blob | null> => {
2106
2232
  const buildNativeShapeFill = async (): Promise<any> => {
2107
2233
  // 图片填充 → 原生 a:blipFill
2108
2234
  if (patternUrl) {
2109
- const pUrl = await resolveMediaSrc(patternUrl)
2235
+ const pUrl = await resolveMediaSrc(patternUrl, 'image')
2110
2236
  if (pUrl && /^data:image\/svg\+xml/i.test(pUrl)) {
2111
2237
  const pngUrl = await rasterizeSvgFill(pUrl, pixelWidth, pixelHeight)
2112
2238
  return pngUrl ? { type: 'image', data: pngUrl } : undefined
@@ -2529,9 +2655,9 @@ const generatePptxBlob = async (): Promise<Blob | null> => {
2529
2655
  }
2530
2656
  }
2531
2657
  if (!mediaPath) {
2532
- mediaPath = await resolveMediaSrc(mediaInput)
2658
+ mediaPath = await resolveMediaSrc(mediaInput, el.type as 'video' | 'audio')
2533
2659
  }
2534
- if (mediaPath) {
2660
+ if (mediaPath && mediaPath.startsWith('data:')) {
2535
2661
  let extn = el.type === 'audio' ? 'mp3' : 'mp4'
2536
2662
  if (mediaPath.startsWith('data:audio/')) {
2537
2663
  if (mediaPath.includes('audio/wav')) extn = 'wav'
@@ -2544,23 +2670,40 @@ const generatePptxBlob = async (): Promise<Blob | null> => {
2544
2670
  else extn = 'mp4'
2545
2671
  }
2546
2672
 
2547
- if (mediaPath.startsWith('data:')) {
2548
- pptxSlide.addMedia({
2549
- type: el.type,
2550
- data: mediaPath,
2551
- extn,
2552
- x, y, w, h,
2553
- objectName: el.id
2554
- })
2555
- } else if (mediaPath.startsWith('http') || mediaPath.startsWith('/') || mediaPath.startsWith('./')) {
2556
- pptxSlide.addMedia({
2557
- type: el.type,
2558
- path: mediaPath,
2559
- extn,
2560
- x, y, w, h,
2561
- objectName: el.id
2562
- })
2563
- }
2673
+ pptxSlide.addMedia({
2674
+ type: el.type,
2675
+ data: mediaPath,
2676
+ extn,
2677
+ x, y, w, h,
2678
+ objectName: el.id
2679
+ })
2680
+ } 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
+ })
2696
+
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
+ })
2564
2707
  }
2565
2708
  }
2566
2709
  else if (el.type === 'table') {
@@ -3027,7 +3170,7 @@ const getMaxFontSize = (html: string, defaultFontSize: number = 18): number => {
3027
3170
  const getParagraphMetrics = (html: string, ratio: number, defaultFontSize: number = 18) => {
3028
3171
  const tagRegex = /<(div|p|li)(?![a-z0-9])[^>]*>/gi
3029
3172
 
3030
- const lineHeights = []
3173
+ const explicitLineHeights: number[] = []
3031
3174
  const margins = []
3032
3175
  let paragraphCount = 0
3033
3176
 
@@ -3055,6 +3198,7 @@ const getParagraphMetrics = (html: string, ratio: number, defaultFontSize: numbe
3055
3198
  const marginTop = getProp('margin-top')
3056
3199
  const marginBottom = getProp('margin-bottom')
3057
3200
  const lineHeight = getProp('line-height')
3201
+ const hasExplicitLineHeight = !!lineHeight
3058
3202
 
3059
3203
  const tagStartIndex = match.index
3060
3204
  const tagName = match[1]
@@ -3083,7 +3227,7 @@ const getParagraphMetrics = (html: string, ratio: number, defaultFontSize: numbe
3083
3227
  if (lineHeightValue < 1.1) {
3084
3228
  lineHeightValue = 1.2
3085
3229
  }
3086
- lineHeights.push(lineHeightValue)
3230
+ if (hasExplicitLineHeight) explicitLineHeights.push(lineHeightValue)
3087
3231
 
3088
3232
  const isFirstParagraph = paragraphIndex === 0
3089
3233
  const isLastParagraph = match.index + fullTag.length >= html.lastIndexOf('</' + tagName + '>')
@@ -3094,7 +3238,10 @@ const getParagraphMetrics = (html: string, ratio: number, defaultFontSize: numbe
3094
3238
  marginTopValue = parseFloat(marginTop.replace('pt', ''))
3095
3239
  }
3096
3240
  else if (marginTop.indexOf('em') !== -1) {
3097
- marginTopValue = parseFloat(marginTop.replace('em', '')) * maxFontSize
3241
+ // 解析器把 OOXML spcPct(千分比)输出为 `val/1000 + 'em'`(如 val=20000 → "20em"),
3242
+ // 其真实语义是 20%(= 0.2em)。必须按百分比换算;若按字面 em × 字号会放大 100 倍,
3243
+ // 段间距变成几百像素。
3244
+ marginTopValue = parseFloat(marginTop.replace('em', '')) / 100 * maxFontSize
3098
3245
  }
3099
3246
  if (marginTopValue > 0) margins.push(marginTopValue)
3100
3247
  }
@@ -3105,7 +3252,8 @@ const getParagraphMetrics = (html: string, ratio: number, defaultFontSize: numbe
3105
3252
  marginBottomValue = parseFloat(marginBottom.replace('pt', ''))
3106
3253
  }
3107
3254
  else if (marginBottom.indexOf('em') !== -1) {
3108
- marginBottomValue = parseFloat(marginBottom.replace('em', '')) * maxFontSize
3255
+ // 同 margin-top:解析器的 em 实为 OOXML spcPct 百分比(见上方注释)
3256
+ marginBottomValue = parseFloat(marginBottom.replace('em', '')) / 100 * maxFontSize
3109
3257
  }
3110
3258
  if (marginBottomValue > 0) margins.push(marginBottomValue)
3111
3259
  }
@@ -3114,8 +3262,12 @@ const getParagraphMetrics = (html: string, ratio: number, defaultFontSize: numbe
3114
3262
  }
3115
3263
 
3116
3264
  let lineHeight = 1.2
3117
- if (lineHeights.length) {
3118
- lineHeight = +(lineHeights.reduce((sum, height) => sum + height, 0) / paragraphCount).toFixed(2)
3265
+ // OOXML 的行距(lnSpc)是段落级属性:容器行高不能取各段平均值,
3266
+ // 否则没有显式行距的段落(默认 100%)会被其它段落的行距(如 200%)污染,
3267
+ // 整块文本行距虚高、溢出文本框。容器只在"所有段落都有显式行距"时取最小值兜底,
3268
+ // 其余情况保持默认 1.2,显式行距由段落内联 style 自行生效。
3269
+ if (explicitLineHeights.length && explicitLineHeights.length === paragraphCount) {
3270
+ lineHeight = +Math.min(...explicitLineHeights).toFixed(2)
3119
3271
  }
3120
3272
 
3121
3273
  let margin = 0
@@ -3664,7 +3816,7 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
3664
3816
  const defaultThemeColors = [globalThemeColor.value || '#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4', '#70ad47']
3665
3817
 
3666
3818
  const shapeList: any[] = []
3667
- for (const item of PPTX_SHAPE_LIST) {
3819
+ for (const item of getPptxShapeList()) {
3668
3820
  shapeList.push(...item.children)
3669
3821
  }
3670
3822
 
@@ -3849,7 +4001,10 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
3849
4001
  shapeEl.text!.inset = [0, 0, 0, 0]
3850
4002
  shapeEl.style.padding = '0px'
3851
4003
  }
3852
- if (metrics.lineHeight) shapeEl.text!.lineHeight = metrics.lineHeight
4004
+ if (metrics.lineHeight) {
4005
+ shapeEl.text!.lineHeight = metrics.lineHeight
4006
+ shapeEl.style.lineHeight = metrics.lineHeight
4007
+ }
3853
4008
  if (metrics.margin) shapeEl.text!.paragraphSpace = metrics.margin
3854
4009
  slide.elements.push(shapeEl)
3855
4010
  }
@@ -3983,7 +4138,10 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
3983
4138
  textEl.inset = [0, 0, 0, 0]
3984
4139
  textEl.style.padding = '0px'
3985
4140
  }
3986
- if (metrics.lineHeight) textEl.lineHeight = metrics.lineHeight
4141
+ if (metrics.lineHeight) {
4142
+ textEl.lineHeight = metrics.lineHeight
4143
+ textEl.style.lineHeight = metrics.lineHeight
4144
+ }
3987
4145
  if (metrics.margin) textEl.paragraphSpace = metrics.margin
3988
4146
  slide.elements.push(textEl)
3989
4147
  }
@@ -4285,7 +4443,10 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
4285
4443
  element.color = shapeTextColor
4286
4444
  }
4287
4445
  if (el.link) element.link = { type: 'web', target: el.link }
4288
- if (metrics.lineHeight) element.text!.lineHeight = metrics.lineHeight
4446
+ if (metrics.lineHeight) {
4447
+ element.text!.lineHeight = metrics.lineHeight
4448
+ element.style.lineHeight = metrics.lineHeight
4449
+ }
4289
4450
  if (metrics.margin) element.text!.paragraphSpace = metrics.margin
4290
4451
  if (el.textInset) {
4291
4452
  element.text!.inset = [
@@ -4358,7 +4519,7 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
4358
4519
  element.pathFormula = shape.pathFormula
4359
4520
  element.viewBox = [el.width, el.height]
4360
4521
 
4361
- const pathFormula = PPTX_SHAPE_PATH_FORMULAS[shape.pathFormula]
4522
+ const pathFormula = shapeFormulas()[shape.pathFormula]
4362
4523
  if (pathFormula && 'editable' in pathFormula && pathFormula.editable) {
4363
4524
  let keypointValues = pathFormula.defaultValue
4364
4525
  if (el.keypoints) {
@@ -4632,7 +4793,7 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
4632
4793
  }
4633
4794
  }
4634
4795
  }
4635
- else if (el.path && el.path.indexOf('NaN') === -1) {
4796
+ else if (el.path && el.path.indexOf('NaN') === -1 && el.shapType !== 'custom') {
4636
4797
  if (el.shapType === 'arc') {
4637
4798
  element.path = el.path
4638
4799
  element.viewBox = [originWidth, originHeight]
@@ -4656,22 +4817,14 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
4656
4817
  }
4657
4818
  }
4658
4819
  if (el.shapType === 'custom') {
4659
- let pathInput = el.path!
4820
+ let pathInput = el.path || ''
4660
4821
  if (pathInput.indexOf('NaN') !== -1) {
4661
4822
  if (element.width === 0) element.width = 0.1
4662
4823
  if (element.height === 0) element.height = 0.1
4663
4824
  pathInput = pathInput.replace(/NaN/g, '0')
4664
4825
  }
4665
- const normalized = normalizeSvgPath(pathInput)
4666
- element.path = normalized.path
4667
- const w = normalized.width
4668
- const h = normalized.height
4669
- if ((w / h) > (originWidth / originHeight)) {
4670
- element.viewBox = [w, w * originHeight / originWidth]
4671
- }
4672
- else {
4673
- element.viewBox = [h * originWidth / originHeight, h]
4674
- }
4826
+ element.path = pathInput
4827
+ element.viewBox = [originWidth, originHeight]
4675
4828
  }
4676
4829
 
4677
4830
  if (element.path && typeof element.path === 'string') {
@@ -4906,7 +5059,7 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
4906
5059
 
4907
5060
  const normType = String(shapType).toLowerCase()
4908
5061
 
4909
- for (const group of PPTX_SHAPE_LIST) {
5062
+ for (const group of getPptxShapeList()) {
4910
5063
  for (const item of group.children) {
4911
5064
  const itemPptxType = (item.pptxShapeType || '').toLowerCase()
4912
5065
  const itemFormula = (item.pathFormula || '').toLowerCase()
@@ -4927,8 +5080,8 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
4927
5080
  const matchedShapType = item.pptxShapeType || shapType
4928
5081
  const matchedPathFormula = item.pathFormula
4929
5082
 
4930
- if (item.pathFormula && PPTX_SHAPE_PATH_FORMULAS[item.pathFormula]) {
4931
- const formula = PPTX_SHAPE_PATH_FORMULAS[item.pathFormula]
5083
+ if (item.pathFormula && shapeFormulas()[item.pathFormula]) {
5084
+ const formula = shapeFormulas()[item.pathFormula]
4932
5085
  let kps = formula.defaultValue ? [...formula.defaultValue] : []
4933
5086
  if (_element.keypoints) {
4934
5087
  if (normType === 'roundrect' || normType === 'snip1rect' || normType === 'cutrectsingle' || normType === 'round1rect' || normType === 'roundrectsingle') {
@@ -5094,18 +5247,27 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
5094
5247
  const isChildVertical = checkIsVertical(_el)
5095
5248
 
5096
5249
  // 提取原生字号,防止 CanvasShapeElement 回退到 16px 导致小形状文本溢出/折行被裁切
5250
+ // 必须取所有 run 字号的最小值:容器字号决定 <p> 的 strut(字号 × lineHeight),
5251
+ // 若取首个字号(如标题 21px)会把 16px 正文行撑到 27px+,行距虚高、文字溢出文本框;
5252
+ // 各 run 自身字号仍由内联 span 生效,取最小值不影响大字号标题的渲染。
5097
5253
  let childFontSize = _el.fontSize || _el.style?.fontSize
5098
5254
  if (!childFontSize && (_el.content || _el.text)) {
5099
5255
  const textToProbe = typeof _el.content === 'string' ? _el.content : (typeof _el.text === 'string' ? _el.text : (_el.text?.content || ''))
5100
- const ptMatch = textToProbe.match(/font-size\s*:\s*([\d.]+)pt/i)
5101
- if (ptMatch) {
5102
- childFontSize = Math.round(parseFloat(ptMatch[1]) * ratio)
5103
- } else {
5104
- const pxMatch = textToProbe.match(/font-size\s*:\s*([\d.]+)px/i)
5105
- if (pxMatch) {
5106
- childFontSize = Math.round(parseFloat(pxMatch[1]))
5107
- }
5256
+ const sizeCandidates: number[] = []
5257
+ for (const m of textToProbe.matchAll(/font-size\s*:\s*([\d.]+)\s*(pt|px)/gi)) {
5258
+ const v = parseFloat(m[1])
5259
+ sizeCandidates.push(m[2].toLowerCase() === 'pt' ? Math.round(v * ratio) : Math.round(v))
5108
5260
  }
5261
+ if (sizeCandidates.length) childFontSize = Math.min(...sizeCandidates)
5262
+ }
5263
+
5264
+ // 组内文本与顶层文本走同一套段落度量(getParagraphMetrics):
5265
+ // 容器 lineHeight(默认 1.2)与段间距,否则 contenteditable 回退 'normal'(≈1.3~1.43)
5266
+ // 同样会造成行距虚高,与容器字号问题叠加后正文明显比原 PPT 疏。
5267
+ let childMetrics: { lineHeight: number; margin: number | null } | null = null
5268
+ const childHtmlForMetrics = typeof _el.content === 'string' ? _el.content : (typeof _el.text === 'string' ? _el.text : (_el.text?.content || ''))
5269
+ if (/<(p|div|li)[\s>]/i.test(childHtmlForMetrics)) {
5270
+ childMetrics = getParagraphMetrics(childHtmlForMetrics, ratio, childFontSize || 16)
5109
5271
  }
5110
5272
 
5111
5273
  const rawChildLineCap = _el.borderLineCap || _el.border?.lineCap || _el.lineCap
@@ -5130,6 +5292,10 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
5130
5292
  fontSize: childFontSize ? `${childFontSize}px` : undefined,
5131
5293
  whiteSpace: (_el.wrap === 'none' || _el.wrap === false) ? 'nowrap' : (_el.style?.whiteSpace || 'normal'),
5132
5294
  }
5295
+ // 段落度量落到容器样式:lineHeight 撑住 strut、paragraphSpace 驱动段间距(px 数值,
5296
+ // CanvasTextElement 以 element.style.paragraphSpace 组装 --paragraphSpace)
5297
+ if (childMetrics?.lineHeight) childStyle.lineHeight = childMetrics.lineHeight
5298
+ if (childMetrics?.margin) childStyle.paragraphSpace = childMetrics.margin
5133
5299
  if (_el.reflection) {
5134
5300
  childStyle.reflection = {
5135
5301
  enabled: _el.reflection.enabled,
@@ -6577,6 +6743,7 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
6577
6743
  registerPPTParser(parsePPTXJSONData)
6578
6744
 
6579
6745
  const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Promise<boolean> => {
6746
+ await ensurePptxShapes() // PPTX 解析依赖形状预设表(已后台预载,此处通常瞬时)
6580
6747
  const nameWithoutExt = fileName ? (fileName.substring(0, fileName.lastIndexOf('.')) || fileName) : presentationTitle.value || '演示文稿'
6581
6748
 
6582
6749
  loadingText.value = '正在导入 PPTX,请稍候...'
@@ -7192,28 +7359,59 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
7192
7359
  const props = withDefaults(defineProps<{
7193
7360
  slotProps?: any
7194
7361
  showMenu?: boolean
7362
+ toolbarMode?: 'auto' | 'more' | 'full'
7363
+ visible?: boolean
7195
7364
  }>(), {
7196
- showMenu: true
7365
+ showMenu: true,
7366
+ toolbarMode: 'auto',
7367
+ visible: true
7197
7368
  })
7198
7369
 
7199
- const headerContext = computed(() => ({
7200
- ...(props.slotProps || {}),
7201
- showMenu: props.showMenu,
7202
- presentationTitle: presentationTitle.value,
7203
- isEditingTitle: isEditingTitle.value,
7204
- startEditingTitle,
7205
- stopEditingTitle,
7206
- zoomRatio: zoomRatio.value,
7207
- setZoomRatio: (val: number) => { zoomRatio.value = val },
7208
- resetZoom,
7209
- isMasterEditingView: isMasterEditingView.value,
7210
- enterMasterView,
7211
- exitMasterView,
7212
- startPresentation,
7213
- startFromBeginning,
7214
- triggerPPTXImport,
7215
- triggerJSONImport
7216
- }))
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
+ })
7403
+ }
7404
+ return merged
7405
+ })
7406
+
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
+ }
7413
+ }
7414
+ }
7217
7415
 
7218
7416
  defineExpose({
7219
7417
  generatePptxBlob,
@@ -7228,7 +7426,10 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
7228
7426
  </script>
7229
7427
 
7230
7428
  <template>
7231
- <header class="app-header">
7429
+ <!-- v-show 落在内层根元素而非组件本身:组件是多根节点(含 teleport),
7430
+ 组件级运行时指令会触发 Vue "non-element root node" 警告,
7431
+ 该警告在 dev 下会被 Vite 的 console 转发炸成异常并中断渲染 -->
7432
+ <header v-show="visible" class="app-header">
7232
7433
  <!-- Left side: Hamburger menu + Title input -->
7233
7434
  <div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0; min-width: 0;">
7234
7435
  <!-- 外部左侧前置插槽 (例如返回按钮、Logo) -->
@@ -7405,18 +7606,20 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
7405
7606
  </div>
7406
7607
 
7407
7608
  <!-- Center Section: Integrated Top Toolbar -->
7408
- <div class="header-center-tools" :style="{ justifyContent: isMasterEditingView ? 'flex-start' : 'center' }" style="flex: 1; display: flex; align-items: center; overflow-x: auto; overflow-y: hidden; min-width: 0; position: relative; z-index: 10; margin: 0 8px; scrollbar-width: none; -ms-overflow-style: none;">
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;">
7409
7610
  <!-- 外部中间工具栏插槽 (默认呈现内置 Toolbar,支持前置/后置插槽扩展或整栏替换) -->
7410
7611
  <slot name="center" v-bind="headerContext">
7411
7612
  <slot name="toolbar" v-bind="headerContext">
7412
- <Toolbar>
7413
- <template v-if="$slots['toolbar-prefix']" #prefix>
7414
- <slot name="toolbar-prefix" v-bind="headerContext"></slot>
7415
- </template>
7416
- <template v-if="$slots['toolbar-suffix']" #suffix>
7417
- <slot name="toolbar-suffix" v-bind="headerContext"></slot>
7418
- </template>
7419
- </Toolbar>
7613
+ <div :style="{ margin: isMasterEditingView ? '0' : '0 auto' }" style="display: flex; align-items: center; flex-shrink: 0;">
7614
+ <Toolbar :toolbar-mode="toolbarMode">
7615
+ <template v-if="$slots['toolbar-prefix']" #prefix>
7616
+ <slot name="toolbar-prefix" v-bind="headerContext"></slot>
7617
+ </template>
7618
+ <template v-if="$slots['toolbar-suffix']" #suffix>
7619
+ <slot name="toolbar-suffix" v-bind="headerContext"></slot>
7620
+ </template>
7621
+ </Toolbar>
7622
+ </div>
7420
7623
  </slot>
7421
7624
  </slot>
7422
7625
  </div>
@@ -7748,7 +7951,7 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
7748
7951
  }
7749
7952
 
7750
7953
  .presentation-split-btn {
7751
- display: flex;
7954
+ display: inline-flex !important;
7752
7955
  align-items: center;
7753
7956
  height: 32px;
7754
7957
  border: 1px solid #3b82f6;
@@ -7756,9 +7959,11 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
7756
7959
  background: #ffffff;
7757
7960
  color: #3b82f6;
7758
7961
  transition: all 0.15s ease;
7759
- box-sizing: border-box;
7760
- white-space: nowrap;
7761
- flex-shrink: 0;
7962
+ box-sizing: border-box !important;
7963
+ white-space: nowrap !important;
7964
+ flex-shrink: 0 !important;
7965
+ position: relative;
7966
+ overflow: hidden;
7762
7967
  }
7763
7968
 
7764
7969
  .presentation-split-btn:hover {
@@ -7767,25 +7972,42 @@ const importPptxArrayBuffer = async (buffer: ArrayBuffer, fileName?: string): Pr
7767
7972
  }
7768
7973
 
7769
7974
  .presentation-main-btn {
7770
- display: flex;
7975
+ display: inline-flex !important;
7771
7976
  align-items: center;
7772
7977
  gap: 6px;
7773
- height: 100%;
7774
- padding: 0 10px 0 12px;
7775
- background: transparent;
7776
- border: none;
7777
- color: #3b82f6;
7778
- font-size: 13px;
7779
- font-weight: 600;
7978
+ height: 100% !important;
7979
+ padding: 0 10px 0 12px !important;
7980
+ background: transparent !important;
7981
+ border: none !important;
7982
+ color: #3b82f6 !important;
7983
+ font-size: 13px !important;
7984
+ font-weight: 600 !important;
7780
7985
  cursor: pointer;
7781
7986
  transition: background-color 0.15s ease;
7782
7987
  border-radius: 5px 0 0 5px;
7783
- white-space: nowrap;
7784
- flex-shrink: 0;
7988
+ white-space: nowrap !important;
7989
+ flex-shrink: 0 !important;
7990
+ line-height: 1 !important;
7991
+ box-sizing: border-box !important;
7992
+ position: relative;
7993
+ }
7994
+
7995
+ .presentation-main-btn::before,
7996
+ .presentation-main-btn::after {
7997
+ display: none !important;
7998
+ content: none !important;
7999
+ }
8000
+
8001
+ .presentation-main-btn span {
8002
+ white-space: nowrap !important;
8003
+ display: inline-block !important;
8004
+ line-height: 1 !important;
8005
+ font-size: 13px !important;
8006
+ color: #3b82f6 !important;
7785
8007
  }
7786
8008
 
7787
8009
  .presentation-main-btn:hover {
7788
- background: rgba(59, 130, 246, 0.08);
8010
+ background: rgba(59, 130, 246, 0.08) !important;
7789
8011
  }
7790
8012
 
7791
8013
  .presentation-arrow-btn {