656 lines
25 KiB
JavaScript
656 lines
25 KiB
JavaScript
// ── 另存为辅助函数 ──
|
||
// 使用浏览器原生 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 失败,请重试。');
|
||
}
|
||
}
|