fix(export): clamp diagram png scale to canvas limits

Closes #7248
This commit is contained in:
t8y2 committed 2026-08-26 18:43:09 +08:00
1 parent 0588a67449
commit 138e48d347
2 files changed
+100 -25

No files matched your search

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { MAX_PNG_CANVAS_SIDE, resolvePngExportScale } from "@/lib/export/diagramFormats";
describe("resolvePngExportScale", () => {
it("keeps the requested scale when the canvas fits", () => {
expect(resolvePngExportScale(3000, 2000, 2)).toBe(2);
expect(resolvePngExportScale(8000, 8000, 1)).toBe(1);
});
it("clamps the scale when a side would exceed the canvas limit", () => {
// 9000x7000 at 2x → 18000 wide, beyond the 16384 side limit.
const scale = resolvePngExportScale(9000, 7000, 2);
expect(scale).toBeLessThan(2);
expect(9000 * scale).toBeLessThanOrEqual(MAX_PNG_CANVAS_SIDE);
expect(7000 * scale).toBeLessThanOrEqual(MAX_PNG_CANVAS_SIDE);
});
it("clamps the scale when the total pixel area would exceed the limit", () => {
// 12000x12000 at 1x fits the side limit but exceeds the area budget at 2x.
const scale = resolvePngExportScale(12000, 12000, 2);
expect(scale).toBeLessThan(2);
expect(12000 * scale * 12000 * scale).toBeLessThanOrEqual(MAX_PNG_CANVAS_SIDE * MAX_PNG_CANVAS_SIDE);
});
it("allows sub-1 scales for oversized diagrams instead of failing", () => {
// A 40000px-wide diagram cannot fit at 1x; export at a reduced scale.
const scale = resolvePngExportScale(40000, 30000, 2);
expect(scale).toBeLessThan(1);
expect(40000 * scale).toBeLessThanOrEqual(MAX_PNG_CANVAS_SIDE);
});
it("returns the requested scale unchanged for invalid dimensions", () => {
expect(resolvePngExportScale(0, 100, 2)).toBe(2);
expect(resolvePngExportScale(NaN, 100, 2)).toBe(2);
expect(resolvePngExportScale(100, 100, 0)).toBe(0);
expect(resolvePngExportScale(100, 100, Number.NaN)).toBe(Number.NaN);
});
});
+62 -25
View File
@@ -136,39 +136,76 @@ export function buildDiagramMermaid(tables: DiagramTable[], relationships: Diagr
return lines.join("\n");
}
// Browsers cap canvas backing stores: Chromium/WebView2 and WebKit both
// reject surfaces beyond 16384 px per side (and beyond ~16384² total pixels),
// in which case rasterization silently fails and `toBlob` yields nothing.
// Large diagrams at the default 2x scale exceed these limits, so the export
// scale is clamped to the largest value that still fits (issue #7248).
export const MAX_PNG_CANVAS_SIDE = 16384;
export const MAX_PNG_CANVAS_AREA = MAX_PNG_CANVAS_SIDE * MAX_PNG_CANVAS_SIDE;
// Never downscale below this when retrying failed rasterizations: a heavily
// reduced PNG is still more useful than a failed export, but beyond this the
// diagram becomes unreadable.
export const MIN_PNG_EXPORT_SCALE = 0.05;
export function resolvePngExportScale(width: number, height: number, requestedScale: number): number {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0 || !Number.isFinite(requestedScale) || requestedScale <= 0) {
return requestedScale;
}
const sideScale = Math.min(MAX_PNG_CANVAS_SIDE / width, MAX_PNG_CANVAS_SIDE / height);
const areaScale = Math.sqrt(MAX_PNG_CANVAS_AREA / (width * height));
// Allow sub-1 scales for oversized diagrams: a lower-resolution PNG beats
// a failed export.
return Math.min(requestedScale, sideScale, areaScale);
}
export function svgToPngBlob(svg: string, scale = 2): Promise<Blob> {
return new Promise((resolve, reject) => {
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
try {
const width = Math.max(1, Math.ceil(img.naturalWidth * scale));
const height = Math.max(1, Math.ceil(img.naturalHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error("Canvas unsupported"));
return;
}
ctx.fillStyle = "#fafafa";
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);
canvas.toBlob((png) => {
if (!png) {
reject(new Error("PNG encode failed"));
// Device-dependent caps (GPU texture limits, available memory) can be
// far below the documented canvas limits, and a failed rasterization
// only surfaces as a null blob. Try the clamped scale first, then keep
// halving it instead of failing the export outright (issue #7248).
const initialScale = Math.max(resolvePngExportScale(img.naturalWidth, img.naturalHeight, scale), MIN_PNG_EXPORT_SCALE);
const rasterize = (attemptScale: number): void => {
try {
const width = Math.max(1, Math.ceil(img.naturalWidth * attemptScale));
const height = Math.max(1, Math.ceil(img.naturalHeight * attemptScale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error("Canvas unsupported"));
return;
}
resolve(png);
}, "image/png");
} catch (err) {
URL.revokeObjectURL(url);
reject(err);
}
ctx.fillStyle = "#fafafa";
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((png) => {
if (png) {
URL.revokeObjectURL(url);
resolve(png);
return;
}
const nextScale = attemptScale / 2;
if (nextScale < MIN_PNG_EXPORT_SCALE) {
URL.revokeObjectURL(url);
reject(new Error(`PNG encode failed for ${width}x${height} canvas`));
return;
}
rasterize(nextScale);
}, "image/png");
} catch (err) {
URL.revokeObjectURL(url);
reject(err);
}
};
rasterize(initialScale);
};
img.onerror = () => {
URL.revokeObjectURL(url);