markmap/javascript/export_utils.js
2026-08-12 16:54:48 +08:00

656 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 另存为辅助函数 ──
// 使用浏览器原生 showSaveFilePicker 弹出保存对话框
// 让用户选择保存位置和修改文件名
async function saveFileAs(blob, suggestedName, fileTypes) {
if (window.showSaveFilePicker) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: suggestedName,
types: fileTypes || []
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return true;
} catch (e) {
if (e.name === 'AbortError') return false; // 用户取消保存
console.warn('showSaveFilePicker 不可用,回退到传统下载:', e);
}
}
// 回退:传统下载方式
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = suggestedName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return true;
}
// 从编辑器内容提取文档标题作为默认文件名
function getSuggestedFileName(ext) {
try {
if (typeof extractDocTitle === 'function' && typeof editor !== 'undefined') {
const title = extractDocTitle(editor.value);
// 清理文件名中的非法字符
const safeName = (title || 'markmap').replace(/[<>:"/\\|?*]/g, '_');
return safeName + '.' + ext;
}
} catch {}
return 'markmap.' + ext;
}
// ── 等待 SVG 中所有图片加载完成 ──
async function waitForImages() {
const svg = document.getElementById('markmap');
if (!svg) return;
const imgs = svg.querySelectorAll('img');
const promises = [];
imgs.forEach(img => {
if (!img.complete && img.src) {
promises.push(new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
}));
}
});
if (promises.length > 0) {
await Promise.all(promises);
}
}
// ── SVG 内容获取 ──
// forCanvas: 为 Canvas 渲染准备时,将 foreignObject 中的 <img> 转为 SVG <image> 元素
// 原因SVG 作为 data URL 加载时,浏览器安全限制不渲染 foreignObject 中的 <img>
// 但原生 SVG <image> 元素可以正常渲染。
function getSVGContent(forCanvas = false) {
const svg = document.getElementById('markmap');
if (!svg) {
console.error('未找到 SVG 元素');
return null;
}
const clonedSvg = svg.cloneNode(true);
// 移除 id 属性,防止 CSS 中的 #markmap { width:100%; height:100% } 覆盖显式宽高
// SVG 作为 data URL 加载时100% 无父元素参照会导致尺寸为 0
clonedSvg.removeAttribute('id');
// 设置命名空间
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
// ── 获取完整内容尺寸(不限于当前视口,支持长图高清导出)──
// markmap 的 SVG: <svg><g transform="zoom"> 所有节点和连线 </g></svg>
// 第一个 <g> 是缩放容器getBBox() 返回全部内容在本地坐标系中的包围盒
const originalMainG = svg.querySelector('g');
let contentX = 0, contentY = 0, contentWidth = 0, contentHeight = 0;
try {
if (originalMainG) {
const contentBBox = originalMainG.getBBox();
contentX = contentBBox.x;
contentY = contentBBox.y;
contentWidth = contentBBox.width;
contentHeight = contentBBox.height;
// 克隆中移除缩放 transform让内容以原始坐标显示1:1 无缩放)
// 这样导出的 SVG/PNG/PDF 包含全部内容,不受当前缩放/平移影响
const clonedMainG = clonedSvg.querySelector('g');
if (clonedMainG) {
clonedMainG.removeAttribute('transform');
}
}
} catch (e) {
console.warn('获取内容包围盒失败,回退到视口尺寸:', e.message);
}
if (contentWidth === 0 || contentHeight === 0) {
// 回退:使用视口尺寸
const rect = svg.getBoundingClientRect();
contentX = 0;
contentY = 0;
contentWidth = Math.ceil(rect.width);
contentHeight = Math.ceil(rect.height);
}
// 添加边距,确保内容不被裁切
const padding = 40;
contentX -= padding;
contentY -= padding;
contentWidth = Math.max(Math.ceil(contentWidth + padding * 2), 100);
contentHeight = Math.max(Math.ceil(contentHeight + padding * 2), 100);
// 设置 viewBox 覆盖完整内容(长图效果:内容多时图片自动变长)
clonedSvg.setAttribute('viewBox', `${contentX} ${contentY} ${contentWidth} ${contentHeight}`);
clonedSvg.setAttribute('width', contentWidth);
clonedSvg.setAttribute('height', contentHeight);
// 确保图片 src 属性正确序列化
// cloneNode(true) 可能不会正确复制 img.src浏览器内部属性 vs HTML 属性)
const originalImgs = svg.querySelectorAll('img');
const clonedImgs = clonedSvg.querySelectorAll('img');
for (let i = 0; i < originalImgs.length && i < clonedImgs.length; i++) {
const src = originalImgs[i].src;
if (src) {
clonedImgs[i].setAttribute('src', src);
}
const style = originalImgs[i].getAttribute('style');
if (style) {
clonedImgs[i].setAttribute('style', style);
}
}
// 为 Canvas 渲染准备:将 foreignObject 中的 <img> 转为 SVG <image> 元素
if (forCanvas) {
convertImagesToSvgImages(clonedSvg, svg);
}
// 收集页面中与 markmap 相关的样式表
const styleElement = document.createElementNS('http://www.w3.org/2000/svg', 'style');
let cssText = '';
for (const sheet of document.styleSheets) {
try {
const rules = sheet.cssRules || [];
for (const rule of rules) {
const selector = rule.selectorText || '';
// 跳过会影响 SVG 根元素尺寸的 CSS 规则100% 在独立 SVG 中无意义)
if ((selector === 'svg' || selector === '#markmap') &&
(rule.cssText.includes('width') || rule.cssText.includes('height'))) {
continue;
}
if (selector.includes('markmap') ||
selector.includes('svg') ||
selector.includes('foreignObject') ||
selector.includes('img') ||
selector.includes(':root') ||
rule.cssText.includes('--markmap')) {
cssText += rule.cssText + '\n';
}
}
} catch {
// 跨域样式表无法访问 cssRules跳过
}
}
// 添加内联样式中的 CSS 变量
const rootStyle = getComputedStyle(document.documentElement);
const markmapVars = [
'--markmap-circle-open-bg',
'--markmap-max-width',
];
let rootCss = ':root {';
for (const varName of markmapVars) {
const val = rootStyle.getPropertyValue(varName).trim();
if (val) rootCss += `${varName}: ${val};`;
}
rootCss += '}';
if (rootCss !== ':root {}') cssText = rootCss + '\n' + cssText;
styleElement.textContent = cssText;
clonedSvg.insertBefore(styleElement, clonedSvg.firstChild);
return new XMLSerializer().serializeToString(clonedSvg);
}
// ── 将 foreignObject 中的 <img> 转为 SVG <image> 元素 ──
// SVG 作为 data URL 图片加载时,浏览器不渲染 foreignObject 中的 HTML <img>
// 但原生 SVG <image> 元素可以正常渲染,因此需要转换。
// 通过 getBoundingClientRect 计算图片在屏幕上的位置,再换算为 SVG 坐标。
function convertImagesToSvgImages(clonedSvg, originalSvg) {
const originalFos = originalSvg.querySelectorAll('foreignObject');
const clonedFos = clonedSvg.querySelectorAll('foreignObject');
for (let i = 0; i < originalFos.length && i < clonedFos.length; i++) {
const originalFo = originalFos[i];
const clonedFo = clonedFos[i];
const originalImgs = originalFo.querySelectorAll('img');
const clonedImgs = clonedFo.querySelectorAll('img');
for (let j = 0; j < originalImgs.length && j < clonedImgs.length; j++) {
const originalImg = originalImgs[j];
const clonedImg = clonedImgs[j];
const src = originalImg.src;
if (!src) continue;
// 获取图片在屏幕上的渲染尺寸和位置
const imgRect = originalImg.getBoundingClientRect();
const foRect = originalFo.getBoundingClientRect();
// 跳过未渲染或尺寸为 0 的图片
if (imgRect.width === 0 || imgRect.height === 0) continue;
// 图片相对于 foreignObject 的偏移(屏幕像素)
const offsetX = imgRect.left - foRect.left;
const offsetY = imgRect.top - foRect.top;
// 计算屏幕像素到 SVG 坐标单位的缩放比
// foreignObject 的 width 属性是 SVG 坐标单位foRect.width 是屏幕像素
const foWidthAttr = parseFloat(clonedFo.getAttribute('width')) || 0;
const scaleFactor = foWidthAttr > 0 && foRect.width > 0
? foWidthAttr / foRect.width
: 1;
// foreignObject 在父级 SVG 坐标中的位置
const foX = parseFloat(clonedFo.getAttribute('x')) || 0;
const foY = parseFloat(clonedFo.getAttribute('y')) || 0;
// 图片在 SVG 坐标中的位置和尺寸
const imgX = foX + offsetX * scaleFactor;
const imgY = foY + offsetY * scaleFactor;
const imgW = imgRect.width * scaleFactor;
const imgH = imgRect.height * scaleFactor;
// 创建 SVG <image> 元素
const svgImage = document.createElementNS('http://www.w3.org/2000/svg', 'image');
svgImage.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', src);
svgImage.setAttribute('href', src);
svgImage.setAttribute('x', imgX);
svgImage.setAttribute('y', imgY);
svgImage.setAttribute('width', imgW);
svgImage.setAttribute('height', imgH);
svgImage.setAttribute('preserveAspectRatio', 'xMidYMid meet');
// 在 foreignObject 之前插入 SVG <image>
clonedFo.parentNode.insertBefore(svgImage, clonedFo);
// 从克隆的 foreignObject 中移除 <img>(避免重复显示)
clonedImg.remove();
}
}
}
// ── 导出 SVG ──
async function exportSVG() {
// 等待图片加载完成getSVGContent(true) 需要用 getBoundingClientRect 计算坐标)
await waitForImages();
// 使用 forCanvas=true 将 foreignObject 中的 <img> 转为原生 SVG <image> 元素
// 原因:许多 SVG 查看器不渲染 foreignObject 中的 HTML 内容,
// 原生 SVG <image> 元素在所有 SVG 查看器中都能正常显示
const svgContent = getSVGContent(true);
if (!svgContent) {
alert('无法生成 SVG请确保思维导图已渲染。');
return;
}
const blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' });
await saveFileAs(blob, getSuggestedFileName('svg'), [{
description: 'SVG 矢量图',
accept: { 'image/svg+xml': ['.svg'] }
}]);
}
// ── SVG 转 Canvas ──
// SVG 中的图片已转换为原生 SVG <image> 元素data URL 方式可正常渲染。
// 失败时回退到 html2canvas。
function svgToCanvasViaDataUrl(svgContent, scale = 2) {
return new Promise((resolve, reject) => {
// 使用 encodeURIComponent 编码,兼容 Unicode 字符
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgContent);
const img = new Image();
img.onload = () => {
const w = img.naturalWidth || img.width || 1;
const h = img.naturalHeight || img.height || 1;
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(w * scale);
canvas.height = Math.ceil(h * scale);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas);
};
img.onerror = () => {
reject(new Error('data URL 方式失败'));
};
img.src = dataUrl;
});
}
async function svgToCanvasViaHtml2canvas(scale = 4) {
if (typeof html2canvas === 'undefined') {
throw new Error('html2canvas 未加载');
}
const container = document.getElementById('markmap-container');
if (!container) throw new Error('未找到容器');
// 隐藏工具栏
const toolbars = container.querySelectorAll('.markmap-toolbar, .mm-toolbar');
const hidden = [];
toolbars.forEach(tb => {
hidden.push({ el: tb, vis: tb.style.visibility, disp: tb.style.display });
tb.style.visibility = 'hidden';
tb.style.display = 'none';
});
const origBg = container.style.background;
container.style.background = 'white';
try {
const canvas = await html2canvas(container, {
backgroundColor: '#ffffff',
scale: scale,
logging: false,
useCORS: true,
allowTaint: true
});
return canvas;
} finally {
container.style.background = origBg;
hidden.forEach(({ el, vis, disp }) => {
el.style.visibility = vis || '';
el.style.display = disp || '';
});
}
}
async function renderToCanvas(svgContent, scale = 2) {
// 优先尝试 SVG data URL 方式(图片已转换为 SVG <image>,可正常渲染)
try {
return await svgToCanvasViaDataUrl(svgContent, scale);
} catch (e) {
console.warn('SVG data URL 渲染失败,回退到 html2canvas:', e.message);
return await svgToCanvasViaHtml2canvas(scale);
}
}
// ── 高清导出辅助函数 ──
// 浏览器 Canvas 最大尺寸限制(大多数浏览器为 16384px
const MAX_CANVAS_DIM = 16384;
// 根据内容尺寸计算最优缩放倍数,防止 Canvas 超出浏览器限制
function getOptimalScale(width, height, desiredScale = 3) {
const scaledW = width * desiredScale;
const scaledH = height * desiredScale;
if (scaledW <= MAX_CANVAS_DIM && scaledH <= MAX_CANVAS_DIM) {
return desiredScale;
}
// 降级:确保不超过浏览器限制,但至少保持 1 倍
return Math.max(1, Math.min(
MAX_CANVAS_DIM / width,
MAX_CANVAS_DIM / height,
desiredScale
));
}
// 获取思维导图完整内容尺寸(与 getSVGContent 中的计算一致)
function getContentDimensions() {
const svg = document.getElementById('markmap');
if (!svg) return { width: 1000, height: 800 };
const mainG = svg.querySelector('g');
try {
if (mainG) {
const bbox = mainG.getBBox();
const padding = 40;
return {
width: Math.max(Math.ceil(bbox.width + padding * 2), 100),
height: Math.max(Math.ceil(bbox.height + padding * 2), 100)
};
}
} catch (e) { /* 回退 */ }
const rect = svg.getBoundingClientRect();
return { width: Math.ceil(rect.width), height: Math.ceil(rect.height) };
}
// 在 Canvas 中查找最佳分页切割点(在内容间隙处切割,避免切穿节点文字)
// 在 [searchStart, searchEnd] 范围内寻找全白行,返回最接近 idealY 的白行位置
function findBestSplitPoint(canvas, idealY, searchStart, searchEnd) {
try {
const ctx = canvas.getContext('2d');
const regionH = searchEnd - searchStart;
if (regionH <= 0) return idealY;
// 获取搜索区域的像素数据
const imageData = ctx.getImageData(0, searchStart, canvas.width, regionH);
const data = imageData.data;
// 采样列以提高性能(约每 100 列采样一次)
const sampleStep = Math.max(1, Math.floor(canvas.width / 100));
// 查找范围内的全白行(像素值 ≥ 240 视为白色背景)
const whiteRows = [];
for (let y = 0; y < regionH; y++) {
let isWhite = true;
for (let x = 0; x < canvas.width; x += sampleStep) {
const idx = (y * canvas.width + x) * 4;
if (data[idx] < 240 || data[idx + 1] < 240 || data[idx + 2] < 240) {
isWhite = false;
break;
}
}
if (isWhite) whiteRows.push(searchStart + y);
}
if (whiteRows.length === 0) return idealY;
// 返回最接近 idealY 的白行位置
let bestRow = whiteRows[0];
let bestDist = Math.abs(bestRow - idealY);
for (const row of whiteRows) {
const dist = Math.abs(row - idealY);
if (dist < bestDist) {
bestDist = dist;
bestRow = row;
}
}
return bestRow;
} catch (e) {
// canvas 可能被污染tainted回退到理想位置
return idealY;
}
}
// ── 导出 PNG ──
async function exportPNG() {
// 等待所有图片加载完成(确保 getBoundingClientRect 返回正确尺寸)
await waitForImages();
const svgContent = getSVGContent(true);
if (!svgContent) {
alert('无法生成 PNG请确保思维导图已渲染。');
return;
}
try {
// 高清导出3 倍缩放,内容过大时自动降级防止 Canvas 超限
const { width, height } = getContentDimensions();
const scale = getOptimalScale(width, height, 3);
const canvas = await renderToCanvas(svgContent, scale);
const blob = await new Promise(resolve => canvas.toBlob(resolve));
if (!blob) {
console.error('生成 PNG Blob 失败');
return;
}
await saveFileAs(blob, getSuggestedFileName('png'), [{
description: 'PNG 图片',
accept: { 'image/png': ['.png'] }
}]);
} catch (error) {
console.error('导出 PNG 失败:', error);
alert('导出 PNG 失败,请重试。');
}
}
// ── 导出 PDF ──
// PDF 生成耗时较长,需在用户手势上下文中先弹出保存对话框,再生成内容
async function exportPDF(orientation = 'auto') {
if (!window.jspdf) {
alert('导出库未加载,无法导出 PDF。');
return;
}
// 先弹出原生保存对话框(必须在用户手势上下文中,耗时操作之前)
const suggestedName = getSuggestedFileName('pdf');
let fileHandle = null;
if (window.showSaveFilePicker) {
try {
fileHandle = await window.showSaveFilePicker({
suggestedName: suggestedName,
types: [{ description: 'PDF 文档', accept: { 'application/pdf': ['.pdf'] } }]
});
} catch (e) {
if (e.name === 'AbortError') return; // 用户取消
console.warn('showSaveFilePicker 不可用,回退到传统下载:', e);
}
}
// 等待所有图片加载完成
await waitForImages();
const svgContent = getSVGContent(true);
if (!svgContent) {
alert('无法生成 PDF请确保思维导图已渲染。');
return;
}
try {
// 高清渲染完整内容
const { width, height } = getContentDimensions();
const scale = getOptimalScale(width, height, 3);
const canvas = await renderToCanvas(svgContent, scale);
// A4 尺寸mm
const A4_SHORT = 210;
const A4_LONG = 297;
// 根据用户选择或内容宽高比确定方向
// orientation: 'auto'(自动)| 'landscape'(横向)| 'portrait'(纵向)
const contentRatio = canvas.width / canvas.height;
let useLandscape;
if (orientation === 'landscape') {
useLandscape = true;
} else if (orientation === 'portrait') {
useLandscape = false;
} else {
// 自动:宽 ≥ 高 → 横向,否则 → 纵向
useLandscape = contentRatio >= 1;
}
// A4 纸张摆放宽度竖向A4用短边(210mm)横向A4用长边(297mm)
// 内容按此宽度缩放,决定每像素对应的毫米数
const pageW = useLandscape ? A4_LONG : A4_SHORT; // 摆放宽度:竖向=短边,横向=长边
const pageH = useLandscape ? A4_SHORT : A4_LONG; // 页面高度:竖向=长边,横向=短边
// 将内容缩放至 A4 摆放宽度
const imgW = pageW;
const imgH = (canvas.height / canvas.width) * imgW;
// 计算总页数:内容高度超过一页时自动分页
const pagesNeeded = Math.max(1, Math.ceil(imgH / pageH));
const pdf = new window.jspdf.jsPDF({
orientation: useLandscape ? 'landscape' : 'portrait',
unit: 'mm',
format: 'a4'
});
if (pagesNeeded === 1) {
// 单页:垂直居中显示
const imgData = canvas.toDataURL('image/png');
const yOffset = (pageH - imgH) / 2;
pdf.addImage(imgData, 'PNG', 0, yOffset, imgW, imgH);
} else {
// 多页:智能分页,在内容间隙(白行)处切割,避免切穿节点文字
// 理想切割点按照A4摆放宽度竖向短边210mm / 横向长边297mm缩放后
// 一页A4高度对应的Canvas像素数 = (pageH / pageW) * canvas.width
// 竖向:(297 / 210) * canvas.width ≈ 1.414 × canvas.width
// 横向:(210 / 297) * canvas.width ≈ 0.707 × canvas.width
const srcSliceHeight = Math.round((pageH / pageW) * canvas.width);
// 搜索范围:在理想切割点向前 15% 页高内寻找白行
// 只向前搜索,确保每页切片不超过一页高度
const searchRange = Math.round(srcSliceHeight * 0.15);
// 逐页计算智能切割点
const splitPoints = [0];
let currentY = 0;
while (currentY < canvas.height - 1) {
const idealEnd = currentY + srcSliceHeight;
if (idealEnd >= canvas.height) {
// 剩余内容不足一页,作为最后一页
splitPoints.push(canvas.height);
break;
}
// 在 [idealEnd - searchRange, idealEnd] 范围搜索白行
const searchStart = Math.max(currentY + 1, idealEnd - searchRange);
const bestSplit = findBestSplitPoint(canvas, idealEnd, searchStart, idealEnd);
if (bestSplit <= currentY) {
// 未找到合适白行,在理想位置切割
splitPoints.push(idealEnd);
currentY = idealEnd;
} else {
splitPoints.push(bestSplit);
currentY = bestSplit;
}
}
// 按切割点逐页生成 PDF
for (let i = 0; i < splitPoints.length - 1; i++) {
if (i > 0) pdf.addPage();
const srcY = splitPoints[i];
const srcH = splitPoints[i + 1] - srcY;
const pageCanvas = document.createElement('canvas');
pageCanvas.width = canvas.width;
pageCanvas.height = srcH;
const ctx = pageCanvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, pageCanvas.width, pageCanvas.height);
ctx.drawImage(canvas, 0, srcY, canvas.width, srcH,
0, 0, canvas.width, srcH);
const pageImgData = pageCanvas.toDataURL('image/png');
// 当前页显示高度mm不超过页面高度
const sliceDisplayH = Math.min((srcH / canvas.width) * pageW, pageH);
// 内容置于顶部,便于多页拼接连续观看
pdf.addImage(pageImgData, 'PNG', 0, 0, pageW, sliceDisplayH);
}
}
const pdfBlob = pdf.output('blob');
// 写入文件:优先使用原生句柄,回退到传统下载
if (fileHandle) {
const writable = await fileHandle.createWritable();
await writable.write(pdfBlob);
await writable.close();
} else {
const url = URL.createObjectURL(pdfBlob);
const link = document.createElement('a');
link.href = url;
link.download = suggestedName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
} catch (error) {
console.error('导出 PDF 失败:', error);
alert('导出 PDF 失败,请重试。');
}
}