Skip to content

Commit

Permalink
feat: improve image export scaling for PNG and SVG
Browse files Browse the repository at this point in the history
  • Loading branch information
hamed-musallam committed Feb 27, 2025
1 parent 12f4db3 commit 967e289
Show file tree
Hide file tree
Showing 7 changed files with 106 additions and 97 deletions.
38 changes: 29 additions & 9 deletions src/component/1d-2d/components/SVGRootContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,49 @@
import type { ReactNode } from 'react';
import type { ReactNode, SVGAttributes } from 'react';

import { useInsetOptions } from '../../1d/inset/InsetProvider.js';
import { useChartData } from '../../context/ChartContext.js';
import { usePreferences } from '../../context/PreferencesContext.js';

interface SVGRootContainerProps {
interface SVGRootContainerProps extends SVGAttributes<SVGSVGElement> {
children: ReactNode;
enableBoxBorder?: boolean;
id?: string;
x?: string | number;
y?: string | number;
width?: number;
height?: number;
}

export function SVGRootContainer(props: SVGRootContainerProps) {
const { children, enableBoxBorder = false, id = 'nmrSVG', x, y } = props;
const {
children,
enableBoxBorder = false,
id = 'nmrSVG',
x,
y,
width: externalWidth,
height: externalHeight,
viewBox: externalViewBox,
...otherProps
} = props;

const {
current: {
general: { spectraRendering },
},
} = usePreferences();
const { width, height, margin, displayerKey } = useChartData();
const {
width: baseWidth,
height: baseHeight,
margin,
displayerKey,
} = useChartData();
const { id: insetKey = 'primary' } = useInsetOptions() || {};

const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const width = externalWidth ?? baseWidth;
const height = externalHeight ?? baseHeight;

const innerWidth = baseWidth - margin.left - margin.right;
const innerHeight = baseHeight - margin.top - margin.bottom;
const viewBox = externalViewBox ?? `0 0 ${width} ${height}`;

return (
<svg
Expand All @@ -33,12 +52,13 @@ export function SVGRootContainer(props: SVGRootContainerProps) {
y={y}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
viewBox={viewBox}
fontFamily="Arial, Helvetica, sans-serif"
shapeRendering={spectraRendering}
style={{
position: 'absolute',
}}
{...otherProps}
>
<defs>
<clipPath id={`${displayerKey}clip-chart-${insetKey}`}>
Expand Down
26 changes: 16 additions & 10 deletions src/component/elements/export/ExportContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface BaseExportProps {
interface RenderSizeOption {
width: number;
minWidth?: number;
rescale?: boolean;
preserveAspectRatio?: boolean;
}
interface BaseExportFrameProps {
children: ReactNode;
Expand Down Expand Up @@ -128,23 +128,29 @@ export function InnerPrintFrame(props: InnerExportFrameProps) {
}
}, [load]);

let widthInPixel = Math.round(width);
let heightInPixel = Math.round(height);
const exportWidthInPixel = Math.round(width);
const exportHeightInPixel = Math.round(height);

const {
width: baseRenderWidth,
rescale = true,
preserveAspectRatio = true,
minWidth = 0,
} = renderOptions;

if (rescale) {
const renderWidth = Math.max(baseRenderWidth, minWidth);
widthInPixel = Math.round(renderWidth);
heightInPixel = Math.round((height / width) * renderWidth);
}
const renderWidth = Math.round(Math.max(baseRenderWidth, minWidth));

const widthInPixel = preserveAspectRatio ? renderWidth : exportWidthInPixel;
const heightInPixel = preserveAspectRatio
? Math.round((height / width) * renderWidth)
: exportHeightInPixel;

return (
<ExportSettingsProvider width={widthInPixel} height={heightInPixel}>
<ExportSettingsProvider
width={widthInPixel}
height={heightInPixel}
exportWidth={exportWidthInPixel}
exportHeight={exportHeightInPixel}
>
<iframe
ref={frameRef}
style={{
Expand Down
11 changes: 4 additions & 7 deletions src/component/elements/export/ExportManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { useExportViewPort } from '../../hooks/useExport.js';
import { useWorkspaceExportSettings } from '../../hooks/useWorkspaceExportSettings.js';

import { ExportContent } from './ExportContent.js';
import { getSizeInPixel } from './utilities/getSizeInPixel.js';

export type ExportFormat = 'png' | 'svg';
export type ExportDestination = 'file' | 'clipboard';
Expand Down Expand Up @@ -101,15 +100,13 @@ export function ExportManagerController(props: ExportManagerControllerProps) {
const { format, destination = 'file' } = exportOptions;
let exportKey: keyof ExportPreferences = format;

const sizeInPixel = getSizeInPixel(options);

if (destination === 'file') {
switch (format) {
case 'png':
await saveAsPNGHandler(targetElement, sizeInPixel);
await saveAsPNGHandler(targetElement);
break;
case 'svg':
await saveAsSVGHandler(targetElement, sizeInPixel);
await saveAsSVGHandler(targetElement);
break;

default:
Expand All @@ -125,7 +122,7 @@ export function ExportManagerController(props: ExportManagerControllerProps) {
exportKey = 'clipboard';
switch (format) {
case 'png':
await copyPNGToClipboardHandler(targetElement, sizeInPixel);
await copyPNGToClipboardHandler(targetElement);
break;
default:
// eslint-disable-next-line no-console
Expand Down Expand Up @@ -162,7 +159,7 @@ export function ExportManagerController(props: ExportManagerControllerProps) {
exportOptions={settings}
defaultExportOptions={settings}
confirmButtonText={destination === 'clipboard' ? 'Copy' : 'Save'}
renderOptions={{ minWidth: 800, width, rescale: format !== 'svg' }}
renderOptions={{ minWidth: 800, width }}
>
{children}
</ExportContent>
Expand Down
13 changes: 8 additions & 5 deletions src/component/elements/export/ExportSettingsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { createContext, useContext, useMemo } from 'react';
export interface ExportSettingsContextProps {
width: number;
height: number;
exportWidth?: number;
exportHeight?: number;
}

const ExportSettingsContext = createContext<ExportSettingsContextProps | null>(
null,
);
const ExportSettingsContext =
createContext<Required<ExportSettingsContextProps> | null>(null);

export function useExportSettings() {
return useContext(ExportSettingsContext);
Expand All @@ -19,14 +20,16 @@ interface ExportSettingsProviderProps extends ExportSettingsContextProps {
}

export function ExportSettingsProvider(props: ExportSettingsProviderProps) {
const { children, width, height } = props;
const { children, width, height, exportWidth, exportHeight } = props;

const state = useMemo(() => {
return {
width,
height,
exportWidth: exportWidth ?? width,
exportHeight: exportHeight ?? height,
};
}, [height, width]);
}, [exportHeight, exportWidth, height, width]);

return (
<ExportSettingsContext.Provider value={state}>
Expand Down
23 changes: 3 additions & 20 deletions src/component/hooks/useExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,6 @@ interface SaveOptions {
pretty: boolean;
}

interface ExportSizeOptions {
width: number;
height: number;
}

export function useExport() {
const toaster = useToaster();
const state = useChartData();
Expand Down Expand Up @@ -88,10 +83,7 @@ export function useExportViewPort() {
const toaster = useToaster();
const state = useChartData();

function copyPNGToClipboardHandler(
targetElement: HTMLElement,
options: Partial<ExportSizeOptions>,
) {
function copyPNGToClipboardHandler(targetElement: HTMLElement) {
return new Promise<void>((resolve) => {
if (state.data.length === 0 || !targetElement) {
return;
Expand All @@ -104,7 +96,6 @@ export function useExportViewPort() {
setTimeout(async () => {
await copyPNGToClipboard('nmrSVG', {
rootElement: targetElement,
...options,
});
toaster.show({
message: 'Image copied to clipboard',
Expand All @@ -116,10 +107,7 @@ export function useExportViewPort() {
});
}

function saveAsSVGHandler(
targetElement: HTMLElement,
options: Partial<ExportSizeOptions>,
) {
function saveAsSVGHandler(targetElement: HTMLElement) {
return new Promise<void>((resolve) => {
if (state.data.length === 0 || !targetElement) {
return;
Expand All @@ -133,18 +121,14 @@ export function useExportViewPort() {
exportAsSVG('nmrSVG', {
rootElement: targetElement,
fileName,
...options,
});
hideLoading();
resolve();
}, 0);
});
}

function saveAsPNGHandler(
targetElement: HTMLElement,
options: Partial<ExportSizeOptions>,
) {
function saveAsPNGHandler(targetElement: HTMLElement) {
return new Promise<void>((resolve) => {
if (state.data.length === 0 || !targetElement) {
return;
Expand All @@ -158,7 +142,6 @@ export function useExportViewPort() {
void exportAsPng('nmrSVG', {
rootElement: targetElement,
fileName,
...options,
});
hideLoading();
resolve();
Expand Down
12 changes: 10 additions & 2 deletions src/component/main/NMRiumViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../hooks/useViewportSize.js';

import type { NMRiumProps } from './NMRium.js';
import { useExportSettings } from '../elements/export/ExportSettingsProvider.js';

interface NMRiumViewerProps {
viewerRef: RefObject<HTMLDivElement>;
Expand All @@ -31,14 +32,21 @@ export function NMRiumViewer(props: NMRiumViewerProps) {
const { emptyText, viewerRef, onRender, style = {} } = props;
const viewPort = useViewportSize();
const { displayerMode } = useChartData();
const exportSettings = useExportSettings();

useOnRender(onRender);

const isExportingProcessStart = useCheckExportStatus();

if (isExportingProcessStart) {
if (isExportingProcessStart && exportSettings) {
const { width, height, exportHeight, exportWidth } = exportSettings;
return (
<SVGRootContainer enableBoxBorder={displayerMode === '2D'}>
<SVGRootContainer
enableBoxBorder={displayerMode === '2D'}
width={exportWidth}
height={exportHeight}
viewBox={`0 0 ${width} ${height}`}
>
<Viewer emptyText={emptyText} renderSvgContentOnly />
<g className="floating-ranges">
<FloatingRanges />
Expand Down
Loading

0 comments on commit 967e289

Please sign in to comment.