blob: 4722a0ca97de613f5036b541cd42648c429f6b04 [file] [log] [blame]
/**
* Copyright (c) 2008, 2022 Borland Software Corporation and others.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Dmitry Stadnik - initial API and implementation
* Obeo - Adaptations.
*/
package org.eclipse.sirius.diagram.ui.tools.api.figure;
import java.io.IOException;
import java.io.InputStream;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.WeakHashMap;
import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
import org.apache.batik.anim.dom.SVGDOMImplementation;
import org.apache.batik.anim.dom.SVGOMDocument;
import org.apache.batik.util.MimeTypeConstants;
import org.apache.batik.util.ParsedURL;
import org.eclipse.draw2d.Figure;
import org.eclipse.draw2d.Graphics;
import org.eclipse.draw2d.XYLayout;
import org.eclipse.draw2d.geometry.PrecisionRectangle;
import org.eclipse.draw2d.geometry.Rectangle;
import org.eclipse.sirius.common.tools.api.util.StringUtil;
import org.eclipse.sirius.diagram.tools.api.DiagramPlugin;
import org.eclipse.sirius.diagram.ui.provider.DiagramUIPlugin;
import org.eclipse.sirius.diagram.ui.provider.Messages;
import org.eclipse.sirius.diagram.ui.tools.internal.figure.svg.SimpleImageTranscoder;
import org.eclipse.sirius.diagram.ui.tools.internal.render.SVGImageRegistry;
import org.eclipse.sirius.diagram.ui.tools.internal.render.SiriusDiagramSVGGenerator;
import org.eclipse.sirius.diagram.ui.tools.internal.render.SiriusGraphicsSVG;
import org.eclipse.sirius.diagram.ui.tools.internal.render.SiriusRenderedMapModeGraphics;
import org.eclipse.sirius.ext.draw2d.figure.ITransparentFigure;
import org.eclipse.sirius.ext.draw2d.figure.ImageFigureWithAlpha;
import org.eclipse.sirius.ext.draw2d.figure.StyledFigure;
import org.eclipse.sirius.ext.draw2d.figure.TransparentFigureGraphicsModifier;
import org.eclipse.swt.SWT;
import org.eclipse.swt.graphics.Image;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.xml.sax.InputSource;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.RemovalListener;
import com.google.common.cache.RemovalNotification;
import com.google.common.cache.Weigher;
//CHECKSTYLE:OFF
public class SVGFigure extends Figure implements StyledFigure, ITransparentFigure, ImageFigureWithAlpha {
/**
* Cache of pre-rendered images.
*/
private static class ImageCache {
/**
* The maximum size of the cache, in bytes.
*/
private static final int MAX_WEIGHT;
static {
String s = System.getProperty("org.eclipse.sirius.diagram.ui.svg.maxCacheSizeMB"); //$NON-NLS-1$
int mb;
try {
mb = Integer.parseInt(s);
} catch (NumberFormatException nfe) {
mb = 50;
}
MAX_WEIGHT = mb * 1024 * 1024;
}
/**
* Computes the weight of a rendered image, as the number of bytes occupied by the raw raster data (assumes 4
* 8-bit channels).
*/
private static final class ImageWeigher implements Weigher<String, Image> {
@Override
public int weigh(String key, Image value) {
if (value != null) {
synchronized (value) {
if (!value.isDisposed()) {
org.eclipse.swt.graphics.Rectangle bounds = value.getBounds();
return bounds.width * bounds.height * 4;
}
}
}
return 0;
}
}
private final class ImageRemovalListener implements RemovalListener<String, Image> {
@Override
public void onRemoval(RemovalNotification<String, Image> notification) {
Image img = notification.getValue();
synchronized (img) {
img.dispose();
}
}
}
/**
* The rendered bitmaps, organized by key..
*/
private final Cache<String, Image> images = CacheBuilder.newBuilder().maximumWeight(ImageCache.MAX_WEIGHT).removalListener(new ImageRemovalListener()).weigher(new ImageWeigher()).build();
/**
* Get the image cached or create new one and cache it.
*
* @param key
* the key
* @param clientArea
* the client area
* @param graphics
* the graphical context
* @return an image store in a cache
*/
public synchronized Image getImage(SVGFigure fig, Rectangle clientArea, Graphics graphics) {
String key = fig.getKey(graphics);
Image result = images.getIfPresent(key);
if (result == null || result.isDisposed()) {
if (fig.transcoder != null) {
result = fig.transcoder.render(fig, clientArea, graphics, CACHE_SCALED_IMAGES);
}
if (result != null) {
images.put(key, result);
}
}
return result;
}
/**
* Remove all entries whose key begins with the given key. Remove from the document map, the entries with the
* given keys to force to re-read the file.
*
* @param documentKey
* the document key.
* @return true of something was removed.
*/
public synchronized boolean doRemoveFromCache(String documentKey) {
if (!StringUtil.isEmpty(documentKey)) {
boolean remove = false;
Collection<String> keyToRemove = new ArrayList<>();
for (String key : images.asMap().keySet()) {
if (key.startsWith(documentKey)) {
keyToRemove.add(key);
}
}
for (String toRemove : keyToRemove) {
images.invalidate(toRemove);
remove = true;
}
return remove;
}
return false;
}
}
private static final ImageCache CACHE = new ImageCache();
private static final boolean CACHE_ENABLED = true;
private static final boolean CACHE_SCALED_IMAGES = true;
/**
* The uri of the image to display when the file has not been found.
*/
protected static final String IMAGE_NOT_FOUND_URI = MessageFormat.format("platform:/plugin/{0}/images/NotFound.svg", DiagramUIPlugin.getPlugin().getSymbolicName()); //$NON-NLS-1$
/**
* Key separator.
*/
protected static final String SEPARATOR = "|"; //$NON-NLS-1$
private String uri;
private int viewpointAlpha = ITransparentFigure.DEFAULT_ALPHA;
private boolean transparent;
private boolean failedToLoadDocument;
private SimpleImageTranscoder transcoder;
/** The image ratio (width/height); computed during a SVG change detected {@link #contentChanged()}. */
private double initialAspectRatio = 1;
/**
* True if the SVG document contains an attribute "viewBox", false otherwise. This attribute is not handled by the
* current version of batik used by Sirius (1.6). And it causes regression, shrinked image, since the fix of bug
* 463051.<BR>
* This field is set at each {@link #contentChanged()}.
*/
protected boolean modeWithViewBox;
protected static WeakHashMap<String, Document> documentsMap = new WeakHashMap<String, Document>();
public SVGFigure() {
this.setLayoutManager(new XYLayout());
}
@Override
public int getSiriusAlpha() {
return viewpointAlpha;
}
@Override
public boolean isTransparent() {
return transparent;
}
@Override
public void setSiriusAlpha(int alpha) {
this.viewpointAlpha = alpha;
}
@Override
public void setTransparent(boolean transparent) {
this.transparent = transparent;
}
@Override
public int getImageHeight() {
return (transcoder != null) ? transcoder.getImageHeight() : 0;
}
@Override
public int getImageWidth() {
return (transcoder != null) ? transcoder.getImageWidth() : 0;
}
/**
* Get the original SVG image ratio (width/height).
*
* @return the original SVG image ratio (width/height).
*/
public double getImageAspectRatio() {
return initialAspectRatio;
}
@Override
public int getImageAlphaValue(int x, int y) {
return (transcoder != null) ? transcoder.getImageAlphaValue(x, y) : 255;
}
public final String getURI() {
return uri;
}
public final void setURI(String uri) {
setURI(uri, true);
}
public void setURI(String uri, boolean loadOnDemand) {
this.uri = uri;
transcoder = null;
failedToLoadDocument = false;
if (loadOnDemand) {
loadDocument();
}
}
private void loadDocument() {
transcoder = null;
failedToLoadDocument = true;
if (uri == null) {
return;
}
String documentKey = getDocumentKey();
Document document;
if (documentsMap.containsKey(documentKey)) {
document = documentsMap.get(documentKey);
} else {
document = createDocument();
documentsMap.put(documentKey, document);
}
if (document != null) {
transcoder = initTranscoder(document);
failedToLoadDocument = false;
}
}
private Document createDocument() {
SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(null) {
/*
* This method is exactly same method as @see
* org.apache.batik.anim.dom.SAXSVGDocumentFactory#createDocument(java.lang.String) but with the workaround
* proposed in https://issues.apache.org/jira/browse/BATIK-1143 by adding a try-with-resource to close the
* stream. Without this, it is not possible to delete the file (at least under Windows, see test
* org.eclipse.sirius.tests.swtbot.SetStyleToWorkspaceImageTests)
*/
@Override
public Document createDocument(String uri) throws IOException {
ParsedURL purl = new ParsedURL(uri);
try (InputStream is = purl.openStream(MimeTypeConstants.MIME_TYPES_SVG_LIST.iterator())) {
uri = purl.getPostConnectionURL();
InputSource isrc = new InputSource(is);
// now looking for a charset encoding in the content type such
// as "image/svg+xml; charset=iso8859-1" this is not official
// for image/svg+xml yet! only for text/xml and maybe
// for application/xml
String contentType = purl.getContentType();
int cindex = -1;
if (contentType != null) {
contentType = contentType.toLowerCase();
cindex = contentType.indexOf(HTTP_CHARSET);
}
String charset = null;
if (cindex != -1) {
int i = cindex + HTTP_CHARSET.length();
int eqIdx = contentType.indexOf('=', i);
if (eqIdx != -1) {
eqIdx++; // no one is interested in the equals sign...
// The patch had ',' as the terminator but I suspect
// that is the delimiter between possible charsets,
// but if another 'attribute' were in the accept header
// charset would be terminated by a ';'. So I look
// for both and take to closer of the two.
int idx = contentType.indexOf(',', eqIdx);
int semiIdx = contentType.indexOf(';', eqIdx);
if ((semiIdx != -1) && ((semiIdx < idx) || (idx == -1)))
idx = semiIdx;
if (idx != -1)
charset = contentType.substring(eqIdx, idx);
else
charset = contentType.substring(eqIdx);
charset = charset.trim();
isrc.setEncoding(charset);
}
}
isrc.setSystemId(uri);
SVGOMDocument doc = (SVGOMDocument) super.createDocument(SVGDOMImplementation.SVG_NAMESPACE_URI, "svg", uri, isrc); //$NON-NLS-1$
doc.setParsedURL(new ParsedURL(uri));
doc.setDocumentInputEncoding(charset);
doc.setXmlStandalone(isStandalone);
doc.setXmlVersion(xmlVersion);
return doc;
}
}
};
return createDocument(factory, false);
}
private Document createDocument(SAXSVGDocumentFactory factory, boolean forceClassLoader) {
if (Messages.BundledImageShape_idMissing.equals(uri)) {
DiagramPlugin.getDefault().logError(Messages.SVGFigure_usingInvalidBundledImageShape);
} else {
ClassLoader previousClassLoader = Thread.currentThread().getContextClassLoader();
if (forceClassLoader) {
Thread.currentThread().setContextClassLoader(this.getClass().getClassLoader());
}
try {
return factory.createDocument(uri);
} catch (IOException e) {
boolean saxParserNotFound = e.getMessage() != null && e.getMessage().contains("SAX2 driver class org.apache.xerces.parsers.SAXParser not found"); //$NON-NLS-1$
if (!forceClassLoader && saxParserNotFound) {
return createDocument(factory, true);
} else {
DiagramPlugin.getDefault().logError(MessageFormat.format(Messages.SVGFigure_loadError, uri), e);
}
} finally {
if (forceClassLoader) {
Thread.currentThread().setContextClassLoader(previousClassLoader);
}
}
}
return null;
}
protected final Document getDocument() {
if (failedToLoadDocument) {
return null;
}
if (transcoder == null) {
loadDocument();
}
return transcoder == null ? null : transcoder.getDocument();
}
/**
* Should be called when SVG document has been changed. It will be re-rendered and figure will be repainted.
*/
public void contentChanged() {
Document document = getDocument();
if (document != null) {
modeWithViewBox = false;
for (int i = 0; i < document.getChildNodes().getLength(); i++) {
org.w3c.dom.Node node = document.getChildNodes().item(i);
if (node instanceof Element) {
String viewBoxValue = ((Element) node).getAttribute("viewBox"); //$NON-NLS-1$
if (!StringUtil.isEmpty(viewBoxValue)) {
// stretch the image is not supported as the current version of Batif used does not handled it
// (org.apache.batik.dom.svg.SVGOMSVGElement.getViewBox()).
modeWithViewBox = true;
}
}
}
}
if (transcoder != null) {
transcoder.contentChanged();
// Each time that SVG document is changed, we store the real ratio of the image (width/height); the
// transcoder aspect ratio. Indeed, after the transcoder aspect ratio will be
// the previous displayed ratio and not the real ratio of the image.
initialAspectRatio = transcoder.getAspectRatio();
}
repaint();
}
protected SimpleImageTranscoder getTranscoder() {
return transcoder;
}
protected SimpleImageTranscoder initTranscoder(Document document) {
return new SimpleImageTranscoder(document);
}
/**
* The key used to store the document.
*
* @return the key.
*/
protected String getDocumentKey() {
return uri;
}
/**
* Compute a key for this figure. This key is used to store in cache the corresponding
* {@link org.eclipse.swt.graphics.Image}.
*
* The key must begin by the document key.
*
* @return The key corresponding to this BundleImageFigure.
*/
protected String getKey(Graphics graphics) {
int aaText = SWT.DEFAULT;
try {
if (graphics != null) {
aaText = graphics.getTextAntialias();
}
} catch (Exception e) {
// not supported
}
StringBuffer result = new StringBuffer();
result.append(getDocumentKey());
result.append(SVGFigure.SEPARATOR);
result.append(getSiriusAlpha());
result.append(SVGFigure.SEPARATOR);
result.append(aaText);
result.append(SVGFigure.SEPARATOR);
Rectangle r = new PrecisionRectangle(getClientArea());
if (CACHE_SCALED_IMAGES && graphics != null) {
r.performScale(graphics.getAbsoluteScale());
}
result.append(r.width());
result.append(SVGFigure.SEPARATOR);
result.append(r.height());
return result.toString();
}
@Override
protected void paintFigure(Graphics graphics) {
TransparentFigureGraphicsModifier modifier = new TransparentFigureGraphicsModifier(this, graphics);
modifier.pushState();
Rectangle svgArea = getClientArea();
if (CACHE_SCALED_IMAGES) {
Rectangle scaledArea = new PrecisionRectangle(svgArea);
scaledArea.performScale(graphics.getAbsoluteScale());
// specific case for SVG export
if (SiriusDiagramSVGGenerator.isEmbeddedSVGinSVGExportEnabled() && graphics instanceof SiriusRenderedMapModeGraphics
&& ((SiriusRenderedMapModeGraphics) graphics).getGraphics() instanceof SiriusGraphicsSVG) {
paintSVGReference(graphics, svgArea, scaledArea);
} else {
// paint rendered bitmap
paintRenderedBitmap(graphics, svgArea, scaledArea);
}
} else {
Image image = getImage(svgArea, graphics);
if (image != null) {
synchronized (image) {
if (!image.isDisposed()) {
graphics.drawImage(image, svgArea.x(), svgArea.y());
}
}
}
}
modifier.popState();
}
/**
* Paint rendered bitmap.
*
* @param graphics
* Graphics
* @param svgArea
* Rectangle
* @param scaledArea
* Rectangle
*/
protected void paintRenderedBitmap(Graphics graphics, Rectangle svgArea, Rectangle scaledArea) {
Image image = getImage(svgArea, graphics);
if (image != null) {
synchronized (image) {
if (!image.isDisposed()) {
if (modeWithViewBox) {
graphics.drawImage(image, 0, 0, scaledArea.width(), scaledArea.height(), svgArea.x(), svgArea.y(), svgArea.width(), svgArea.height());
} else {
// The scaled width (and height) must not be greater than area width (and height). So
// depending
// on the initialAspectRatio and zoom factor, the reference can be the width or the height.
double scaledWidth = svgArea.width() * graphics.getAbsoluteScale();
double scaledHeight = scaledWidth / getImageAspectRatio();
if ((scaledHeight / graphics.getAbsoluteScale()) > svgArea.height()) {
// The height must be the reference to avoid an IllegalArgumentException later.
scaledHeight = svgArea.height() * graphics.getAbsoluteScale();
scaledWidth = scaledHeight * getImageAspectRatio();
}
graphics.drawImage(image, 0, 0, (int) scaledWidth, (int) scaledHeight, svgArea.x(), svgArea.y(), svgArea.width(), svgArea.height());
}
}
}
}
}
/**
* Paint SVG reference using use tag.
*
* @param graphics
* Graphics
* @param svgArea
* Rectangle
* @param scaledArea
* Rectangle
*/
protected void paintSVGReference(Graphics graphics, Rectangle svgArea, Rectangle scaledArea) {
String imageRegistryURI = computeImageKey(svgArea.width, svgArea.height);
registerSVGDocument(imageRegistryURI, getTranscoder().getDocument(), svgArea);
paintSVGReference(graphics, imageRegistryURI, svgArea, scaledArea);
}
/**
* @param graphics
* Graphics
* @param imageRegistryURI
* String
* @param svgArea
* Rectangle
* @param scaledArea
* Rectangle
*/
protected void paintSVGReference(Graphics graphics, String imageRegistryURI, Rectangle svgArea, Rectangle scaledArea) {
if (modeWithViewBox) {
((SiriusRenderedMapModeGraphics) graphics).drawSVGReference(imageRegistryURI, 0, 0, scaledArea.width(), scaledArea.height(), svgArea.x(), svgArea.y(), svgArea.width(), svgArea.height());
} else {
double scaledWidth = svgArea.width() * graphics.getAbsoluteScale();
double scaledHeight = scaledWidth / getImageAspectRatio();
if ((scaledHeight / graphics.getAbsoluteScale()) > svgArea.height()) {
// The height must be the reference to avoid an IllegalArgumentException later.
scaledHeight = svgArea.height() * graphics.getAbsoluteScale();
scaledWidth = scaledHeight * getImageAspectRatio();
}
((SiriusGraphicsSVG) graphics).drawSVGReference(imageRegistryURI, 0, 0, (int) scaledWidth, (int) scaledHeight, svgArea.x(), svgArea.y(), svgArea.width(), svgArea.height());
}
}
/**
* Compute image key for registry.
*
* @param params
* Obect
* @return image key for registry.
*/
protected String computeImageKey(Object... params) {
StringBuffer imageRegistryURI = new StringBuffer();
imageRegistryURI.append(this.getDocumentKey());
imageRegistryURI.append(SVGFigure.SEPARATOR);
// add width and height for viewbox
if (params.length >= 2) {
imageRegistryURI.append(params[0]);
imageRegistryURI.append(SVGFigure.SEPARATOR);
imageRegistryURI.append(params[1]);
}
return imageRegistryURI.toString();
}
/**
* Add SVG document in registry.
*
* @param imageRegistryKey
* String
* @param document
* Document
* @param params
* Object
*/
protected void registerSVGDocument(String imageRegistryKey, Document document, Object... params) {
if (params.length > 0 && params[0] instanceof Rectangle) {
SVGImageRegistry.registerSVGDocument(imageRegistryKey.toString(), SVGImageRegistry.getSVGDocument(getTranscoder().getDocument(), (Rectangle) params[0]));
}
}
/**
* Get the image cached or create new one and cache it.
*
* @param clientArea
* the client area
* @param graphics
* the graphical context
* @return an image store in a cache
*/
protected Image getImage(Rectangle clientArea, Graphics graphics) {
if (CACHE_ENABLED) {
return CACHE.getImage(this, clientArea, graphics);
} else if (transcoder != null) {
return transcoder.render(this, clientArea, graphics, CACHE_SCALED_IMAGES);
} else {
return null;
}
}
/**
* Remove all entries whose key begins with the given key. Remove from the document map, the entries with the given
* keys to force to re-read the file.
*
* @param documentKey
* the document key.
* @return true of something was removed.
*/
protected static boolean doRemoveFromCache(final String documentKey) {
if (!StringUtil.isEmpty(documentKey)) {
return CACHE.doRemoveFromCache(documentKey) || SVGFigure.documentsMap.remove(documentKey) != null;
}
return false;
}
// CHECKSTYLE:ON
}