Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(export): 修复图片引发的导出问题 #294

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/configs/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// 是否允许导出跨域资源
export const allowCorsWhenExport = true
84 changes: 63 additions & 21 deletions src/hooks/useExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { type SvgPoints, toPoints } from '@/utils/svgPathParser'
import { encrypt } from '@/utils/crypto'
import { svg2Base64 } from '@/utils/svg2Base64'
import message from '@/utils/message'
import { allowCorsWhenExport } from '@/configs/common'
import { isCrossOriginResource } from '@/utils/common'
import { urlImgToBase64 } from '@/utils/imgPath2Base64Png'

interface ExportImageConfig {
quality: number
Expand All @@ -33,6 +36,21 @@ export default () => {

const exporting = ref(false)

const isAllowCors = () => {
return allowCorsWhenExport
}

// 将dom内的img标签的空src过滤 以免导出html-to-image失败
const filterEmptyImgInDom = (domRef: HTMLElement): void => {
const imgRefs = Array.from(domRef.querySelectorAll('img'))
imgRefs.forEach(imgRef => {
const src = imgRef.getAttribute('src')
if (!src) {
imgRef.remove()
}
})
}

// 导出图片
const exportImage = (domRef: HTMLElement, format: string, quality: number, ignoreWebfont = true) => {
exporting.value = true
Expand All @@ -48,7 +66,8 @@ export default () => {
}

if (ignoreWebfont) config.fontEmbedCSS = ''

// 当导出图片时,如果图片的src为空,会导致导出失败
filterEmptyImgInDom(domRef)
toImage(domRef, config).then(dataUrl => {
exporting.value = false
saveAs(dataUrl, `${title.value}.${format}`)
Expand All @@ -58,13 +77,13 @@ export default () => {
})
}, 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 = {
Expand Down Expand Up @@ -348,11 +367,11 @@ export default () => {
// 获取边框配置
const getOutlineOption = (outline: PPTElementOutline): pptxgen.ShapeLineProps => {
const c = formatColor(outline?.color || '#000000')

return {
color: c.color,
color: c.color,
transparency: (1 - c.alpha) * 100,
width: (outline.width || 1) / ratioPx2Pt.value,
width: (outline.width || 1) / ratioPx2Pt.value,
dashType: outline.style ? dashTypeMap[outline.style] as 'solid' | 'dash' | 'sysDot' : 'solid',
}
}
Expand All @@ -375,8 +394,28 @@ export default () => {
return url.match(regex) !== null
}

type imageElement = {
path?: string
data?: string
[key: string]: any
}
// 处理PPTX跨域图片数据,来自pptxgenjs的issue建议。目前未使用promiseAll处理,导出时认为可接受的性能损耗
const processPptxImageData = async (element: imageElement): Promise<imageElement> => {
const { path, ...rest } = element
if (path && isAllowCors() && !isBase64Image(path) && isCrossOriginResource(path)) {
try {
const base64 = await urlImgToBase64(path)
return { data: base64, ...rest }
}
catch (e) {
return element
}
}
return element
}

// 导出PPTX文件
const exportPPTX = (_slides: Slide[], masterOverwrite: boolean, ignoreMedia: boolean) => {
const exportPPTX = async (_slides: Slide[], masterOverwrite: boolean, ignoreMedia: boolean) => {
exporting.value = true
const pptx = new pptxgen()

Expand Down Expand Up @@ -407,7 +446,8 @@ export default () => {
const background = slide.background
if (background.type === 'image' && background.image) {
if (isBase64Image(background.image.src)) pptxSlide.background = { data: background.image.src }
else pptxSlide.background = { path: background.image.src }
// 处理跨域图片
else pptxSlide.background = await processPptxImageData({ path: background.image.src })
}
else if (background.type === 'solid' && background.color) {
const c = formatColor(background.color)
Expand Down Expand Up @@ -464,15 +504,17 @@ export default () => {
}

else if (el.type === 'image') {
const options: pptxgen.ImageProps = {
let 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 (!options.path && !options.data) continue
// 处理跨域图片
options = await processPptxImageData(options)
if (el.flipH) options.flipH = el.flipH
if (el.flipV) options.flipV = el.flipV
if (el.rotate) options.rotate = el.rotate
Expand Down Expand Up @@ -533,7 +575,7 @@ export default () => {
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
Expand All @@ -543,7 +585,7 @@ export default () => {
fillColor = formatColor(color)
}
const opacity = el.opacity === undefined ? 1 : el.opacity

const options: pptxgen.ShapeProps = {
x: el.left / ratioPx2Inch.value,
y: el.top / ratioPx2Inch.value,
Expand Down Expand Up @@ -598,9 +640,9 @@ export default () => {
w: (maxX - minX) / ratioPx2Inch.value,
h: (maxY - minY) / ratioPx2Inch.value,
line: {
color: c.color,
color: c.color,
transparency: (1 - c.alpha) * 100,
width: el.width / ratioPx2Pt.value,
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',
Expand Down Expand Up @@ -631,7 +673,7 @@ export default () => {
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,
Expand All @@ -647,7 +689,7 @@ export default () => {
const fontSize = 14 / ratioPx2Pt.value
options.catAxisLabelFontSize = fontSize
options.valAxisLabelFontSize = fontSize

if (el.fill || el.outline) {
const plotArea: pptxgen.IChartPropsFillLine = {}
if (el.fill) {
Expand Down Expand Up @@ -701,7 +743,7 @@ export default () => {
type = pptx.ChartType.doughnut
options.holeSize = 60
}

pptxSlide.addChart(type, chartData, options)
}

Expand Down Expand Up @@ -793,7 +835,7 @@ export default () => {

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)
Expand All @@ -812,7 +854,7 @@ export default () => {

pptxSlide.addImage(options)
}

else if (!ignoreMedia && (el.type === 'video' || el.type === 'audio')) {
const options: pptxgen.MediaProps = {
x: el.left / ratioPx2Inch.value,
Expand All @@ -827,7 +869,7 @@ export default () => {
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)) {
Expand All @@ -852,4 +894,4 @@ export default () => {
exportSpecificFile,
exportPPTX,
}
}
}
18 changes: 17 additions & 1 deletion src/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,20 @@ export const isPC = () => {
*/
export const isValidURL = (url: string) => {
return /^(https?:\/\/)([\w-]+\.)+[\w-]{2,}(\/[\w-./?%&=]*)?$/i.test(url)
}
}

/**
* 判断是否为跨域资源
* @param resourceUrl
*/
export const isCrossOriginResource = (resourceUrl: string): boolean => {
try {
// 相对路径配置当前域名作为基础路径
const resourceOrigin = new URL(resourceUrl, window.location.href).origin
const currentOrigin = window.location.origin
return resourceOrigin !== currentOrigin
}
catch (error) {
return true // 出于安全考虑,如果 URL 无效,将其视为跨域
}
}
41 changes: 41 additions & 0 deletions src/utils/imgPath2Base64Png.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* 通过canvas将外部链接图片转换为base64 png, 报错时返回空字符串
* @param url
*/
export const urlImgToBase64 = (url: string): Promise<string> => {
return new Promise((resolve) => {
if (url.startsWith('data:image/')) {
resolve(url)
}
else {
try {
// 新开一个OffscreenCanvas处理,避免阻塞主线程
const canvas: OffscreenCanvas = new OffscreenCanvas(512, 512)// 创建一个离屏 Canvas
const ctx = canvas.getContext('2d')

const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = function() {
canvas.width = img.width
canvas.height = img.height
ctx!.drawImage(img, 0, 0)
// 将 Canvas 转换为 base64
canvas.convertToBlob({type: 'image/png'}).then((blob: Blob) => {
const reader = new FileReader()
reader.onloadend = () => {
resolve(reader.result as string)
}
reader.readAsDataURL(blob)
})
}
img.onerror = function() {
resolve('')
}
img.src = url
}
catch (e) {
resolve('')
}
}
})
}