Java tutorial
/* * This software Copyright by the RPTools.net development team, and licensed under the Affero GPL Version 3 or, at your option, any later version. * * TokenTool Source Code 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. * * You should have received a copy of the GNU Affero General Public License * along with this source Code. If not, please visit <http://www.gnu.org/licenses/> and specifically the Affero license text * at <http://www.gnu.org/licenses/agpl.html>. */ package net.rptools.tokentool.util; import java.awt.image.BufferedImage; import java.io.File; import java.io.FilenameFilter; import java.io.IOException; import java.nio.IntBuffer; import java.nio.file.Path; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import javax.imageio.ImageIO; import javax.imageio.ImageReader; import javax.imageio.metadata.IIOMetadata; import javax.imageio.metadata.IIOMetadataNode; import javax.imageio.stream.ImageInputStream; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.filefilter.IOFileFilter; import org.apache.commons.io.filefilter.SuffixFileFilter; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.w3c.dom.NodeList; import com.twelvemonkeys.imageio.plugins.psd.PSDImageReader; import com.twelvemonkeys.imageio.plugins.psd.PSDMetadata; import javafx.embed.swing.SwingFXUtils; import javafx.geometry.Rectangle2D; import javafx.scene.Group; import javafx.scene.SnapshotParameters; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.image.PixelReader; import javafx.scene.image.PixelWriter; import javafx.scene.image.WritableImage; import javafx.scene.image.WritablePixelFormat; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.stage.FileChooser.ExtensionFilter; public class ImageUtil { private static final Logger log = LogManager.getLogger(ImageUtil.class); private static final int THUMB_SIZE = 100; private static final int COLOR_THRESHOLD = 1; public static ImageView getOverlayThumb(ImageView thumbView, Path filePath) throws IOException { return getImage(thumbView, filePath, true, THUMB_SIZE); } public static ImageView getOverlayImage(ImageView thumbView, Path overlayFileURI) throws IOException { return getImage(thumbView, overlayFileURI, true, 0); } public static ImageView getMaskImage(ImageView thumbView, Path overlayFileURI) throws IOException { return getImage(thumbView, overlayFileURI, false, 0); } private static ImageView getImage(ImageView thumbView, final Path filePath, final boolean overlayWanted, final int THUMB_SIZE) throws IOException { Image thumb = null; String fileURL = filePath.toUri().toURL().toString(); if (ImageUtil.SUPPORTED_IMAGE_FILE_FILTER.accept(null, fileURL)) { if (THUMB_SIZE <= 0) thumb = processMagenta(new Image(fileURL), COLOR_THRESHOLD, overlayWanted); else thumb = processMagenta(new Image(fileURL, THUMB_SIZE, THUMB_SIZE, true, true), COLOR_THRESHOLD, overlayWanted); } else if (ImageUtil.PSD_FILE_FILTER.accept(null, fileURL)) { ImageInputStream is = null; PSDImageReader reader = null; int imageIndex = 1; // Mask layer should always be layer 1 and overlay image on layer 2. Note, layer 0 will be a combined layer composite if (overlayWanted) imageIndex = 2; File file = filePath.toFile(); try { is = ImageIO.createImageInputStream(file); if (is == null || is.length() == 0) { log.info("Image from file " + file.getAbsolutePath() + " is null"); } Iterator<ImageReader> iterator = ImageIO.getImageReaders(is); if (iterator == null || !iterator.hasNext()) { throw new IOException("Image file format not supported by ImageIO: " + filePath); } reader = (PSDImageReader) iterator.next(); reader.setInput(is); BufferedImage thumbBI; thumbBI = reader.read(imageIndex); if (thumbBI != null) { int layerIndex = 0; if (overlayWanted) layerIndex = 1; IIOMetadata metadata = reader.getImageMetadata(0); IIOMetadataNode root = (IIOMetadataNode) metadata .getAsTree(PSDMetadata.NATIVE_METADATA_FORMAT_NAME); NodeList layerInfos = root.getElementsByTagName("LayerInfo"); // Layer index corresponds to imageIndex - 1 in the reader IIOMetadataNode layerInfo = (IIOMetadataNode) layerInfos.item(layerIndex); // Get the width & height of the Mask layer so we can create the overlay the same size int width = reader.getWidth(0); int height = reader.getHeight(0); // Get layer offsets, PhotoShop PSD layers can have different widths/heights and all images start at 0,0 with a layer offset applied int x = Math.max(Integer.parseInt(layerInfo.getAttribute("left")), 0); int y = Math.max(Integer.parseInt(layerInfo.getAttribute("top")), 0); // Lets pad the overlay with transparency to make it the same size as the PSD canvas size thumb = resizeCanvas(SwingFXUtils.toFXImage(thumbBI, null), width, height, x, y); // Finally set ImageView to thumbnail size if (THUMB_SIZE > 0) { thumbView.setFitWidth(THUMB_SIZE); thumbView.setPreserveRatio(true); } } } catch (Exception e) { log.error("Processing: " + file.getAbsolutePath(), e); } finally { // Dispose reader in finally block to avoid memory leaks reader.dispose(); is.close(); } } thumbView.setImage(thumb); return thumbView; } public static Image resizeCanvas(Image imageSource, double newWidth, double newHeight) { int offsetX = (int) ((newWidth - imageSource.getWidth()) / 2); int offsetY = (int) ((newHeight - imageSource.getHeight()) / 2); return resizeCanvas(imageSource, (int) newWidth, (int) newHeight, offsetX, offsetY); } /* * Resize the overall image width/height without scaling the actual image, eg resize the canvas */ public static Image resizeCanvas(Image imageSource, int newWidth, int newHeight, int offsetX, int offsetY) { int sourceWidth = (int) imageSource.getWidth(); int sourceHeight = (int) imageSource.getHeight(); // No work needed here... if (sourceWidth == newWidth && sourceHeight == newHeight) return imageSource; WritableImage outputImage = new WritableImage(newWidth, newHeight); PixelReader pixelReader = imageSource.getPixelReader(); PixelWriter pixelWriter = outputImage.getPixelWriter(); WritablePixelFormat<IntBuffer> format = WritablePixelFormat.getIntArgbInstance(); int[] buffer = new int[sourceWidth * sourceHeight]; pixelReader.getPixels(0, 0, sourceWidth, sourceHeight, format, buffer, 0, sourceWidth); pixelWriter.setPixels(offsetX, offsetY, sourceWidth, sourceHeight, format, buffer, 0, sourceWidth); return outputImage; } /* * Resize the overall image width/height scaled to the target width/height */ public static Image scaleImage(Image source, double targetWidth, double targetHeight, boolean preserveRatio) { ImageView imageView = new ImageView(source); imageView.setPreserveRatio(preserveRatio); imageView.setFitWidth(targetWidth); imageView.setFitHeight(targetHeight); return imageView.snapshot(null, null); } /* * Return the intersection between the source image and the mask. Note, the mask does not need to be magenta anymore, any non-transparent pixel is considering a mask */ private static Image clipImageWithMask(Image imageSource, Image imageMask) { int imageWidth = (int) imageMask.getWidth(); int imageHeight = (int) imageMask.getHeight(); WritableImage outputImage = new WritableImage(imageWidth, imageHeight); PixelReader pixelReader_Mask = imageMask.getPixelReader(); PixelReader pixelReader_Source = imageSource.getPixelReader(); PixelWriter pixelWriter = outputImage.getPixelWriter(); for (int readY = 0; readY < imageHeight; readY++) { for (int readX = 0; readX < imageWidth; readX++) { Color pixelColor = pixelReader_Mask.getColor(readX, readY); if (pixelColor.equals(Color.TRANSPARENT)) pixelWriter.setColor(readX, readY, pixelReader_Source.getColor(readX, readY)); } } return outputImage; } /* * Crop image to smallest width/height based on transparency */ private static Image autoCropImage(Image imageSource) { ImageView croppedImageView = new ImageView(imageSource); PixelReader pixelReader = imageSource.getPixelReader(); int imageWidth = (int) imageSource.getWidth(); int imageHeight = (int) imageSource.getHeight(); int minX = imageWidth, minY = imageHeight, maxX = 0, maxY = 0; // Find the first and last pixels that are not transparent to create a bounding viewport for (int readY = 0; readY < imageHeight; readY++) { for (int readX = 0; readX < imageWidth; readX++) { Color pixelColor = pixelReader.getColor(readX, readY); if (!pixelColor.equals(Color.TRANSPARENT)) { if (readX < minX) minX = readX; if (readX > maxX) maxX = readX; if (readY < minY) minY = readY; if (readY > maxY) maxY = readY; } } } if (maxX - minX <= 0 || maxY - minY <= 0) return new WritableImage(1, 1); // Create a viewport to clip the image using snapshot Rectangle2D viewPort = new Rectangle2D(minX, minY, maxX - minX, maxY - minY); SnapshotParameters parameter = new SnapshotParameters(); parameter.setViewport(viewPort); parameter.setFill(Color.TRANSPARENT); return croppedImageView.snapshot(parameter, null); } public static Image composePreview(StackPane compositeTokenPane, Color bgColor, ImageView portraitImageView, ImageView maskImageView, ImageView overlayImageView, boolean useAsBase, boolean clipImage) { // Process layout as maskImage may have changed size if the overlay was changed compositeTokenPane.layout(); SnapshotParameters parameter = new SnapshotParameters(); Image finalImage = null; Group blend; if (clipImage) { // We need to clip the portrait image first then blend the overlay image over it // We will first get a snapshot of the portrait equal to the mask overlay image width/height double x, y, width, height; x = maskImageView.getParent().getLayoutX(); y = maskImageView.getParent().getLayoutY(); width = maskImageView.getFitWidth(); height = maskImageView.getFitHeight(); Rectangle2D viewPort = new Rectangle2D(x, y, width, height); Rectangle2D maskViewPort = new Rectangle2D(1, 1, width, height); WritableImage newImage = new WritableImage((int) width, (int) height); WritableImage newMaskImage = new WritableImage((int) width, (int) height); ImageView overlayCopyImageView = new ImageView(); ImageView clippedImageView = new ImageView(); parameter.setViewport(viewPort); parameter.setFill(bgColor); portraitImageView.snapshot(parameter, newImage); parameter.setViewport(maskViewPort); parameter.setFill(Color.TRANSPARENT); maskImageView.setVisible(true); maskImageView.snapshot(parameter, newMaskImage); maskImageView.setVisible(false); clippedImageView.setFitWidth(width); clippedImageView.setFitHeight(height); clippedImageView.setImage(clipImageWithMask(newImage, newMaskImage)); // Our masked portrait image is now stored in clippedImageView, lets now blend the overlay image over it // We'll create a temporary group to hold our temporary ImageViews's and blend them and take a snapshot overlayCopyImageView.setImage(overlayImageView.getImage()); overlayCopyImageView.setFitWidth(overlayImageView.getFitWidth()); overlayCopyImageView.setFitHeight(overlayImageView.getFitHeight()); overlayCopyImageView.setOpacity(overlayImageView.getOpacity()); if (useAsBase) { blend = new Group(overlayCopyImageView, clippedImageView); } else { blend = new Group(clippedImageView, overlayCopyImageView); } // Last, we'll clean up any excess transparent edges by cropping it finalImage = autoCropImage(blend.snapshot(parameter, null)); } else { parameter.setFill(Color.TRANSPARENT); finalImage = autoCropImage(compositeTokenPane.snapshot(parameter, null)); } return finalImage; } public static double getScaleXRatio(ImageView imageView) { return imageView.getBoundsInParent().getWidth() / imageView.getImage().getWidth(); } public static double getScaleYRatio(ImageView imageView) { return imageView.getBoundsInParent().getHeight() / imageView.getImage().getHeight(); } /* * This is for Legacy support but can cause magenta bleed on edges if there is transparency overlap. The preferred overlay storage is now PhotoShop PSD format with layer 1 containing the mask and * layer 2 containing the image */ private static Image processMagenta(Image inputImage, int colorThreshold, boolean overlayWanted) { int imageWidth = (int) inputImage.getWidth(); int imageHeight = (int) inputImage.getHeight(); WritableImage outputImage = new WritableImage(imageWidth, imageHeight); PixelReader pixelReader = inputImage.getPixelReader(); PixelWriter pixelWriter = outputImage.getPixelWriter(); for (int readY = 0; readY < imageHeight; readY++) { for (int readX = 0; readX < imageWidth; readX++) { Color pixelColor = pixelReader.getColor(readX, readY); if (isMagenta(pixelColor, COLOR_THRESHOLD) == overlayWanted) pixelWriter.setColor(readX, readY, Color.TRANSPARENT); else pixelWriter.setColor(readX, readY, pixelColor); } } return outputImage; } // Using some fudge factor... private static boolean isMagenta(Color color, int fudge) { if (color.equals(Color.MAGENTA)) return true; double r = color.getRed(); double g = color.getGreen(); double b = color.getBlue(); if (Math.abs(r - b) > fudge) return false; if (g > r - fudge || g > b - fudge) return false; return true; } public static String getFileType(File imageFile) { if (FilenameUtils.getExtension(imageFile.getName()).toLowerCase().equals("psd")) { return "Adobe Photoshop Image"; } else { return FilenameUtils.getExtension(imageFile.getName()).toUpperCase() + " File"; } } /* * These are the file types supported by TokenTool */ public static final String[] SUPPORTED_FILE_FILTER_ARRAY = new String[] { ".psd", ".png", ".gif", ".jpg", ".jpeg", ".bmp" }; public static final IOFileFilter SUPPORTED_FILE_FILTER = new SuffixFileFilter(SUPPORTED_FILE_FILTER_ARRAY); public static final List<ExtensionFilter> GET_EXTENSION_FILTERS() { List<ExtensionFilter> extensionFilters = new ArrayList<ExtensionFilter>(); extensionFilters .add(new ExtensionFilter("All Images", "*.psd", "*.png", "*.gif", "*.jpg", "*.jpeg", "*.bmp")); extensionFilters.add(new ExtensionFilter("PSD Files", "*.psd")); extensionFilters.add(new ExtensionFilter("PNG Files", "*.png")); extensionFilters.add(new ExtensionFilter("JPG Files", "*.jpg")); extensionFilters.add(new ExtensionFilter("JPEG Files", "*.jpeg")); extensionFilters.add(new ExtensionFilter("BMP Files", "*.bmp")); return extensionFilters; } /* * These are the supported image types used in the new Image class */ public static final FilenameFilter SUPPORTED_IMAGE_FILE_FILTER = new FilenameFilter() { public boolean accept(File dir, String name) { name = name.toLowerCase(); return name.endsWith(".png") || name.endsWith(".gif") || name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".bmp"); } }; /* * PSD Support using com.twelvemonkeys.imageio */ public static final FilenameFilter PSD_FILE_FILTER = new FilenameFilter() { public boolean accept(File dir, String name) { return name.toLowerCase().endsWith(".psd"); } }; /* * These are the supported types used in the new Image class */ public static final FilenameFilter SUPPORTED_FILENAME_FILTER = new FilenameFilter() { public boolean accept(File dir, String name) { name = name.toLowerCase(); return name.endsWith(".psd") || name.endsWith(".png") || name.endsWith(".gif") || name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".bmp"); } }; }