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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user