下载图片
This commit is contained in:
@@ -230,6 +230,7 @@
|
|||||||
<script src="js/modules/mermaid.js"></script>
|
<script src="js/modules/mermaid.js"></script>
|
||||||
<script src="libs/js/mermaid.min.js"></script>
|
<script src="libs/js/mermaid.min.js"></script>
|
||||||
<script src="libs/js/svg-pan-zoom.min.js"></script>
|
<script src="libs/js/svg-pan-zoom.min.js"></script>
|
||||||
|
<script src="libs/js/pako.min.js"></script>
|
||||||
<script src="libs/js/echarts.min.js"></script>
|
<script src="libs/js/echarts.min.js"></script>
|
||||||
<script src="js/core/module-runtime.js"></script>
|
<script src="js/core/module-runtime.js"></script>
|
||||||
<script src="js/core/app-shell.js"></script>
|
<script src="js/core/app-shell.js"></script>
|
||||||
|
|||||||
@@ -1233,6 +1233,229 @@
|
|||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMermaidCode(artifact) {
|
||||||
|
if (!artifact) return '';
|
||||||
|
const code = artifact.code || artifact.content || '';
|
||||||
|
return typeof code === 'string' ? code : String(code || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
getMermaidImageWidth(svgElement, svgMarkup) {
|
||||||
|
if (!svgElement) return null;
|
||||||
|
let width = null;
|
||||||
|
try {
|
||||||
|
if (typeof svgElement.getBBox === 'function') {
|
||||||
|
width = svgElement.getBBox().width;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('计算 Mermaid 图宽度失败,回退到外框尺寸:', error);
|
||||||
|
}
|
||||||
|
if (!width || Number.isNaN(width) || width <= 0) {
|
||||||
|
const viewBox = svgElement.viewBox?.baseVal;
|
||||||
|
if (viewBox && viewBox.width > 0) {
|
||||||
|
width = viewBox.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!width || Number.isNaN(width) || width <= 0) {
|
||||||
|
const rect = svgElement.getBoundingClientRect?.();
|
||||||
|
if (rect && rect.width > 0) {
|
||||||
|
width = rect.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((!width || Number.isNaN(width) || width <= 0) && svgMarkup) {
|
||||||
|
const dims = this.parseSvgDimensions(svgMarkup);
|
||||||
|
if (dims.width && dims.width > 0) {
|
||||||
|
width = dims.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return width && width > 0 ? width : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
encodeMermaidState(code) {
|
||||||
|
if (!window.pako) {
|
||||||
|
throw new Error('缺少 Pako 压缩依赖,无法导出 Mermaid 图片');
|
||||||
|
}
|
||||||
|
if (typeof TextEncoder === 'undefined') {
|
||||||
|
throw new Error('当前浏览器不支持 TextEncoder,无法导出 Mermaid 图片');
|
||||||
|
}
|
||||||
|
this.textEncoder = this.textEncoder || new TextEncoder();
|
||||||
|
const payload = {
|
||||||
|
code,
|
||||||
|
mermaid: {
|
||||||
|
theme: 'default'
|
||||||
|
},
|
||||||
|
autoSync: true,
|
||||||
|
updateDiagram: true,
|
||||||
|
editorMode: 'code'
|
||||||
|
};
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
const compressed = window.pako.deflate(this.textEncoder.encode(json), {
|
||||||
|
level: 9
|
||||||
|
});
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < compressed.length; i += chunkSize) {
|
||||||
|
const slice = compressed.subarray(i, Math.min(i + chunkSize, compressed.length));
|
||||||
|
binary += String.fromCharCode.apply(null, slice);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
buildMermaidImageUrl(code, { type = 'png', width } = {}) {
|
||||||
|
const encoded = this.encodeMermaidState(code);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('type', type);
|
||||||
|
if (width && Number.isFinite(width) && width > 0) {
|
||||||
|
params.set('width', Math.round(width));
|
||||||
|
}
|
||||||
|
return `https://mermaid.ink/img/pako:${encoded}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMermaidExportScale() {
|
||||||
|
return Math.max(6, this.imageExportScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseSvgNumeric(value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
const raw = String(value).trim();
|
||||||
|
if (!raw || raw.toLowerCase() === 'auto') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const compact = raw.replace(/\s+/g, '');
|
||||||
|
const normalized = compact.replace(/!important$/i, '');
|
||||||
|
const lower = normalized.toLowerCase();
|
||||||
|
if (
|
||||||
|
lower.includes('calc(') ||
|
||||||
|
lower.includes('min(') ||
|
||||||
|
lower.includes('max(') ||
|
||||||
|
lower.includes('var(') ||
|
||||||
|
lower.endsWith('%')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const unitMatch = normalized.match(/[a-zA-Z]+$/);
|
||||||
|
if (unitMatch && unitMatch[0].toLowerCase() !== 'px') {
|
||||||
|
// 相对单位缺乏参照,留给 viewBox 等信息推导
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const numeric = parseFloat(normalized.replace(/[^0-9.\-eE]/g, ''));
|
||||||
|
return Number.isFinite(numeric) ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseSvgDimensions(svgMarkup) {
|
||||||
|
if (!svgMarkup) return {};
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(svgMarkup, 'image/svg+xml');
|
||||||
|
const svgElement = doc.querySelector('svg');
|
||||||
|
if (!svgElement) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const widthAttr = svgElement.getAttribute('width');
|
||||||
|
const heightAttr = svgElement.getAttribute('height');
|
||||||
|
const styleAttr = svgElement.getAttribute('style') || '';
|
||||||
|
let width = this.parseSvgNumeric(widthAttr);
|
||||||
|
let height = this.parseSvgNumeric(heightAttr);
|
||||||
|
|
||||||
|
if ((!width || !height) && styleAttr) {
|
||||||
|
const stylePairs = styleAttr
|
||||||
|
.split(';')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const pair of stylePairs) {
|
||||||
|
const [keyRaw, valRaw] = pair.split(':');
|
||||||
|
if (!keyRaw || !valRaw) continue;
|
||||||
|
const key = keyRaw.trim().toLowerCase();
|
||||||
|
const val = valRaw.trim();
|
||||||
|
if (!width && key === 'width') {
|
||||||
|
width = this.parseSvgNumeric(val);
|
||||||
|
} else if (!height && key === 'height') {
|
||||||
|
height = this.parseSvgNumeric(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!width || !height) && svgElement.hasAttribute('viewBox')) {
|
||||||
|
const viewBox = svgElement.getAttribute('viewBox');
|
||||||
|
const parts = viewBox
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((part) => this.parseSvgNumeric(part));
|
||||||
|
if (parts.length === 4) {
|
||||||
|
const [, , vbWidth, vbHeight] = parts;
|
||||||
|
if (!width || width <= 0) width = vbWidth;
|
||||||
|
if (!height || height <= 0) height = vbHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!width || width <= 0 || !height || height <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('解析 SVG 尺寸失败,将使用默认尺寸:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
computeExportSize(svgMarkup, image, artifactType) {
|
||||||
|
const defaultSize = 1024;
|
||||||
|
const dims = this.parseSvgDimensions(svgMarkup);
|
||||||
|
const naturalWidth = (image && (image.naturalWidth || image.width)) || null;
|
||||||
|
const naturalHeight = (image && (image.naturalHeight || image.height)) || null;
|
||||||
|
const baseWidth = dims.width || naturalWidth || defaultSize;
|
||||||
|
const baseHeight = dims.height || naturalHeight || defaultSize;
|
||||||
|
const safeHeight = baseHeight > 0 ? baseHeight : baseWidth;
|
||||||
|
const exportScale =
|
||||||
|
artifactType === 'mermaid' ? this.getMermaidExportScale() : this.imageExportScale;
|
||||||
|
const targetWidth = Math.max(1, Math.round(baseWidth * exportScale));
|
||||||
|
const targetHeight = Math.max(1, Math.round(safeHeight * exportScale));
|
||||||
|
return {
|
||||||
|
baseWidth,
|
||||||
|
baseHeight: safeHeight,
|
||||||
|
exportScale,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMermaidImageBlob(artifact, options = {}) {
|
||||||
|
const code = this.getMermaidCode(artifact);
|
||||||
|
if (!code.trim()) {
|
||||||
|
throw new Error('缺少 Mermaid 代码,无法导出图像');
|
||||||
|
}
|
||||||
|
const manifest = this.getActiveManifest();
|
||||||
|
const svgContent = await this.getMermaidSvgContent(artifact, manifest);
|
||||||
|
const svgElement = this.el.viewer?.querySelector('svg');
|
||||||
|
const baseWidth = this.getMermaidImageWidth(svgElement, svgContent);
|
||||||
|
const exportMetrics = this.computeExportSize(svgContent, null, artifact.type);
|
||||||
|
const exportScale =
|
||||||
|
exportMetrics && Number.isFinite(exportMetrics.exportScale)
|
||||||
|
? exportMetrics.exportScale
|
||||||
|
: this.getMermaidExportScale();
|
||||||
|
const scaledBaseWidth = baseWidth ? Math.round(baseWidth * exportScale) : 0;
|
||||||
|
const computedTargetWidth =
|
||||||
|
exportMetrics && exportMetrics.targetWidth ? exportMetrics.targetWidth : 0;
|
||||||
|
const widthCandidate = Math.max(computedTargetWidth, scaledBaseWidth) || null;
|
||||||
|
// 限制远程渲染服务的宽度参数,避免请求过大导致失败
|
||||||
|
const MAX_MERMAID_WIDTH = 8192;
|
||||||
|
const width =
|
||||||
|
widthCandidate && Number.isFinite(widthCandidate)
|
||||||
|
? Math.min(widthCandidate, MAX_MERMAID_WIDTH)
|
||||||
|
: null;
|
||||||
|
width = width * exportScale;
|
||||||
|
|
||||||
|
const url = this.buildMermaidImageUrl(code, {
|
||||||
|
type: options.type || 'png',
|
||||||
|
width
|
||||||
|
});
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('远程生成 Mermaid 图片失败');
|
||||||
|
}
|
||||||
|
return await response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
async getSvgMarkupForArtifact(artifact, manifest) {
|
async getSvgMarkupForArtifact(artifact, manifest) {
|
||||||
if (!artifact) return null;
|
if (!artifact) return null;
|
||||||
if (artifact.type === 'svg') {
|
if (artifact.type === 'svg') {
|
||||||
@@ -1468,6 +1691,24 @@
|
|||||||
const artifact = state.artifacts[id];
|
const artifact = state.artifacts[id];
|
||||||
if (!artifact) return;
|
if (!artifact) return;
|
||||||
|
|
||||||
|
if (artifact.type === 'mermaid') {
|
||||||
|
if (!this.copyClipboardSupported) {
|
||||||
|
alert('当前环境不支持复制图片到剪贴板');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const blob = await this.fetchMermaidImageBlob(artifact, {
|
||||||
|
type: 'png'
|
||||||
|
});
|
||||||
|
const clipboardItem = new ClipboardItem({ 'image/png': blob });
|
||||||
|
await navigator.clipboard.write([clipboardItem]);
|
||||||
|
alert('图像已复制到剪贴板');
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('远程复制 Mermaid 图片失败,改用本地渲染回退:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const svgContent = await this.getSvgMarkupForArtifact(artifact, manifest);
|
const svgContent = await this.getSvgMarkupForArtifact(artifact, manifest);
|
||||||
if (!svgContent) {
|
if (!svgContent) {
|
||||||
alert('暂不支持复制此类型图表到剪贴板');
|
alert('暂不支持复制此类型图表到剪贴板');
|
||||||
@@ -1484,11 +1725,16 @@
|
|||||||
|
|
||||||
image.onload = async () => {
|
image.onload = async () => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = image.width * this.imageExportScale;
|
const { targetWidth, targetHeight } = this.computeExportSize(
|
||||||
canvas.height = image.height * this.imageExportScale;
|
svgContent,
|
||||||
|
image,
|
||||||
|
artifact.type
|
||||||
|
);
|
||||||
|
canvas.width = targetWidth;
|
||||||
|
canvas.height = targetHeight;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.setTransform(this.imageExportScale, 0, 0, this.imageExportScale, 0, 0);
|
ctx.clearRect(0, 0, targetWidth, targetHeight);
|
||||||
ctx.drawImage(image, 0, 0);
|
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||||
|
|
||||||
const finalize = () => URL.revokeObjectURL(svgUrl);
|
const finalize = () => URL.revokeObjectURL(svgUrl);
|
||||||
|
|
||||||
@@ -1524,6 +1770,27 @@
|
|||||||
const artifact = state.artifacts[id];
|
const artifact = state.artifacts[id];
|
||||||
if (!artifact) return;
|
if (!artifact) return;
|
||||||
|
|
||||||
|
if (artifact.type === 'mermaid') {
|
||||||
|
try {
|
||||||
|
const blob = await this.fetchMermaidImageBlob(artifact, {
|
||||||
|
type: 'png'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
console.log('url :>> ', url);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${manifest.id}-${Utils.formatDateTime()
|
||||||
|
.replace(/\W/g, '')}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('远程导出 Mermaid 图片失败,改用本地渲染回退:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const svgContent = await this.getSvgMarkupForArtifact(artifact, manifest);
|
const svgContent = await this.getSvgMarkupForArtifact(artifact, manifest);
|
||||||
if (svgContent) {
|
if (svgContent) {
|
||||||
const svgBlob = new Blob([svgContent], {
|
const svgBlob = new Blob([svgContent], {
|
||||||
@@ -1535,15 +1802,20 @@
|
|||||||
image.src = svgUrl;
|
image.src = svgUrl;
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = image.width * this.imageExportScale;
|
const { targetWidth, targetHeight } = this.computeExportSize(
|
||||||
canvas.height = image.height * this.imageExportScale;
|
svgContent,
|
||||||
|
image,
|
||||||
|
artifact.type
|
||||||
|
);
|
||||||
|
canvas.width = targetWidth;
|
||||||
|
canvas.height = targetHeight;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.setTransform(this.imageExportScale, 0, 0, this.imageExportScale, 0, 0);
|
ctx.clearRect(0, 0, targetWidth, targetHeight);
|
||||||
ctx.drawImage(image, 0, 0);
|
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||||
const pngUrl = canvas.toDataURL('image/png');
|
const pngUrl = canvas.toDataURL('image/png');
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = pngUrl;
|
link.href = pngUrl;
|
||||||
link.download = `${manifest.id}-${Utils.formatDateTime().replace(/\\W/g, '')}.png`;
|
link.download = `${manifest.id}-${Utils.formatDateTime().replace(/\W/g, '')}.png`;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
@@ -1586,6 +1858,8 @@
|
|||||||
let content = '';
|
let content = '';
|
||||||
if (artifact.type === 'svg') {
|
if (artifact.type === 'svg') {
|
||||||
content = artifact.content;
|
content = artifact.content;
|
||||||
|
} else if (artifact.type === 'mermaid') {
|
||||||
|
content = artifact.code || artifact.content || '';
|
||||||
} else if (artifact.type === 'echarts-option') {
|
} else if (artifact.type === 'echarts-option') {
|
||||||
content = artifact.optionText || JSON.stringify(artifact.option, null, 2);
|
content = artifact.optionText || JSON.stringify(artifact.option, null, 2);
|
||||||
}
|
}
|
||||||
|
|||||||
2
libs/js/pako.min.js
vendored
Normal file
2
libs/js/pako.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user