Skip to content

Add utility functions for printing figures and graphical viewers #999#1001

Merged
ptziegler merged 1 commit intoeclipse-gef:masterfrom
ptziegler:issue999
Apr 27, 2026
Merged

Add utility functions for printing figures and graphical viewers #999#1001
ptziegler merged 1 commit intoeclipse-gef:masterfrom
ptziegler:issue999

Conversation

@ptziegler
Copy link
Copy Markdown
Contributor

This adds the new utility classes ImagePrintFigureOperation and ImagePrintGraphicalViewerOperation which can be used to paint the contents of a Figure/GraphicalViewer onto an SWT Image.

When capturing a figure, clipping may occur because text sizes are not consistent across different zoom levels. When Draw2D-based scaling is enabled, the FigureCanvas is configured to always draw as if at 100% zoom and then scale its contents to match the actual monitor zoom.

However, when painting on an Image, the Image GC may use the monitor zoom, leading to inconsistencies. To avoid this, the GC must be forced into "100% mode" by first calling Image.getImageData(100). Then the SWTGraphics instance must be scaled by the monitor zoom so that the Figure can be painted.

The result is an exact representation of the source figure.

@ptziegler ptziegler requested a review from azoitl February 4, 2026 19:46
@ptziegler
Copy link
Copy Markdown
Contributor Author

Side-by-side comparison when drawing the image directly compared to the printer:

Screenshot 2026-02-04 203712 Screenshot 2026-02-04 204900

@azoitl azoitl added this to the 3.27.0 milestone Feb 4, 2026
@ptziegler
Copy link
Copy Markdown
Contributor Author

fyi @HeikoKlare @Phillipus

@Phillipus
Copy link
Copy Markdown
Contributor

Phillipus commented Feb 5, 2026

@ptziegler Thanks for working on this it looks great!

I did a test of one use case in Archi where we create a preview image of a figure and that looked good with no clipping.

We have another use case where we get the printable layer of a diagram and then do some scaling (in case use exports image at 1x, 2x, or 3x size) and bounds limiting and account for negative space like this:

static Image createDiagramImage(IFigure figure, double scale, int margin) {
    Rectangle bounds = getMinimumBounds(figure); // Get smallest bounds of diagram from child figures
    bounds.expand(margin / scale, margin / scale);
    
    Image image = new Image(Display.getDefault(), (int)(bounds.width * scale), (int)(bounds.height * scale) );
    GC gc = new GC(image);
    SWTGraphics graphics = new SWTGraphics(gc);
    
    graphics.scale(scale);
    
    // Compensate for negative co-ordinates
    graphics.translate(bounds.x * -1, bounds.y * -1);

    // Paint onto graphics
    figure.paint(graphics);
    
    // Dispose
    gc.dispose();
    graphics.dispose();
    
    return image;
}

How could I use the ImagePrintFigureOperation in this case?

@ptziegler
Copy link
Copy Markdown
Contributor Author

How could I use the ImagePrintFigureOperation in this case?

I'd simply adapt the preparePrintSource() and restorePrintSource() method to also pass along the SWTGraphics instance used for painting. Then you can subclass the ImagePrintFigureOperation and add the pre-processing to the preparePrintSource() method.

Perhaps it makes sense to also add a getBounds() method to return the Image size? Otherwise injecting the minimum size would be tricky.

