/* * $Id$ * * Copyright (c) 2000-2010 by Rodney Kinney, Joel Uckelman * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Library General Public * License (LGPL) as published by the Free Software Foundation. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Library General Public License for more details. * * You should have received a copy of the GNU Library General Public * License along with this library; if not, copies are available * at http://www.opensource.org. */ package VASSAL.build.module.map; import java.awt.Color; import java.awt.Dimension; import java.awt.Frame; import java.awt.Graphics2D; import java.awt.Image; import java.awt.MediaTracker; import java.awt.Rectangle; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.image.BufferedImage; import java.awt.image.RenderedImage; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.io.File; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import javax.imageio.ImageIO; import javax.imageio.ImageWriter; import javax.imageio.event.IIOWriteProgressListener; import javax.imageio.stream.ImageOutputStream; import javax.swing.SwingUtilities; import org.jdesktop.swingworker.SwingWorker; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import VASSAL.build.AbstractConfigurable; import VASSAL.build.AutoConfigurable; import VASSAL.build.Buildable; import VASSAL.build.GameModule; import VASSAL.build.module.Map; import VASSAL.build.module.documentation.HelpFile; import VASSAL.configure.ColorConfigurer; import VASSAL.configure.Configurer; import VASSAL.configure.ConfigurerFactory; import VASSAL.configure.IconConfigurer; import VASSAL.i18n.Resources; import VASSAL.tools.ErrorDialog; import VASSAL.tools.LaunchButton; import VASSAL.tools.NamedKeyStroke; import VASSAL.tools.WriteErrorDialog; import VASSAL.tools.filechooser.FileChooser; import VASSAL.tools.filechooser.PNGFileFilter; import VASSAL.tools.io.IOUtils; import VASSAL.tools.swing.ProgressDialog; // FIXME: Replace this in 3.2 with tiling code. /** * This allows the user to capture a snapshot of the entire map into * a PNG file. */ public class ImageSaver extends AbstractConfigurable { private static final Logger logger = LoggerFactory.getLogger(ImageSaver.class); protected LaunchButton launch; protected Map map; protected boolean promptToSplit = false; protected static final String DEFAULT_ICON = "/images/camera.gif"; protected static ProgressDialog dialog; public ImageSaver() { final ActionListener al = new ActionListener() { public void actionPerformed(ActionEvent e) { writeMapAsImage(); } }; launch = new LaunchButton(null, TOOLTIP, BUTTON_TEXT, HOTKEY, ICON_NAME, al); // Set defaults for backward compatibility launch.setAttribute(TOOLTIP, "Save Map as PNG image"); launch.setAttribute(BUTTON_TEXT, ""); launch.setAttribute(ICON_NAME, DEFAULT_ICON); } public ImageSaver(Map m) { super(); map = m; } /** * Expects to be added to a {@link Map}. Adds a button to the map window * toolbar that initiates the capture */ public void addTo(Buildable b) { map = (Map) b; map.getToolBar().add(launch); } public void removeFrom(Buildable b) { map = (Map) b; map.getToolBar().remove(launch); map.getToolBar().revalidate(); } protected static final String HOTKEY = "hotkey"; protected static final String BUTTON_TEXT = "buttonText"; protected static final String TOOLTIP = "tooltip"; protected static final String ICON_NAME = "icon"; public String[] getAttributeNames() { return new String[] { BUTTON_TEXT, TOOLTIP, ICON_NAME, HOTKEY }; } public String[] getAttributeDescriptions() { return new String[] { Resources.getString(Resources.BUTTON_TEXT), Resources.getString(Resources.TOOLTIP_TEXT), Resources.getString(Resources.BUTTON_ICON), Resources.getString(Resources.HOTKEY_LABEL), }; } public Class<?>[] getAttributeTypes() { return new Class<?>[] { String.class, String.class, IconConfig.class, NamedKeyStroke.class }; } public static class IconConfig implements ConfigurerFactory { public Configurer getConfigurer(AutoConfigurable c, String key, String name) { return new IconConfigurer(key, name, DEFAULT_ICON); } } public void setAttribute(String key, Object value) { launch.setAttribute(key, value); } public String getAttributeValueString(String key) { return launch.getAttributeValueString(key); } /** * Write a PNG-encoded snapshot of the map. */ public void writeMapAsImage() { // prompt user for image filename final FileChooser fc = GameModule.getGameModule().getFileChooser(); fc.setSelectedFile( new File(fc.getCurrentDirectory(), GameModule.getGameModule().getLocalizedGameName() + "Map.png") ); fc.addChoosableFileFilter(new PNGFileFilter()); final Frame frame = (Frame) SwingUtilities.getAncestorOfClass(Frame.class, map.getView()); if (fc.showSaveDialog(frame) != FileChooser.APPROVE_OPTION) return; final File file = fc.getSelectedFile(); dialog = new ProgressDialog(frame, "Saving Map Image", "Saving map image..."); // force the dialog to be a reasonable width // FIXME: this is not really a good way to do this---should do // something with the minimum size or font metrics final int l = "Saving map image as ".length() + file.getName().length() + 6; final StringBuilder b = new StringBuilder(); for (int i = 0; i < l; i++) b.append("N"); dialog.setLabel(b.toString()); dialog.pack(); dialog.setLabel("Saving map image as "); dialog.setIndeterminate(true); dialog.setLocationRelativeTo(frame); // get the dimensions of the image to write final Dimension s = map.mapSize(); if (s.width == 0) s.width = 1; if (s.height == 0) s.height = 1; int w = (int) Math.round(s.width * map.getZoom()); int h = (int) Math.round(s.height * map.getZoom()); // ensure that the resulting image is at least 1x1 if (w < 1 || h < 1) { if (s.width < s.height) { w = 1; h = s.height/s.width; } else { h = 1; w = s.width/s.height; } } writeMapRectAsImage(file, 0, 0, w, h); dialog.setVisible(true); } /** * Helper method for writing images. * * @param file the file to write * @param x the left edge of the map area to write * @param y the top edge of the map area to write * @param w the width of the map area to write * @param h the height of the map area to write */ protected void writeMapRectAsImage(File file, int x, int y, int w, int h) { final SnapshotTask task = new SnapshotTask(file, x, y, w, h); task.addPropertyChangeListener(new PropertyChangeListener() { public void propertyChange(PropertyChangeEvent e) { if ("progress".equals(e.getPropertyName())) { dialog.setProgress((Integer) e.getNewValue()); } else if ("state".equals(e.getPropertyName())) { if ((SwingWorker.StateValue) e.getNewValue() == SwingWorker.StateValue.DONE) { // close the dialog on cancellation or completion dialog.setVisible(false); dialog.dispose(); } } } }); dialog.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent e) { task.cancel(true); } }); task.execute(); } private class SnapshotTask extends SwingWorker<Void,Void> { private int tiles; private int tilesDone = 0; private final File file; @SuppressWarnings("unused") private final int x; @SuppressWarnings("unused") private final int y; private final int w; private final int h; private final Color bg = ColorConfigurer.stringToColor( map.getAttributeValueString(Map.BACKGROUND_COLOR)); private final List<File> files = new ArrayList<File>(); // FIXME: SnapshotTask ignores x,y! public SnapshotTask(File file, int x, int y, int w, int h) { this.file = file; this.x = x; this.y = y; this.w = w; this.h = h; } private void writeImage(final File f, BufferedImage img, Rectangle r) throws IOException { files.add(f); // make sure that we can write the file before proceeding if (f.exists()) { if (!f.canWrite()) { throw new IOException( "Cannot write to the file \"" + f.getAbsolutePath() + "\"" ); } } else { final File p = f.getParentFile(); if (p.isDirectory() && !p.canWrite()) { throw new IOException( "Cannot write to the directory \"" + p.getAbsolutePath() + "\"" ); } } // update the dialog on the EDT SwingUtilities.invokeLater(new Runnable() { public void run() { dialog.setLabel("Saving map image as " + f.getName() + ":"); dialog.setIndeterminate(true); } }); // FIXME: do something to estimate how long painting will take final Graphics2D g = img.createGraphics(); final Color oc = g.getColor(); g.setColor(bg); g.fillRect(0, 0, img.getWidth(), img.getHeight()); g.setColor(oc); g.translate(-r.x, -r.y); map.paintRegion(g, r, null); g.dispose(); // update the dialog on the EDT SwingUtilities.invokeLater(new Runnable() { public void run() { dialog.setIndeterminate(false); } }); final ImageWriter iw = ImageIO.getImageWritersByFormatName("png").next(); iw.addIIOWriteProgressListener(new IIOWriteProgressListener() { public void imageComplete(ImageWriter source) { } public void imageProgress(ImageWriter source, float percentageDone) { setProgress(Math.round((100*tilesDone + percentageDone)/tiles)); } public void imageStarted(ImageWriter source, int imageIndex) { } public void thumbnailComplete(ImageWriter source) { } public void thumbnailProgress(ImageWriter source, float percentageDone) { } public void thumbnailStarted(ImageWriter source, int imageIndex, int thumbnailIndex) { } public void writeAborted(ImageWriter source) { } }); ImageOutputStream os = null; try { os = ImageIO.createImageOutputStream(f); if (os == null) { throw new IOException("Failed to write file " + f.getAbsolutePath()); } iw.setOutput(os); iw.write(img); os.close(); } finally { iw.dispose(); IOUtils.closeQuietly(os); } } @Override public Void doInBackground() throws IOException { setProgress(0); int tw = w; int th = h; BufferedImage img = null; // ensure that the size of the image data array (4 bytes per pixel) // does not exceed the maximum array size, 2^31-1 elements; // otherwise we'll overflow an int and have a negavive array size while ((long)tw * (long)th > Integer.MAX_VALUE/4) { if (tw > th) { tw = (int) Math.ceil(tw/2.0); } else { th = (int) Math.ceil(th/2.0); } } // find a size of BufferedImage we can allocate successfully while (img == null) { try { img = new BufferedImage(tw, th, BufferedImage.TYPE_INT_ARGB); } catch (OutOfMemoryError e) { if (tw > th) { tw = (int) Math.ceil(tw/2.0); } else { th = (int) Math.ceil(th/2.0); } } } if (tw == w && th == h) { // write the whole map as one image tiles = 1; writeImage(file, img, new Rectangle(0, 0, w, h)); } else { // get the base name of the files to write final String base; final String suffix; final String s = file.getName(); if (s.endsWith(".png")) { base = s.substring(0, s.lastIndexOf('.')); suffix = ".png"; } else { base = s; suffix = ""; } // calculate total tiles final int tcols = (int) Math.ceil((double) w / tw); final int trows = (int) Math.ceil((double) h / th); tiles = tcols * trows; // tile across the map with images of size tw by th. for (int tx = 0; tx < tcols; ++tx) { for (int ty = 0; ty < trows; ++ty) { final File f = new File(file.getParent(), base + "." + tx + "." + ty + suffix); final Rectangle r = new Rectangle( tw*tx, th*ty, Math.min(tw, w - tw*tx), Math.min(th, h - th*ty) ); writeImage(f, img, r); ++tilesDone; } } } return null; } @Override protected void done() { try { get(); } catch (CancellationException e) { // on cancellation, remove all files we created for (File f : files) f.delete(); } catch (InterruptedException e) { ErrorDialog.bug(e); } catch (ExecutionException e) { final Throwable c = e.getCause(); if (c instanceof IOException) { WriteErrorDialog.error(e, (IOException) c, files.get(files.size()-1)); } else { ErrorDialog.bug(e); } } } } /** * Write a PNG-encoded snapshot of the map to the given OutputStreams, * dividing the map into vertical sections, one per stream * * @deprecated */ @Deprecated public void writeImage(OutputStream[] out) throws IOException { Dimension buffer = map.getEdgeBuffer(); int totalWidth = (int) ((map.mapSize().width - 2 * buffer.width) * map.getZoom()); int totalHeight = (int) ((map.mapSize().height - 2 * buffer.height) * map.getZoom()); for (int i = 0; i < out.length; ++i) { int height = totalHeight / out.length; if (i == out.length - 1) { height = totalHeight - height * (out.length - 1); } Image output = map.getView().createImage(totalWidth, height); Graphics2D gg = (Graphics2D) output.getGraphics(); map.paintRegion(gg, new Rectangle( -(int) (map.getZoom() * buffer.width), -(int) (map.getZoom() * buffer.height) + height * i, totalWidth, totalHeight), null); gg.dispose(); try { MediaTracker t = new MediaTracker(map.getView()); t.addImage(output, 0); t.waitForID(0); } catch (Exception e) { logger.error("", e); } try { if (output instanceof RenderedImage) { ImageIO.write((RenderedImage) output, "png", out[i]); } else { throw new IOException("Bad image type"); } } finally { try { out[i].close(); } catch (IOException e) { logger.error("", e); } } } } public HelpFile getHelpFile() { return HelpFile.getReferenceManualPage("Map.htm", "ImageCapture"); } public static String getConfigureTypeName() { return Resources.getString("Editor.ImageSaver.component_type"); //$NON-NLS-1$ } public Class<?>[] getAllowableConfigureComponents() { return new Class<?>[0]; } }