|
|
@@ -11,7 +11,9 @@ import useSlideHandler from '@/hooks/useSlideHandler'
|
|
|
import useHistorySnapshot from './useHistorySnapshot'
|
|
|
import message from '@/utils/message'
|
|
|
import { getSvgPathRange } from '@/utils/svgPathParser'
|
|
|
-//import utifUrl from '/UTIF.js';
|
|
|
+import { EMFJS, WMFJS } from 'rtf.js'
|
|
|
+import * as UTIF from 'utif2'
|
|
|
+
|
|
|
import type {
|
|
|
Slide,
|
|
|
TableCellStyle,
|
|
|
@@ -30,10 +32,12 @@ import type {
|
|
|
} from '@/types/slides'
|
|
|
|
|
|
const convertFontSizePtToPx = (html: string, ratio: number, autoFit: any) => {
|
|
|
- if (autoFit?.fontScale && autoFit?.type == "text") { ratio = ratio * autoFit.fontScale / 100; }
|
|
|
+ if (autoFit?.fontScale && autoFit?.type == 'text') {
|
|
|
+ ratio = ratio * autoFit.fontScale / 100
|
|
|
+ }
|
|
|
// return html;
|
|
|
return html.replace(/\s*([\d.]+)pt/g, (match, p1) => {
|
|
|
- return `${(parseFloat(p1) * ratio - 1) | 0}px `
|
|
|
+ return `${Math.round(parseFloat(p1) * ratio)}px `
|
|
|
})
|
|
|
|
|
|
}
|
|
|
@@ -45,9 +49,9 @@ const getStyle = (htmlString: string) => {
|
|
|
// 2. 解析 HTML 字符串为文档对象
|
|
|
const doc = parser.parseFromString(htmlString, 'text/html')
|
|
|
// 3. 获取 p 元素
|
|
|
- const p = doc.querySelector('p')
|
|
|
- // 4. 读取 style 属性(内联样式字符串)
|
|
|
- const styleAttr = p?.getAttribute('allstyle')
|
|
|
+ const firstElem = doc.querySelector('p, ul, ol, table');
|
|
|
+ // 读取该元素的 allstyle 属性
|
|
|
+ const styleAttr = firstElem?.getAttribute('allstyle');
|
|
|
console.log(styleAttr) // 输出完整的 style 字符串
|
|
|
return styleAttr || ''
|
|
|
|
|
|
@@ -62,6 +66,7 @@ export default () => {
|
|
|
const { isEmptySlide } = useSlideHandler()
|
|
|
|
|
|
const exporting = ref(false)
|
|
|
+ const imgExporting = ref(false)
|
|
|
|
|
|
// 导入JSON文件
|
|
|
const importJSON = (files: FileList, cover = false) => {
|
|
|
@@ -222,6 +227,7 @@ export default () => {
|
|
|
const win = window as any
|
|
|
if (!win.exportJSON) win.exportJSON = exportJSON2
|
|
|
if (!win.readJSON) win.readJSON = readJSON
|
|
|
+ if (!win.imgExporting) win.imgExporting = () => imgExporting.value
|
|
|
}
|
|
|
|
|
|
// 导入pptist文件
|
|
|
@@ -567,106 +573,430 @@ export default () => {
|
|
|
* @param options.removeMatte 是否去除白色边缘(默认true,可改善白边)
|
|
|
* @returns 处理后的 PNG 格式 File 对象
|
|
|
*/
|
|
|
+ /*
|
|
|
+ const makeWhiteTransparent = async (
|
|
|
+ data: string | Blob,
|
|
|
+ filename: string,
|
|
|
+ options?: { tolerance?: number }
|
|
|
+ ): Promise<File> => {
|
|
|
+ const tolerance = options?.tolerance ?? 15;
|
|
|
+
|
|
|
+ // ----- 辅助函数:将输入统一转换为 { blob, mime } -----
|
|
|
+ async function getBlobAndMime(input: string | Blob): Promise<{ blob: Blob; mime: string }> {
|
|
|
+ if (input instanceof Blob) {
|
|
|
+ return { blob: input, mime: input.type };
|
|
|
+ }
|
|
|
+ if (input.startsWith('data:')) {
|
|
|
+ const response = await fetch(input);
|
|
|
+ const blob = await response.blob();
|
|
|
+ return { blob, mime: blob.type };
|
|
|
+ }
|
|
|
+ // 纯 base64 字符串,默认当作 PNG
|
|
|
+ const binary = atob(input);
|
|
|
+ const bytes = new Uint8Array(binary.length);
|
|
|
+ for (let i = 0; i < binary.length; i++) {
|
|
|
+ bytes[i] = binary.charCodeAt(i);
|
|
|
+ }
|
|
|
+ const blob = new Blob([bytes], { type: 'image/png' });
|
|
|
+ return { blob, mime: 'image/png' };
|
|
|
+ }
|
|
|
+
|
|
|
+ // ----- 辅助函数:通过 MIME 或文件扩展名判断格式 -----
|
|
|
+ function getFormat(mime: string, filename: string): string {
|
|
|
+ const ext = filename.split('.').pop()?.toLowerCase();
|
|
|
+ if (mime.startsWith('image/')) {
|
|
|
+ const sub = mime.split('/')[1];
|
|
|
+ if (sub === 'vnd.microsoft.icon') return 'ico';
|
|
|
+ if (sub === 'x-emf' || sub === 'x-msmetafile') return 'emf';
|
|
|
+ if (sub === 'tiff' || sub === 'x-tiff') return 'tiff';
|
|
|
+ return sub;
|
|
|
+ }
|
|
|
+ // 兜底扩展名判断
|
|
|
+ if (ext === 'emf' || ext === 'wmf') return 'emf';
|
|
|
+ if (ext === 'tif' || ext === 'tiff') return 'tiff';
|
|
|
+ return 'unknown';
|
|
|
+ }
|
|
|
+
|
|
|
+ // ----- 格式转换器 -----
|
|
|
+ // 1. 浏览器原生支持的格式:通过 Image + Canvas 转为 PNG
|
|
|
+ async function convertBrowserImageToPng(blob: Blob): Promise<Blob> {
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
+ try {
|
|
|
+ const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
|
|
+ const image = new Image();
|
|
|
+ image.onload = () => resolve(image);
|
|
|
+ image.onerror = reject;
|
|
|
+ image.src = url;
|
|
|
+ });
|
|
|
+ const canvas = document.createElement('canvas');
|
|
|
+ canvas.width = img.width;
|
|
|
+ canvas.height = img.height;
|
|
|
+ const ctx = canvas.getContext('2d')!;
|
|
|
+ ctx.drawImage(img, 0, 0);
|
|
|
+ return await new Promise((resolve, reject) => {
|
|
|
+ canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('Canvas toBlob failed'))), 'image/png');
|
|
|
+ });
|
|
|
+ } finally {
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. EMF/WMF 转 PNG(需要 wmf2png 库)
|
|
|
+ async function convertEmfToPng(blob: Blob): Promise<Blob> {
|
|
|
+ const arrayBuffer = await blob.arrayBuffer();
|
|
|
+ const pngBuffer = await wmf2png(arrayBuffer);
|
|
|
+ return new Blob([pngBuffer], { type: 'image/png' });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. TIFF 转 PNG(需要 UTIF.js 库)
|
|
|
+ async function convertTiffToPng(blob: Blob): Promise<Blob> {
|
|
|
+ const arrayBuffer = await blob.arrayBuffer();
|
|
|
+ const ifds = UTIF.decode(arrayBuffer);
|
|
|
+ if (!ifds || ifds.length === 0) throw new Error('No TIFF image found');
|
|
|
+ const rgba = UTIF.toRGBA8(ifds[0]);
|
|
|
+ const canvas = document.createElement('canvas');
|
|
|
+ canvas.width = ifds[0].width;
|
|
|
+ canvas.height = ifds[0].height;
|
|
|
+ const ctx = canvas.getContext('2d')!;
|
|
|
+ const imageData = new ImageData(rgba, ifds[0].width, ifds[0].height);
|
|
|
+ ctx.putImageData(imageData, 0, 0);
|
|
|
+ return await new Promise((resolve, reject) => {
|
|
|
+ canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('TIFF to PNG failed'))), 'image/png');
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ----- 主逻辑:获取原始 blob 和格式 -----
|
|
|
+ let { blob, mime } = await getBlobAndMime(data);
|
|
|
+ let format = getFormat(mime, filename);
|
|
|
+
|
|
|
+ // 统一转为 PNG(除已经是 PNG 的以外,其它都转换)
|
|
|
+ let pngBlob: Blob;
|
|
|
+ if (format === 'png') {
|
|
|
+ pngBlob = blob; // 直接复用,稍后做白色透明
|
|
|
+ } else {
|
|
|
+ // 根据格式选择转换器
|
|
|
+ if (format === 'emf' || format === 'wmf') {
|
|
|
+ pngBlob = await convertEmfToPng(blob);
|
|
|
+ } else if (format === 'tiff' || format === 'x-tiff') {
|
|
|
+ pngBlob = await convertTiffToPng(blob);
|
|
|
+ } else {
|
|
|
+ // 其它所有格式(jpeg, bmp, gif, webp, ico 等)都尝试用浏览器原生方法转换
|
|
|
+ pngBlob = await convertBrowserImageToPng(blob);
|
|
|
+ }
|
|
|
+ // 更新文件名后缀为 .png
|
|
|
+ filename = filename.replace(/\.[^.]*$/, '') + '.png';
|
|
|
+ }
|
|
|
+
|
|
|
+ // ----- 白色变透明处理(仅对 PNG 执行)-----
|
|
|
+ const imageUrl = URL.createObjectURL(pngBlob);
|
|
|
+ try {
|
|
|
+ const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
|
|
+ const image = new Image();
|
|
|
+ image.onload = () => resolve(image);
|
|
|
+ image.onerror = reject;
|
|
|
+ image.src = imageUrl;
|
|
|
+ });
|
|
|
+ const canvas = document.createElement('canvas');
|
|
|
+ canvas.width = img.width;
|
|
|
+ canvas.height = img.height;
|
|
|
+ const ctx = canvas.getContext('2d')!;
|
|
|
+ ctx.drawImage(img, 0, 0);
|
|
|
+
|
|
|
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
|
+ const dataArray = imageData.data;
|
|
|
+ for (let i = 0; i < dataArray.length; i += 4) {
|
|
|
+ const r = dataArray[i];
|
|
|
+ const g = dataArray[i + 1];
|
|
|
+ const b = dataArray[i + 2];
|
|
|
+ const dr = r - 255;
|
|
|
+ const dg = g - 255;
|
|
|
+ const db = b - 255;
|
|
|
+ const dist = Math.sqrt(dr * dr + dg * dg + db * db);
|
|
|
+ if (dist <= tolerance) {
|
|
|
+ dataArray[i + 3] = 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ctx.putImageData(imageData, 0, 0);
|
|
|
+
|
|
|
+ const outputBlob = await new Promise<Blob>((resolve, reject) => {
|
|
|
+ canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('Canvas toBlob failed'))), 'image/png');
|
|
|
+ });
|
|
|
+ return new File([outputBlob], filename, { type: 'image/png' });
|
|
|
+ } finally {
|
|
|
+ URL.revokeObjectURL(imageUrl);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ */
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将图片统一处理:
|
|
|
+ * - 对于浏览器原生支持的格式(JPEG, BMP, GIF, WebP 等):直接返回原始文件
|
|
|
+ * - 对于 PNG:执行白色变透明处理后返回
|
|
|
+ * - 对于 TIFF / EMF / WMF:先转换为 PNG,再执行白色变透明处理后返回
|
|
|
+ */
|
|
|
const makeWhiteTransparent = async (
|
|
|
data: string | Blob,
|
|
|
filename: string,
|
|
|
options?: { tolerance?: number }
|
|
|
): Promise<File> => {
|
|
|
const tolerance = options?.tolerance ?? 15
|
|
|
-
|
|
|
- // ----- 辅助函数:将输入统一转换为 { blob, mime } -----
|
|
|
- async function getBlobAndMime(input: string | Blob): Promise<{ blob: Blob; mime: string }> {
|
|
|
- // 1. 已经是 Blob
|
|
|
- if (input instanceof Blob) {
|
|
|
- return { blob: input, mime: input.type }
|
|
|
- }
|
|
|
-
|
|
|
- // 2. 处理字符串
|
|
|
- if (input.startsWith('data:')) {
|
|
|
- // data URL → 通过 fetch 获取 Blob(自动获得正确的 MIME 类型)
|
|
|
- const response = await fetch(input)
|
|
|
- const blob = await response.blob()
|
|
|
- return { blob, mime: blob.type }
|
|
|
- }
|
|
|
- // 纯 base64 字符串 → 按原逻辑默认当作 PNG
|
|
|
- const binary = atob(input)
|
|
|
- const bytes = new Uint8Array(binary.length)
|
|
|
- for (let i = 0; i < binary.length; i++) {
|
|
|
- bytes[i] = binary.charCodeAt(i)
|
|
|
- }
|
|
|
- // 默认 MIME 为 image/png(与原函数行为一致)
|
|
|
- const blob = new Blob([bytes], { type: 'image/png' })
|
|
|
- return { blob, mime: 'image/png' }
|
|
|
-
|
|
|
+
|
|
|
+ // 1. 统一输入为 Blob 和 MIME
|
|
|
+ const { blob, mime } = await getBlobAndMime(data)
|
|
|
+ const format = getFormat(mime, filename)
|
|
|
+
|
|
|
+ // 2. 浏览器原生支持的格式直接返回
|
|
|
+ if (format === 'browser') {
|
|
|
+ return new File([blob], filename, { type: mime })
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 需要转换成 PNG 的格式
|
|
|
+ let pngBlob: Blob
|
|
|
+ if (format === 'tiff') {
|
|
|
+ pngBlob = await convertTiffToPng(blob)
|
|
|
+ }
|
|
|
+ else if (format === 'emf') {
|
|
|
+ pngBlob = await convertEmfToPng(blob)
|
|
|
+ }
|
|
|
+ else if (format === 'wmf') {
|
|
|
+ pngBlob = await convertWmfToPng(blob)
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ // format === 'png' 的情况
|
|
|
+ pngBlob = blob
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- 新增:检测 PNG 是否已经包含透明背景 ---
|
|
|
+ let alreadyTransparent = false
|
|
|
+ // 无论原始格式是 PNG 还是转换后得到的 PNG,都进行检测
|
|
|
+ const checkUrl = URL.createObjectURL(pngBlob)
|
|
|
+ try {
|
|
|
+ const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
|
|
+ const image = new Image()
|
|
|
+ image.onload = () => resolve(image)
|
|
|
+ image.onerror = reject
|
|
|
+ image.src = checkUrl
|
|
|
+ })
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
+ canvas.width = img.width
|
|
|
+ canvas.height = img.height
|
|
|
+ const ctx = canvas.getContext('2d')!
|
|
|
+ ctx.drawImage(img, 0, 0)
|
|
|
+ alreadyTransparent = hasTransparency(img, ctx)
|
|
|
+ }
|
|
|
+ finally {
|
|
|
+ URL.revokeObjectURL(checkUrl)
|
|
|
}
|
|
|
+
|
|
|
+ let transparentPngBlob: Blob
|
|
|
+ transparentPngBlob = pngBlob
|
|
|
+ /*
|
|
|
+ if (alreadyTransparent) {
|
|
|
+ // 图片已有透明背景,直接使用原 PNG Blob
|
|
|
+ console.log('检测到透明背景,跳过白色变透明处理')
|
|
|
+ transparentPngBlob = pngBlob
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ // 否则执行白色变透明处理
|
|
|
+ transparentPngBlob = await makeWhiteTransparentFromPng(pngBlob, tolerance)
|
|
|
+ }
|
|
|
+ */
|
|
|
+ const finalFilename = format === 'png' ? filename : filename.replace(/\.[^.]*$/, '') + '.png'
|
|
|
+ return new File([transparentPngBlob], finalFilename, { type: 'image/png' })
|
|
|
+ }
|
|
|
+
|
|
|
|
|
|
- // 获取统一的 blob 和实际 MIME 类型
|
|
|
- const { blob, mime } = await getBlobAndMime(data)
|
|
|
+ // ================== 辅助函数 ==================
|
|
|
|
|
|
- // ----- 非 PNG 格式:直接返回原始文件(不处理透明)-----
|
|
|
- if (mime !== 'image/png') {
|
|
|
- return new File([blob], filename, { type: mime })
|
|
|
+ async function getBlobAndMime(input: string | Blob): Promise<{ blob: Blob; mime: string }> {
|
|
|
+ if (input instanceof Blob) return { blob: input, mime: input.type }
|
|
|
+ if (input.startsWith('data:') || input.startsWith('blob:')) {
|
|
|
+ const res = await fetch(input)
|
|
|
+ const blob = await res.blob()
|
|
|
+ return { blob, mime: blob.type }
|
|
|
}
|
|
|
+ const binary = atob(input)
|
|
|
+ const bytes = new Uint8Array(binary.length)
|
|
|
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
|
+ const blob = new Blob([bytes], { type: 'image/png' })
|
|
|
+ return { blob, mime: 'image/png' }
|
|
|
+ }
|
|
|
|
|
|
- // ----- PNG 格式:执行白色变透明处理 -----
|
|
|
- // 1. 创建对象 URL 用于加载图片
|
|
|
- const imageUrl = URL.createObjectURL(blob)
|
|
|
- const needRevoke = true
|
|
|
+ function getFormat(mime: string, filename: string): string {
|
|
|
+ const ext = filename.split('.').pop()?.toLowerCase()
|
|
|
+ if (mime === 'image/png') return 'png'
|
|
|
+ if (mime === 'image/tiff' || mime === 'image/x-tiff' || ext === 'tiff' || ext === 'tif') return 'tiff'
|
|
|
+ if (mime === 'image/x-emf' || mime === 'application/x-emf' || ext === 'emf') return 'emf'
|
|
|
+ if (mime === 'image/x-wmf' || mime === 'application/x-wmf' || ext === 'wmf') return 'wmf'
|
|
|
+ return 'browser'
|
|
|
+ }
|
|
|
|
|
|
- // 2. 加载图像
|
|
|
- const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
|
|
- const image = new Image()
|
|
|
- image.onload = () => resolve(image)
|
|
|
- image.onerror = reject
|
|
|
- // Blob URL 不需要设置 crossOrigin
|
|
|
- image.src = imageUrl
|
|
|
+ // TIFF 转 PNG(使用 UTIF.js)
|
|
|
+ async function convertTiffToPng(blob: Blob): Promise<Blob> {
|
|
|
+ const arrayBuffer = await blob.arrayBuffer()
|
|
|
+ const ifds = UTIF.decode(arrayBuffer)
|
|
|
+ if (!ifds || ifds.length === 0) throw new Error('No TIFF image found')
|
|
|
+ UTIF.decodeImage(arrayBuffer, ifds[0])
|
|
|
+ const rgba = UTIF.toRGBA8(ifds[0])
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
+ canvas.width = ifds[0].width
|
|
|
+ canvas.height = ifds[0].height
|
|
|
+ const ctx = canvas.getContext('2d')!
|
|
|
+ ctx.putImageData(new ImageData(rgba, ifds[0].width, ifds[0].height), 0, 0)
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('TIFF to PNG failed'))), 'image/png')
|
|
|
})
|
|
|
+ }
|
|
|
|
|
|
- const canvas = document.createElement('canvas')
|
|
|
+ // 通用函数:将 EMF/WMF 通过 Renderer 转换为 PNG
|
|
|
+ // 参考示例:https://github.com/wood/rtf.js/blob/master/demo/WMFJS.html
|
|
|
+ async function convertMetafileToPng(
|
|
|
+ arrayBuffer: ArrayBuffer,
|
|
|
+ RendererClass: any // new (data: ArrayBuffer) => { render(settings: any): SVGElement }
|
|
|
+ ): Promise<Blob> {
|
|
|
+ // 1. 创建 Renderer 实例
|
|
|
+ const renderer = new RendererClass(arrayBuffer)
|
|
|
+
|
|
|
+ // 2. 先尝试获取图片的真实尺寸(通过临时渲染并解析 SVG 的 viewBox)
|
|
|
+ let width = 800, height = 600 // 默认值
|
|
|
try {
|
|
|
+ // 使用一个较大的临时尺寸进行第一次渲染,以获取 SVG 的 viewBox
|
|
|
+ const tempSettings = {
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ xExt: 1000,
|
|
|
+ yExt: 1000,
|
|
|
+ mapMode: 8, // 保持宽高比
|
|
|
+ }
|
|
|
+ const tempSvg = renderer.render(tempSettings)
|
|
|
+ const viewBox = tempSvg.getAttribute('viewBox')
|
|
|
+ if (viewBox) {
|
|
|
+ const parts = viewBox.split(/[\s,]+/)
|
|
|
+ if (parts.length >= 4) {
|
|
|
+ width = parseFloat(parts[2])
|
|
|
+ height = parseFloat(parts[3])
|
|
|
+ }
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ // 尝试从 width/height 属性获取
|
|
|
+ const svgWidth = tempSvg.getAttribute('width')
|
|
|
+ const svgHeight = tempSvg.getAttribute('height')
|
|
|
+ if (svgWidth && svgHeight) {
|
|
|
+ width = parseFloat(svgWidth)
|
|
|
+ height = parseFloat(svgHeight)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ catch (e) {
|
|
|
+ console.warn('Failed to get dimensions from SVG, using default', e)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 使用实际尺寸重新渲染
|
|
|
+ const settings = {
|
|
|
+ width: width + 'px',
|
|
|
+ height: height + 'px',
|
|
|
+ xExt: width,
|
|
|
+ yExt: height,
|
|
|
+ mapMode: 8, // 保持宽高比
|
|
|
+ }
|
|
|
+ const svg = renderer.render(settings)
|
|
|
+
|
|
|
+ // 4. 将 SVG 转为 data URL 并用 Image 加载
|
|
|
+ const serializer = new XMLSerializer()
|
|
|
+ let svgString = serializer.serializeToString(svg)
|
|
|
+ // 确保有命名空间
|
|
|
+ if (!svgString.includes('xmlns="http://www.w3.org/2000/svg"')) {
|
|
|
+ svgString = svgString.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"')
|
|
|
+ }
|
|
|
+ const blob = new Blob([svgString], { type: 'image/svg+xml' })
|
|
|
+ const url = URL.createObjectURL(blob)
|
|
|
+ try {
|
|
|
+ const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
|
|
+ const image = new Image()
|
|
|
+ image.onload = () => resolve(image)
|
|
|
+ image.onerror = reject
|
|
|
+ image.src = url
|
|
|
+ })
|
|
|
+ // 5. 绘制到 canvas
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
canvas.width = img.width
|
|
|
canvas.height = img.height
|
|
|
const ctx = canvas.getContext('2d')!
|
|
|
ctx.drawImage(img, 0, 0)
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('Metafile to PNG failed'))), 'image/png')
|
|
|
+ })
|
|
|
+ }
|
|
|
+ finally {
|
|
|
+ URL.revokeObjectURL(url)
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- // 3. 获取像素数据,将接近白色的像素设为透明
|
|
|
- const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
|
|
- const dataArray = imageData.data
|
|
|
+ // EMF 转 PNG(使用 EMFJS.Renderer)
|
|
|
+ async function convertEmfToPng(blob: Blob): Promise<Blob> {
|
|
|
+ const arrayBuffer = await blob.arrayBuffer()
|
|
|
+ return convertMetafileToPng(arrayBuffer, EMFJS.Renderer)
|
|
|
+ }
|
|
|
|
|
|
- for (let i = 0; i < dataArray.length; i += 4) {
|
|
|
- const r = dataArray[i]
|
|
|
- const g = dataArray[i + 1]
|
|
|
- const b = dataArray[i + 2]
|
|
|
+ // WMF 转 PNG(使用 WMFJS.Renderer)
|
|
|
+ async function convertWmfToPng(blob: Blob): Promise<Blob> {
|
|
|
+ const arrayBuffer = await blob.arrayBuffer()
|
|
|
+ return convertMetafileToPng(arrayBuffer, WMFJS.Renderer)
|
|
|
+ }
|
|
|
|
|
|
+ function hasTransparency(img: HTMLImageElement, ctx: CanvasRenderingContext2D): boolean {
|
|
|
+ const imageData = ctx.getImageData(0, 0, img.width, img.height)
|
|
|
+ const data = imageData.data
|
|
|
+ // 遍历 Alpha 通道(索引 3)
|
|
|
+ for (let i = 3; i < data.length; i += 4) {
|
|
|
+ if (data[i] < 255) {
|
|
|
+ return true // 发现任意一个像素不是完全不透明
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ // 对 PNG 执行白色变透明
|
|
|
+ async function makeWhiteTransparentFromPng(pngBlob: Blob, tolerance: number): Promise<Blob> {
|
|
|
+ const url = URL.createObjectURL(pngBlob)
|
|
|
+ try {
|
|
|
+ const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
|
|
+ const image = new Image()
|
|
|
+ image.onload = () => resolve(image)
|
|
|
+ image.onerror = reject
|
|
|
+ image.src = url
|
|
|
+ })
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
+ canvas.width = img.width
|
|
|
+ canvas.height = img.height
|
|
|
+ const ctx = canvas.getContext('2d')!
|
|
|
+ ctx.drawImage(img, 0, 0)
|
|
|
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
|
|
+ const data = imageData.data
|
|
|
+ for (let i = 0; i < data.length; i += 4) {
|
|
|
+ const r = data[i]
|
|
|
+ const g = data[i + 1]
|
|
|
+ const b = data[i + 2]
|
|
|
const dr = r - 255
|
|
|
const dg = g - 255
|
|
|
const db = b - 255
|
|
|
- const dist = Math.sqrt(dr * dr + dg * dg + db * db)
|
|
|
-
|
|
|
- if (dist <= tolerance) {
|
|
|
- dataArray[i + 3] = 0 // 完全透明
|
|
|
+ if (Math.sqrt(dr * dr + dg * dg + db * db) <= tolerance) {
|
|
|
+ data[i + 3] = 0
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
ctx.putImageData(imageData, 0, 0)
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('Canvas toBlob failed'))), 'image/png')
|
|
|
+ })
|
|
|
}
|
|
|
finally {
|
|
|
- if (needRevoke) {
|
|
|
- URL.revokeObjectURL(imageUrl)
|
|
|
- }
|
|
|
+ URL.revokeObjectURL(url)
|
|
|
}
|
|
|
-
|
|
|
- // 4. 导出为 PNG Blob
|
|
|
- const outputBlob = await new Promise<Blob>((resolve, reject) => {
|
|
|
- canvas.toBlob((blob) => {
|
|
|
- if (blob) resolve(blob)
|
|
|
- else reject(new Error('Canvas toBlob failed'))
|
|
|
- }, 'image/png')
|
|
|
- })
|
|
|
-
|
|
|
- return new File([outputBlob], filename, { type: 'image/png' })
|
|
|
}
|
|
|
|
|
|
+
|
|
|
/**
|
|
|
* 上传 File 到 S3,返回公开访问的 URL
|
|
|
*/
|
|
|
@@ -1264,10 +1594,16 @@ export default () => {
|
|
|
}
|
|
|
const { cover, fixedViewport, signal, onclose } = { ...defaultOptions, ...options }
|
|
|
|
|
|
+ let isNone = false
|
|
|
+ if (slides.value.length === 1 && slides.value[0].elements.length === 0) {
|
|
|
+ isNone = true
|
|
|
+ }
|
|
|
+
|
|
|
const file = files[0]
|
|
|
if (!file) return
|
|
|
|
|
|
exporting.value = true // 假设 exporting 是一个全局 ref
|
|
|
+ imgExporting.value = true // 假设 imgExporting 是一个全局 ref
|
|
|
|
|
|
// 预加载形状库(用于后续形状匹配)
|
|
|
const shapeList: ShapePoolItem[] = []
|
|
|
@@ -1280,6 +1616,7 @@ export default () => {
|
|
|
// 检查是否已取消
|
|
|
if (signal?.aborted) {
|
|
|
exporting.value = false
|
|
|
+ imgExporting.value = false
|
|
|
return
|
|
|
}
|
|
|
|
|
|
@@ -1289,6 +1626,7 @@ export default () => {
|
|
|
}
|
|
|
catch (error) {
|
|
|
exporting.value = false
|
|
|
+ imgExporting.value = false
|
|
|
console.log('导入PPTX文件失败:', error)
|
|
|
message.error(lang.ssFileReadFail)
|
|
|
return
|
|
|
@@ -1296,6 +1634,7 @@ export default () => {
|
|
|
|
|
|
if (signal?.aborted) {
|
|
|
exporting.value = false
|
|
|
+ imgExporting.value = false
|
|
|
return
|
|
|
}
|
|
|
|
|
|
@@ -1308,7 +1647,11 @@ export default () => {
|
|
|
ratio = 1000 / width // 固定视口宽度为 1000px
|
|
|
}
|
|
|
else {
|
|
|
- slidesStore.setViewportSize(width * ratio) // 调整画布大小
|
|
|
+ const targetViewportSize = width * ratio
|
|
|
+ if (isNone || targetViewportSize > slidesStore.viewportSize) {
|
|
|
+ slidesStore.setViewportSize(targetViewportSize) // 调整画布大小
|
|
|
+ }
|
|
|
+ slidesStore.setViewportRatio(viewportRatio)
|
|
|
}
|
|
|
|
|
|
// 设置主题色
|
|
|
@@ -1405,7 +1748,7 @@ export default () => {
|
|
|
defaultColor: theme.value.fontColor,
|
|
|
content: convertFontSizePtToPx(el.content, ratio, el.autoFit),
|
|
|
style: getStyle(convertFontSizePtToPx(el.content, ratio, el.autoFit)),
|
|
|
- lineHeight: 1.15,
|
|
|
+ lineHeight: 1.5,
|
|
|
align: vAlignMap[el.vAlign] || 'middle',
|
|
|
outline: {
|
|
|
color: el.borderColor,
|
|
|
@@ -1469,7 +1812,7 @@ export default () => {
|
|
|
}
|
|
|
|
|
|
// 如果 src 是 base64,触发上传
|
|
|
- if (el.src && typeof el.src === 'string' && el.src.startsWith('data:')) {
|
|
|
+ if (el.src && typeof el.src === 'string' && el.src.startsWith('blob:')) {
|
|
|
const uploadTask = (async () => {
|
|
|
try {
|
|
|
const file = await makeWhiteTransparent(el.src, `image_${Date.now()}.png`)
|
|
|
@@ -1502,27 +1845,27 @@ export default () => {
|
|
|
fixedRatio: true,
|
|
|
rotate: 0,
|
|
|
}
|
|
|
- /*
|
|
|
- // 如果 src 是 base64,触发上传
|
|
|
- if (el.picBase64 && typeof el.picBase64 === 'string' && el.picBase64.startsWith('data:')) {
|
|
|
- const uploadTask = (async () => {
|
|
|
- try {
|
|
|
- const file = makeWhiteTransparent(el.picBase64, `image_${Date.now()}.png`)
|
|
|
- if (file) {
|
|
|
- const url = await uploadFileToS3(file)
|
|
|
- element.src = url // 替换为远程 URL
|
|
|
- const slidesStore = useSlidesStore()
|
|
|
- slidesStore.updateElement({ id: element.id, props: { src: url } })
|
|
|
- }
|
|
|
- }
|
|
|
- catch (error) {
|
|
|
- console.error('Image upload failed:', error)
|
|
|
- // 失败时保留原 base64(或可置空)
|
|
|
- }
|
|
|
- })()
|
|
|
- uploadTasks.push(uploadTask)
|
|
|
- }
|
|
|
- */
|
|
|
+
|
|
|
+ // 如果 src 是 base64,触发上传
|
|
|
+ if (el.picBase64 && typeof el.picBase64 === 'string' && el.picBase64.startsWith('blob:')) {
|
|
|
+ const uploadTask = (async () => {
|
|
|
+ try {
|
|
|
+ const file = makeWhiteTransparent(el.picBase64, `image_${Date.now()}.png`)
|
|
|
+ if (file) {
|
|
|
+ const url = await uploadFileToS3(file)
|
|
|
+ element.src = url // 替换为远程 URL
|
|
|
+ const slidesStore = useSlidesStore()
|
|
|
+ slidesStore.updateElement({ id: element.id, props: { src: url } })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ catch (error) {
|
|
|
+ console.error('Image upload failed:', error)
|
|
|
+ // 失败时保留原 base64(或可置空)
|
|
|
+ }
|
|
|
+ })()
|
|
|
+ uploadTasks.push(uploadTask)
|
|
|
+ }
|
|
|
+
|
|
|
|
|
|
slide.elements.push(element)
|
|
|
|
|
|
@@ -1579,26 +1922,26 @@ export default () => {
|
|
|
rotate: 0,
|
|
|
autoplay: false,
|
|
|
}
|
|
|
- /*
|
|
|
- const localData = el.blob || (el.src && typeof el.src === 'string' && el.src.startsWith('data:') ? el.src : null)
|
|
|
- if (localData) {
|
|
|
- const uploadTask = (async () => {
|
|
|
- try {
|
|
|
- const file = await dataToFile(localData, `video_${Date.now()}.mp4`, 'video/mp4')
|
|
|
- if (file) {
|
|
|
- const url = await uploadFileToS3(file)
|
|
|
- element.src = url
|
|
|
- const slidesStore = useSlidesStore()
|
|
|
- slidesStore.updateElement({ id: element.id, props: { src: url } })
|
|
|
- }
|
|
|
- }
|
|
|
- catch (error) {
|
|
|
- console.error('Video upload failed:', error)
|
|
|
- }
|
|
|
- })()
|
|
|
- uploadTasks.push(uploadTask)
|
|
|
- }
|
|
|
- */
|
|
|
+
|
|
|
+ const localData = el.blob || (el.src && typeof el.src === 'string' && el.src.startsWith('data:') ? el.src : null)
|
|
|
+ if (localData) {
|
|
|
+ const uploadTask = (async () => {
|
|
|
+ try {
|
|
|
+ const file = await dataToFile(localData, `video_${Date.now()}.mp4`, 'video/mp4')
|
|
|
+ if (file) {
|
|
|
+ const url = await uploadFileToS3(file)
|
|
|
+ element.src = url
|
|
|
+ const slidesStore = useSlidesStore()
|
|
|
+ slidesStore.updateElement({ id: element.id, props: { src: url } })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ catch (error) {
|
|
|
+ console.error('Video upload failed:', error)
|
|
|
+ }
|
|
|
+ })()
|
|
|
+ uploadTasks.push(uploadTask)
|
|
|
+ }
|
|
|
+
|
|
|
slide.elements.push(element)
|
|
|
}
|
|
|
|
|
|
@@ -1631,8 +1974,8 @@ export default () => {
|
|
|
: undefined
|
|
|
|
|
|
const pattern: string | undefined = el.fill?.type === 'image' ? el.fill.value.picBase64 : undefined
|
|
|
- const fill = el.fill?.type === 'color' ? el.fill.value : ''
|
|
|
- const style = getStyle(convertFontSizePtToPx(el.content, ratio, el.autoFit)) + (el.pathBBox.pWidth ? ';width:' + (el.pathBBox.pWidth * ratio) + 'px;height:' + (el.pathBBox.pHeight * ratio) + 'px;' : '') // 设置字体的样式等,这里由于不支持的样式在里面会过滤
|
|
|
+ const fill = el.fill?.type === 'color' ? el.fill.value : 'none'
|
|
|
+ const style = getStyle(convertFontSizePtToPx(el.content, ratio, el.autoFit)) + (el.pathBBox?.pWidth ? ';width:' + (el.pathBBox?.pWidth * ratio) + 'px;height:' + (el.pathBBox?.pHeight * ratio) + 'px;' : '') // 设置字体的样式等,这里由于不支持的样式在里面会过滤
|
|
|
const element: PPTShapeElement = {
|
|
|
type: 'shape',
|
|
|
id: nanoid(10),
|
|
|
@@ -1674,10 +2017,10 @@ export default () => {
|
|
|
}
|
|
|
|
|
|
if (shape) {
|
|
|
- element.path = shape.path
|
|
|
- // const { maxX, maxY } = getSvgPathRange(el.path);
|
|
|
- element.viewBox = shape.viewBox
|
|
|
- // element.viewBox = [originWidth || maxX, originHeight || maxY];
|
|
|
+ const { maxX, maxY } = getSvgPathRange(el.path)
|
|
|
+ element.path = el.path
|
|
|
+ element.viewBox = poriginWidth ? [maxX, maxY] : [originWidth, originHeight]
|
|
|
+ /*
|
|
|
if (shape.pathFormula) {
|
|
|
element.pathFormula = shape.pathFormula
|
|
|
element.viewBox = [el.width, el.height]
|
|
|
@@ -1691,6 +2034,7 @@ export default () => {
|
|
|
element.path = pathFormula.formula(el.width, el.height)
|
|
|
}
|
|
|
}
|
|
|
+ */
|
|
|
}
|
|
|
else if (el.path && el.path.indexOf('NaN') === -1) {
|
|
|
const { maxX, maxY } = getSvgPathRange(el.path)
|
|
|
@@ -1947,7 +2291,11 @@ export default () => {
|
|
|
|
|
|
// 等待当前幻灯片内所有上传任务完成
|
|
|
// await Promise.all(uploadTasks)
|
|
|
- Promise.all(uploadTasks)
|
|
|
+ Promise.all(uploadTasks).then(() => {
|
|
|
+ imgExporting.value = false
|
|
|
+ }).catch(() => {
|
|
|
+ imgExporting.value = false
|
|
|
+ })
|
|
|
|
|
|
exporting.value = false
|
|
|
onclose?.()
|
|
|
@@ -2096,6 +2444,7 @@ export default () => {
|
|
|
readJSON,
|
|
|
exportJSON2,
|
|
|
exporting,
|
|
|
+ imgExporting,
|
|
|
getFile,
|
|
|
getFile2,
|
|
|
dataToFile,
|