| /** |
| * 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 |
| } |