public Image run() {
Objects.requireNonNull(printSource, "Print source must not be null"); //$NON-NLS-1$
try {
preparePrintSource();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work when using the CachedImageDataProvider, because the image data is only generated when painting the image. But this happens after the method returns.

@Phillipus
Copy link
Copy Markdown
Contributor

I'd simply adapt the preparePrintSource() and restorePrintSource() method to also pass along the SWTGraphics instance used for painting.

I can't see how that can be done as createImageAtCurrentZoom and printAtZoom are private and uses its own SWTGraphics instance

@ptziegler
Copy link
Copy Markdown
Contributor Author

I can't see how that can be done as createImageAtCurrentZoom and printAtZoom are private and uses its own SWTGraphics instance

By "I'd simply adapt" I mean that I'll update this PR so that the internal SWTGraphics instance is accessible within the prepare/restore methods.

@Phillipus
Copy link
Copy Markdown
Contributor

By "I'd simply adapt" I mean that I'll update this PR so that the internal SWTGraphics instance is accessible within the prepare/restore methods.

The English language, eh? ;-)

@Phillipus
Copy link
Copy Markdown
Contributor

Phillipus commented Feb 5, 2026

When creating an image from a Figure from a GraphicalViewer a temporary Shell is used to set as its control. At some point that shell needs to be disposed. But if it's disposed too soon before the Image is created it will lead to a "Widget is disposed" error when accessing the Image.

In Archi we create thumbnail images for each diagram by using a GraphicalViewer with a temporary Shell for each GraphicalViewer in an ArchiMate model that might be displayed later. At the moment the temporary Shell is disposed once the Image has been created. But with ImagePrintFigureOperation the image at 200% scale might be created later.

How to know when the Image has been initialised at all zoom levels and so the Shell can be disposed?

Image createImageFromGraphicalViewer(Shell tmpShell) {
    GraphicalViewer viewer = new GraphicalViewerImpl();
    viewer.setControl(tmpShell);
    
    LayerManager layerManager = (LayerManager)viewer.getEditPartRegistry().get(LayerManager.ID);
    IFigure printableLayer = layerManager.getLayer(LayerConstants.PRINTABLE_LAYERS);
    
    Image img = new ImagePrintFigureOperation(Display.getDefault(), printableLayer).run();
    
    // When can tmpShell be disposed?
    
    return img;
}

@ptziegler
Copy link
Copy Markdown
Contributor Author

How to know when the Image has been initialised at all zoom levels and so the Shell can be disposed?

I don't think that's possible with the current implementation. I've thought a little bit about this and perhaps it makes more sense to require a Control, rather than a Display?

I need a way to get the zoom at which the image should be painted. If I have the Control, I can already calculate the ImageData during creation and the image remains valid, even if the figure control is disposed.

The consequence is that the image only contains the image data at the current monitor zoom. So a new image needs to be created after every zoom change.

@ptziegler ptziegler marked this pull request as draft February 6, 2026 08:21
@Phillipus
Copy link
Copy Markdown
Contributor

Phillipus commented Feb 6, 2026

We have these use cases:

  1. Create an image of the diagram (figure) to export to file at the current display scale. This is a one off image and so doesn't need to update when switching monitors.
  2. Create a preview image of a figure. The image is added to a SWT Label container (just to be lazy about centering it)
  3. Create thumbnail images of diagrams in a Nebula Gallery. I'm not sure if these need to update on monitor change.

@ptziegler ptziegler force-pushed the issue999 branch 3 times, most recently from 85ed40c to 5f49d68 Compare February 9, 2026 17:38
@ptziegler ptziegler marked this pull request as ready for review February 9, 2026 17:40
@ptziegler ptziegler force-pushed the issue999 branch 2 times, most recently from efc8e6c to db52781 Compare February 9, 2026 17:44
@ptziegler
Copy link
Copy Markdown
Contributor Author

I've updated the PR so that the Graphics object used for painting the image can be accessed via the preparePrintSource() and restorePrintSource() methods. The size of the image can also be configured by overriding getSize().

The example #1001 (comment) in could be implemented similar to this:

public class ImagePrintDiagramOperation extends ImagePrintFigureOperation {
	private final Rectangle boundsOrig;
	private final double scale;
	private final int margin;

	public ImagePrintDiagramOperation(Control control, IFigure printSource, double scale, int margin) {
		super(control, printSource);
		this.scale = scale;
		this.margin = margin;
		this.boundsOrig = getMinimumBounds(printSource); // Get smallest bounds of diagram from child figures
	}

	private Rectangle getMinimumBounds(IFigure figure) {
		return figure.getBounds().scale(scale);
	}

	@Override
	protected Dimension getSize() {
		Rectangle bounds = boundsOrig.getCopy();
		bounds.expand(margin / scale, margin / scale);
		return bounds.getSize();
	}
	

	protected void preparePrintSource(Graphics graphics) {
		super.preparePrintSource(graphics);
		graphics.scale(scale);
		// Compensate for negative co-ordinates
		graphics.translate(boundsOrig.x * -1, boundsOrig.y * -1);
	}
}

@Phillipus
Copy link
Copy Markdown
Contributor

Phillipus commented Feb 10, 2026

@ptziegler Thanks for these changes. I'm testing this now. The only problem I'm getting in the above use case is if double scale > 1 or the diagram has negative co-ordinates. This creates a wrong size figure and offset. Something wrong going on here:

graphics.translate(boundsOrig.x * -1, boundsOrig.y * -1);

@Phillipus
Copy link
Copy Markdown
Contributor

Phillipus commented Feb 10, 2026

I think I've nailed it now. I was scaling the bounds when I should have been scaling the size. So far this works:

class ImagePrintDiagramOperation extends ImagePrintFigureOperation {
    private final Rectangle originalBounds;
    private final double scale;
    private final int margin;

    public ImagePrintDiagramOperation(Control control, IFigure printSource, double scale, int margin) {
        super(control, printSource);
        this.scale = scale;
        this.margin = margin;
        originalBounds = getOriginalBounds(printSource);
    }

    private Rectangle getOriginalBounds(IFigure figure) {
        // Get smallest bounds of diagram from child figures (helper method elsewhere)
        Rectangle bounds = getMinimumBounds(figure);
        bounds.expand(margin / scale, margin / scale);
        return bounds;
    }

    @Override
    protected Dimension getSize() {
        // Scale the size not the bounds
        return originalBounds.getSize().scale(scale);
    }

    @Override
    protected void preparePrintSource(Graphics graphics) {
        graphics.scale(scale);
        // Translate x,y by original bounds x, y and compensate for negative co-ordinates
        graphics.translate(originalBounds.x * -1, originalBounds.y * -1);
    }
}

@Phillipus
Copy link
Copy Markdown
Contributor

Phillipus commented Feb 10, 2026

There's a problem when changing display scale from a lower setting to a higher setting once the Image has been created.

The problem I have with ImagePrintFigureOperation is creating Images that are re-used when adding them to an ImageRegistry and then changing monitor scaling.

  1. Set monitor scaling to 150% or 175%
  2. Run ImagePrintExample
  3. With the shell still open change the monitor scaling higher (175% or 200%)

The Image is distorted because it's using the one from the lower resolution.

Image 001

@ptziegler
Copy link
Copy Markdown
Contributor Author

The Image is distorted because it's using the one from the lower resolution.

It's much simpler. ImageData.scaleTo simply sucks when scaling text. I've updated the PR to scale the image using both antialias and interpolation so that the scaled image is still blurry, but at least readable.

Screenshot 2026-02-10 205717

But at the end of the day, you can't have it both ways. If you want to have an image that is crisp at every zoom level, it needs to be backed by a Shell. But I think what would make everything exceedingly complicated...

@ptziegler ptziegler force-pushed the issue999 branch 2 times, most recently from 242d163 to e508e19 Compare February 10, 2026 20:12
@ptziegler
Copy link
Copy Markdown
Contributor Author

In my usage there is no FigureCanvas used. I create a Figure and then use a Shell as source control:

But you would still need a LightweightSystem in order to return the proper FigureUtilities instance. Using the class like this probably won't break anything. But neither will it work properly, because a lot of the context is missing.

@Phillipus
Copy link
Copy Markdown
Contributor

But neither will it work properly, because a lot of the context is missing.

I've not noticed any problems. I went from 175% to 200% and it scaled just fine.

@ptziegler
Copy link
Copy Markdown
Contributor Author

I've not noticed any problems. I went from 175% to 200% and it scaled just fine.

Because if the figure doesn't belong to a FigureCanvas, its layout should be calculated using SWT scaling, which generally should match the way the shell is scaled. If that works, great, but it is not the intended purpose of this class.

Copy link
Copy Markdown
Contributor

@azoitl azoitl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would also be good to have for 3.27, or?

@Phillipus
Copy link
Copy Markdown
Contributor

Phillipus commented Feb 23, 2026

There's a problem with Images generated using ImagePrintFigureOperation when they are used in a Nebula Gallery widget. Sometimes the image is not drawn depending on the size of the Gallery Item. I can't easily create a standalone test snippet.

java.lang.IllegalArgumentException: Argument not valid
at org.eclipse.swt.SWT.error(SWT.java:4931)
at org.eclipse.swt.SWT.error(SWT.java:4865)
at org.eclipse.swt.SWT.error(SWT.java:4836)
at org.eclipse.swt.graphics.GC$DrawScalingImageToImageOperation.computeSourceRectangle(GC.java:1251)
at org.eclipse.swt.graphics.GC$DrawScalingImageToImageOperation.lambda$0(GC.java:1223)
at org.eclipse.swt.graphics.Image.executeOnImageHandleAtBestFittingSize(Image.java:946)
at org.eclipse.swt.graphics.GC$DrawScalingImageToImageOperation.draw(GC.java:1222)
at org.eclipse.swt.graphics.GC$DrawScalingImageToImageOperation.apply(GC.java:1204)
at org.eclipse.swt.graphics.GC.storeAndApplyOperationForExistingHandle(GC.java:6097)
at org.eclipse.swt.graphics.GC.drawImage(GC.java:1134)

@ptziegler ptziegler force-pushed the issue999 branch 2 times, most recently from 9dab3f8 to 784abba Compare February 23, 2026 16:40
@ptziegler
Copy link
Copy Markdown
Contributor Author

This error indicates that the image data is not properly sized after a zoom change.

java.lang.IllegalArgumentException: Argument not valid
at org.eclipse.swt.SWT.error(SWT.java:4931)
at org.eclipse.swt.SWT.error(SWT.java:4865)
at org.eclipse.swt.SWT.error(SWT.java:4836)
at org.eclipse.swt.graphics.GC$DrawScalingImageToImageOperation.computeSourceRectangle(GC.java:1251)

But I don't really see how this can happen with the default implementation. If Draw2D-based scaling is disabled, the image data is created at 100% zoom and then scaled by SWT. Otherwise the image data is always scaled by the zoom factor.

Dimension size = getImageSize().getCopy();
if (isDraw2DAutoScalingEnabled()) {
	size.scale(scale);
}

I've also added a small test case to make sure that this is the case.

private static void testPrintWithOperation(ImagePrintFigureOperation printer) {
    Image image = printer.run();

    assertImageDataSize(image.getImageData(100), 70, 80);
    assertImageDataSize(image.getImageData(150), 105, 120);
    assertImageDataSize(image.getImageData(200), 140, 160);
    assertImageDataSize(image.getImageData(400), 280, 320);

    image.dispose();
}

@ptziegler
Copy link
Copy Markdown
Contributor Author

I think this would also be good to have for 3.27, or?

@azoitl Let's wait until the next release to see if there are any edge cases that were missed.

@azoitl azoitl modified the milestones: 3.27.0, 3.28.0 Feb 24, 2026
@Phillipus
Copy link
Copy Markdown
Contributor

Phillipus commented Mar 6, 2026

Regarding the exception thrown using the Image in a Nebula Gallery Item, this only occurs if zoom < 100 at getImageDataAtZoom. A workaround is to return null for that one:

protected ImageData getImageDataAtZoom(int zoom) {
	if (zoom < 100) {
		return null;
	}

	Image image = createImageAtScale(zoom / 100.0);
	try {
		return image.getImageData(100);
	} finally {
		image.dispose();
	}
}

@ptziegler
Copy link
Copy Markdown
Contributor Author

Regarding the exception thrown using the Image in a Nebula Gallery Item, this only occurs if zoom < 100 at getImageDataAtZoom. A workaround is to return null for that one:

protected ImageData getImageDataAtZoom(int zoom) {
	if (zoom < 100) {
		return null;
	}

	Image image = createImageAtScale(zoom / 100.0);
	try {
		return image.getImageData(100);
	} finally {
		image.dispose();
	}
}

I managed to reproduce this issue. It happens if you e.g. have an image of size 16x16 and try to get the image data at 1% zoom. The scaled size is (16 * 0.01, 16 * 0.01), which is rounded down to (0, 0) and therefore invalid.

@ptziegler ptziegler force-pushed the issue999 branch 2 times, most recently from 5b46a8a to 30052fd Compare April 11, 2026 22:18
@Phillipus
Copy link
Copy Markdown
Contributor

I managed to reproduce this issue. It happens if you e.g. have an image of size 16x16 and try to get the image data at 1% zoom. The scaled size is (16 * 0.01, 16 * 0.01), which is rounded down to (0, 0) and therefore invalid.

Right. I'm not sure if my suggested workaround (return null on zoom < 100) is the right solution here.

@ptziegler
Copy link
Copy Markdown
Contributor Author

ptziegler commented Apr 13, 2026

I played around with this problem and in the end, the only way I managed to reproduce the exact stack-trace is via this example:

package org.eclipse.swt.tests.manual;

import org.eclipse.swt.SWT;
import org.eclipse.swt.graphics.GC;
import org.eclipse.swt.graphics.Image;
import org.eclipse.swt.layout.BorderData;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.Slider;

public class DrawGC {

	public static void main(String[] args) {
		Shell shell = new Shell();
		shell.setSize(100, 100);

		Display display = shell.getDisplay();
		Image image = createTestImage(display, 16);

		shell.addPaintListener(event -> {
			event.gc.drawImage(image, 2, 2, 16, 16, 0, 0, 16, 16);
		});

		shell.open();

		while (!shell.isDisposed()) {
			if (!display.readAndDispatch()) {
				display.sleep();
			}
		}

		image.dispose();
	}

	private static Image createTestImage(Display display, int size) {
		Image image = new Image(display, size, size);
		GC gc = new GC(image);
		gc.setBackground(display.getSystemColor(SWT.COLOR_RED));
		gc.fillRectangle(0, 0, size, size);
		gc.dispose();
		return image;
	}
}

The computeSourceRectangle(...) method is only called from within drawImage(Image image, int srcX, int srcY, int srcWidth, int srcHeight, int destX, int destY, int destWidth, int destHeight) and the exception is thrown when

a) the source rectangle differs too much from the target rectangle
b) the monitor zoom is not 100%.

