From 138e48d347502b7789ddfe03c2429c21ec7d3e3c Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 26 Aug 2026 18:43:09 +0800 Subject: [PATCH] fix(export): clamp diagram png scale to canvas limits Closes #7248 --- .../export/__tests__/diagramFormats.spec.ts | 38 ++++++++ apps/desktop/src/lib/export/diagramFormats.ts | 87 +++++++++++++------ 2 files changed, 100 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/lib/export/__tests__/diagramFormats.spec.ts diff --git a/apps/desktop/src/lib/export/__tests__/diagramFormats.spec.ts b/apps/desktop/src/lib/export/__tests__/diagramFormats.spec.ts new file mode 100644 index 0000000000..be9214a4ec --- /dev/null +++ b/apps/desktop/src/lib/export/__tests__/diagramFormats.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/export/diagramFormats.ts b/apps/desktop/src/lib/export/diagramFormats.ts index f71edd2a80..be08418ae5 100644 --- a/apps/desktop/src/lib/export/diagramFormats.ts +++ b/apps/desktop/src/lib/export/diagramFormats.ts @@ -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 { 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);