Skip to content

Commit

Permalink
#4539 - Export macromolecules canvas as svg image
Browse files Browse the repository at this point in the history
  • Loading branch information
ilya-asiyuk-epam committed May 15, 2024
1 parent 560f410 commit 64dee36
Show file tree
Hide file tree
Showing 27 changed files with 293 additions and 25 deletions.
22 changes: 22 additions & 0 deletions ketcher-autotests/tests/Macromolecule-editor/API/set-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test } from '@playwright/test';
import {
waitForPageInit,
turnOnMacromoleculesEditor,
openFileAndAddToCanvasMacro,
setMode,
takePageScreenshot,
} from '@utils';

test.describe('setMode', () => {
test.beforeEach(async ({ page }) => {
await waitForPageInit(page);
await turnOnMacromoleculesEditor(page);
});

test('Should set "sequence" mode', async ({ page }) => {
await openFileAndAddToCanvasMacro('KET/rna-and-peptide.ket', page);
await takePageScreenshot(page);
await setMode(page, 'sequence');
await takePageScreenshot(page);
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions ketcher-autotests/tests/Macromolecule-editor/API/set-zoom.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { test } from '@playwright/test';
import {
waitForPageInit,
takeEditorScreenshot,
turnOnMacromoleculesEditor,
openFileAndAddToCanvasMacro,
setZoom,
} from '@utils';

test.describe('setZoom', () => {
test.beforeEach(async ({ page }) => {
await waitForPageInit(page);
await turnOnMacromoleculesEditor(page);
});

test('Should zoom drawn structures', async ({ page }) => {
await openFileAndAddToCanvasMacro('KET/rna-and-peptide.ket', page);
const zoomValue = 2;
await takeEditorScreenshot(page);
await setZoom(page, zoomValue);
await takeEditorScreenshot(page);
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { test } from '@playwright/test';
import {
TopPanelButton,
openFileAndAddToCanvasMacro,
selectTopPanelButton,
takeEditorScreenshot,
waitForPageInit,
chooseFileFormat,
selectRectangleArea,
} from '@utils';
import { turnOnMacromoleculesEditor } from '@utils/macromolecules';

test.describe('Saving in .svg files', () => {
test.beforeEach(async ({ page }) => {
await waitForPageInit(page);
await turnOnMacromoleculesEditor(page);
});

test('Should convert .ket file to .svg format in save modal', async ({
page,
}) => {
await openFileAndAddToCanvasMacro('KET/rna-and-peptide.ket', page);
// Coordinates for rectangle selection
const startX = 100;
const startY = 100;
const endX = 600;
const endY = 450;
await selectRectangleArea(page, startX, startY, endX, endY);
await takeEditorScreenshot(page);
await selectTopPanelButton(TopPanelButton.Save, page);
await chooseFileFormat(page, 'SVG Document');
// Should clean up dynamic svg elements (selections...) in drawn structure
await takeEditorScreenshot(page);
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion ketcher-autotests/tests/utils/formats/formats.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Page } from '@playwright/test';
import { MolfileFormat } from 'ketcher-core';
import { MolfileFormat, SupportedModes } from 'ketcher-core';

export async function getKet(page: Page): Promise<string> {
return await page.evaluate(() => window.ketcher.getKet());
Expand All @@ -17,6 +17,14 @@ export async function getSequence(page: Page): Promise<string> {
return await page.evaluate(() => window.ketcher.getSequence());
}

export function setZoom(page: Page, value: number) {
return page.evaluate((value) => window.ketcher.setZoom(value), value);
}

export function setMode(page: Page, mode: SupportedModes) {
return page.evaluate((mode) => window.ketcher.setMode(mode), mode);
}

export async function getCml(page: Page): Promise<string> {
return await page.evaluate(() => window.ketcher.getCml());
}
Expand Down
8 changes: 7 additions & 1 deletion ketcher-autotests/tests/utils/macromolecules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ export async function scrollDown(page: Page, scrollDelta: number) {

export async function chooseFileFormat(
page: Page,
fileFomat: 'Ket' | 'MDL Molfile V3000' | 'FASTA' | 'Sequence' | 'IDT',
fileFomat:
| 'Ket'
| 'MDL Molfile V3000'
| 'FASTA'
| 'Sequence'
| 'IDT'
| 'SVG Document',
) {
await page.getByTestId('dropdown-select').click();
await waitForSpinnerFinishedWork(page, async () => {
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/ketcher-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"ajv": "^8.10.0",
"assert": "^2.0.0",
"d3": "^7.8.5",
"file-saver": "^2.0.5",
"lodash": "^4.17.21",
"raphael": "^2.3.0",
"react-device-detect": "^2.2.2",
Expand Down
7 changes: 6 additions & 1 deletion packages/ketcher-core/src/application/editor/tools/Zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,8 @@ class ZoomTool implements BaseTool {
.attr('cursor', 'pointer')
.attr('stroke', this.COLOR)
.attr('fill', this.COLOR)
.attr('data-testid', scrollBar.name + '-bar');
.attr('data-testid', scrollBar.name + '-bar')
.attr('class', 'dynamic-element');
}

calculateDynamicAttr(scrollBar: ScrollBar) {
Expand Down Expand Up @@ -322,6 +323,10 @@ class ZoomTool implements BaseTool {
this.zoom?.scaleTo(this.canvasWrapper, this.zoomLevel - zoomStep);
}

public zoomTo(zoomLevel: number) {
this.zoom?.scaleTo(this.canvasWrapper, zoomLevel);
}

public resetZoom() {
this.zoom?.transform(this.canvasWrapper, new ZoomTransform(1, 0, 0));
}
Expand Down
44 changes: 44 additions & 0 deletions packages/ketcher-core/src/application/ketcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
***************************************************************************/

import { saveAs } from 'file-saver';
import { FormatterFactory, SupportedFormat } from './formatters';
import { GenerateImageOptions, StructService } from 'domain/services';

Expand All @@ -28,6 +29,7 @@ import {
LogLevel,
runAsyncAction,
SettingsManager,
getSvgFromDrawnStructures,
} from 'utilities';
import {
deleteAllEntitiesOnCanvas,
Expand All @@ -36,6 +38,13 @@ import {
prepareStructToRender,
} from './utils';
import { EditorSelection } from './editor/editor.types';
import {
BlobTypes,
ExportImageParams,
ModeTypes,
SupportedImageFormats,
SupportedModes,
} from 'application/ketcher.types';

const allowedApiSettings = {
'general.dearomatize-on-load': 'dearomatize-on-load',
Expand Down Expand Up @@ -389,6 +398,41 @@ export class Ketcher {
}, this.eventBus);
}

/**
* @param {number} value - in a range [ZoomTool.instance.MINZOOMSCALE, ZoomTool.instance.MAXZOOMSCALE]
*/
setZoom(value: number) {
const editor = CoreEditor.provideEditorInstance();
if (editor && value) editor.zoomTool.zoomTo(value);
}

setMode(mode: SupportedModes) {
const editor = CoreEditor.provideEditorInstance();
if (editor && mode) editor.events.selectMode.dispatch(ModeTypes[mode]);
}

exportImage(format: SupportedImageFormats, params?: ExportImageParams) {
const editor = CoreEditor.provideEditorInstance();
const fileName = 'ketcher';
let blobPart;

if (format === 'svg' && editor?.canvas) {
blobPart = getSvgFromDrawnStructures(
editor.canvas,
'file',
params?.margin,
);
}
if (!blobPart) {
throw new Error('Cannot export image');
}

const blob = new Blob([blobPart], {
type: BlobTypes[format],
});
saveAs(blob, `${fileName}.${format}`);
}

recognize(image: Blob, version?: string): Promise<Struct> {
if (window.isPolymerEditorTurnedOn) {
throw new Error('Recognize is not available in macro mode');
Expand Down
16 changes: 16 additions & 0 deletions packages/ketcher-core/src/application/ketcher.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Types for 'setMode'
export enum ModeTypes {
flex = 'flex-layout-mode',
snake = 'snake-layout-mode',
sequence = 'sequence-layout-mode',
}
export type SupportedModes = keyof typeof ModeTypes;

// Types for 'exportImage'
export enum BlobTypes {
svg = 'image/svg+xml',
}
export type SupportedImageFormats = keyof typeof BlobTypes;
export type ExportImageParams = {
margin?: number;
};
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,8 @@ export abstract class BaseMonomerRenderer extends BaseRenderer {
return hoverAreaElement
.append('use')
.attr('href', this.monomerHoveredElementId)
.attr('pointer-events', 'none');
.attr('pointer-events', 'none')
.attr('class', 'dynamic-element');
}

public removeHover() {
Expand Down Expand Up @@ -360,15 +361,17 @@ export abstract class BaseMonomerRenderer extends BaseRenderer {
?.append('use')
.attr('href', this.monomerSelectedElementId)
.attr('stroke', '#57FF8F')
.attr('pointer-events', 'none');
.attr('pointer-events', 'none')
.attr('class', 'dynamic-element');

this.selectionCircle = this.canvas
?.insert('circle', ':first-child')
.attr('r', '21px')
.attr('opacity', '0.7')
.attr('cx', this.center.x)
.attr('cy', this.center.y)
.attr('fill', '#57FF8F');
.attr('fill', '#57FF8F')
.attr('class', 'dynamic-element');
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,8 @@ export class PolymerBondRenderer extends BaseRenderer {
.attr('y1', this.scaledPosition.startPosition.y)
.attr('x2', this.scaledPosition.endPosition.x)
.attr('y2', this.scaledPosition.endPosition.y)
.attr('stroke-width', '5');
.attr('stroke-width', '5')
.attr('class', 'dynamic-element');
}
} else {
this.selectionElement?.remove();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,14 +267,16 @@ export abstract class BaseSequenceItemRenderer extends BaseSequenceRenderer {
.attr('y', -21)
.attr('width', 18)
.attr('height', 30)
.attr('rx', 3);
.attr('rx', 3)
.attr('class', 'dynamic-element');
} else {
this.selectionRectangle
?.attr('fill', '#57FF8F')
.attr('x', -4)
.attr('y', -16)
.attr('width', 20)
.attr('height', 20);
.attr('height', 20)
.attr('class', 'dynamic-element');
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/ketcher-core/src/domain/AttachmentPoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ export class AttachmentPoint {
this.attachmentPoint = this.rootElement
.insert('g', ':first-child')
.data([this])
.style('pointer-events', 'none');
.style('pointer-events', 'none')
.attr('class', 'dynamic-element');

const attachmentPointElement = this.attachmentPoint.append('g');

Expand Down
1 change: 1 addition & 0 deletions packages/ketcher-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export * from 'application/formatters';
export * from 'application/render';
export * from 'application/editor';
export * from 'application/ketcher';
export * from 'application/ketcher.types';
export * from 'application/ketcherBuilder';
export * from 'application/utils';

Expand Down
57 changes: 57 additions & 0 deletions packages/ketcher-core/src/utilities/getSvgFromDrawnStructures.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { KetcherLogger } from 'utilities';

const SVG_NAMESPACE_URI = 'http://www.w3.org/2000/svg';
const ADDITIONAL_TOP_MARGIN = 54;
const ADDITIONAL_LEFT_MARGIN = 50;
const DEFAULT_MARGIN = 10;

export const getSvgFromDrawnStructures = (
canvas: SVGSVGElement,
type: 'preview' | 'file',
margin = DEFAULT_MARGIN,
) => {
// Copy and clean up svg structures before previewing or saving
let svgInnerHTML = canvas?.innerHTML || '';
const wrapper = document.createElementNS(SVG_NAMESPACE_URI, 'svg');
wrapper.innerHTML = svgInnerHTML;
// remove #rectangle-selection-area
wrapper.querySelector('#rectangle-selection-area')?.remove();
// remove dynamic elements (scrolls, highlighters, attachment points...)
wrapper.querySelectorAll('.dynamic-element')?.forEach((el) => el.remove());
// set default cursor, mostly for sequence mode
wrapper
.querySelectorAll('text')
?.forEach((el) => el.setAttribute('cursor', 'default'));
wrapper.querySelectorAll('rect')?.forEach((el) => {
if (el.getAttribute('cursor') === 'text') el.removeAttribute('cursor');
});
// remove opacity of structures, mostly for sequence "edit in RNA builder" mode
wrapper.querySelectorAll('g')?.forEach((el) => {
if (el.hasAttribute('opacity')) el.removeAttribute('opacity');
});
svgInnerHTML = wrapper.innerHTML;
// remove "cursor: pointer" style
svgInnerHTML = svgInnerHTML?.replaceAll('cursor: pointer;', '');

const drawStructureClientRect = canvas
?.getElementsByClassName('drawn-structures')[0]
.getBoundingClientRect();

if (!drawStructureClientRect || !svgInnerHTML) {
const errorMessage = 'Cannot get drawn structures!';
KetcherLogger.error(errorMessage);
return;
}

const viewBoxX = drawStructureClientRect.x - ADDITIONAL_LEFT_MARGIN - margin;
const viewBoxY = drawStructureClientRect.y - ADDITIONAL_TOP_MARGIN - margin;
const viewBoxWidth = drawStructureClientRect.width + margin * 2;
const viewBoxHeight = drawStructureClientRect.height + margin * 2;
const viewBox = `${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}`;

if (type === 'preview')
return `<svg width='100%' height='100%' style='position: absolute' viewBox='${viewBox}'>${svgInnerHTML}</svg>`;
else if (type === 'file')
return `<svg width='${viewBoxWidth}' height='${viewBoxHeight}' viewBox='${viewBox}' xmlns='${SVG_NAMESPACE_URI}'>${svgInnerHTML}</svg>`;
else return `<svg xmlns='${SVG_NAMESPACE_URI}' />`;
};
1 change: 1 addition & 0 deletions packages/ketcher-core/src/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export * from './SettingsManager';
export * from './keynorm';
export * from './shortcutsUtil';
export * from './clipboardUtils';
export * from './getSvgFromDrawnStructures';
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,9 @@ export const Loader = styled.div`
align-items: center;
background: #fff;
`;

export const SvgPreview = styled('div')(({ theme }) => ({
height: '100%',
position: 'relative',
border: `1px solid ${theme.ketcher.color.input.border.regular}`,
}));
Loading

0 comments on commit 64dee36

Please sign in to comment.