Until I see a reproducer, I would argue that this is a bug in whatever widget is painting the image. Because as far as I can tell, the computeSourceRectangle method only considers the source and destination size of the image.

@Phillipus
Copy link
Copy Markdown
Contributor

Phillipus commented Apr 13, 2026

This is the more complete stack trace with the latest SWT build:

java.lang.IllegalArgumentException: Argument not valid
	at org.eclipse.swt.SWT.error(SWT.java:4915)
	at org.eclipse.swt.SWT.error(SWT.java:4849)
	at org.eclipse.swt.SWT.error(SWT.java:4820)
	at org.eclipse.swt.graphics.GC$DrawScalingImageToImageOperation.computeSourceRectangle(GC.java:1251)
	at org.eclipse.swt.graphics.GC$DrawScalingImageToImageOperation.lambda$0(GC.java:1223)
	at org.eclipse.swt.graphics.Image.executeOnImageHandleAtBestFittingSize(Image.java:946)
	at org.eclipse.swt.graphics.GC$DrawScalingImageToImageOperation.draw(GC.java:1222)
	at org.eclipse.swt.graphics.GC$DrawScalingImageToImageOperation.apply(GC.java:1204)
	at org.eclipse.swt.graphics.GC.storeAndApplyOperationForExistingHandle(GC.java:6102)
	at org.eclipse.swt.graphics.GC.drawImage(GC.java:1134)
	at org.eclipse.nebula.widgets.gallery.DefaultGalleryItemRenderer.draw(DefaultGalleryItemRenderer.java:226)
	at org.eclipse.nebula.widgets.gallery.AbstractGridGroupRenderer.drawItem(AbstractGridGroupRenderer.java:267)
	at org.eclipse.nebula.widgets.gallery.NoGroupRenderer.draw(NoGroupRenderer.java:66)
	at org.eclipse.nebula.widgets.gallery.Gallery._drawGroup(Gallery.java:1355)
	at org.eclipse.nebula.widgets.gallery.Gallery.onPaint(Gallery.java:1186)
	at org.eclipse.nebula.widgets.gallery.Gallery.lambda$2(Gallery.java:585)
	at org.eclipse.swt.widgets.EventTable.sendEvent(EventTable.java:91)
	at org.eclipse.swt.widgets.Display.sendEvent(Display.java:4363)

