web_ppt / frontend /src /hooks /useExport.ts
CatPtain's picture
Upload 71 files
f5b4781 verified
raw
history blame
46.3 kB
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 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 toImage = format === 'png' ? toPng : toJpeg
const foreignObjectSpans = domRef.querySelectorAll('foreignObject [xmlns]')
foreignObjectSpans.forEach(spanRef => spanRef.removeAttribute('xmlns'))
setTimeout(() => {
const config: ExportImageConfig = {
quality,
width: 1600,
}
if (ignoreWebfont) config.fontEmbedCSS = ''
toImage(domRef, config).then(dataUrl => {
exporting.value = false
saveAs(dataUrl, `${title.value}.${format}`)
}).catch(() => {
exporting.value = false
message.error('导出图片失败')
})
}, 200)
}
// 导出pptist文件(特有 .pptist 后缀文件)
const exportSpecificFile = (_slides: Slide[]) => {
const blob = new Blob([encrypt(JSON.stringify(_slides))], { type: '' })
saveAs(blob, `${title.value}.pptist`)
}
// 导出JSON文件
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`)
}
// 新增:导出为HTML网页
const exportHTML = (_slides: Slide[], options: any = {}) => {
exporting.value = true
try {
const {
includeInteractivity = false,
standalone = true,
includeCSS = true
} = options
// 生成HTML内容
const htmlContent = generateHTMLPresentation(_slides, {
title: title.value,
theme: theme.value,
viewportSize: viewportSize.value,
viewportRatio: viewportRatio.value,
includeInteractivity,
standalone,
includeCSS
})
// 创建并下载HTML文件
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
}
}
// 生成完整的HTML演示文稿
const generateHTMLPresentation = (slides: Slide[], config: any) => {
const { title, theme, viewportSize, viewportRatio, includeInteractivity, includeCSS } = config
const slideHeight = Math.round(viewportSize * viewportRatio)
// CSS样式
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>
` : ''
// JavaScript交互功能
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>
` : ''
// 生成幻灯片HTML
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('')
// 导航控件HTML
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>
` : ''
// 组装完整HTML
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 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') {
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>`
}
// 格式化颜色值为 透明度 + HexString,供pptxgenjs使用
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>
// 将HTML字符串格式化为pptxgenjs所需的格式
// 核心思路:将HTML字符串按样式分片平铺,每个片段需要继承祖先元素的样式信息,遇到块级元素需要换行
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(/&nbsp;/g, ' ').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/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 }
>
// 将SVG路径信息格式化为pptxgenjs所需要的格式
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
}
// 判断是否为Base64图片地址
const isBase64Image = (url: string) => {
const regex = /^data:image\/[^;]+;base64,/
return url.match(regex) !== null
}
// 判断是否为SVG图片地址
const isSVGImage = (url: string) => {
const isSVGBase64 = /^data:image\/svg\+xml;base64,/.test(url)
const isSVGUrl = /\.svg$/.test(url)
return isSVGBase64 || isSVGUrl
}
// 导出PPTX文件
const exportPPTX = (_slides: Slide[], masterOverwrite: boolean, ignoreMedia: boolean) => {
exporting.value = true
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) {
const svgRef = document.querySelector(`.thumbnail-list .base-element-${el.id} svg`) as HTMLElement
if (svgRef.clientWidth < 1 || svgRef.clientHeight < 1) continue // 临时处理(导入PPTX文件带来的异常数据)
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.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
}
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,
}
}