/* * Copyright 2015 Igor Maznitsa. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.igormaznitsa.mindmap.plugins.exporters; import static com.igormaznitsa.mindmap.swing.panel.MindMapPanel.calculateSizeOfMapInPixels; import static com.igormaznitsa.mindmap.swing.panel.MindMapPanel.drawOnGraphicsForConfiguration; import static com.igormaznitsa.mindmap.swing.panel.MindMapPanel.layoutFullDiagramWithCenteringToPaper; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Component; import java.awt.Font; import java.awt.Graphics2D; import java.awt.Image; import java.awt.Rectangle; import java.awt.Shape; import java.awt.Stroke; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.geom.Dimension2D; import java.awt.geom.Path2D; import java.awt.geom.PathIterator; import java.awt.geom.Rectangle2D; import java.awt.geom.RoundRectangle2D; import java.awt.image.BufferedImage; import java.awt.image.RenderedImage; import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import com.igormaznitsa.mindmap.plugins.api.AbstractExporter; import com.igormaznitsa.mindmap.model.Topic; import com.igormaznitsa.mindmap.swing.panel.MindMapPanel; import java.io.IOException; import java.io.OutputStream; import java.text.DecimalFormat; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.imageio.ImageIO; import javax.swing.BorderFactory; import javax.swing.BoxLayout; import javax.swing.JComponent; import com.igormaznitsa.mindmap.swing.panel.Texts; import com.igormaznitsa.mindmap.swing.services.IconID; import com.igormaznitsa.mindmap.swing.services.ImageIconServiceProvider; import com.igormaznitsa.meta.annotation.MustNotContainNull; import javax.swing.Icon; import javax.swing.JCheckBox; import javax.swing.JPanel; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringEscapeUtils; import com.igormaznitsa.meta.common.utils.Assertions; import com.igormaznitsa.mindmap.model.MindMap; import com.igormaznitsa.mindmap.model.logger.Logger; import com.igormaznitsa.mindmap.model.logger.LoggerFactory; import com.igormaznitsa.mindmap.plugins.api.HasOptions; import com.igormaznitsa.mindmap.swing.panel.MindMapPanelConfig; import com.igormaznitsa.mindmap.swing.panel.ui.gfx.StrokeType; import com.igormaznitsa.mindmap.swing.panel.utils.MindMapUtils; import com.igormaznitsa.mindmap.swing.panel.utils.Utils; import com.igormaznitsa.mindmap.swing.services.UIComponentFactory; import com.igormaznitsa.mindmap.swing.services.UIComponentFactoryProvider; import com.igormaznitsa.mindmap.swing.panel.ui.gfx.MMGraphics; public class SVGImageExporter extends AbstractExporter { private static final Logger LOGGER = LoggerFactory.getLogger(SVGImageExporter.class); private static final UIComponentFactory UI_FACTORY = UIComponentFactoryProvider.findInstance(); private boolean flagExpandAllNodes = false; private boolean flagDrawBackground = true; private static final Icon ICO = ImageIconServiceProvider.findInstance().getIconForId(IconID.POPUP_EXPORT_SVG); private static final String SVG_HEADER = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<!-- Generated by SVG Image Exporter plugin of NB Mind Map Swing panel -->\n<svg version=\"1.1\" baseProfile=\"tiny\" id=\"svg-root\" width=\"%d%%\" height=\"%d%%\" viewBox=\"0 0 %s %s\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">"; private static final String NEXT_LINE = "\n"; private static final DecimalFormat DOUBLE = new DecimalFormat("#.###"); private static class Options implements HasOptions { private static final String KEY_EXPAND_ALL = "expand.all"; private static final String KEY_DRAW_BACK = "draw.back"; private boolean expandAll; private boolean drawBack; private Options(final boolean expandAllNodes, final boolean drawBackground) { this.expandAll = expandAllNodes; this.drawBack = drawBackground; } @Override public boolean doesSupportKey(@Nonnull final String key) { return KEY_DRAW_BACK.equals(key) || KEY_EXPAND_ALL.equals(key); } @Override @Nonnull @MustNotContainNull public String[] getOptionKeys() { return new String[]{KEY_EXPAND_ALL, KEY_DRAW_BACK}; } @Override @Nonnull public String getOptionKeyDescription(@Nonnull final String key) { if (KEY_DRAW_BACK.equals(key)) { return "Draw background"; } if (KEY_EXPAND_ALL.equals(key)) { return "Unfold all topics"; } return ""; } @Override public void setOption(@Nonnull final String key, @Nullable final String value) { if (KEY_DRAW_BACK.equals(key)) { this.drawBack = Boolean.parseBoolean(value); } else if (KEY_EXPAND_ALL.equals(key)) { this.expandAll = Boolean.parseBoolean(value); } } @Override @Nullable public String getOption(@Nonnull final String key) { if (KEY_DRAW_BACK.equals(key)) { return Boolean.toString(this.drawBack); } if (KEY_EXPAND_ALL.equals(key)) { return Boolean.toString(this.expandAll); } return null; } } private static final class SVGMMGraphics implements MMGraphics { private final StringBuilder buffer; private final Graphics2D context; private double translateX; private double translateY; private float strokeWidth = 1.0f; private StrokeType strokeType = StrokeType.SOLID; private static final DecimalFormat ALPHA = new DecimalFormat("#.##"); @Nonnull private static String svgRgb(@Nonnull final Color color) { return "rgb(" + color.getRed() + ',' + color.getGreen() + ',' + color.getBlue() + ')'; } private void printFillOpacity(@Nonnull final Color color) { if (color.getAlpha() < 255) { this.buffer.append(" fill-opacity=\"").append(ALPHA.format(color.getAlpha() / 255.0f)).append("\" "); } } private void printFontData() { final Font font = this.context.getFont(); final int style = font.getStyle(); this.buffer.append("font-size=\"").append(dbl2str(font.getSize2D())).append("\" font-family=\"").append(StringEscapeUtils.escapeXml(font.getFamily())).append("\" font-weight=\"") .append((style & Font.BOLD) == 0 ? "normal" : "bold").append("\" font-style=\"").append((style & Font.ITALIC) == 0 ? "normal" : "italic").append('\"'); } private void printStrokeData(@Nonnull final Color color) { this.buffer.append(" stroke=\"").append(svgRgb(color)) .append("\" stroke-width=\"").append(dbl2str(this.strokeWidth)).append("\""); switch (this.strokeType) { case SOLID: this.buffer.append(" stroke-linecap=\"round\""); break; case DASHES: this.buffer.append(" stroke-linecap=\"butt\" stroke-dasharray=\"").append(dbl2str(this.strokeWidth * 3.0f)).append(',').append(dbl2str(this.strokeWidth)).append("\""); break; case DOTS: this.buffer.append(" stroke-linecap=\"butt\" stroke-dasharray=\"").append(dbl2str(this.strokeWidth)).append(',').append(dbl2str(this.strokeWidth * 2.0f)).append("\""); break; } } private SVGMMGraphics(@Nonnull final StringBuilder buffer, @Nonnull final Graphics2D context) { this.buffer = buffer; this.context = (Graphics2D) context.create(); } @Override public float getFontMaxAscent() { return this.context.getFontMetrics().getMaxAscent(); } @Override @Nonnull public Rectangle2D getStringBounds(@Nonnull final String s) { return this.context.getFontMetrics().getStringBounds(s, this.context); } @Override public void setClip(final int x, final int y, final int w, final int h) { this.context.setClip(x, y, w, h); } @Override @Nonnull public MMGraphics copy() { final SVGMMGraphics result = new SVGMMGraphics(this.buffer, this.context); result.translateX = this.translateX; result.translateY = this.translateY; result.strokeType = this.strokeType; result.strokeWidth = this.strokeWidth; return result; } @Override public void dispose() { this.context.dispose(); } @Override public void translate(final double x, final double y) { this.translateX += x; this.translateY += y; this.context.translate(x, y); } @Override @Nullable public Rectangle getClipBounds() { return this.context.getClipBounds(); } @Override public void setStroke(final float width, @Nonnull final StrokeType type) { if (type != this.strokeType || Float.compare(this.strokeWidth, width) != 0) { this.strokeType = type; this.strokeWidth = width; if (type != this.strokeType || Float.compare(this.strokeWidth, width) != 0) { this.strokeType = type; this.strokeWidth = width; final Stroke stroke; switch (type) { case SOLID: stroke = new BasicStroke(width, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER); break; case DASHES: stroke = new BasicStroke(width, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 10.0f, new float[]{width * 2.0f, width}, 0.0f); break; case DOTS: stroke = new BasicStroke(width, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, new float[]{width, width * 2.0f}, 0.0f); break; default: throw new Error("Unexpected stroke type : " + type); } this.context.setStroke(stroke); } } } @Override public void drawLine(final int startX, final int startY, final int endX, final int endY, @Nullable final Color color) { this.buffer.append("<line x1=\"").append(dbl2str(startX + this.translateX)) .append("\" y1=\"").append(dbl2str(startY + this.translateY)) .append("\" x2=\"").append(dbl2str(endX + this.translateX)) .append("\" y2=\"").append(dbl2str(endY + this.translateY)).append("\" "); if (color != null) { printStrokeData(color); printFillOpacity(color); } this.buffer.append("/>").append(NEXT_LINE); } @Override public void drawString(@Nonnull final String text, final int x, final int y, @Nullable final Color color) { this.buffer.append("<text x=\"").append(dbl2str(this.translateX + x)).append("\" y=\"").append(dbl2str(this.translateY + y)).append('\"'); if (color != null) { this.buffer.append(" fill=\"").append(svgRgb(color)).append("\""); printFillOpacity(color); } this.buffer.append(' '); printFontData(); this.buffer.append('>').append(StringEscapeUtils.escapeXml(text)).append("</text>").append(NEXT_LINE); } @Override public void drawRect(final int x, final int y, final int width, final int height, final @Nullable Color border, final @Nullable Color fill) { this.buffer.append("<rect x=\"").append(dbl2str(this.translateX + x)) .append("\" y=\"").append(dbl2str(translateY + y)) .append("\" width=\"").append(dbl2str(width)) .append("\" height=\"").append(dbl2str(height)) .append("\" "); if (border != null) { printStrokeData(border); } if (fill == null) { this.buffer.append(" fill=\"none\""); } else { this.buffer.append(" fill=\"").append(svgRgb(fill)).append("\""); printFillOpacity(fill); } this.buffer.append("/>").append(NEXT_LINE); } @Override public void draw(@Nonnull final Shape shape, @Nullable final Color border, @Nullable final Color fill) { if (shape instanceof RoundRectangle2D) { final RoundRectangle2D rect = (RoundRectangle2D) shape; this.buffer.append("<rect x=\"").append(dbl2str(this.translateX + rect.getX())) .append("\" y=\"").append(dbl2str(translateY + rect.getY())) .append("\" width=\"").append(dbl2str(rect.getWidth())) .append("\" height=\"").append(dbl2str(rect.getHeight())) .append("\" rx=\"").append(dbl2str(rect.getArcWidth() / 2.0d)) .append("\" ry=\"").append(dbl2str(rect.getArcHeight() / 2.0d)) .append("\" "); } else if (shape instanceof Rectangle2D) { final Rectangle2D rect = (Rectangle2D) shape; this.buffer.append("<rect x=\"").append(dbl2str(this.translateX + rect.getX())) .append("\" y=\"").append(dbl2str(translateY + rect.getY())) .append("\" width=\"").append(dbl2str(rect.getWidth())) .append("\" height=\"").append(dbl2str(rect.getHeight())) .append("\" "); } else if (shape instanceof Path2D) { final Path2D path = (Path2D) shape; final double[] data = new double[6]; this.buffer.append("<path d=\""); boolean nofirst = false; for (final PathIterator pi = path.getPathIterator(null); !pi.isDone(); pi.next()) { if (nofirst) { this.buffer.append(' '); } switch (pi.currentSegment(data)) { case PathIterator.SEG_MOVETO: { this.buffer.append("M ").append(dbl2str(this.translateX + data[0])).append(' ').append(dbl2str(this.translateY + data[1])); } break; case PathIterator.SEG_LINETO: { this.buffer.append("L ").append(dbl2str(this.translateX + data[0])).append(' ').append(dbl2str(this.translateY + data[1])); } break; case PathIterator.SEG_CUBICTO: { this.buffer.append("C ") .append(dbl2str(this.translateX + data[0])).append(' ').append(dbl2str(this.translateY + data[1])).append(',') .append(dbl2str(this.translateX + data[2])).append(' ').append(dbl2str(this.translateY + data[3])).append(',') .append(dbl2str(this.translateX + data[4])).append(' ').append(dbl2str(this.translateY + data[5])); } break; case PathIterator.SEG_QUADTO: { this.buffer.append("Q ") .append(dbl2str(this.translateX + data[0])).append(' ').append(dbl2str(this.translateY + data[1])).append(',') .append(dbl2str(this.translateX + data[2])).append(' ').append(dbl2str(this.translateY + data[3])); } break; case PathIterator.SEG_CLOSE: { this.buffer.append("Z"); } break; default: LOGGER.warn("Unexpected path segment type"); } nofirst = true; } this.buffer.append("\" "); } else { LOGGER.warn("Detected unexpected shape : " + shape.getClass().getName()); } if (border != null) { printStrokeData(border); } if (fill == null) { this.buffer.append(" fill=\"none\""); } else { this.buffer.append(" fill=\"").append(svgRgb(fill)).append("\""); printFillOpacity(fill); } this.buffer.append("/>").append(NEXT_LINE); } @Override public void drawCurve(final double startX, final double startY, final double endX, final double endY, @Nullable final Color color) { this.buffer.append("<path d=\"M").append(dbl2str(startX + this.translateX)).append(',').append(startY + this.translateY) .append(" C").append(dbl2str(startX)) .append(',').append(dbl2str(endY)) .append(' ').append(dbl2str(startX)) .append(',').append(dbl2str(endY)) .append(' ').append(dbl2str(endX)) .append(',').append(dbl2str(endY)) .append("\" fill=\"none\""); if (color != null) { printStrokeData(color); } this.buffer.append(" />").append(NEXT_LINE); } @Override public void drawOval(final int x, final int y, final int w, final int h, @Nullable final Color border, @Nullable final Color fill) { final double rx = (double) w / 2.0d; final double ry = (double) h / 2.0d; final double cx = (double) x + this.translateX + rx; final double cy = (double) y + this.translateY + ry; this.buffer.append("<ellipse cx=\"").append(dbl2str(cx)) .append("\" cy=\"").append(dbl2str(cy)) .append("\" rx=\"").append(dbl2str(rx)) .append("\" ry=\"").append(dbl2str(ry)) .append("\" "); if (border != null) { printStrokeData(border); } if (fill == null) { this.buffer.append(" fill=\"none\""); } else { this.buffer.append(" fill=\"").append(svgRgb(fill)).append("\""); printFillOpacity(fill); } this.buffer.append("/>").append(NEXT_LINE); } @Override public void drawImage(@Nonnull final Image image, final int x, final int y) { if (image instanceof RenderedImage) { final RenderedImage ri = (RenderedImage) image; final ByteArrayOutputStream imageBuffer = new ByteArrayOutputStream(1024); try { if (ImageIO.write(ri, "png", imageBuffer)) { this.buffer.append("<image width=\"").append(ri.getWidth()).append("\" height=\"").append(ri.getHeight()).append("\" x=\"").append(dbl2str(this.translateX + x)).append("\" y=\"").append(dbl2str(this.translateY + y)).append("\" xlink:href=\"data:image/png;base64,"); this.buffer.append(Utils.base64encode(imageBuffer.toByteArray())); this.buffer.append("\"/>").append(NEXT_LINE); } else { LOGGER.warn("Can't place image because PNG writer is not found"); } } catch (IOException ex) { LOGGER.error("Can't place image for error", ex); } } else { LOGGER.warn("Can't place image because it is not rendered one : " + image.getClass().getName()); } } @Override public void setFont(@Nonnull final Font font) { this.context.setFont(font); } } @Override @Nullable public String getMnemonic() { return "svg"; } @Override @Nullable public JComponent makeOptions() { final Options options = new Options(flagExpandAllNodes, flagDrawBackground); final JPanel panel = UI_FACTORY.makePanelWithOptions(options); final JCheckBox checkBoxExpandAll = UI_FACTORY.makeCheckBox(); checkBoxExpandAll.setSelected(flagExpandAllNodes); checkBoxExpandAll.setText(Texts.getString("SvgExporter.optionUnfoldAll")); checkBoxExpandAll.setActionCommand("unfold"); final JCheckBox checkBoxDrawBackground = UI_FACTORY.makeCheckBox(); checkBoxDrawBackground.setSelected(flagDrawBackground); checkBoxDrawBackground.setText(Texts.getString("SvgExporter.optionDrawBackground")); checkBoxDrawBackground.setActionCommand("back"); panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); panel.add(checkBoxExpandAll); panel.add(checkBoxDrawBackground); panel.setBorder(BorderFactory.createEmptyBorder(16, 32, 16, 32)); final ActionListener actionListener = new ActionListener() { @Override public void actionPerformed(@Nonnull final ActionEvent e) { if (e.getSource() == checkBoxExpandAll) { options.setOption(Options.KEY_EXPAND_ALL, Boolean.toString(checkBoxExpandAll.isSelected())); } if (e.getSource() == checkBoxDrawBackground) { options.setOption(Options.KEY_DRAW_BACK, Boolean.toString(checkBoxDrawBackground.isSelected())); } } }; checkBoxExpandAll.addActionListener(actionListener); checkBoxDrawBackground.addActionListener(actionListener); return panel; } @Nonnull private static String dbl2str(final double value) { return DOUBLE.format(value); } @Override public void doExport(@Nonnull final MindMapPanel panel, @Nullable final JComponent options, @Nullable final OutputStream out) throws IOException { if (options instanceof HasOptions) { final HasOptions opts = (HasOptions) options; this.flagExpandAllNodes = Boolean.parseBoolean(opts.getOption(Options.KEY_EXPAND_ALL)); this.flagDrawBackground = Boolean.parseBoolean(opts.getOption(Options.KEY_DRAW_BACK)); } else { for (final Component compo : Assertions.assertNotNull(options).getComponents()) { if (compo instanceof JCheckBox) { final JCheckBox cb = (JCheckBox) compo; if ("unfold".equalsIgnoreCase(cb.getActionCommand())) { this.flagExpandAllNodes = cb.isSelected(); } else if ("back".equalsIgnoreCase(cb.getActionCommand())) { this.flagDrawBackground = cb.isSelected(); } } } } final MindMap workMap = new MindMap(panel.getModel(), null); workMap.resetPayload(); if (this.flagExpandAllNodes) { MindMapUtils.removeCollapseAttr(workMap); } final MindMapPanelConfig newConfig = new MindMapPanelConfig(panel.getConfiguration(), false); newConfig.setDrawBackground(this.flagDrawBackground); newConfig.setScale(1.0f); final Dimension2D blockSize = calculateSizeOfMapInPixels(workMap, newConfig, flagExpandAllNodes); if (blockSize == null) { return; } final StringBuilder buffer = new StringBuilder(16384); buffer.append(String.format(SVG_HEADER, 100, 100, dbl2str(blockSize.getWidth()), dbl2str(blockSize.getHeight()))).append(NEXT_LINE); final BufferedImage image = new BufferedImage(32, 32, BufferedImage.TYPE_INT_RGB); final Graphics2D g = image.createGraphics(); final MMGraphics gfx = new SVGMMGraphics(buffer, g); gfx.setClip(0, 0, (int) Math.round(blockSize.getWidth()), (int) Math.round(blockSize.getHeight())); try { layoutFullDiagramWithCenteringToPaper(gfx, workMap, newConfig, blockSize); drawOnGraphicsForConfiguration(gfx, newConfig, workMap, false, null); } finally { gfx.dispose(); } buffer.append("</svg>"); final String text = buffer.toString(); File fileToSaveMap = null; OutputStream theOut = out; if (theOut == null) { fileToSaveMap = MindMapUtils.selectFileToSaveForFileFilter(panel, Texts.getString("SvgExporter.saveDialogTitle"), ".svg", Texts.getString("SvgExporter.filterDescription"), Texts.getString("SvgExporter.approveButtonText")); fileToSaveMap = MindMapUtils.checkFileAndExtension(panel, fileToSaveMap, ".svg");//NOI18N theOut = fileToSaveMap == null ? null : new BufferedOutputStream(new FileOutputStream(fileToSaveMap, false)); } if (theOut != null) { try { IOUtils.write(text, theOut, "UTF-8"); } finally { if (fileToSaveMap != null) { IOUtils.closeQuietly(theOut); } } } } @Override @Nonnull public String getName(@Nonnull final MindMapPanel panel, @Nullable Topic actionTopic, @Nonnull @MustNotContainNull Topic[] selectedTopics) { return Texts.getString("SvgExporter.exporterName"); } @Override @Nonnull public String getReference(@Nonnull final MindMapPanel panel, @Nullable Topic actionTopic, @Nonnull @MustNotContainNull Topic[] selectedTopics) { return Texts.getString("SvgExporter.exporterReference"); } @Override @Nonnull public Icon getIcon(@Nonnull final MindMapPanel panel, @Nullable Topic actionTopic, @Nonnull @MustNotContainNull Topic[] selectedTopics) { return ICO; } @Override public int getOrder() { return 5; } }