Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"dependencies": {
"async-mutex": "^0",
"color": "^5.0.2",
"modern-screenshot": "^4",
"p-debounce": "^5",
"p-memoize": "^8.0.0",
"quick-lru": "^7.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/

export const IMAGE_EXPORT_FORMATS = ['SVG', 'PNG'] as const;

export type ImageExportFormat = (typeof IMAGE_EXPORT_FORMATS)[number];

export interface IImageExportOptions {
fileName: string;
format: ImageExportFormat;
transparent: boolean;
}

/** Turns the requested options into the name the file is saved under, see {@link timestampedImageFileName}. */
export type ImageExportFileNameFormatter = (options: IImageExportOptions) => string;

export interface IImageExportResult {
fileName: string;
format: ImageExportFormat;
blob: Blob;
}

export type ImageExportReadyHandler = (result: IImageExportResult) => void | Promise<void>;

/** For consumers that cannot take a Blob, see {@link toDataUrlImageExport}. */
export interface IImageExportDataUrlResult {
fileName: string;
format: ImageExportFormat;
dataUrl: string;
}

export type ImageExportDataUrlReadyHandler = (result: IImageExportDataUrlResult) => void;
16 changes: 16 additions & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/src/blobToDataUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/

export function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
32 changes: 32 additions & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/src/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/

export function download(content: Blob | string, fileName = '', blank?: boolean): void {
Comment thread
sergeyteleshev marked this conversation as resolved.
const saveLink = document.createElement('a');

saveLink.tabIndex = -1;
saveLink.download = fileName;
if (blank) {
saveLink.target = '_blank';
saveLink.rel = 'noopener';
}
saveLink.style.display = 'none';
document.body.appendChild(saveLink);

try {
const url = typeof content === 'string' ? content : URL.createObjectURL(content);
saveLink.href = url;
saveLink.onclick = () => requestAnimationFrame(() => URL.revokeObjectURL(url));
} catch (e: any) {
console.error(e);
console.warn('Error while getting object URL.');
}

saveLink.click();
document.body.removeChild(saveLink);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { download } from './download.js';
import type { IImageExportResult } from './IImageExportOptions.js';

export function downloadImageExport(result: IImageExportResult): void {
download(result.blob, result.fileName);
}
91 changes: 91 additions & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/src/exportDomImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { domToBlob, domToForeignObjectSvg, type Options } from 'modern-screenshot';

import { DEFAULT_MAX_IMAGE_SIDE, fitToPixelBudget, type IPixelBudget } from './fitToPixelBudget.js';
import type { IImageExportOptions, ImageExportFileNameFormatter, ImageExportReadyHandler } from './IImageExportOptions.js';
import { isImageBroken } from './isImageBroken.js';
import { timestampedImageFileName } from './timestampedImageFileName.js';

export const IMAGE_EXPORT_BROKEN_ERROR = 'Something went wrong. Please try to select another file format';

const DEFAULT_BACKGROUND_COLOR = '#ffffff';
const DEFAULT_PNG_SCALE = 2;

export interface IDomImageExportConfig extends IPixelBudget {
/** In CSS pixels, defaults to the bounding box of `node`. Required for nodes with no box of their own. */
width?: number;
height?: number;
/** Used when `options.transparent` is false. */
backgroundColor?: string;
/** Applied to the clone before rendering, the live DOM is left alone. */
style?: Partial<CSSStyleDeclaration>;
/** Return false to keep a node (and its children) out of the snapshot. */
filter?: (node: Node) => boolean;
/** Defaults to {@link timestampedImageFileName}. */
formatFileName?: ImageExportFileNameFormatter;
}

/**
* PNG is scaled down to fit {@link fitToPixelBudget}, SVG is always 1:1
*/
export async function exportDomImage(
node: Element,
options: IImageExportOptions,
onReady: ImageExportReadyHandler,
config?: IDomImageExportConfig,
): Promise<void> {
const { width, height } = resolveSize(node, config);
const scale = options.format === 'PNG' ? fitToPixelBudget(width, height, { ...config, scale: config?.scale ?? DEFAULT_PNG_SCALE }) : 1;

const screenshotOptions: Options = {
// a size given here is forced onto the clone, so pass it on only when asked for
...(config?.width === undefined ? {} : { width: config.width }),
...(config?.height === undefined ? {} : { height: config.height }),
scale,
style: config?.style,
filter: config?.filter,
backgroundColor: options.transparent ? 'transparent' : (config?.backgroundColor ?? DEFAULT_BACKGROUND_COLOR),
quality: 1,
maximumCanvasSize: config?.maxSide ?? DEFAULT_MAX_IMAGE_SIDE,
};

const blob = options.format === 'PNG' ? await renderPng(node, screenshotOptions) : await renderSvg(node, screenshotOptions);
const uri = URL.createObjectURL(blob);
const broken = await isImageBroken(uri);
URL.revokeObjectURL(uri);

if (broken) {
throw new Error(IMAGE_EXPORT_BROKEN_ERROR);
}

const formatFileName = config?.formatFileName ?? timestampedImageFileName;

await onReady({ fileName: formatFileName(options), format: options.format, blob });
}

function renderPng(node: Element, screenshotOptions: Options): Promise<Blob> {
return domToBlob(node, { ...screenshotOptions, type: 'image/png' });
}

async function renderSvg(node: Element, screenshotOptions: Options): Promise<Blob> {
const svg = await domToForeignObjectSvg(node, screenshotOptions);
const svgData = new XMLSerializer().serializeToString(svg);

return new Blob(['<?xml version="1.0" standalone="no"?>\r\n', svgData], { type: 'image/svg+xml;charset=utf-8' });
}

function resolveSize(node: Element, config?: IDomImageExportConfig): { width: number; height: number } {
if (config?.width !== undefined && config.height !== undefined) {
return { width: config.width, height: config.height };
}

const rect = node.getBoundingClientRect();

return { width: config?.width ?? rect.width, height: config?.height ?? rect.height };
Comment thread
sergeyteleshev marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { describe, expect, test } from 'vitest';

import { DEFAULT_MAX_IMAGE_PIXELS, DEFAULT_MAX_IMAGE_SIDE, fitToPixelBudget } from './fitToPixelBudget.js';

describe('fitToPixelBudget', () => {
test('should keep the requested scale when the content fits', () => {
expect(fitToPixelBudget(1200, 800, { scale: 2 })).toBe(2);
});

test('should default to scale 1', () => {
expect(fitToPixelBudget(1200, 800)).toBe(1);
});

test('should scale down when the total area exceeds the pixel budget', () => {
const scale = fitToPixelBudget(12000, 9000, { scale: 2 });

expect(scale).toBeLessThan(1);
expect(12000 * scale * 9000 * scale).toBeLessThanOrEqual(DEFAULT_MAX_IMAGE_PIXELS + 1);
});

test('should scale down when a single side exceeds the max side', () => {
const scale = fitToPixelBudget(20000, 300, { scale: 1 });

expect(20000 * scale).toBeLessThanOrEqual(DEFAULT_MAX_IMAGE_SIDE + 1);
});

test('should respect custom budgets', () => {
expect(fitToPixelBudget(2000, 2000, { scale: 1, maxSide: 1000 })).toBe(0.5);
expect(fitToPixelBudget(2000, 2000, { scale: 1, maxPixels: 1_000_000 })).toBe(0.5);
});

test('should not collapse to zero on an enormous content', () => {
expect(fitToPixelBudget(10_000_000, 10_000_000, { scale: 1 })).toBeGreaterThan(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/

/** Canvases stop rendering reliably somewhere around this side length. */
export const DEFAULT_MAX_IMAGE_SIDE = 8192;

/** ~16 Mpx, about 64 MB of RGBA. */
export const DEFAULT_MAX_IMAGE_PIXELS = 16_777_216;

const MIN_SCALE = 0.05;

export interface IPixelBudget {
/** Device pixel ratio to aim for. Defaults to 1. */
scale?: number;
maxSide?: number;
maxPixels?: number;
}

export function fitToPixelBudget(width: number, height: number, budget?: IPixelBudget): number {
const { scale = 1, maxSide = DEFAULT_MAX_IMAGE_SIDE, maxPixels = DEFAULT_MAX_IMAGE_PIXELS } = budget ?? {};

if (width <= 0 || height <= 0) {
return scale;
}
Comment thread
sergeyteleshev marked this conversation as resolved.

const fitted = Math.min(scale, maxSide / width, maxSide / height, Math.sqrt(maxPixels / (width * height)));

return Math.max(fitted, Math.min(scale, MIN_SCALE));
}
10 changes: 10 additions & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,18 @@
* you may not use this file except in compliance with the License.
*/

export * from './blobToDataUrl.js';
export * from './ColorConvert.js';
export * from './debouncePromise.js';
export * from './download.js';
export * from './downloadImageExport.js';
export * from './exportDomImage.js';
export * from './fitToPixelBudget.js';
export * from './IImageExportOptions.js';
export * from './isImageBroken.js';
export * from './timestampedImageFileName.js';
export * from './toDataUrlImageExport.js';
export * from './withTimestamp.js';
export * from './eventContext.js';
export * from './eventStopPropagationFlag.js';
export * from './isDefined.js';
Expand Down
23 changes: 23 additions & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/src/isImageBroken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/

export function isImageBroken(imageUrl: string): Promise<boolean> {
Comment thread
sergeyteleshev marked this conversation as resolved.
return new Promise(resolve => {
const img = new Image();

img.onload = () => {
resolve(false);
};

img.onerror = () => {
resolve(true);
};

img.src = imageUrl;
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { describe, expect, test } from 'vitest';

import { timestampedImageFileName } from './timestampedImageFileName.js';

describe('timestampedImageFileName', () => {
test('should append the timestamp and the extension', () => {
const name = timestampedImageFileName({ fileName: 'Chart', format: 'PNG', transparent: false });

expect(name).toMatch(/^Chart \d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2}\.png$/);
});

test('should lowercase the extension per format', () => {
const name = timestampedImageFileName({ fileName: 'Plan', format: 'SVG', transparent: false });

expect(name.endsWith('.svg')).toBe(true);
});

test('should trim the requested name', () => {
const name = timestampedImageFileName({ fileName: ' Chart ', format: 'PNG', transparent: false });

expect(name.startsWith('Chart 20')).toBe(true);
});

test('should fall back when the requested name is blank', () => {
const name = timestampedImageFileName({ fileName: '', format: 'PNG', transparent: false });

expect(name.startsWith('image')).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import type { IImageExportOptions } from './IImageExportOptions.js';
import { withTimestamp } from './withTimestamp.js';

const FALLBACK_FILE_NAME = 'image';

/** `{ fileName: 'Chart', format: 'PNG' }` → `'Chart 2026-07-27 10-30-00.png'` */
export function timestampedImageFileName(options: IImageExportOptions): string {
return `${withTimestamp(options.fileName.trim() || FALLBACK_FILE_NAME)}.${options.format.toLowerCase()}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2026 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { blobToDataUrl } from './blobToDataUrl.js';
import type { ImageExportDataUrlReadyHandler, ImageExportReadyHandler } from './IImageExportOptions.js';

export function toDataUrlImageExport(handler: ImageExportDataUrlReadyHandler): ImageExportReadyHandler {
return async result => {
handler({ fileName: result.fileName, format: result.format, dataUrl: await blobToDataUrl(result.blob) });
};
}
Loading
Loading