|
import { ref } from 'vue' |
|
import { storeToRefs } from 'pinia' |
|
import { parse, type Shape, type Element, type ChartItem, type BaseElement } from 'pptxtojson' |
|
import { nanoid } from 'nanoid' |
|
import { useSlidesStore } from '@/store' |
|
import { decrypt } from '@/utils/crypto' |
|
import { type ShapePoolItem, SHAPE_LIST, SHAPE_PATH_FORMULAS } from '@/configs/shapes' |
|
import useAddSlidesOrElements from '@/hooks/useAddSlidesOrElements' |
|
import useSlideHandler from '@/hooks/useSlideHandler' |
|
import useHistorySnapshot from './useHistorySnapshot' |
|
import message from '@/utils/message' |
|
import { getSvgPathRange } from '@/utils/svgPathParser' |
|
import type { |
|
Slide, |
|
TableCellStyle, |
|
TableCell, |
|
ChartType, |
|
SlideBackground, |
|
PPTShapeElement, |
|
PPTLineElement, |
|
PPTImageElement, |
|
ShapeTextAlign, |
|
PPTTextElement, |
|
ChartOptions, |
|
Gradient, |
|
PPTElement, |
|
} from '@/types/slides' |
|
import { getElementListRange } from '@/utils/element' |
|
|
|
const convertFontSizePtToPx = (html: string, ratio: number) => { |
|
return html.replace(/font-size:\s*([\d.]+)pt/g, (match, p1) => { |
|
return `font-size: ${(parseFloat(p1) * ratio).toFixed(1)}px` |
|
}) |
|
} |
|
|
|
export default () => { |
|
const slidesStore = useSlidesStore() |
|
const { theme } = storeToRefs(useSlidesStore()) |
|
|
|
const { addHistorySnapshot } = useHistorySnapshot() |
|
const { addSlidesFromData } = useAddSlidesOrElements() |
|
const { isEmptySlide } = useSlideHandler() |
|
|
|
const exporting = ref(false) |
|
|
|
|
|
const importSpecificFile = (files: FileList, cover = false) => { |
|
const file = files[0] |
|
|
|
const reader = new FileReader() |
|
reader.addEventListener('load', () => { |
|
try { |
|
const slides = JSON.parse(decrypt(reader.result as string)) |
|
if (cover) { |
|
slidesStore.updateSlideIndex(0) |
|
slidesStore.setSlides(slides) |
|
addHistorySnapshot() |
|
} |
|
else if (isEmptySlide.value) { |
|
slidesStore.setSlides(slides) |
|
addHistorySnapshot() |
|
} |
|
else addSlidesFromData(slides) |
|
} |
|
catch { |
|
message.error('无法正确读取 / 解析该文件') |
|
} |
|
}) |
|
reader.readAsText(file) |
|
} |
|
|
|
const parseLineElement = (el: Shape, ratio: number) => { |
|
let start: [number, number] = [0, 0] |
|
let end: [number, number] = [0, 0] |
|
|
|
if (!el.isFlipV && !el.isFlipH) { |
|
start = [0, 0] |
|
end = [el.width, el.height] |
|
} |
|
else if (el.isFlipV && el.isFlipH) { |
|
start = [el.width, el.height] |
|
end = [0, 0] |
|
} |
|
else if (el.isFlipV && !el.isFlipH) { |
|
start = [0, el.height] |
|
end = [el.width, 0] |
|
} |
|
else { |
|
start = [el.width, 0] |
|
end = [0, el.height] |
|
} |
|
|
|
const data: PPTLineElement = { |
|
type: 'line', |
|
id: nanoid(10), |
|
width: +((el.borderWidth || 1) * ratio).toFixed(2), |
|
left: el.left, |
|
top: el.top, |
|
start, |
|
end, |
|
style: el.borderType, |
|
color: el.borderColor, |
|
points: ['', /straightConnector/.test(el.shapType) ? 'arrow' : ''] |
|
} |
|
if (/bentConnector/.test(el.shapType)) { |
|
data.broken2 = [ |
|
Math.abs(start[0] - end[0]) / 2, |
|
Math.abs(start[1] - end[1]) / 2, |
|
] |
|
} |
|
|
|
return data |
|
} |
|
|
|
const flipGroupElements = (elements: BaseElement[], axis: 'x' | 'y') => { |
|
const minX = Math.min(...elements.map(el => el.left)) |
|
const maxX = Math.max(...elements.map(el => el.left + el.width)) |
|
const minY = Math.min(...elements.map(el => el.top)) |
|
const maxY = Math.max(...elements.map(el => el.top + el.height)) |
|
|
|
const centerX = (minX + maxX) / 2 |
|
const centerY = (minY + maxY) / 2 |
|
|
|
return elements.map(element => { |
|
const newElement = { ...element } |
|
|
|
if (axis === 'y') newElement.left = 2 * centerX - element.left - element.width |
|
if (axis === 'x') newElement.top = 2 * centerY - element.top - element.height |
|
|
|
return newElement |
|
}) |
|
} |
|
|
|
const calculateRotatedPosition = ( |
|
x: number, |
|
y: number, |
|
w: number, |
|
h: number, |
|
ox: number, |
|
oy: number, |
|
k: number, |
|
) => { |
|
const radians = k * (Math.PI / 180) |
|
|
|
const containerCenterX = x + w / 2 |
|
const containerCenterY = y + h / 2 |
|
|
|
const relativeX = ox - w / 2 |
|
const relativeY = oy - h / 2 |
|
|
|
const rotatedX = relativeX * Math.cos(radians) + relativeY * Math.sin(radians) |
|
const rotatedY = -relativeX * Math.sin(radians) + relativeY * Math.cos(radians) |
|
|
|
const graphicX = containerCenterX + rotatedX |
|
const graphicY = containerCenterY + rotatedY |
|
|
|
return { x: graphicX, y: graphicY } |
|
} |
|
|
|
|
|
const importPPTXFile = (files: FileList, options?: { cover?: boolean; fixedViewport?: boolean }) => { |
|
const defaultOptions = { |
|
cover: false, |
|
fixedViewport: false, |
|
} |
|
const { cover, fixedViewport } = { ...defaultOptions, ...options } |
|
|
|
const file = files[0] |
|
if (!file) return |
|
|
|
exporting.value = true |
|
|
|
const shapeList: ShapePoolItem[] = [] |
|
for (const item of SHAPE_LIST) { |
|
shapeList.push(...item.children) |
|
} |
|
|
|
const reader = new FileReader() |
|
reader.onload = async e => { |
|
let json = null |
|
try { |
|
json = await parse(e.target!.result as ArrayBuffer) |
|
} |
|
catch { |
|
exporting.value = false |
|
message.error('无法正确读取 / 解析该文件') |
|
return |
|
} |
|
|
|
let ratio = 96 / 72 |
|
const width = json.size.width |
|
|
|
if (fixedViewport) ratio = 1000 / width |
|
else slidesStore.setViewportSize(width * ratio) |
|
|
|
slidesStore.setTheme({ themeColors: json.themeColors }) |
|
|
|
const slides: Slide[] = [] |
|
for (const item of json.slides) { |
|
const { type, value } = item.fill |
|
let background: SlideBackground |
|
if (type === 'image') { |
|
background = { |
|
type: 'image', |
|
image: { |
|
src: value.picBase64, |
|
size: 'cover', |
|
}, |
|
} |
|
} |
|
else if (type === 'gradient') { |
|
background = { |
|
type: 'gradient', |
|
gradient: { |
|
type: value.path === 'line' ? 'linear' : 'radial', |
|
colors: value.colors.map(item => ({ |
|
...item, |
|
pos: parseInt(item.pos), |
|
})), |
|
rotate: value.rot + 90, |
|
}, |
|
} |
|
} |
|
else { |
|
background = { |
|
type: 'solid', |
|
color: value || '#fff', |
|
} |
|
} |
|
|
|
const slide: Slide = { |
|
id: nanoid(10), |
|
elements: [], |
|
background, |
|
remark: item.note || '', |
|
} |
|
|
|
const parseElements = (elements: Element[]) => { |
|
const sortedElements = elements.sort((a, b) => a.order - b.order) |
|
|
|
for (const el of sortedElements) { |
|
const originWidth = el.width || 1 |
|
const originHeight = el.height || 1 |
|
const originLeft = el.left |
|
const originTop = el.top |
|
|
|
el.width = el.width * ratio |
|
el.height = el.height * ratio |
|
el.left = el.left * ratio |
|
el.top = el.top * ratio |
|
|
|
if (el.type === 'text') { |
|
const textEl: PPTTextElement = { |
|
type: 'text', |
|
id: nanoid(10), |
|
width: el.width, |
|
height: el.height, |
|
left: el.left, |
|
top: el.top, |
|
rotate: el.rotate, |
|
defaultFontName: theme.value.fontName, |
|
defaultColor: theme.value.fontColor, |
|
content: convertFontSizePtToPx(el.content, ratio), |
|
lineHeight: 1, |
|
outline: { |
|
color: el.borderColor, |
|
width: +(el.borderWidth * ratio).toFixed(2), |
|
style: el.borderType, |
|
}, |
|
fill: el.fill.type === 'color' ? el.fill.value : '', |
|
vertical: el.isVertical, |
|
} |
|
if (el.shadow) { |
|
textEl.shadow = { |
|
h: el.shadow.h * ratio, |
|
v: el.shadow.v * ratio, |
|
blur: el.shadow.blur * ratio, |
|
color: el.shadow.color, |
|
} |
|
} |
|
slide.elements.push(textEl) |
|
} |
|
else if (el.type === 'image') { |
|
const element: PPTImageElement = { |
|
type: 'image', |
|
id: nanoid(10), |
|
src: el.src, |
|
width: el.width, |
|
height: el.height, |
|
left: el.left, |
|
top: el.top, |
|
fixedRatio: true, |
|
rotate: el.rotate, |
|
flipH: el.isFlipH, |
|
flipV: el.isFlipV, |
|
} |
|
if (el.borderWidth) { |
|
element.outline = { |
|
color: el.borderColor, |
|
width: +(el.borderWidth * ratio).toFixed(2), |
|
style: el.borderType, |
|
} |
|
} |
|
const clipShapeTypes = ['roundRect', 'ellipse', 'triangle', 'rhombus', 'pentagon', 'hexagon', 'heptagon', 'octagon', 'parallelogram', 'trapezoid'] |
|
if (el.rect) { |
|
element.clip = { |
|
shape: (el.geom && clipShapeTypes.includes(el.geom)) ? el.geom : 'rect', |
|
range: [ |
|
[ |
|
el.rect.l || 0, |
|
el.rect.t || 0, |
|
], |
|
[ |
|
100 - (el.rect.r || 0), |
|
100 - (el.rect.b || 0), |
|
], |
|
] |
|
} |
|
} |
|
else if (el.geom && clipShapeTypes.includes(el.geom)) { |
|
element.clip = { |
|
shape: el.geom, |
|
range: [[0, 0], [100, 100]] |
|
} |
|
} |
|
slide.elements.push(element) |
|
} |
|
else if (el.type === 'math') { |
|
slide.elements.push({ |
|
type: 'image', |
|
id: nanoid(10), |
|
src: el.picBase64, |
|
width: el.width, |
|
height: el.height, |
|
left: el.left, |
|
top: el.top, |
|
fixedRatio: true, |
|
rotate: 0, |
|
}) |
|
} |
|
else if (el.type === 'audio') { |
|
slide.elements.push({ |
|
type: 'audio', |
|
id: nanoid(10), |
|
src: el.blob, |
|
width: el.width, |
|
height: el.height, |
|
left: el.left, |
|
top: el.top, |
|
rotate: 0, |
|
fixedRatio: false, |
|
color: theme.value.themeColors[0], |
|
loop: false, |
|
autoplay: false, |
|
}) |
|
} |
|
else if (el.type === 'video') { |
|
slide.elements.push({ |
|
type: 'video', |
|
id: nanoid(10), |
|
src: (el.blob || el.src)!, |
|
width: el.width, |
|
height: el.height, |
|
left: el.left, |
|
top: el.top, |
|
rotate: 0, |
|
autoplay: false, |
|
}) |
|
} |
|
else if (el.type === 'shape') { |
|
if (el.shapType === 'line' || /Connector/.test(el.shapType)) { |
|
const lineElement = parseLineElement(el, ratio) |
|
slide.elements.push(lineElement) |
|
} |
|
else { |
|
const shape = shapeList.find(item => item.pptxShapeType === el.shapType) |
|
|
|
const vAlignMap: { [key: string]: ShapeTextAlign } = { |
|
'mid': 'middle', |
|
'down': 'bottom', |
|
'up': 'top', |
|
} |
|
|
|
const gradient: Gradient | undefined = el.fill?.type === 'gradient' ? { |
|
type: el.fill.value.path === 'line' ? 'linear' : 'radial', |
|
colors: el.fill.value.colors.map(item => ({ |
|
...item, |
|
pos: parseInt(item.pos), |
|
})), |
|
rotate: el.fill.value.rot, |
|
} : undefined |
|
|
|
const pattern: string | undefined = el.fill?.type === 'image' ? el.fill.value.picBase64 : undefined |
|
|
|
const fill = el.fill?.type === 'color' ? el.fill.value : '' |
|
|
|
const element: PPTShapeElement = { |
|
type: 'shape', |
|
id: nanoid(10), |
|
width: el.width, |
|
height: el.height, |
|
left: el.left, |
|
top: el.top, |
|
viewBox: [200, 200], |
|
path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z', |
|
fill, |
|
gradient, |
|
pattern, |
|
fixedRatio: false, |
|
rotate: el.rotate, |
|
outline: { |
|
color: el.borderColor, |
|
width: +(el.borderWidth * ratio).toFixed(2), |
|
style: el.borderType, |
|
}, |
|
text: { |
|
content: convertFontSizePtToPx(el.content, ratio), |
|
defaultFontName: theme.value.fontName, |
|
defaultColor: theme.value.fontColor, |
|
align: vAlignMap[el.vAlign] || 'middle', |
|
}, |
|
flipH: el.isFlipH, |
|
flipV: el.isFlipV, |
|
} |
|
if (el.shadow) { |
|
element.shadow = { |
|
h: el.shadow.h * ratio, |
|
v: el.shadow.v * ratio, |
|
blur: el.shadow.blur * ratio, |
|
color: el.shadow.color, |
|
} |
|
} |
|
|
|
if (shape) { |
|
element.path = shape.path |
|
element.viewBox = shape.viewBox |
|
|
|
if (shape.pathFormula) { |
|
element.pathFormula = shape.pathFormula |
|
element.viewBox = [el.width, el.height] |
|
|
|
const pathFormula = SHAPE_PATH_FORMULAS[shape.pathFormula] |
|
if ('editable' in pathFormula && pathFormula.editable) { |
|
element.path = pathFormula.formula(el.width, el.height, pathFormula.defaultValue) |
|
element.keypoints = pathFormula.defaultValue |
|
} |
|
else element.path = pathFormula.formula(el.width, el.height) |
|
} |
|
} |
|
if (el.shapType === 'custom') { |
|
if (el.path!.indexOf('NaN') !== -1) element.path = '' |
|
else { |
|
element.special = true |
|
element.path = el.path! |
|
|
|
const { maxX, maxY } = getSvgPathRange(element.path) |
|
element.viewBox = [maxX || originWidth, maxY || originHeight] |
|
} |
|
} |
|
|
|
if (element.path) slide.elements.push(element) |
|
} |
|
} |
|
else if (el.type === 'table') { |
|
const row = el.data.length |
|
const col = el.data[0].length |
|
|
|
const style: TableCellStyle = { |
|
fontname: theme.value.fontName, |
|
color: theme.value.fontColor, |
|
} |
|
const data: TableCell[][] = [] |
|
for (let i = 0; i < row; i++) { |
|
const rowCells: TableCell[] = [] |
|
for (let j = 0; j < col; j++) { |
|
const cellData = el.data[i][j] |
|
|
|
let textDiv: HTMLDivElement | null = document.createElement('div') |
|
textDiv.innerHTML = cellData.text |
|
const p = textDiv.querySelector('p') |
|
const align = p?.style.textAlign || 'left' |
|
|
|
const span = textDiv.querySelector('span') |
|
const fontsize = span?.style.fontSize ? (parseInt(span?.style.fontSize) * ratio).toFixed(1) + 'px' : '' |
|
const fontname = span?.style.fontFamily || '' |
|
const color = span?.style.color || cellData.fontColor |
|
|
|
rowCells.push({ |
|
id: nanoid(10), |
|
colspan: cellData.colSpan || 1, |
|
rowspan: cellData.rowSpan || 1, |
|
text: textDiv.innerText, |
|
style: { |
|
...style, |
|
align: ['left', 'right', 'center'].includes(align) ? (align as 'left' | 'right' | 'center') : 'left', |
|
fontsize, |
|
fontname, |
|
color, |
|
bold: cellData.fontBold, |
|
backcolor: cellData.fillColor, |
|
}, |
|
}) |
|
textDiv = null |
|
} |
|
data.push(rowCells) |
|
} |
|
|
|
const allWidth = el.colWidths.reduce((a, b) => a + b, 0) |
|
const colWidths: number[] = el.colWidths.map(item => item / allWidth) |
|
|
|
const firstCell = el.data[0][0] |
|
const border = firstCell.borders.top || |
|
firstCell.borders.bottom || |
|
el.borders.top || |
|
el.borders.bottom || |
|
firstCell.borders.left || |
|
firstCell.borders.right || |
|
el.borders.left || |
|
el.borders.right |
|
const borderWidth = border?.borderWidth || 0 |
|
const borderStyle = border?.borderType || 'solid' |
|
const borderColor = border?.borderColor || '#eeece1' |
|
|
|
slide.elements.push({ |
|
type: 'table', |
|
id: nanoid(10), |
|
width: el.width, |
|
height: el.height, |
|
left: el.left, |
|
top: el.top, |
|
colWidths, |
|
rotate: 0, |
|
data, |
|
outline: { |
|
width: +(borderWidth * ratio || 2).toFixed(2), |
|
style: borderStyle, |
|
color: borderColor, |
|
}, |
|
cellMinHeight: el.rowHeights[0] ? el.rowHeights[0] * ratio : 36, |
|
}) |
|
} |
|
else if (el.type === 'chart') { |
|
let labels: string[] |
|
let legends: string[] |
|
let series: number[][] |
|
|
|
if (el.chartType === 'scatterChart' || el.chartType === 'bubbleChart') { |
|
labels = el.data[0].map((item, index) => `坐标${index + 1}`) |
|
legends = ['X', 'Y'] |
|
series = el.data |
|
} |
|
else { |
|
const data = el.data as ChartItem[] |
|
labels = Object.values(data[0].xlabels) |
|
legends = data.map(item => item.key) |
|
series = data.map(item => item.values.map(v => v.y)) |
|
} |
|
|
|
const options: ChartOptions = {} |
|
|
|
let chartType: ChartType = 'bar' |
|
|
|
switch (el.chartType) { |
|
case 'barChart': |
|
case 'bar3DChart': |
|
chartType = 'bar' |
|
if (el.barDir === 'bar') chartType = 'column' |
|
if (el.grouping === 'stacked' || el.grouping === 'percentStacked') options.stack = true |
|
break |
|
case 'lineChart': |
|
case 'line3DChart': |
|
if (el.grouping === 'stacked' || el.grouping === 'percentStacked') options.stack = true |
|
chartType = 'line' |
|
break |
|
case 'areaChart': |
|
case 'area3DChart': |
|
if (el.grouping === 'stacked' || el.grouping === 'percentStacked') options.stack = true |
|
chartType = 'area' |
|
break |
|
case 'scatterChart': |
|
case 'bubbleChart': |
|
chartType = 'scatter' |
|
break |
|
case 'pieChart': |
|
case 'pie3DChart': |
|
chartType = 'pie' |
|
break |
|
case 'radarChart': |
|
chartType = 'radar' |
|
break |
|
case 'doughnutChart': |
|
chartType = 'ring' |
|
break |
|
default: |
|
} |
|
|
|
slide.elements.push({ |
|
type: 'chart', |
|
id: nanoid(10), |
|
chartType: chartType, |
|
width: el.width, |
|
height: el.height, |
|
left: el.left, |
|
top: el.top, |
|
rotate: 0, |
|
themeColors: el.colors.length ? el.colors : theme.value.themeColors, |
|
textColor: theme.value.fontColor, |
|
data: { |
|
labels, |
|
legends, |
|
series, |
|
}, |
|
options, |
|
}) |
|
} |
|
else if (el.type === 'group') { |
|
let elements: BaseElement[] = el.elements.map(_el => { |
|
let left = _el.left + originLeft |
|
let top = _el.top + originTop |
|
|
|
if (el.rotate) { |
|
const { x, y } = calculateRotatedPosition(originLeft, originTop, originWidth, originHeight, _el.left, _el.top, el.rotate) |
|
left = x |
|
top = y |
|
} |
|
|
|
const element = { |
|
..._el, |
|
left, |
|
top, |
|
} |
|
if (el.isFlipH && 'isFlipH' in element) element.isFlipH = true |
|
if (el.isFlipV && 'isFlipV' in element) element.isFlipV = true |
|
|
|
return element |
|
}) |
|
if (el.isFlipH) elements = flipGroupElements(elements, 'y') |
|
if (el.isFlipV) elements = flipGroupElements(elements, 'x') |
|
parseElements(elements) |
|
} |
|
else if (el.type === 'diagram') { |
|
const elements = el.elements.map(_el => ({ |
|
..._el, |
|
left: _el.left + originLeft, |
|
top: _el.top + originTop, |
|
})) |
|
parseElements(elements) |
|
} |
|
} |
|
} |
|
parseElements([...item.elements, ...item.layoutElements]) |
|
slides.push(slide) |
|
} |
|
|
|
if (cover) { |
|
slidesStore.updateSlideIndex(0) |
|
slidesStore.setSlides(slides) |
|
addHistorySnapshot() |
|
} |
|
else if (isEmptySlide.value) { |
|
slidesStore.setSlides(slides) |
|
addHistorySnapshot() |
|
} |
|
else addSlidesFromData(slides) |
|
|
|
exporting.value = false |
|
} |
|
reader.readAsArrayBuffer(file) |
|
} |
|
|
|
return { |
|
importSpecificFile, |
|
importPPTXFile, |
|
exporting, |
|
} |
|
} |