docnest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +83 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +79 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +814 -0
- package/package.json +60 -0
- package/server/favicon.ico +0 -0
- package/server/static/css/highlight.css +72 -0
- package/server/static/css/styles.css +5762 -0
- package/server/static/favicon.ico +0 -0
- package/server/static/img/404-deep-space-light.webp +0 -0
- package/server/static/img/404-deep-space.webp +0 -0
- package/server/static/js/core/doc-client-nav.js +260 -0
- package/server/static/js/core/doc-pdf-export.js +579 -0
- package/server/static/js/core/mermaid-init.js +36 -0
- package/server/static/js/core/sidebar.js +141 -0
- package/server/static/js/core/theme.js +138 -0
- package/server/static/js/diagram/diagram-controls.js +90 -0
- package/server/static/js/diagram/diagram-download.js +594 -0
- package/server/static/js/diagram/diagram-drag.js +157 -0
- package/server/static/js/diagram/diagram-viewer.js +405 -0
- package/server/static/js/diagram/diagram-zoom.js +67 -0
- package/server/static/js/utils/app.js +14 -0
- package/server/static/js/utils/modal-stack.js +33 -0
- package/server/views/dir.ejs +142 -0
- package/server/views/doc.ejs +483 -0
- package/server/views/index.ejs +77 -0
- package/server/views/not-found.ejs +57 -0
- package/server/views/partials/breadcrumbs.ejs +15 -0
- package/server/views/partials/diagram-viewer.ejs +16 -0
- package/server/views/partials/header.ejs +37 -0
- package/server/views/partials/sidebar.ejs +19 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// 图表下载和复制功能
|
|
2
|
+
function downloadCurrentDiagram() {
|
|
3
|
+
if (!currentSvg) return;
|
|
4
|
+
// 获取原始 SVG(移除缩放样式)
|
|
5
|
+
const originalSvg = currentSvg.cloneNode(true);
|
|
6
|
+
originalSvg.style.transform = '';
|
|
7
|
+
originalSvg.style.width = '';
|
|
8
|
+
originalSvg.style.height = '';
|
|
9
|
+
showDownloadMenu(originalSvg, 'diagram');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function copyCurrentDiagram() {
|
|
13
|
+
if (!currentSvg) return;
|
|
14
|
+
|
|
15
|
+
// 获取原始 SVG(移除缩放样式)
|
|
16
|
+
const originalSvg = currentSvg.cloneNode(true);
|
|
17
|
+
originalSvg.style.transform = '';
|
|
18
|
+
originalSvg.style.width = '';
|
|
19
|
+
originalSvg.style.height = '';
|
|
20
|
+
|
|
21
|
+
copyDiagramToClipboard(originalSvg);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function copyDiagramToClipboard(svg) {
|
|
25
|
+
// 获取 SVG 的真实尺寸
|
|
26
|
+
const svgClone = svg.cloneNode(true);
|
|
27
|
+
let viewBox = svgClone.getAttribute('viewBox');
|
|
28
|
+
let svgWidth, svgHeight;
|
|
29
|
+
|
|
30
|
+
// 优先从 viewBox 获取尺寸
|
|
31
|
+
if (viewBox) {
|
|
32
|
+
const parts = viewBox.split(/\s+/);
|
|
33
|
+
if (parts.length >= 4) {
|
|
34
|
+
svgWidth = parseFloat(parts[2]);
|
|
35
|
+
svgHeight = parseFloat(parts[3]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 如果没有 viewBox,尝试从属性获取
|
|
40
|
+
if (!svgWidth || !svgHeight || isNaN(svgWidth) || isNaN(svgHeight)) {
|
|
41
|
+
svgWidth = parseFloat(svgClone.getAttribute('width')) || 800;
|
|
42
|
+
svgHeight = parseFloat(svgClone.getAttribute('height')) || 600;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 确保 SVG 有明确的尺寸属性
|
|
46
|
+
svgClone.setAttribute('width', svgWidth);
|
|
47
|
+
svgClone.setAttribute('height', svgHeight);
|
|
48
|
+
if (!viewBox) {
|
|
49
|
+
svgClone.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 清理 SVG
|
|
53
|
+
cleanSVG(svgClone);
|
|
54
|
+
|
|
55
|
+
const svgData = new XMLSerializer().serializeToString(svgClone);
|
|
56
|
+
const svgDataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgData);
|
|
57
|
+
|
|
58
|
+
const img = new Image();
|
|
59
|
+
img.onload = function() {
|
|
60
|
+
try {
|
|
61
|
+
// 使用高分辨率(2倍缩放,平衡清晰度和文件大小)
|
|
62
|
+
const scale = 2;
|
|
63
|
+
const padding = 20;
|
|
64
|
+
const canvasWidth = Math.round(svgWidth * scale + padding * 2);
|
|
65
|
+
const canvasHeight = Math.round(svgHeight * scale + padding * 2);
|
|
66
|
+
|
|
67
|
+
const canvas = document.createElement('canvas');
|
|
68
|
+
canvas.width = canvasWidth;
|
|
69
|
+
canvas.height = canvasHeight;
|
|
70
|
+
const ctx = canvas.getContext('2d');
|
|
71
|
+
|
|
72
|
+
if (!ctx) {
|
|
73
|
+
throw new Error('无法创建 Canvas 上下文');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 禁用图像平滑(保持锐利边缘)
|
|
77
|
+
ctx.imageSmoothingEnabled = false;
|
|
78
|
+
|
|
79
|
+
// 白色背景
|
|
80
|
+
ctx.fillStyle = 'white';
|
|
81
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
82
|
+
|
|
83
|
+
// 绘制图片
|
|
84
|
+
ctx.drawImage(img, padding, padding, svgWidth * scale, svgHeight * scale);
|
|
85
|
+
|
|
86
|
+
// 转换为 Blob 并复制到剪贴板
|
|
87
|
+
canvas.toBlob(function(blob) {
|
|
88
|
+
if (!blob) {
|
|
89
|
+
showCopyNotification('复制失败,请重试', false);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 使用 Clipboard API 复制图片
|
|
94
|
+
if (navigator.clipboard && navigator.clipboard.write) {
|
|
95
|
+
const item = new ClipboardItem({
|
|
96
|
+
'image/png': blob
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
navigator.clipboard.write([item]).then(function() {
|
|
100
|
+
showCopyNotification('图片已复制到剪贴板', true);
|
|
101
|
+
}).catch(function(error) {
|
|
102
|
+
console.error('复制失败:', error);
|
|
103
|
+
// 降级方案:尝试使用 execCommand
|
|
104
|
+
fallbackCopyToClipboard(canvas);
|
|
105
|
+
});
|
|
106
|
+
} else {
|
|
107
|
+
// 降级方案:使用 execCommand
|
|
108
|
+
fallbackCopyToClipboard(canvas);
|
|
109
|
+
}
|
|
110
|
+
}, 'image/png', 1.0);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error('复制失败:', error);
|
|
113
|
+
showCopyNotification('复制失败,请重试', false);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
img.onerror = function() {
|
|
117
|
+
showCopyNotification('图片加载失败,请重试', false);
|
|
118
|
+
};
|
|
119
|
+
img.src = svgDataUrl;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 降级方案:使用 execCommand 复制
|
|
123
|
+
function fallbackCopyToClipboard(canvas) {
|
|
124
|
+
try {
|
|
125
|
+
// 将 Canvas 转换为 Data URL
|
|
126
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
127
|
+
|
|
128
|
+
// 创建一个临时图片元素
|
|
129
|
+
const img = document.createElement('img');
|
|
130
|
+
img.src = dataUrl;
|
|
131
|
+
img.style.position = 'fixed';
|
|
132
|
+
img.style.left = '-9999px';
|
|
133
|
+
document.body.appendChild(img);
|
|
134
|
+
|
|
135
|
+
// 选中图片
|
|
136
|
+
const range = document.createRange();
|
|
137
|
+
range.selectNode(img);
|
|
138
|
+
const selection = window.getSelection();
|
|
139
|
+
selection.removeAllRanges();
|
|
140
|
+
selection.addRange(range);
|
|
141
|
+
|
|
142
|
+
// 尝试复制
|
|
143
|
+
try {
|
|
144
|
+
const successful = document.execCommand('copy');
|
|
145
|
+
if (successful) {
|
|
146
|
+
showCopyNotification('图片已复制到剪贴板', true);
|
|
147
|
+
} else {
|
|
148
|
+
showCopyNotification('复制失败,请使用下载功能', false);
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
showCopyNotification('复制失败,请使用下载功能', false);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 清理
|
|
155
|
+
selection.removeAllRanges();
|
|
156
|
+
document.body.removeChild(img);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error('降级复制失败:', error);
|
|
159
|
+
showCopyNotification('复制失败,请使用下载功能', false);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 显示复制通知
|
|
164
|
+
function showCopyNotification(message, success) {
|
|
165
|
+
// 移除已存在的通知
|
|
166
|
+
const existingNotification = document.querySelector('.copy-notification');
|
|
167
|
+
if (existingNotification) {
|
|
168
|
+
existingNotification.remove();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const notification = document.createElement('div');
|
|
172
|
+
notification.className = 'copy-notification';
|
|
173
|
+
notification.textContent = message;
|
|
174
|
+
notification.style.cssText = `
|
|
175
|
+
position: fixed;
|
|
176
|
+
top: 20px;
|
|
177
|
+
right: 20px;
|
|
178
|
+
background: ${success ? '#4CAF50' : '#f44336'};
|
|
179
|
+
color: white;
|
|
180
|
+
padding: 12px 24px;
|
|
181
|
+
border-radius: 4px;
|
|
182
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
183
|
+
z-index: 20001;
|
|
184
|
+
font-size: 14px;
|
|
185
|
+
animation: slideIn 0.3s ease-out;
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
document.body.appendChild(notification);
|
|
189
|
+
|
|
190
|
+
setTimeout(() => {
|
|
191
|
+
notification.style.animation = 'slideOut 0.3s ease-out';
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
if (notification.parentNode) {
|
|
194
|
+
notification.parentNode.removeChild(notification);
|
|
195
|
+
}
|
|
196
|
+
}, 300);
|
|
197
|
+
}, 2000);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 下载功能
|
|
201
|
+
function showDownloadMenu(svgElement, defaultName) {
|
|
202
|
+
const menu = document.createElement('div');
|
|
203
|
+
menu.className = 'download-menu';
|
|
204
|
+
menu.id = 'download-menu';
|
|
205
|
+
menu.innerHTML = `
|
|
206
|
+
<div class="download-menu-content">
|
|
207
|
+
<h3>选择下载格式</h3>
|
|
208
|
+
<button onclick="downloadDiagram('png', '${defaultName}')">📷 PNG 图片</button>
|
|
209
|
+
<button onclick="downloadDiagram('svg', '${defaultName}')">📄 SVG 矢量图</button>
|
|
210
|
+
<button onclick="downloadDiagram('pdf', '${defaultName}')">📑 PDF 文档</button>
|
|
211
|
+
<button onclick="closeDownloadMenu()" class="cancel-btn">取消</button>
|
|
212
|
+
</div>
|
|
213
|
+
`;
|
|
214
|
+
|
|
215
|
+
// 保存 SVG 到全局变量供下载函数使用
|
|
216
|
+
window.currentDownloadSvg = svgElement;
|
|
217
|
+
window.currentDownloadName = defaultName;
|
|
218
|
+
|
|
219
|
+
document.body.appendChild(menu);
|
|
220
|
+
|
|
221
|
+
// 将下载菜单加入弹窗栈
|
|
222
|
+
if (typeof pushModal === 'function') {
|
|
223
|
+
pushModal('download-menu', closeDownloadMenu);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 点击外部关闭
|
|
227
|
+
menu.addEventListener('click', function(e) {
|
|
228
|
+
if (e.target === menu) {
|
|
229
|
+
closeDownloadMenu();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function closeDownloadMenu() {
|
|
235
|
+
const menu = document.querySelector('.download-menu');
|
|
236
|
+
if (menu) {
|
|
237
|
+
document.body.removeChild(menu);
|
|
238
|
+
}
|
|
239
|
+
window.currentDownloadSvg = null;
|
|
240
|
+
window.currentDownloadName = null;
|
|
241
|
+
|
|
242
|
+
// 从弹窗栈中移除
|
|
243
|
+
if (typeof removeModal === 'function') {
|
|
244
|
+
removeModal('download-menu');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function downloadDiagram(format, name) {
|
|
249
|
+
const svg = window.currentDownloadSvg;
|
|
250
|
+
if (!svg) return;
|
|
251
|
+
|
|
252
|
+
closeDownloadMenu();
|
|
253
|
+
|
|
254
|
+
switch (format) {
|
|
255
|
+
case 'png':
|
|
256
|
+
downloadAsPNG(svg, name);
|
|
257
|
+
break;
|
|
258
|
+
case 'svg':
|
|
259
|
+
downloadAsSVG(svg, name);
|
|
260
|
+
break;
|
|
261
|
+
case 'pdf':
|
|
262
|
+
downloadAsPDF(svg, name);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function downloadAsPNG(svg, name) {
|
|
268
|
+
// 获取 SVG 的真实尺寸(从 viewBox 或实际渲染尺寸)
|
|
269
|
+
const svgClone = svg.cloneNode(true);
|
|
270
|
+
let viewBox = svgClone.getAttribute('viewBox');
|
|
271
|
+
let svgWidth, svgHeight;
|
|
272
|
+
|
|
273
|
+
// 优先从 viewBox 获取尺寸(这是 SVG 的真实尺寸)
|
|
274
|
+
if (viewBox) {
|
|
275
|
+
const parts = viewBox.split(/\s+/);
|
|
276
|
+
if (parts.length >= 4) {
|
|
277
|
+
svgWidth = parseFloat(parts[2]);
|
|
278
|
+
svgHeight = parseFloat(parts[3]);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 如果没有 viewBox,尝试从属性获取
|
|
283
|
+
if (!svgWidth || !svgHeight) {
|
|
284
|
+
svgWidth = parseFloat(svgClone.getAttribute('width')) || 800;
|
|
285
|
+
svgHeight = parseFloat(svgClone.getAttribute('height')) || 600;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 确保 SVG 有明确的尺寸属性
|
|
289
|
+
svgClone.setAttribute('width', svgWidth);
|
|
290
|
+
svgClone.setAttribute('height', svgHeight);
|
|
291
|
+
if (!viewBox) {
|
|
292
|
+
svgClone.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 清理 SVG,移除可能导致污染的外部引用
|
|
296
|
+
cleanSVG(svgClone);
|
|
297
|
+
|
|
298
|
+
const svgData = new XMLSerializer().serializeToString(svgClone);
|
|
299
|
+
// 使用 Data URL 代替 Blob URL,避免 Canvas 污染
|
|
300
|
+
const svgDataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgData);
|
|
301
|
+
|
|
302
|
+
const img = new Image();
|
|
303
|
+
img.onload = function() {
|
|
304
|
+
try {
|
|
305
|
+
// 使用超高分辨率(4倍缩放,相当于 600 DPI,适合打印)
|
|
306
|
+
const scale = 4; // 4倍分辨率
|
|
307
|
+
const padding = 40; // 添加边距(像素)
|
|
308
|
+
const canvasWidth = Math.round(svgWidth * scale + padding * 2);
|
|
309
|
+
const canvasHeight = Math.round(svgHeight * scale + padding * 2);
|
|
310
|
+
|
|
311
|
+
const canvas = document.createElement('canvas');
|
|
312
|
+
canvas.width = canvasWidth;
|
|
313
|
+
canvas.height = canvasHeight;
|
|
314
|
+
const ctx = canvas.getContext('2d');
|
|
315
|
+
|
|
316
|
+
if (!ctx) {
|
|
317
|
+
throw new Error('无法创建 Canvas 上下文');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 禁用图像平滑(对于矢量图,保持锐利边缘更好)
|
|
321
|
+
ctx.imageSmoothingEnabled = false;
|
|
322
|
+
|
|
323
|
+
// 白色背景
|
|
324
|
+
ctx.fillStyle = 'white';
|
|
325
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
326
|
+
|
|
327
|
+
// 绘制图片(放大4倍)
|
|
328
|
+
ctx.drawImage(img, padding, padding, svgWidth * scale, svgHeight * scale);
|
|
329
|
+
|
|
330
|
+
// 下载(使用最高质量)
|
|
331
|
+
canvas.toBlob(function(blob) {
|
|
332
|
+
if (!blob) {
|
|
333
|
+
alert('图片生成失败,请重试');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const downloadUrl = URL.createObjectURL(blob);
|
|
337
|
+
const a = document.createElement('a');
|
|
338
|
+
a.href = downloadUrl;
|
|
339
|
+
a.download = `${name}.png`;
|
|
340
|
+
document.body.appendChild(a);
|
|
341
|
+
a.click();
|
|
342
|
+
document.body.removeChild(a);
|
|
343
|
+
URL.revokeObjectURL(downloadUrl);
|
|
344
|
+
}, 'image/png', 1.0); // 最高质量
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.error('PNG 生成失败:', error);
|
|
347
|
+
alert('图片生成失败,请重试');
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
img.onerror = function() {
|
|
351
|
+
alert('图片加载失败,请重试');
|
|
352
|
+
};
|
|
353
|
+
img.src = svgDataUrl;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function downloadAsSVG(svg, name) {
|
|
357
|
+
// 确保 SVG 有正确的命名空间和格式
|
|
358
|
+
const svgClone = svg.cloneNode(true);
|
|
359
|
+
if (!svgClone.getAttribute('xmlns')) {
|
|
360
|
+
svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
361
|
+
}
|
|
362
|
+
if (!svgClone.getAttribute('xmlns:xlink')) {
|
|
363
|
+
svgClone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const svgData = new XMLSerializer().serializeToString(svgClone);
|
|
367
|
+
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
|
368
|
+
const url = URL.createObjectURL(svgBlob);
|
|
369
|
+
|
|
370
|
+
const a = document.createElement('a');
|
|
371
|
+
a.href = url;
|
|
372
|
+
a.download = `${name}.svg`;
|
|
373
|
+
document.body.appendChild(a);
|
|
374
|
+
a.click();
|
|
375
|
+
document.body.removeChild(a);
|
|
376
|
+
URL.revokeObjectURL(url);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 清理 SVG,移除可能导致 Canvas 污染的外部引用
|
|
380
|
+
function cleanSVG(svg) {
|
|
381
|
+
// 移除所有外部资源引用
|
|
382
|
+
const images = svg.querySelectorAll('image');
|
|
383
|
+
images.forEach(img => {
|
|
384
|
+
const href = img.getAttribute('href') || img.getAttribute('xlink:href');
|
|
385
|
+
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
|
|
386
|
+
img.remove();
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// 移除外部样式表引用
|
|
391
|
+
const styleSheets = svg.querySelectorAll('style');
|
|
392
|
+
styleSheets.forEach(style => {
|
|
393
|
+
const content = style.textContent || '';
|
|
394
|
+
if (content.includes('@import') || content.includes('url(')) {
|
|
395
|
+
// 移除包含外部引用的样式
|
|
396
|
+
style.remove();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// 确保 SVG 有正确的命名空间
|
|
401
|
+
if (!svg.getAttribute('xmlns')) {
|
|
402
|
+
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function downloadAsPDF(svg, name) {
|
|
407
|
+
// 获取 SVG 的真实尺寸(从 viewBox 或实际渲染尺寸)
|
|
408
|
+
const svgClone = svg.cloneNode(true);
|
|
409
|
+
let viewBox = svgClone.getAttribute('viewBox');
|
|
410
|
+
let svgWidth, svgHeight;
|
|
411
|
+
|
|
412
|
+
// 优先从 viewBox 获取尺寸(这是 SVG 的真实尺寸)
|
|
413
|
+
if (viewBox) {
|
|
414
|
+
const parts = viewBox.split(/\s+/);
|
|
415
|
+
if (parts.length >= 4) {
|
|
416
|
+
svgWidth = parseFloat(parts[2]);
|
|
417
|
+
svgHeight = parseFloat(parts[3]);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 如果没有 viewBox,尝试从属性获取
|
|
422
|
+
if (!svgWidth || !svgHeight) {
|
|
423
|
+
svgWidth = parseFloat(svgClone.getAttribute('width')) || 800;
|
|
424
|
+
svgHeight = parseFloat(svgClone.getAttribute('height')) || 600;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 确保 SVG 有明确的尺寸属性
|
|
428
|
+
svgClone.setAttribute('width', svgWidth);
|
|
429
|
+
svgClone.setAttribute('height', svgHeight);
|
|
430
|
+
if (!viewBox) {
|
|
431
|
+
svgClone.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 清理 SVG,移除可能导致污染的外部引用
|
|
435
|
+
cleanSVG(svgClone);
|
|
436
|
+
|
|
437
|
+
const svgData = new XMLSerializer().serializeToString(svgClone);
|
|
438
|
+
// 使用 Data URL 代替 Blob URL,避免 Canvas 污染
|
|
439
|
+
const svgDataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgData);
|
|
440
|
+
|
|
441
|
+
const img = new Image();
|
|
442
|
+
img.onload = function() {
|
|
443
|
+
try {
|
|
444
|
+
// 使用超高分辨率(4倍缩放,相当于 600 DPI,适合打印)
|
|
445
|
+
const scale = 4; // 4倍分辨率
|
|
446
|
+
const padding = 40; // 添加边距(像素)
|
|
447
|
+
const canvasWidth = Math.round(svgWidth * scale + padding * 2);
|
|
448
|
+
const canvasHeight = Math.round(svgHeight * scale + padding * 2);
|
|
449
|
+
|
|
450
|
+
const canvas = document.createElement('canvas');
|
|
451
|
+
canvas.width = canvasWidth;
|
|
452
|
+
canvas.height = canvasHeight;
|
|
453
|
+
const ctx = canvas.getContext('2d');
|
|
454
|
+
|
|
455
|
+
if (!ctx) {
|
|
456
|
+
throw new Error('无法创建 Canvas 上下文');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 禁用图像平滑(对于矢量图,保持锐利边缘更好)
|
|
460
|
+
ctx.imageSmoothingEnabled = false;
|
|
461
|
+
|
|
462
|
+
// 白色背景
|
|
463
|
+
ctx.fillStyle = 'white';
|
|
464
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
465
|
+
|
|
466
|
+
// 绘制图片(放大4倍)
|
|
467
|
+
ctx.drawImage(img, padding, padding, svgWidth * scale, svgHeight * scale);
|
|
468
|
+
|
|
469
|
+
// 限制最大尺寸,避免 PDF 生成失败
|
|
470
|
+
const maxWidth = 8000; // 提高最大尺寸限制(4倍分辨率)
|
|
471
|
+
const maxHeight = 8000;
|
|
472
|
+
let finalCanvas = canvas;
|
|
473
|
+
if (canvas.width > maxWidth || canvas.height > maxHeight) {
|
|
474
|
+
const scaleDown = Math.min(maxWidth / canvas.width, maxHeight / canvas.height);
|
|
475
|
+
const scaledCanvas = document.createElement('canvas');
|
|
476
|
+
scaledCanvas.width = Math.floor(canvas.width * scaleDown);
|
|
477
|
+
scaledCanvas.height = Math.floor(canvas.height * scaleDown);
|
|
478
|
+
const scaledCtx = scaledCanvas.getContext('2d');
|
|
479
|
+
if (scaledCtx) {
|
|
480
|
+
scaledCtx.imageSmoothingEnabled = false;
|
|
481
|
+
scaledCtx.fillStyle = 'white';
|
|
482
|
+
scaledCtx.fillRect(0, 0, scaledCanvas.width, scaledCanvas.height);
|
|
483
|
+
scaledCtx.drawImage(canvas, 0, 0, scaledCanvas.width, scaledCanvas.height);
|
|
484
|
+
finalCanvas = scaledCanvas;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// 使用 toBlob 获取图片数据(最高质量)
|
|
489
|
+
finalCanvas.toBlob(function(blob) {
|
|
490
|
+
if (!blob) {
|
|
491
|
+
// 如果 toBlob 失败,尝试降级方案
|
|
492
|
+
generatePDFFallback(finalCanvas, name);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 将 Blob 转换为 Data URL 用于 PDF
|
|
497
|
+
const reader = new FileReader();
|
|
498
|
+
reader.onload = function() {
|
|
499
|
+
const imgData = reader.result;
|
|
500
|
+
|
|
501
|
+
// 动态加载 jsPDF(本地 /vendor/jspdf,支持 base 路径)
|
|
502
|
+
if (typeof window.jspdf === 'undefined' && typeof window.jsPDF === 'undefined') {
|
|
503
|
+
const script = document.createElement('script');
|
|
504
|
+
var base = document.querySelector('base');
|
|
505
|
+
script.src = (base && base.getAttribute('href'))
|
|
506
|
+
? (base.getAttribute('href').replace(/\/?$/, '/') + 'vendor/jspdf/jspdf.umd.min.js')
|
|
507
|
+
: '/vendor/jspdf/jspdf.umd.min.js';
|
|
508
|
+
script.onload = function() {
|
|
509
|
+
generatePDFFromBlob(imgData, finalCanvas, name);
|
|
510
|
+
};
|
|
511
|
+
script.onerror = function() {
|
|
512
|
+
alert('加载 PDF 库失败,请检查网络连接。建议下载 PNG 格式。');
|
|
513
|
+
};
|
|
514
|
+
document.head.appendChild(script);
|
|
515
|
+
} else {
|
|
516
|
+
generatePDFFromBlob(imgData, finalCanvas, name);
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
reader.onerror = function() {
|
|
520
|
+
generatePDFFallback(finalCanvas, name);
|
|
521
|
+
};
|
|
522
|
+
reader.readAsDataURL(blob);
|
|
523
|
+
}, 'image/png', 1.0); // 最高质量
|
|
524
|
+
} catch (error) {
|
|
525
|
+
console.error('Canvas 处理失败:', error);
|
|
526
|
+
alert('处理图片失败,请尝试下载 PNG 格式');
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
img.onerror = function() {
|
|
530
|
+
alert('图片加载失败,请重试');
|
|
531
|
+
};
|
|
532
|
+
img.src = svgDataUrl;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function generatePDFFromBlob(imgData, canvas, name) {
|
|
536
|
+
try {
|
|
537
|
+
// 尝试多种方式获取 jsPDF
|
|
538
|
+
let jsPDF;
|
|
539
|
+
if (typeof window.jspdf !== 'undefined') {
|
|
540
|
+
// UMD 格式
|
|
541
|
+
jsPDF = window.jspdf.jsPDF || (window.jspdf.default && window.jspdf.default.jsPDF) || window.jspdf;
|
|
542
|
+
} else if (typeof window.jsPDF !== 'undefined') {
|
|
543
|
+
// 直接暴露的格式
|
|
544
|
+
jsPDF = window.jsPDF;
|
|
545
|
+
} else {
|
|
546
|
+
throw new Error('jsPDF 库未正确加载');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 使用 mm 单位,更稳定
|
|
550
|
+
const pdfWidth = (canvas.width * 0.264583).toFixed(2); // px to mm
|
|
551
|
+
const pdfHeight = (canvas.height * 0.264583).toFixed(2);
|
|
552
|
+
|
|
553
|
+
const pdf = new jsPDF({
|
|
554
|
+
orientation: canvas.width > canvas.height ? 'landscape' : 'portrait',
|
|
555
|
+
unit: 'mm',
|
|
556
|
+
format: [parseFloat(pdfWidth), parseFloat(pdfHeight)]
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
if (!imgData || imgData === 'data:,') {
|
|
560
|
+
throw new Error('图片数据无效');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// 使用高质量压缩(SLOW 模式,但质量更好)
|
|
564
|
+
pdf.addImage(imgData, 'PNG', 0, 0, parseFloat(pdfWidth), parseFloat(pdfHeight), undefined, 'SLOW');
|
|
565
|
+
pdf.save(`${name}.pdf`);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
console.error('生成 PDF 失败:', error);
|
|
568
|
+
generatePDFFallback(canvas, name);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function generatePDFFallback(canvas, name) {
|
|
573
|
+
// 降级方案:直接下载 PNG(高质量)
|
|
574
|
+
try {
|
|
575
|
+
canvas.toBlob(function(blob) {
|
|
576
|
+
if (blob) {
|
|
577
|
+
const downloadUrl = URL.createObjectURL(blob);
|
|
578
|
+
const a = document.createElement('a');
|
|
579
|
+
a.href = downloadUrl;
|
|
580
|
+
a.download = `${name}.png`;
|
|
581
|
+
document.body.appendChild(a);
|
|
582
|
+
a.click();
|
|
583
|
+
document.body.removeChild(a);
|
|
584
|
+
URL.revokeObjectURL(downloadUrl);
|
|
585
|
+
alert('PDF 生成失败,已自动下载为 PNG 格式');
|
|
586
|
+
} else {
|
|
587
|
+
alert('PDF 生成失败,请尝试直接下载 PNG 格式');
|
|
588
|
+
}
|
|
589
|
+
}, 'image/png', 1.0); // 最高质量
|
|
590
|
+
} catch (error) {
|
|
591
|
+
console.error('PNG 下载也失败:', error);
|
|
592
|
+
alert('PDF 生成失败,请尝试直接下载 PNG 格式');
|
|
593
|
+
}
|
|
594
|
+
}
|