|
import { computed, ref } from 'vue' |
|
import { storeToRefs } from 'pinia' |
|
import { trim } from 'lodash' |
|
import { saveAs } from 'file-saver' |
|
import pptxgen from 'pptxgenjs' |
|
import tinycolor from 'tinycolor2' |
|
import { toPng, toJpeg } from 'html-to-image' |
|
import { useSlidesStore } from '@/store' |
|
import type { PPTElementOutline, PPTElementShadow, PPTElementLink, Slide } from '@/types/slides' |
|
import { getElementRange, getLineElementPath, getTableSubThemeColor } from '@/utils/element' |
|
import { type AST, toAST } from '@/utils/htmlParser' |
|
import { type SvgPoints, toPoints } from '@/utils/svgPathParser' |
|
import { encrypt } from '@/utils/crypto' |
|
import { svg2Base64 } from '@/utils/svg2Base64' |
|
import { renderElementToBase64, isCanvasRenderSupported, getElementDimensions } from '@/utils/canvasRenderer' |
|
import { renderWithHuggingfaceFix, isHuggingfaceEnvironment } from '@/utils/huggingfaceRenderer' |
|
import { vectorRenderManager, RenderStrategy } from '@/utils/VectorRenderManager' |
|
import { VECTOR_EXPORT_CONFIG } from '@/config/vectorExportConfig' |
|
import message from '@/utils/message' |
|
|
|
interface ExportImageConfig { |
|
quality: number |
|
width: number |
|
fontEmbedCSS?: string |
|
} |
|
|
|
export default () => { |
|
const slidesStore = useSlidesStore() |
|
const { slides, theme, viewportRatio, title, viewportSize } = storeToRefs(slidesStore) |
|
|
|
const defaultFontSize = 16 |
|
|
|
const ratioPx2Inch = computed(() => { |
|
return 96 * (viewportSize.value / 960) |
|
}) |
|
const ratioPx2Pt = computed(() => { |
|
return 96 / 72 * (viewportSize.value / 960) |
|
}) |
|
|
|
const exporting = ref(false) |
|
|
|
|
|
const exportImage = (domRef: HTMLElement, format: string, quality: number, ignoreWebfont = true) => { |
|
exporting.value = true |
|
|
|
|
|
const isHF = VECTOR_EXPORT_CONFIG.ENVIRONMENT.isHuggingface() |
|
const isProd = VECTOR_EXPORT_CONFIG.ENVIRONMENT.isProduction() |
|
|
|
if (!isProd) { |
|
console.log('exportImage: Environment check:', { isHuggingface: isHF, format, production: isProd }) |
|
} |
|
|
|
|
|
const preprocessVectorElements = async () => { |
|
const svgElements = domRef.querySelectorAll('svg'); |
|
const vectorShapes = domRef.querySelectorAll('.vector-shape'); |
|
|
|
|
|
for (const svg of Array.from(svgElements)) { |
|
try { |
|
const renderResult = await vectorRenderManager.renderElement(svg); |
|
if (renderResult.success && renderResult.data) { |
|
|
|
const tempDiv = document.createElement('div'); |
|
tempDiv.innerHTML = renderResult.data; |
|
const optimizedSvg = tempDiv.querySelector('svg'); |
|
if (optimizedSvg && svg.parentNode) { |
|
svg.parentNode.replaceChild(optimizedSvg, svg); |
|
} |
|
} else { |
|
|
|
svg.removeAttribute('vector-effect'); |
|
const vectorEffectElements = svg.querySelectorAll('[vector-effect]'); |
|
vectorEffectElements.forEach(el => el.removeAttribute('vector-effect')); |
|
|
|
if (!svg.hasAttribute('xmlns')) { |
|
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); |
|
} |
|
} |
|
} catch (error) { |
|
if (!isProd) { |
|
console.warn('exportImage: VectorRenderManager failed, using fallback:', error); |
|
} |
|
|
|
svg.removeAttribute('vector-effect'); |
|
if (!svg.hasAttribute('xmlns')) { |
|
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); |
|
} |
|
} |
|
} |
|
|
|
|
|
for (const shape of Array.from(vectorShapes)) { |
|
try { |
|
const svgChild = shape.querySelector('svg'); |
|
if (svgChild) { |
|
const renderResult = await vectorRenderManager.renderElement(svgChild); |
|
if (renderResult.success && renderResult.data) { |
|
const tempDiv = document.createElement('div'); |
|
tempDiv.innerHTML = renderResult.data; |
|
const optimizedSvg = tempDiv.querySelector('svg'); |
|
if (optimizedSvg) { |
|
svgChild.parentNode?.replaceChild(optimizedSvg, svgChild); |
|
} |
|
} else { |
|
|
|
svgChild.removeAttribute('vector-effect'); |
|
if (!svgChild.hasAttribute('xmlns')) { |
|
svgChild.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); |
|
} |
|
} |
|
} |
|
} catch (error) { |
|
if (!isProd) { |
|
console.warn('exportImage: Vector shape optimization failed:', error); |
|
} |
|
} |
|
} |
|
}; |
|
|
|
const foreignObjectSpans = domRef.querySelectorAll('foreignObject [xmlns]') |
|
foreignObjectSpans.forEach(spanRef => spanRef.removeAttribute('xmlns')) |
|
|
|
setTimeout(async () => { |
|
try { |
|
|
|
await preprocessVectorElements(); |
|
|
|
let dataUrl: string |
|
|
|
if (isHF) { |
|
|
|
console.log('exportImage: Using Huggingface renderer') |
|
dataUrl = await renderWithHuggingfaceFix(domRef) |
|
|
|
|
|
if (format === 'jpeg' && dataUrl.startsWith('data:image/png')) { |
|
const canvas = document.createElement('canvas') |
|
const ctx = canvas.getContext('2d') |
|
const img = new Image() |
|
|
|
await new Promise((resolve, reject) => { |
|
img.onload = () => { |
|
canvas.width = img.width |
|
canvas.height = img.height |
|
ctx?.drawImage(img, 0, 0) |
|
dataUrl = canvas.toDataURL('image/jpeg', quality) |
|
resolve(dataUrl) |
|
} |
|
img.onerror = reject |
|
img.src = dataUrl |
|
}) |
|
} |
|
} else { |
|
|
|
const toImage = format === 'png' ? toPng : toJpeg |
|
const config: ExportImageConfig = { |
|
quality, |
|
width: 1600, |
|
} |
|
|
|
if (ignoreWebfont) config.fontEmbedCSS = '' |
|
dataUrl = await toImage(domRef, config) |
|
} |
|
|
|
exporting.value = false |
|
saveAs(dataUrl, `${title.value}.${format}`) |
|
|
|
if (isHF) { |
|
message.success('图片导出成功(Huggingface优化版本)') |
|
} |
|
} catch (error) { |
|
console.error('exportImage failed:', error) |
|
exporting.value = false |
|
|
|
if (isHF) { |
|
message.error('图片导出失败,Huggingface环境可能存在兼容性问题') |
|
} else { |
|
message.error('导出图片失败') |
|
} |
|
} |
|
}, 200) |
|
} |
|
|
|
|
|
const exportSpecificFile = (_slides: Slide[]) => { |
|
const blob = new Blob([encrypt(JSON.stringify(_slides))], { type: '' }) |
|
saveAs(blob, `${title.value}.pptist`) |
|
} |
|
|
|
|
|
const exportJSON = () => { |
|
const json = { |
|
title: title.value, |
|
width: viewportSize.value, |
|
height: viewportSize.value * viewportRatio.value, |
|
theme: theme.value, |
|
slides: slides.value, |
|
} |
|
const blob = new Blob([JSON.stringify(json)], { type: '' }) |
|
saveAs(blob, `${title.value}.json`) |
|
} |
|
|
|
|
|
const exportHTML = (_slides: Slide[], options: any = {}) => { |
|
exporting.value = true |
|
|
|
try { |
|
const { |
|
includeInteractivity = false, |
|
standalone = true, |
|
includeCSS = true |
|
} = options |
|
|
|
|
|
const htmlContent = generateHTMLPresentation(_slides, { |
|
title: title.value, |
|
theme: theme.value, |
|
viewportSize: viewportSize.value, |
|
viewportRatio: viewportRatio.value, |
|
includeInteractivity, |
|
standalone, |
|
includeCSS |
|
}) |
|
|
|
|
|
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' }) |
|
saveAs(blob, `${title.value}.html`) |
|
|
|
message.success('HTML网页导出成功') |
|
} |
|
catch (error: any) { |
|
message.error('HTML网页导出失败') |
|
} |
|
finally { |
|
exporting.value = false |
|
} |
|
} |
|
|
|
|
|
const generateHTMLPresentation = (slides: Slide[], config: any) => { |
|
const { title, theme, viewportSize, viewportRatio, includeInteractivity, includeCSS } = config |
|
|
|
const slideHeight = Math.round(viewportSize * viewportRatio) |
|
|
|
|
|
const css = includeCSS ? ` |
|
<style> |
|
* { |
|
margin: 0; |
|
padding: 0; |
|
box-sizing: border-box; |
|
} |
|
|
|
body { |
|
font-family: '${theme.fontName}', 'Microsoft YaHei', Arial, sans-serif; |
|
background: ${theme.backgroundColor}; |
|
color: ${theme.fontColor}; |
|
overflow: hidden; |
|
} |
|
|
|
.presentation-container { |
|
width: 100vw; |
|
height: 100vh; |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
background: #000; |
|
} |
|
|
|
.slide-container { |
|
width: ${viewportSize}px; |
|
height: ${slideHeight}px; |
|
background: #fff; |
|
position: relative; |
|
overflow: hidden; |
|
transform-origin: center; |
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3); |
|
} |
|
|
|
.slide { |
|
width: 100%; |
|
height: 100%; |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
display: none; |
|
} |
|
|
|
.slide.active { |
|
display: block; |
|
} |
|
|
|
.element { |
|
position: absolute; |
|
word-wrap: break-word; |
|
} |
|
|
|
.text-element { |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
text-align: center; |
|
} |
|
|
|
.image-element img { |
|
width: 100%; |
|
height: 100%; |
|
object-fit: contain; |
|
} |
|
|
|
.shape-element { |
|
border-radius: var(--border-radius, 0); |
|
} |
|
|
|
.navigation { |
|
position: fixed; |
|
bottom: 30px; |
|
left: 50%; |
|
transform: translateX(-50%); |
|
display: flex; |
|
gap: 10px; |
|
z-index: 1000; |
|
} |
|
|
|
.nav-btn { |
|
padding: 12px 20px; |
|
background: rgba(255,255,255,0.9); |
|
border: none; |
|
border-radius: 25px; |
|
cursor: pointer; |
|
font-size: 14px; |
|
font-weight: bold; |
|
transition: all 0.3s ease; |
|
box-shadow: 0 4px 15px rgba(0,0,0,0.2); |
|
} |
|
|
|
.nav-btn:hover { |
|
background: rgba(255,255,255,1); |
|
transform: translateY(-2px); |
|
} |
|
|
|
.nav-btn:disabled { |
|
opacity: 0.5; |
|
cursor: not-allowed; |
|
} |
|
|
|
.slide-counter { |
|
position: fixed; |
|
top: 30px; |
|
right: 30px; |
|
background: rgba(0,0,0,0.7); |
|
color: white; |
|
padding: 8px 16px; |
|
border-radius: 20px; |
|
font-size: 14px; |
|
z-index: 1000; |
|
} |
|
|
|
.fullscreen-btn { |
|
position: fixed; |
|
top: 30px; |
|
left: 30px; |
|
background: rgba(0,0,0,0.7); |
|
color: white; |
|
border: none; |
|
padding: 8px 16px; |
|
border-radius: 20px; |
|
cursor: pointer; |
|
font-size: 14px; |
|
z-index: 1000; |
|
} |
|
|
|
/* 响应式缩放 */ |
|
@media (max-width: 1200px) { |
|
.slide-container { |
|
transform: scale(0.8); |
|
} |
|
} |
|
|
|
@media (max-width: 800px) { |
|
.slide-container { |
|
transform: scale(0.6); |
|
} |
|
} |
|
|
|
@media (max-width: 600px) { |
|
.slide-container { |
|
transform: scale(0.4); |
|
} |
|
} |
|
</style> |
|
` : '' |
|
|
|
|
|
const javascript = includeInteractivity ? ` |
|
<script> |
|
class PPTViewer { |
|
constructor() { |
|
this.currentSlide = 0; |
|
this.totalSlides = ${slides.length}; |
|
this.init(); |
|
} |
|
|
|
init() { |
|
this.updateSlide(); |
|
this.bindEvents(); |
|
this.autoScale(); |
|
window.addEventListener('resize', () => this.autoScale()); |
|
} |
|
|
|
bindEvents() { |
|
document.addEventListener('keydown', (e) => { |
|
switch(e.key) { |
|
case 'ArrowLeft': |
|
case 'ArrowUp': |
|
this.prevSlide(); |
|
break; |
|
case 'ArrowRight': |
|
case 'ArrowDown': |
|
case ' ': |
|
this.nextSlide(); |
|
break; |
|
case 'Home': |
|
this.goToSlide(0); |
|
break; |
|
case 'End': |
|
this.goToSlide(this.totalSlides - 1); |
|
break; |
|
case 'F11': |
|
this.toggleFullscreen(); |
|
break; |
|
} |
|
}); |
|
} |
|
|
|
nextSlide() { |
|
if (this.currentSlide < this.totalSlides - 1) { |
|
this.currentSlide++; |
|
this.updateSlide(); |
|
} |
|
} |
|
|
|
prevSlide() { |
|
if (this.currentSlide > 0) { |
|
this.currentSlide--; |
|
this.updateSlide(); |
|
} |
|
} |
|
|
|
goToSlide(index) { |
|
if (index >= 0 && index < this.totalSlides) { |
|
this.currentSlide = index; |
|
this.updateSlide(); |
|
} |
|
} |
|
|
|
updateSlide() { |
|
// 隐藏所有幻灯片 |
|
document.querySelectorAll('.slide').forEach(slide => { |
|
slide.classList.remove('active'); |
|
}); |
|
|
|
// 显示当前幻灯片 |
|
const currentSlideEl = document.getElementById('slide-' + this.currentSlide); |
|
if (currentSlideEl) { |
|
currentSlideEl.classList.add('active'); |
|
} |
|
|
|
// 更新计数器 |
|
const counter = document.querySelector('.slide-counter'); |
|
if (counter) { |
|
counter.textContent = \`\${this.currentSlide + 1} / \${this.totalSlides}\`; |
|
} |
|
|
|
// 更新导航按钮状态 |
|
const prevBtn = document.getElementById('prevBtn'); |
|
const nextBtn = document.getElementById('nextBtn'); |
|
if (prevBtn) prevBtn.disabled = this.currentSlide === 0; |
|
if (nextBtn) nextBtn.disabled = this.currentSlide === this.totalSlides - 1; |
|
} |
|
|
|
autoScale() { |
|
const container = document.querySelector('.slide-container'); |
|
if (!container) return; |
|
|
|
const windowWidth = window.innerWidth; |
|
const windowHeight = window.innerHeight; |
|
const slideWidth = ${viewportSize}; |
|
const slideHeight = ${slideHeight}; |
|
|
|
const scaleX = (windowWidth * 0.9) / slideWidth; |
|
const scaleY = (windowHeight * 0.9) / slideHeight; |
|
const scale = Math.min(scaleX, scaleY, 1); |
|
|
|
container.style.transform = \`scale(\${scale})\`; |
|
} |
|
|
|
toggleFullscreen() { |
|
if (!document.fullscreenElement) { |
|
document.documentElement.requestFullscreen(); |
|
} else { |
|
document.exitFullscreen(); |
|
} |
|
} |
|
} |
|
|
|
// 初始化PPT查看器 |
|
document.addEventListener('DOMContentLoaded', () => { |
|
new PPTViewer(); |
|
}); |
|
</script> |
|
` : '' |
|
|
|
|
|
const slidesHTML = slides.map((slide, index) => { |
|
const slideBackground = formatSlideBackground(slide.background) |
|
const elementsHTML = slide.elements.map(element => formatElement(element)).join('') |
|
|
|
return ` |
|
<div id="slide-${index}" class="slide ${index === 0 ? 'active' : ''}" style="${slideBackground}"> |
|
${elementsHTML} |
|
</div> |
|
` |
|
}).join('') |
|
|
|
|
|
const navigationHTML = includeInteractivity ? ` |
|
<div class="navigation"> |
|
<button id="prevBtn" class="nav-btn" onclick="window.pptViewer?.prevSlide()">上一页</button> |
|
<button id="nextBtn" class="nav-btn" onclick="window.pptViewer?.nextSlide()">下一页</button> |
|
</div> |
|
<div class="slide-counter">1 / ${slides.length}</div> |
|
<button class="fullscreen-btn" onclick="window.pptViewer?.toggleFullscreen()">全屏</button> |
|
` : '' |
|
|
|
|
|
const html = `<!DOCTYPE html> |
|
<html lang="zh-CN"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>${title}</title> |
|
<meta name="description" content="PPTist导出的HTML演示文稿"> |
|
<meta name="generator" content="PPTist"> |
|
${css} |
|
</head> |
|
<body> |
|
<div class="presentation-container"> |
|
<div class="slide-container"> |
|
${slidesHTML} |
|
</div> |
|
</div> |
|
${navigationHTML} |
|
${javascript} |
|
</body> |
|
</html>` |
|
|
|
return html |
|
} |
|
|
|
|
|
const formatSlideBackground = (background: any) => { |
|
if (!background) { |
|
return 'background: #ffffff;' |
|
} |
|
|
|
if (background.type === 'solid') { |
|
return `background: ${background.color || '#ffffff'};` |
|
} |
|
|
|
if (background.type === 'gradient') { |
|
const { gradientType, colors } = background |
|
if (gradientType === 'linear') { |
|
return `background: linear-gradient(${background.gradientRotate || 0}deg, ${colors.map((c: any) => c.color).join(', ')});` |
|
} |
|
return `background: radial-gradient(${colors.map((c: any) => c.color).join(', ')});` |
|
} |
|
|
|
if (background.type === 'image' && background.image) { |
|
return `background-image: url(${background.image.src}); background-size: cover; background-position: center;` |
|
} |
|
|
|
return 'background: #ffffff;' |
|
} |
|
|
|
|
|
|
|
const generateSVGFromShape = (element: any): string => { |
|
try { |
|
const { width, height, path, fill, outline, viewBox, opacity = 1 } = element; |
|
const [vbX, vbY, vbWidth, vbHeight] = viewBox || [0, 0, width, height]; |
|
|
|
|
|
const fillColor = fill || '#000000'; |
|
const strokeColor = outline?.color || 'none'; |
|
const strokeWidth = outline?.width || 0; |
|
|
|
|
|
const svgAttributes = [ |
|
`width="${width}"`, |
|
`height="${height}"`, |
|
`viewBox="${vbX} ${vbY} ${vbWidth} ${vbHeight}"`, |
|
'xmlns="http://www.w3.org/2000/svg"', |
|
'style="display: block; width: 100%; height: 100%;"' |
|
].join(' '); |
|
|
|
const pathAttributes = [ |
|
`d="${path}"`, |
|
`fill="${fillColor}"`, |
|
strokeWidth > 0 ? `stroke="${strokeColor}"` : '', |
|
strokeWidth > 0 ? `stroke-width="${strokeWidth}"` : '', |
|
opacity < 1 ? `opacity="${opacity}"` : '', |
|
'vector-effect="non-scaling-stroke"' |
|
].filter(Boolean).join(' '); |
|
|
|
return `<svg ${svgAttributes}><path ${pathAttributes} /></svg>`; |
|
} catch (error) { |
|
console.error('generateSVGFromShape error:', error); |
|
|
|
return `<svg width="${element.width || 100}" height="${element.height || 100}" xmlns="http://www.w3.org/2000/svg"> |
|
<rect width="100%" height="100%" fill="#f5f5f5" stroke="#ddd" stroke-width="1"/> |
|
<text x="50%" y="50%" text-anchor="middle" dy="0.3em" font-size="12" fill="#999">Vector</text> |
|
</svg>`; |
|
} |
|
}; |
|
|
|
const formatElement = (element: any) => { |
|
const baseStyle = ` |
|
left: ${element.left || 0}px; |
|
top: ${element.top || 0}px; |
|
width: ${element.width || 100}px; |
|
height: ${element.height || 100}px; |
|
transform: rotate(${element.rotate || 0}deg); |
|
` |
|
|
|
if (element.type === 'text') { |
|
const textStyle = ` |
|
font-size: ${element.fontSize || 16}px; |
|
font-family: ${element.fontName || 'Microsoft YaHei'}; |
|
color: ${element.color || '#000000'}; |
|
font-weight: ${element.bold ? 'bold' : 'normal'}; |
|
font-style: ${element.italic ? 'italic' : 'normal'}; |
|
text-decoration: ${element.underline ? 'underline' : 'none'}; |
|
text-align: ${element.align || 'left'}; |
|
line-height: ${element.lineHeight || 1.5}; |
|
` |
|
|
|
return `<div class="element text-element" style="${baseStyle}${textStyle}">${element.content || ''}</div>` |
|
} |
|
|
|
if (element.type === 'image' && element.src) { |
|
return `<div class="element image-element" style="${baseStyle}"><img src="${element.src}" alt="图片" /></div>` |
|
} |
|
|
|
if (element.type === 'shape') { |
|
|
|
if (element.special && element.path) { |
|
try { |
|
const svgContent = generateSVGFromShape(element); |
|
|
|
|
|
const tempDiv = document.createElement('div'); |
|
tempDiv.innerHTML = svgContent; |
|
const svgElement = tempDiv.querySelector('svg'); |
|
|
|
if (svgElement) { |
|
|
|
vectorRenderManager.renderElement(svgElement).then(result => { |
|
if (result.success && result.data) { |
|
|
|
if (!VECTOR_EXPORT_CONFIG.ENVIRONMENT.isProduction()) { |
|
console.log('formatElement: SVG optimized successfully'); |
|
} |
|
} |
|
}).catch(error => { |
|
if (!VECTOR_EXPORT_CONFIG.ENVIRONMENT.isProduction()) { |
|
console.warn('formatElement: Background SVG optimization failed:', error); |
|
} |
|
}); |
|
} |
|
|
|
return `<div class="element shape-element vector-shape" style="${baseStyle}">${svgContent}</div>`; |
|
} catch (error) { |
|
|
|
if (VECTOR_EXPORT_CONFIG.ERROR_CONFIG.LOGGING.VERBOSE) { |
|
console.warn('formatElement: Vector shape generation failed, using fallback:', error); |
|
} |
|
|
|
|
|
try { |
|
const placeholderElement = document.createElement('div'); |
|
placeholderElement.style.width = `${element.width}px`; |
|
placeholderElement.style.height = `${element.height}px`; |
|
|
|
|
|
vectorRenderManager.renderElement(placeholderElement).then(result => { |
|
if (result.success && result.data) { |
|
|
|
if (!VECTOR_EXPORT_CONFIG.ENVIRONMENT.isProduction()) { |
|
console.log('formatElement: Fallback SVG generated successfully'); |
|
} |
|
} |
|
}).catch(() => { |
|
|
|
}); |
|
} catch (managerError) { |
|
|
|
} |
|
|
|
|
|
const fallbackSvg = `<svg width="${element.width}" height="${element.height}" xmlns="http://www.w3.org/2000/svg"> |
|
<rect width="100%" height="100%" fill="${element.fill || '#f5f5f5'}" stroke="${element.outline?.color || '#ddd'}" stroke-width="${element.outline?.width || 1}"/> |
|
<text x="50%" y="50%" text-anchor="middle" dy="0.3em" font-size="12" fill="#999">Vector</text> |
|
</svg>`; |
|
|
|
return `<div class="element shape-element vector-shape-fallback" style="${baseStyle}">${fallbackSvg}</div>`; |
|
} |
|
} |
|
|
|
|
|
const shapeStyle = ` |
|
background: ${element.fill || '#ffffff'}; |
|
border: ${element.outline?.width || 0}px solid ${element.outline?.color || '#000000'}; |
|
border-radius: ${element.borderRadius || 0}px; |
|
` |
|
|
|
return `<div class="element shape-element" style="${baseStyle}${shapeStyle}"></div>` |
|
} |
|
|
|
|
|
return `<div class="element" style="${baseStyle}"></div>` |
|
} |
|
|
|
|
|
const formatColor = (_color: string) => { |
|
if (!_color) { |
|
return { |
|
alpha: 0, |
|
color: '#000000', |
|
} |
|
} |
|
|
|
const c = tinycolor(_color) |
|
const alpha = c.getAlpha() |
|
const color = alpha === 0 ? '#ffffff' : c.setAlpha(1).toHexString() |
|
return { |
|
alpha, |
|
color, |
|
} |
|
} |
|
|
|
type FormatColor = ReturnType<typeof formatColor> |
|
|
|
|
|
|
|
const formatHTML = (html: string) => { |
|
const ast = toAST(html) |
|
let bulletFlag = false |
|
let indent = 0 |
|
|
|
const slices: pptxgen.TextProps[] = [] |
|
const parse = (obj: AST[], baseStyleObj: { [key: string]: string } = {}) => { |
|
|
|
for (const item of obj) { |
|
const isBlockTag = 'tagName' in item && ['div', 'li', 'p'].includes(item.tagName) |
|
|
|
if (isBlockTag && slices.length) { |
|
const lastSlice = slices[slices.length - 1] |
|
if (!lastSlice.options) lastSlice.options = {} |
|
lastSlice.options.breakLine = true |
|
} |
|
|
|
const styleObj = { ...baseStyleObj } |
|
const styleAttr = 'attributes' in item ? item.attributes.find(attr => attr.key === 'style') : null |
|
if (styleAttr && styleAttr.value) { |
|
const styleArr = styleAttr.value.split(';') |
|
for (const styleItem of styleArr) { |
|
const [_key, _value] = styleItem.split(': ') |
|
const [key, value] = [trim(_key), trim(_value)] |
|
if (key && value) styleObj[key] = value |
|
} |
|
} |
|
|
|
if ('tagName' in item) { |
|
if (item.tagName === 'em') { |
|
styleObj['font-style'] = 'italic' |
|
} |
|
if (item.tagName === 'strong') { |
|
styleObj['font-weight'] = 'bold' |
|
} |
|
if (item.tagName === 'sup') { |
|
styleObj['vertical-align'] = 'super' |
|
} |
|
if (item.tagName === 'sub') { |
|
styleObj['vertical-align'] = 'sub' |
|
} |
|
if (item.tagName === 'a') { |
|
const attr = item.attributes.find(attr => attr.key === 'href') |
|
styleObj['href'] = attr?.value || '' |
|
} |
|
if (item.tagName === 'ul') { |
|
styleObj['list-type'] = 'ul' |
|
} |
|
if (item.tagName === 'ol') { |
|
styleObj['list-type'] = 'ol' |
|
} |
|
if (item.tagName === 'li') { |
|
bulletFlag = true |
|
} |
|
if (item.tagName === 'p') { |
|
if ('attributes' in item) { |
|
const dataIndentAttr = item.attributes.find(attr => attr.key === 'data-indent') |
|
if (dataIndentAttr && dataIndentAttr.value) indent = +dataIndentAttr.value |
|
} |
|
} |
|
} |
|
|
|
if ('tagName' in item && item.tagName === 'br') { |
|
slices.push({ text: '', options: { breakLine: true } }) |
|
} |
|
else if ('content' in item) { |
|
const text = item.content.replace(/ /g, ' ').replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&').replace(/\n/g, '') |
|
const options: pptxgen.TextPropsOptions = {} |
|
|
|
if (styleObj['font-size']) { |
|
options.fontSize = parseInt(styleObj['font-size']) / ratioPx2Pt.value |
|
} |
|
if (styleObj['color']) { |
|
options.color = formatColor(styleObj['color']).color |
|
} |
|
if (styleObj['background-color']) { |
|
options.highlight = formatColor(styleObj['background-color']).color |
|
} |
|
if (styleObj['text-decoration-line']) { |
|
if (styleObj['text-decoration-line'].indexOf('underline') !== -1) { |
|
options.underline = { |
|
color: options.color || '#000000', |
|
style: 'sng', |
|
} |
|
} |
|
if (styleObj['text-decoration-line'].indexOf('line-through') !== -1) { |
|
options.strike = 'sngStrike' |
|
} |
|
} |
|
if (styleObj['text-decoration']) { |
|
if (styleObj['text-decoration'].indexOf('underline') !== -1) { |
|
options.underline = { |
|
color: options.color || '#000000', |
|
style: 'sng', |
|
} |
|
} |
|
if (styleObj['text-decoration'].indexOf('line-through') !== -1) { |
|
options.strike = 'sngStrike' |
|
} |
|
} |
|
if (styleObj['vertical-align']) { |
|
if (styleObj['vertical-align'] === 'super') options.superscript = true |
|
if (styleObj['vertical-align'] === 'sub') options.subscript = true |
|
} |
|
if (styleObj['text-align']) options.align = styleObj['text-align'] as pptxgen.HAlign |
|
if (styleObj['font-weight']) options.bold = styleObj['font-weight'] === 'bold' |
|
if (styleObj['font-style']) options.italic = styleObj['font-style'] === 'italic' |
|
if (styleObj['font-family']) options.fontFace = styleObj['font-family'] |
|
if (styleObj['href']) options.hyperlink = { url: styleObj['href'] } |
|
|
|
if (bulletFlag && styleObj['list-type'] === 'ol') { |
|
options.bullet = { type: 'number', indent: (options.fontSize || defaultFontSize) * 1.25 } |
|
options.paraSpaceBefore = 0.1 |
|
bulletFlag = false |
|
} |
|
if (bulletFlag && styleObj['list-type'] === 'ul') { |
|
options.bullet = { indent: (options.fontSize || defaultFontSize) * 1.25 } |
|
options.paraSpaceBefore = 0.1 |
|
bulletFlag = false |
|
} |
|
if (indent) { |
|
options.indentLevel = indent |
|
indent = 0 |
|
} |
|
|
|
slices.push({ text, options }) |
|
} |
|
else if ('children' in item) parse(item.children, styleObj) |
|
} |
|
} |
|
parse(ast) |
|
return slices |
|
} |
|
|
|
type Points = Array< |
|
| { x: number; y: number; moveTo?: boolean } |
|
| { x: number; y: number; curve: { type: 'arc'; hR: number; wR: number; stAng: number; swAng: number } } |
|
| { x: number; y: number; curve: { type: 'quadratic'; x1: number; y1: number } } |
|
| { x: number; y: number; curve: { type: 'cubic'; x1: number; y1: number; x2: number; y2: number } } |
|
| { close: true } |
|
> |
|
|
|
|
|
const formatPoints = (points: SvgPoints, scale = { x: 1, y: 1 }): Points => { |
|
return points.map(point => { |
|
if (point.close !== undefined) { |
|
return { close: true } |
|
} |
|
else if (point.type === 'M') { |
|
return { |
|
x: point.x / ratioPx2Inch.value * scale.x, |
|
y: point.y / ratioPx2Inch.value * scale.y, |
|
moveTo: true, |
|
} |
|
} |
|
else if (point.curve) { |
|
if (point.curve.type === 'cubic') { |
|
return { |
|
x: point.x / ratioPx2Inch.value * scale.x, |
|
y: point.y / ratioPx2Inch.value * scale.y, |
|
curve: { |
|
type: 'cubic', |
|
x1: (point.curve.x1 as number) / ratioPx2Inch.value * scale.x, |
|
y1: (point.curve.y1 as number) / ratioPx2Inch.value * scale.y, |
|
x2: (point.curve.x2 as number) / ratioPx2Inch.value * scale.x, |
|
y2: (point.curve.y2 as number) / ratioPx2Inch.value * scale.y, |
|
}, |
|
} |
|
} |
|
else if (point.curve.type === 'quadratic') { |
|
return { |
|
x: point.x / ratioPx2Inch.value * scale.x, |
|
y: point.y / ratioPx2Inch.value * scale.y, |
|
curve: { |
|
type: 'quadratic', |
|
x1: (point.curve.x1 as number) / ratioPx2Inch.value * scale.x, |
|
y1: (point.curve.y1 as number) / ratioPx2Inch.value * scale.y, |
|
}, |
|
} |
|
} |
|
} |
|
return { |
|
x: point.x / ratioPx2Inch.value * scale.x, |
|
y: point.y / ratioPx2Inch.value * scale.y, |
|
} |
|
}) |
|
} |
|
|
|
|
|
const getShadowOption = (shadow: PPTElementShadow): pptxgen.ShadowProps => { |
|
const c = formatColor(shadow.color) |
|
const { h, v } = shadow |
|
|
|
let offset = 4 |
|
let angle = 45 |
|
|
|
if (h === 0 && v === 0) { |
|
offset = 4 |
|
angle = 45 |
|
} |
|
else if (h === 0) { |
|
if (v > 0) { |
|
offset = v |
|
angle = 90 |
|
} |
|
else { |
|
offset = -v |
|
angle = 270 |
|
} |
|
} |
|
else if (v === 0) { |
|
if (h > 0) { |
|
offset = h |
|
angle = 1 |
|
} |
|
else { |
|
offset = -h |
|
angle = 180 |
|
} |
|
} |
|
else if (h > 0 && v > 0) { |
|
offset = Math.max(h, v) |
|
angle = 45 |
|
} |
|
else if (h > 0 && v < 0) { |
|
offset = Math.max(h, -v) |
|
angle = 315 |
|
} |
|
else if (h < 0 && v > 0) { |
|
offset = Math.max(-h, v) |
|
angle = 135 |
|
} |
|
else if (h < 0 && v < 0) { |
|
offset = Math.max(-h, -v) |
|
angle = 225 |
|
} |
|
|
|
return { |
|
type: 'outer', |
|
color: c.color.replace('#', ''), |
|
opacity: c.alpha, |
|
blur: shadow.blur / ratioPx2Pt.value, |
|
offset, |
|
angle, |
|
} |
|
} |
|
|
|
const dashTypeMap = { |
|
'solid': 'solid', |
|
'dashed': 'dash', |
|
'dotted': 'sysDot', |
|
} |
|
|
|
|
|
const getOutlineOption = (outline: PPTElementOutline): pptxgen.ShapeLineProps => { |
|
const c = formatColor(outline?.color || '#000000') |
|
|
|
return { |
|
color: c.color, |
|
transparency: (1 - c.alpha) * 100, |
|
width: (outline.width || 1) / ratioPx2Pt.value, |
|
dashType: outline.style ? dashTypeMap[outline.style] as 'solid' | 'dash' | 'sysDot' : 'solid', |
|
} |
|
} |
|
|
|
|
|
const getLinkOption = (link: PPTElementLink): pptxgen.HyperlinkProps | null => { |
|
const { type, target } = link |
|
if (type === 'web') return { url: target } |
|
if (type === 'slide') { |
|
const index = slides.value.findIndex(slide => slide.id === target) |
|
if (index !== -1) return { slide: index + 1 } |
|
} |
|
|
|
return null |
|
} |
|
|
|
|
|
const isBase64Image = (url: string) => { |
|
const regex = /^data:image\/[^;]+;base64,/ |
|
return url.match(regex) !== null |
|
} |
|
|
|
|
|
const isSVGImage = (url: string) => { |
|
const isSVGBase64 = /^data:image\/svg\+xml;base64,/.test(url) |
|
const isSVGUrl = /\.svg$/.test(url) |
|
return isSVGBase64 || isSVGUrl |
|
} |
|
|
|
|
|
const ensureElementsRendered = async () => { |
|
return new Promise<void>((resolve) => { |
|
|
|
document.body.offsetHeight; |
|
|
|
|
|
requestAnimationFrame(() => { |
|
requestAnimationFrame(() => { |
|
resolve(); |
|
}); |
|
}); |
|
}); |
|
}; |
|
|
|
|
|
const exportPPTX = async (_slides: Slide[], masterOverwrite: boolean, ignoreMedia: boolean) => { |
|
exporting.value = true |
|
|
|
try { |
|
|
|
await ensureElementsRendered(); |
|
} catch (error) { |
|
console.warn('Failed to ensure elements rendered:', error); |
|
} |
|
|
|
const pptx = new pptxgen() |
|
|
|
if (viewportRatio.value === 0.625) pptx.layout = 'LAYOUT_16x10' |
|
else if (viewportRatio.value === 0.75) pptx.layout = 'LAYOUT_4x3' |
|
else if (viewportRatio.value === 0.70710678) { |
|
pptx.defineLayout({ name: 'A3', width: 10, height: 7.0710678 }) |
|
pptx.layout = 'A3' |
|
} |
|
else if (viewportRatio.value === 1.41421356) { |
|
pptx.defineLayout({ name: 'A3_V', width: 10, height: 14.1421356 }) |
|
pptx.layout = 'A3_V' |
|
} |
|
else pptx.layout = 'LAYOUT_16x9' |
|
|
|
if (masterOverwrite) { |
|
const { color: bgColor, alpha: bgAlpha } = formatColor(theme.value.backgroundColor) |
|
pptx.defineSlideMaster({ |
|
title: 'PPTIST_MASTER', |
|
background: { color: bgColor, transparency: (1 - bgAlpha) * 100 }, |
|
}) |
|
} |
|
|
|
for (const slide of _slides) { |
|
const pptxSlide = pptx.addSlide() |
|
|
|
if (slide.background) { |
|
const background = slide.background |
|
if (background.type === 'image' && background.image) { |
|
if (isSVGImage(background.image.src)) { |
|
pptxSlide.addImage({ |
|
data: background.image.src, |
|
x: 0, |
|
y: 0, |
|
w: viewportSize.value / ratioPx2Inch.value, |
|
h: viewportSize.value * viewportRatio.value / ratioPx2Inch.value, |
|
}) |
|
} |
|
else if (isBase64Image(background.image.src)) { |
|
pptxSlide.background = { data: background.image.src } |
|
} |
|
else { |
|
pptxSlide.background = { path: background.image.src } |
|
} |
|
} |
|
else if (background.type === 'solid' && background.color) { |
|
const c = formatColor(background.color) |
|
pptxSlide.background = { color: c.color, transparency: (1 - c.alpha) * 100 } |
|
} |
|
else if (background.type === 'gradient' && background.gradient) { |
|
const colors = background.gradient.colors |
|
const color1 = colors[0].color |
|
const color2 = colors[colors.length - 1].color |
|
const color = tinycolor.mix(color1, color2).toHexString() |
|
const c = formatColor(color) |
|
pptxSlide.background = { color: c.color, transparency: (1 - c.alpha) * 100 } |
|
} |
|
} |
|
if (slide.remark) { |
|
const doc = new DOMParser().parseFromString(slide.remark, 'text/html') |
|
const pList = doc.body.querySelectorAll('p') |
|
const text = [] |
|
for (const p of pList) { |
|
const textContent = p.textContent |
|
text.push(textContent || '') |
|
} |
|
pptxSlide.addNotes(text.join('\n')) |
|
} |
|
|
|
if (!slide.elements) continue |
|
|
|
for (const el of slide.elements) { |
|
if (el.type === 'text') { |
|
const textProps = formatHTML(el.content) |
|
|
|
const options: pptxgen.TextPropsOptions = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
fontSize: defaultFontSize / ratioPx2Pt.value, |
|
fontFace: '微软雅黑', |
|
color: '#000000', |
|
valign: 'top', |
|
margin: 10 / ratioPx2Pt.value, |
|
paraSpaceBefore: 5 / ratioPx2Pt.value, |
|
lineSpacingMultiple: 1.5 / 1.25, |
|
autoFit: true, |
|
} |
|
if (el.rotate) options.rotate = el.rotate |
|
if (el.wordSpace) options.charSpacing = el.wordSpace / ratioPx2Pt.value |
|
if (el.lineHeight) options.lineSpacingMultiple = el.lineHeight / 1.25 |
|
if (el.fill) { |
|
const c = formatColor(el.fill) |
|
const opacity = el.opacity === undefined ? 1 : el.opacity |
|
options.fill = { color: c.color, transparency: (1 - c.alpha * opacity) * 100 } |
|
} |
|
if (el.defaultColor) options.color = formatColor(el.defaultColor).color |
|
if (el.defaultFontName) options.fontFace = el.defaultFontName |
|
if (el.shadow) options.shadow = getShadowOption(el.shadow) |
|
if (el.outline?.width) options.line = getOutlineOption(el.outline) |
|
if (el.opacity !== undefined) options.transparency = (1 - el.opacity) * 100 |
|
if (el.paragraphSpace !== undefined) options.paraSpaceBefore = el.paragraphSpace / ratioPx2Pt.value |
|
if (el.vertical) options.vert = 'eaVert' |
|
|
|
pptxSlide.addText(textProps, options) |
|
} |
|
|
|
else if (el.type === 'image') { |
|
const options: pptxgen.ImageProps = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
} |
|
if (isBase64Image(el.src)) options.data = el.src |
|
else options.path = el.src |
|
|
|
if (el.flipH) options.flipH = el.flipH |
|
if (el.flipV) options.flipV = el.flipV |
|
if (el.rotate) options.rotate = el.rotate |
|
if (el.link) { |
|
const linkOption = getLinkOption(el.link) |
|
if (linkOption) options.hyperlink = linkOption |
|
} |
|
if (el.filters?.opacity) options.transparency = 100 - parseInt(el.filters?.opacity) |
|
if (el.clip) { |
|
if (el.clip.shape === 'ellipse') options.rounding = true |
|
|
|
const [start, end] = el.clip.range |
|
const [startX, startY] = start |
|
const [endX, endY] = end |
|
|
|
const originW = el.width / ((endX - startX) / ratioPx2Inch.value) |
|
const originH = el.height / ((endY - startY) / ratioPx2Inch.value) |
|
|
|
options.w = originW / ratioPx2Inch.value |
|
options.h = originH / ratioPx2Inch.value |
|
|
|
options.sizing = { |
|
type: 'crop', |
|
x: startX / ratioPx2Inch.value * originW / ratioPx2Inch.value, |
|
y: startY / ratioPx2Inch.value * originH / ratioPx2Inch.value, |
|
w: (endX - startX) / ratioPx2Inch.value * originW / ratioPx2Inch.value, |
|
h: (endY - startY) / ratioPx2Inch.value * originH / ratioPx2Inch.value, |
|
} |
|
} |
|
|
|
pptxSlide.addImage(options) |
|
} |
|
|
|
else if (el.type === 'shape') { |
|
if (el.special) { |
|
console.log(`Processing special shape ${el.id} with Canvas rendering:`, { |
|
type: el.type, |
|
special: el.special, |
|
width: el.width, |
|
height: el.height, |
|
left: el.left, |
|
top: el.top |
|
}); |
|
|
|
|
|
if (!isCanvasRenderSupported()) { |
|
console.warn('Canvas rendering not supported, falling back to path-based export'); |
|
|
|
const scale = { |
|
x: el.width / el.viewBox[0], |
|
y: el.height / el.viewBox[1], |
|
}; |
|
const points = formatPoints(toPoints(el.path), scale); |
|
|
|
let fillColor = formatColor(el.fill); |
|
if (el.gradient) { |
|
const colors = el.gradient.colors; |
|
const color1 = colors[0].color; |
|
const color2 = colors[colors.length - 1].color; |
|
const color = tinycolor.mix(color1, color2).toHexString(); |
|
fillColor = formatColor(color); |
|
} |
|
if (el.pattern) fillColor = formatColor('#00000000'); |
|
const opacity = el.opacity === undefined ? 1 : el.opacity; |
|
|
|
const fallbackOptions: pptxgen.ShapeProps = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
fill: { color: fillColor.color, transparency: (1 - fillColor.alpha * opacity) * 100 }, |
|
points, |
|
}; |
|
|
|
if (el.flipH) fallbackOptions.flipH = el.flipH; |
|
if (el.flipV) fallbackOptions.flipV = el.flipV; |
|
if (el.shadow) fallbackOptions.shadow = getShadowOption(el.shadow); |
|
if (el.outline?.width) fallbackOptions.line = getOutlineOption(el.outline); |
|
if (el.rotate) fallbackOptions.rotate = el.rotate; |
|
if (el.link) { |
|
const linkOption = getLinkOption(el.link); |
|
if (linkOption) fallbackOptions.hyperlink = linkOption; |
|
} |
|
|
|
pptxSlide.addShape('custGeom' as pptxgen.ShapeType, fallbackOptions); |
|
continue; |
|
} |
|
|
|
|
|
let targetElement: HTMLElement | null = null; |
|
|
|
|
|
const containerSelectors = [ |
|
`.thumbnail-list .base-element-${el.id}`, |
|
`[data-element-id="${el.id}"]`, |
|
`#element-${el.id}`, |
|
`[class*="${el.id}"]` |
|
]; |
|
|
|
for (const selector of containerSelectors) { |
|
const container = document.querySelector(selector) as HTMLElement; |
|
if (container) { |
|
|
|
const svgInContainer = container.querySelector('svg'); |
|
if (svgInContainer) { |
|
targetElement = container; |
|
console.log(`Found container with SVG for ${el.id} using selector: ${selector}`); |
|
break; |
|
} |
|
|
|
if (container.tagName.toLowerCase() === 'svg' || container.children.length > 0) { |
|
targetElement = container; |
|
console.log(`Found target element for ${el.id} using selector: ${selector}`); |
|
break; |
|
} |
|
} |
|
} |
|
|
|
|
|
if (!targetElement) { |
|
const svgSelectors = [ |
|
`.thumbnail-list .base-element-${el.id} svg`, |
|
`[data-element-id="${el.id}"] svg`, |
|
`#element-${el.id} svg` |
|
]; |
|
|
|
for (const selector of svgSelectors) { |
|
const svgElement = document.querySelector(selector) as HTMLElement; |
|
if (svgElement) { |
|
targetElement = svgElement; |
|
console.log(`Found SVG element for ${el.id} using selector: ${selector}`); |
|
break; |
|
} |
|
} |
|
} |
|
|
|
|
|
if (!targetElement) { |
|
const allSvgs = document.querySelectorAll('svg'); |
|
console.log(`Searching through ${allSvgs.length} SVG elements for ${el.id}`); |
|
|
|
for (const svg of allSvgs) { |
|
const parent = svg.closest(`[class*="${el.id}"]`); |
|
if (parent) { |
|
targetElement = svg as HTMLElement; |
|
console.log(`Found SVG for ${el.id} using parent matching`); |
|
break; |
|
} |
|
} |
|
} |
|
|
|
if (!targetElement) { |
|
console.warn(`No target element found for shape ${el.id}, falling back to path-based export`); |
|
|
|
const scale = { |
|
x: el.width / el.viewBox[0], |
|
y: el.height / el.viewBox[1], |
|
}; |
|
const points = formatPoints(toPoints(el.path), scale); |
|
|
|
let fillColor = formatColor(el.fill); |
|
if (el.gradient) { |
|
const colors = el.gradient.colors; |
|
const color1 = colors[0].color; |
|
const color2 = colors[colors.length - 1].color; |
|
const color = tinycolor.mix(color1, color2).toHexString(); |
|
fillColor = formatColor(color); |
|
} |
|
if (el.pattern) fillColor = formatColor('#00000000'); |
|
const opacity = el.opacity === undefined ? 1 : el.opacity; |
|
|
|
const fallbackOptions: pptxgen.ShapeProps = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
fill: { color: fillColor.color, transparency: (1 - fillColor.alpha * opacity) * 100 }, |
|
points, |
|
}; |
|
|
|
if (el.flipH) fallbackOptions.flipH = el.flipH; |
|
if (el.flipV) fallbackOptions.flipV = el.flipV; |
|
if (el.shadow) fallbackOptions.shadow = getShadowOption(el.shadow); |
|
if (el.outline?.width) fallbackOptions.line = getOutlineOption(el.outline); |
|
if (el.rotate) fallbackOptions.rotate = el.rotate; |
|
if (el.link) { |
|
const linkOption = getLinkOption(el.link); |
|
if (linkOption) fallbackOptions.hyperlink = linkOption; |
|
} |
|
|
|
pptxSlide.addShape('custGeom' as pptxgen.ShapeType, fallbackOptions); |
|
continue; |
|
} |
|
|
|
|
|
const dimensions = getElementDimensions(targetElement); |
|
console.log(`Target element dimensions for ${el.id}:`, dimensions); |
|
|
|
|
|
const hasValidDimensions = ( |
|
dimensions.width > 0 || dimensions.height > 0 || |
|
dimensions.clientWidth > 0 || dimensions.clientHeight > 0 || |
|
dimensions.offsetWidth > 0 || dimensions.offsetHeight > 0 || |
|
dimensions.boundingRect.width > 0 || dimensions.boundingRect.height > 0 |
|
); |
|
|
|
if (!hasValidDimensions) { |
|
console.warn(`No valid dimensions found for shape ${el.id}:`, dimensions); |
|
console.log('Falling back to path-based export'); |
|
|
|
const scale = { |
|
x: el.width / el.viewBox[0], |
|
y: el.height / el.viewBox[1], |
|
}; |
|
const points = formatPoints(toPoints(el.path), scale); |
|
|
|
let fillColor = formatColor(el.fill); |
|
if (el.gradient) { |
|
const colors = el.gradient.colors; |
|
const color1 = colors[0].color; |
|
const color2 = colors[colors.length - 1].color; |
|
const color = tinycolor.mix(color1, color2).toHexString(); |
|
fillColor = formatColor(color); |
|
} |
|
if (el.pattern) fillColor = formatColor('#00000000'); |
|
const opacity = el.opacity === undefined ? 1 : el.opacity; |
|
|
|
const fallbackOptions: pptxgen.ShapeProps = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
fill: { color: fillColor.color, transparency: (1 - fillColor.alpha * opacity) * 100 }, |
|
points, |
|
}; |
|
|
|
if (el.flipH) fallbackOptions.flipH = el.flipH; |
|
if (el.flipV) fallbackOptions.flipV = el.flipV; |
|
if (el.shadow) fallbackOptions.shadow = getShadowOption(el.shadow); |
|
if (el.outline?.width) fallbackOptions.line = getOutlineOption(el.outline); |
|
if (el.rotate) fallbackOptions.rotate = el.rotate; |
|
if (el.link) { |
|
const linkOption = getLinkOption(el.link); |
|
if (linkOption) fallbackOptions.hyperlink = linkOption; |
|
} |
|
|
|
pptxSlide.addShape('custGeom' as pptxgen.ShapeType, fallbackOptions); |
|
continue; |
|
} |
|
|
|
|
|
let base64Image; |
|
let renderSuccess = false; |
|
|
|
|
|
try { |
|
console.log(`Attempting SVG serialization for ${el.id}`); |
|
|
|
const svgElement = targetElement.tagName.toLowerCase() === 'svg' |
|
? targetElement |
|
: targetElement.querySelector('svg'); |
|
|
|
if (svgElement) { |
|
|
|
await new Promise(resolve => { |
|
requestAnimationFrame(() => { |
|
requestAnimationFrame(resolve); |
|
}); |
|
}); |
|
|
|
base64Image = svg2Base64(svgElement); |
|
|
|
console.log(`SVG serialization result for ${el.id}:`, { |
|
success: !!base64Image, |
|
length: base64Image ? base64Image.length : 0, |
|
preview: base64Image ? base64Image.substring(0, 100) + '...' : 'null' |
|
}); |
|
|
|
if (base64Image && base64Image.length > 100) { |
|
renderSuccess = true; |
|
console.log(`SVG serialization successful for ${el.id}`); |
|
} |
|
} |
|
} catch (svgError) { |
|
console.warn(`SVG serialization failed for ${el.id}:`, svgError); |
|
} |
|
|
|
|
|
if (!renderSuccess) { |
|
try { |
|
console.log(`Attempting Canvas rendering for ${el.id}`); |
|
|
|
|
|
const renderOptions = { |
|
scale: 2, |
|
backgroundColor: null, |
|
useCORS: true, |
|
timeout: 15000, |
|
format: 'png' as const, |
|
quality: 0.95 |
|
}; |
|
|
|
base64Image = await renderElementToBase64(targetElement, renderOptions); |
|
|
|
console.log(`Canvas rendering result for ${el.id}:`, { |
|
success: !!base64Image, |
|
length: base64Image ? base64Image.length : 0, |
|
preview: base64Image ? base64Image.substring(0, 100) + '...' : 'null' |
|
}); |
|
|
|
if (base64Image && base64Image.length > 100) { |
|
renderSuccess = true; |
|
console.log(`Canvas rendering successful for ${el.id}`); |
|
} |
|
} catch (canvasError) { |
|
console.error(`Canvas rendering failed for ${el.id}:`, canvasError); |
|
} |
|
} |
|
|
|
|
|
if (!renderSuccess) { |
|
try { |
|
console.log(`Attempting simplified SVG rendering for ${el.id}`); |
|
|
|
const svgElement = targetElement.tagName.toLowerCase() === 'svg' |
|
? targetElement |
|
: targetElement.querySelector('svg'); |
|
|
|
if (svgElement) { |
|
|
|
const simplifiedSVG = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); |
|
const rect = svgElement.getBoundingClientRect(); |
|
|
|
simplifiedSVG.setAttribute('width', (rect.width || 100).toString()); |
|
simplifiedSVG.setAttribute('height', (rect.height || 100).toString()); |
|
simplifiedSVG.setAttribute('viewBox', `0 0 ${rect.width || 100} ${rect.height || 100}`); |
|
simplifiedSVG.innerHTML = svgElement.innerHTML; |
|
|
|
base64Image = svg2Base64(simplifiedSVG); |
|
|
|
if (base64Image && base64Image.length > 100) { |
|
renderSuccess = true; |
|
console.log(`Simplified SVG rendering successful for ${el.id}`); |
|
} |
|
} |
|
} catch (simplifiedError) { |
|
console.error(`Simplified SVG rendering failed for ${el.id}:`, simplifiedError); |
|
} |
|
} |
|
|
|
|
|
if (!renderSuccess) { |
|
console.warn(`All rendering strategies failed for ${el.id}, falling back to path export`); |
|
|
|
console.log(`Final fallback to path-based export for ${el.id}`); |
|
const scale = { |
|
x: el.width / el.viewBox[0], |
|
y: el.height / el.viewBox[1], |
|
}; |
|
const points = formatPoints(toPoints(el.path), scale); |
|
|
|
let fillColor = formatColor(el.fill); |
|
if (el.gradient) { |
|
const colors = el.gradient.colors; |
|
const color1 = colors[0].color; |
|
const color2 = colors[colors.length - 1].color; |
|
const color = tinycolor.mix(color1, color2).toHexString(); |
|
fillColor = formatColor(color); |
|
} |
|
if (el.pattern) fillColor = formatColor('#00000000'); |
|
const opacity = el.opacity === undefined ? 1 : el.opacity; |
|
|
|
const fallbackOptions: pptxgen.ShapeProps = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
fill: { color: fillColor.color, transparency: (1 - fillColor.alpha * opacity) * 100 }, |
|
points, |
|
}; |
|
|
|
if (el.flipH) fallbackOptions.flipH = el.flipH; |
|
if (el.flipV) fallbackOptions.flipV = el.flipV; |
|
if (el.shadow) fallbackOptions.shadow = getShadowOption(el.shadow); |
|
if (el.outline?.width) fallbackOptions.line = getOutlineOption(el.outline); |
|
if (el.rotate) fallbackOptions.rotate = el.rotate; |
|
if (el.link) { |
|
const linkOption = getLinkOption(el.link); |
|
if (linkOption) fallbackOptions.hyperlink = linkOption; |
|
} |
|
|
|
pptxSlide.addShape('custGeom' as pptxgen.ShapeType, fallbackOptions); |
|
continue; |
|
} |
|
|
|
|
|
const options: pptxgen.ImageProps = { |
|
data: base64Image, |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
} |
|
if (el.rotate) options.rotate = el.rotate |
|
if (el.flipH) options.flipH = el.flipH |
|
if (el.flipV) options.flipV = el.flipV |
|
if (el.link) { |
|
const linkOption = getLinkOption(el.link) |
|
if (linkOption) options.hyperlink = linkOption |
|
} |
|
|
|
console.log(`Successfully added Canvas-rendered image for shape ${el.id}`); |
|
pptxSlide.addImage(options) |
|
} |
|
else { |
|
const scale = { |
|
x: el.width / el.viewBox[0], |
|
y: el.height / el.viewBox[1], |
|
} |
|
const points = formatPoints(toPoints(el.path), scale) |
|
|
|
let fillColor = formatColor(el.fill) |
|
if (el.gradient) { |
|
const colors = el.gradient.colors |
|
const color1 = colors[0].color |
|
const color2 = colors[colors.length - 1].color |
|
const color = tinycolor.mix(color1, color2).toHexString() |
|
fillColor = formatColor(color) |
|
} |
|
if (el.pattern) fillColor = formatColor('#00000000') |
|
const opacity = el.opacity === undefined ? 1 : el.opacity |
|
|
|
const options: pptxgen.ShapeProps = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
fill: { color: fillColor.color, transparency: (1 - fillColor.alpha * opacity) * 100 }, |
|
points, |
|
} |
|
if (el.flipH) options.flipH = el.flipH |
|
if (el.flipV) options.flipV = el.flipV |
|
if (el.shadow) options.shadow = getShadowOption(el.shadow) |
|
if (el.outline?.width) options.line = getOutlineOption(el.outline) |
|
if (el.rotate) options.rotate = el.rotate |
|
if (el.link) { |
|
const linkOption = getLinkOption(el.link) |
|
if (linkOption) options.hyperlink = linkOption |
|
} |
|
|
|
pptxSlide.addShape('custGeom' as pptxgen.ShapeType, options) |
|
} |
|
if (el.text) { |
|
const textProps = formatHTML(el.text.content) |
|
|
|
const options: pptxgen.TextPropsOptions = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
fontSize: defaultFontSize / ratioPx2Pt.value, |
|
fontFace: '微软雅黑', |
|
color: '#000000', |
|
paraSpaceBefore: 5 / ratioPx2Pt.value, |
|
valign: el.text.align, |
|
} |
|
if (el.rotate) options.rotate = el.rotate |
|
if (el.text.defaultColor) options.color = formatColor(el.text.defaultColor).color |
|
if (el.text.defaultFontName) options.fontFace = el.text.defaultFontName |
|
|
|
pptxSlide.addText(textProps, options) |
|
} |
|
if (el.pattern) { |
|
const options: pptxgen.ImageProps = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
} |
|
if (isBase64Image(el.pattern)) options.data = el.pattern |
|
else options.path = el.pattern |
|
|
|
if (el.flipH) options.flipH = el.flipH |
|
if (el.flipV) options.flipV = el.flipV |
|
if (el.rotate) options.rotate = el.rotate |
|
if (el.link) { |
|
const linkOption = getLinkOption(el.link) |
|
if (linkOption) options.hyperlink = linkOption |
|
} |
|
|
|
pptxSlide.addImage(options) |
|
} |
|
} |
|
|
|
else if (el.type === 'line') { |
|
const path = getLineElementPath(el) |
|
const points = formatPoints(toPoints(path)) |
|
const { minX, maxX, minY, maxY } = getElementRange(el) |
|
const c = formatColor(el.color) |
|
|
|
const options: pptxgen.ShapeProps = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: (maxX - minX) / ratioPx2Inch.value, |
|
h: (maxY - minY) / ratioPx2Inch.value, |
|
line: { |
|
color: c.color, |
|
transparency: (1 - c.alpha) * 100, |
|
width: el.width / ratioPx2Pt.value, |
|
dashType: dashTypeMap[el.style] as 'solid' | 'dash' | 'sysDot', |
|
beginArrowType: el.points[0] ? 'arrow' : 'none', |
|
endArrowType: el.points[1] ? 'arrow' : 'none', |
|
}, |
|
points, |
|
} |
|
if (el.shadow) options.shadow = getShadowOption(el.shadow) |
|
|
|
pptxSlide.addShape('custGeom' as pptxgen.ShapeType, options) |
|
} |
|
|
|
else if (el.type === 'chart') { |
|
const chartData = [] |
|
for (let i = 0; i < el.data.series.length; i++) { |
|
const item = el.data.series[i] |
|
chartData.push({ |
|
name: `系列${i + 1}`, |
|
labels: el.data.labels, |
|
values: item, |
|
}) |
|
} |
|
|
|
let chartColors: string[] = [] |
|
if (el.themeColors.length === 10) chartColors = el.themeColors.map(color => formatColor(color).color) |
|
else if (el.themeColors.length === 1) chartColors = tinycolor(el.themeColors[0]).analogous(10).map(color => formatColor(color.toHexString()).color) |
|
else { |
|
const len = el.themeColors.length |
|
const supplement = tinycolor(el.themeColors[len - 1]).analogous(10 + 1 - len).map(color => color.toHexString()) |
|
chartColors = [...el.themeColors.slice(0, len - 1), ...supplement].map(color => formatColor(color).color) |
|
} |
|
|
|
const options: pptxgen.IChartOpts = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
chartColors: (el.chartType === 'pie' || el.chartType === 'ring') ? chartColors : chartColors.slice(0, el.data.series.length), |
|
} |
|
|
|
const textColor = formatColor(el.textColor || '#000000').color |
|
options.catAxisLabelColor = textColor |
|
options.valAxisLabelColor = textColor |
|
|
|
const fontSize = 14 / ratioPx2Pt.value |
|
options.catAxisLabelFontSize = fontSize |
|
options.valAxisLabelFontSize = fontSize |
|
|
|
if (el.fill || el.outline) { |
|
const plotArea: pptxgen.IChartPropsFillLine = {} |
|
if (el.fill) { |
|
plotArea.fill = { color: formatColor(el.fill).color } |
|
} |
|
if (el.outline) { |
|
plotArea.border = { |
|
pt: el.outline.width! / ratioPx2Pt.value, |
|
color: formatColor(el.outline.color!).color, |
|
} |
|
} |
|
options.plotArea = plotArea |
|
} |
|
|
|
if ((el.data.series.length > 1 && el.chartType !== 'scatter') || el.chartType === 'pie' || el.chartType === 'ring') { |
|
options.showLegend = true |
|
options.legendPos = 'b' |
|
options.legendColor = textColor |
|
options.legendFontSize = fontSize |
|
} |
|
|
|
let type = pptx.ChartType.bar |
|
if (el.chartType === 'bar') { |
|
type = pptx.ChartType.bar |
|
options.barDir = 'col' |
|
if (el.options?.stack) options.barGrouping = 'stacked' |
|
} |
|
else if (el.chartType === 'column') { |
|
type = pptx.ChartType.bar |
|
options.barDir = 'bar' |
|
if (el.options?.stack) options.barGrouping = 'stacked' |
|
} |
|
else if (el.chartType === 'line') { |
|
type = pptx.ChartType.line |
|
if (el.options?.lineSmooth) options.lineSmooth = true |
|
} |
|
else if (el.chartType === 'area') { |
|
type = pptx.ChartType.area |
|
} |
|
else if (el.chartType === 'radar') { |
|
type = pptx.ChartType.radar |
|
} |
|
else if (el.chartType === 'scatter') { |
|
type = pptx.ChartType.scatter |
|
options.lineSize = 0 |
|
} |
|
else if (el.chartType === 'pie') { |
|
type = pptx.ChartType.pie |
|
} |
|
else if (el.chartType === 'ring') { |
|
type = pptx.ChartType.doughnut |
|
options.holeSize = 60 |
|
} |
|
|
|
pptxSlide.addChart(type, chartData, options) |
|
} |
|
|
|
else if (el.type === 'table') { |
|
const hiddenCells = [] |
|
for (let i = 0; i < el.data.length; i++) { |
|
const rowData = el.data[i] |
|
|
|
for (let j = 0; j < rowData.length; j++) { |
|
const cell = rowData[j] |
|
if (cell.colspan > 1 || cell.rowspan > 1) { |
|
for (let row = i; row < i + cell.rowspan; row++) { |
|
for (let col = row === i ? j + 1 : j; col < j + cell.colspan; col++) hiddenCells.push(`${row}_${col}`) |
|
} |
|
} |
|
} |
|
} |
|
|
|
const tableData = [] |
|
|
|
const theme = el.theme |
|
let themeColor: FormatColor | null = null |
|
let subThemeColors: FormatColor[] = [] |
|
if (theme) { |
|
themeColor = formatColor(theme.color) |
|
subThemeColors = getTableSubThemeColor(theme.color).map(item => formatColor(item)) |
|
} |
|
|
|
for (let i = 0; i < el.data.length; i++) { |
|
const row = el.data[i] |
|
const _row = [] |
|
|
|
for (let j = 0; j < row.length; j++) { |
|
const cell = row[j] |
|
const cellOptions: pptxgen.TableCellProps = { |
|
colspan: cell.colspan, |
|
rowspan: cell.rowspan, |
|
bold: cell.style?.bold || false, |
|
italic: cell.style?.em || false, |
|
underline: { style: cell.style?.underline ? 'sng' : 'none' }, |
|
align: cell.style?.align || 'left', |
|
valign: 'middle', |
|
fontFace: cell.style?.fontname || '微软雅黑', |
|
fontSize: (cell.style?.fontsize ? parseInt(cell.style?.fontsize) : 14) / ratioPx2Pt.value, |
|
} |
|
if (theme && themeColor) { |
|
let c: FormatColor |
|
if (i % 2 === 0) c = subThemeColors[1] |
|
else c = subThemeColors[0] |
|
|
|
if (theme.rowHeader && i === 0) c = themeColor |
|
else if (theme.rowFooter && i === el.data.length - 1) c = themeColor |
|
else if (theme.colHeader && j === 0) c = themeColor |
|
else if (theme.colFooter && j === row.length - 1) c = themeColor |
|
|
|
cellOptions.fill = { color: c.color, transparency: (1 - c.alpha) * 100 } |
|
} |
|
if (cell.style?.backcolor) { |
|
const c = formatColor(cell.style.backcolor) |
|
cellOptions.fill = { color: c.color, transparency: (1 - c.alpha) * 100 } |
|
} |
|
if (cell.style?.color) cellOptions.color = formatColor(cell.style.color).color |
|
|
|
if (!hiddenCells.includes(`${i}_${j}`)) { |
|
_row.push({ |
|
text: cell.text, |
|
options: cellOptions, |
|
}) |
|
} |
|
} |
|
if (_row.length) tableData.push(_row) |
|
} |
|
|
|
const options: pptxgen.TableProps = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
colW: el.colWidths.map(item => el.width * item / ratioPx2Inch.value), |
|
} |
|
if (el.theme) options.fill = { color: '#ffffff' } |
|
if (el.outline.width && el.outline.color) { |
|
options.border = { |
|
type: el.outline.style === 'solid' ? 'solid' : 'dash', |
|
pt: el.outline.width / ratioPx2Pt.value, |
|
color: formatColor(el.outline.color).color, |
|
} |
|
} |
|
|
|
pptxSlide.addTable(tableData, options) |
|
} |
|
|
|
else if (el.type === 'latex') { |
|
const svgRef = document.querySelector(`.thumbnail-list .base-element-${el.id} svg`) as HTMLElement |
|
const base64SVG = svg2Base64(svgRef) |
|
|
|
const options: pptxgen.ImageProps = { |
|
data: base64SVG, |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
} |
|
if (el.link) { |
|
const linkOption = getLinkOption(el.link) |
|
if (linkOption) options.hyperlink = linkOption |
|
} |
|
|
|
pptxSlide.addImage(options) |
|
} |
|
|
|
else if (!ignoreMedia && (el.type === 'video' || el.type === 'audio')) { |
|
const options: pptxgen.MediaProps = { |
|
x: el.left / ratioPx2Inch.value, |
|
y: el.top / ratioPx2Inch.value, |
|
w: el.width / ratioPx2Inch.value, |
|
h: el.height / ratioPx2Inch.value, |
|
path: el.src, |
|
type: el.type, |
|
} |
|
if (el.type === 'video' && el.poster) options.cover = el.poster |
|
|
|
const extMatch = el.src.match(/\.([a-zA-Z0-9]+)(?:[\?#]|$)/) |
|
if (extMatch && extMatch[1]) options.extn = extMatch[1] |
|
else if (el.ext) options.extn = el.ext |
|
|
|
const videoExts = ['avi', 'mp4', 'm4v', 'mov', 'wmv'] |
|
const audioExts = ['mp3', 'm4a', 'mp4', 'wav', 'wma'] |
|
if (options.extn && [...videoExts, ...audioExts].includes(options.extn)) { |
|
pptxSlide.addMedia(options) |
|
} |
|
} |
|
} |
|
} |
|
|
|
setTimeout(() => { |
|
pptx.writeFile({ fileName: `${title.value}.pptx` }).then(() => exporting.value = false).catch(() => { |
|
exporting.value = false |
|
message.error('导出失败') |
|
}) |
|
}, 200) |
|
} |
|
|
|
return { |
|
exporting, |
|
exportImage, |
|
exportJSON, |
|
exportSpecificFile, |
|
exportHTML, |
|
exportPPTX, |
|
generateHTMLPresentation, |
|
} |
|
} |