@ptziegler
Copy link
Copy Markdown
Contributor Author

The Gallery widget is working on the raw image size to determine some obscure width and height which is passed to the drawImage(...) call. Which means that this has nothing to do with the print-operation class. Or rather, it means that this class may produce an image whose bounds break the Gallery calculation.

But I will not go and try out every possible combination of width and height to see which one the widget doesn't like. So as said before: As long as there isn't a reproducer, I consider this to be a Nebula bug.

@Phillipus
Copy link
Copy Markdown
Contributor

As long as there isn't a reproducer, I consider this to be a Nebula bug.

I tend to agree with that. My reports here are just to document the (potential) issue.

…pse-gef#999

This adds the new utility classes `ImagePrintFigureOperation` and
`ImagePrintGraphicalViewerOperation` which can be used to paint the
contents of a Figure/GraphicalViewer onto an SWT Image.

When capturing a figure, clipping may occur because text sizes are not
consistent across different zoom levels. When Draw2D-based scaling is
enabled, the `FigureCanvas` is configured to always draw as if at 100%
zoom and then scale its contents to match the actual monitor zoom.

However, when painting on an Image, the Image GC may use the monitor
zoom, leading to inconsistencies. To avoid this, the GC must be forced
into "100% mode" by first calling `Image.getImageData(100)`. Then the
SWTGraphics instance must be scaled by the monitor zoom so that the
Figure can be painted.

The result is an exact representation of the source figure.
@ptziegler
Copy link
Copy Markdown
Contributor Author

I don't think there'll be any progress with the Nebula problem unless there's a simple reproducer. If you see this issue again, can you check what "best" size is calculated in the widget and what the initial the bounds are the renderer method is called with?

image

@ptziegler ptziegler merged commit 2de1462 into eclipse-gef:master Apr 27, 2026
14 checks passed
@ptziegler ptziegler deleted the issue999 branch April 27, 2026 20:17
@Phillipus
Copy link
Copy Markdown
Contributor

I don't think there'll be any progress with the Nebula problem unless there's a simple reproducer. If you see this issue again, can you check what "best" size is calculated in the widget and what the initial the bounds are the renderer method is called with?

Sure. I'll come back to this one at some point. Thanks for your work on this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants