blob: b0cffe0201bef884973d2d1e99c5d48d6ea488b9 [file] [log] [blame]
/*******************************************************************************
* (c) Copyright 2015 l33t labs LLC and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* l33t labs LLC and others - initial contribution
*******************************************************************************/
package org.eclipse.ui.images.renderer;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import javax.imageio.ImageIO;
import org.apache.batik.dom.svg.SAXSVGDocumentFactory;
import org.apache.batik.gvt.renderer.ImageRenderer;
import org.apache.batik.transcoder.ErrorHandler;
import org.apache.batik.transcoder.TranscoderException;
import org.apache.batik.transcoder.TranscoderInput;
import org.apache.batik.transcoder.TranscoderOutput;
import org.apache.batik.transcoder.image.PNGTranscoder;
import org.apache.batik.util.XMLResourceDescriptor;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugin.logging.Log;
import org.w3c.dom.Element;
import org.w3c.dom.svg.SVGDocument;
import com.jhlabs.image.ContrastFilter;
import com.jhlabs.image.GrayscaleFilter;
import com.jhlabs.image.HSBAdjustFilter;
import com.jhlabs.image.PointFilter;
import com.jhlabs.image.TransferFilter;
/**
* <p>Mojo which renders SVG icons into PNG format.</p>
*
* @goal render-icons
* @phase generate-resources
*/
public class RenderMojo extends AbstractMojo {
/** Maven logger */
Log log;
/** Used for high res rendering support. */
public static final String ECLIPSE_SVG_SCALE = "eclipse.svg.scale";
/** Used to specify the number of render threads when rasterizing icons. */
public static final String RENDERTHREADS = "eclipse.svg.renderthreads";
/** A list of directories with svg sources to rasterize. */
private List<IconEntry> icons;
/** The pool used to render multiple icons concurrently. */
private ExecutorService execPool;
/** The number of threads to use when rendering icons. */
private int threads;
/**
* A counter used to keep track of the number of rendered icons. Atomic is
* used to make it easy to access between threads concurrently.
*/
private AtomicInteger counter;
/** List of icons that failed to render, made safe for parallel access */
List<IconEntry> failedIcons = Collections
.synchronizedList(new ArrayList<IconEntry>(5));
/** The amount of scaling to apply to rasterized images. */
private double outputScale;
/**
* @return the number of icons rendered at the time of the call
*/
public int getIconsRendered() {
return counter.get();
}
/**
* @return the number of icons that failed during the rendering process
*/
public int getFailedIcons() {
return failedIcons.size();
}
/**
* <p>Generates raster images from the input SVG vector image.</p>
*
* @param icon
* the icon to render
*/
public void rasterize(IconEntry icon, GrayscaleFilter grayFilter, HSBAdjustFilter desaturator, ContrastFilter decontrast) {
if (icon == null) {
log.error("Null icon definition, skipping.");
failedIcons.add(icon);
return;
}
if (icon.inputPath == null) {
log.error("Null icon input path, skipping: "
+ icon.nameBase);
failedIcons.add(icon);
return;
}
if (!icon.inputPath.exists()) {
log.error("Input path specified does not exist, skipping: "
+ icon.nameBase);
failedIcons.add(icon);
return;
}
if (icon.outputPath != null && !icon.outputPath.exists()) {
icon.outputPath.mkdirs();
}
if (icon.disabledPath != null && !icon.disabledPath.exists()) {
icon.disabledPath.mkdirs();
}
// Create the document to rasterize
SVGDocument svgDocument = generateSVGDocument(icon);
if(svgDocument == null) {
return;
}
// Determine the output sizes (native, double, quad)
// We render at quad size and resample down for output
Element svgDocumentNode = svgDocument.getDocumentElement();
String nativeWidthStr = svgDocumentNode.getAttribute("width");
String nativeHeightStr = svgDocumentNode.getAttribute("height");
int nativeWidth = Integer.parseInt(nativeWidthStr);
int nativeHeight = Integer.parseInt(nativeHeightStr);
int outputWidth = (int) (nativeWidth * outputScale);
int outputHeight = (int) (nativeHeight * outputScale);
// Guesstimate the PNG size in memory, BAOS will enlarge if necessary.
int outputInitSize = nativeWidth * nativeHeight * 4 + 1024;
ByteArrayOutputStream iconOutput = new ByteArrayOutputStream(
outputInitSize);
// Render to SVG
try {
log.info(Thread.currentThread().getName() + " "
+ " Rasterizing: " + icon.nameBase + ".png at " + outputWidth
+ "x" + outputHeight);
TranscoderInput svgInput = new TranscoderInput(svgDocument);
boolean success = renderIcon(icon.nameBase, outputWidth, outputHeight, svgInput, iconOutput);
if (!success) {
log.error("Failed to render icon: " + icon.nameBase + ".png, skipping.");
failedIcons.add(icon);
return;
}
} catch (Exception e) {
log.error("Failed to render icon: " + e.getMessage());
failedIcons.add(icon);
return;
}
// Generate a buffered image from Batik's png output
byte[] imageBytes = iconOutput.toByteArray();
ByteArrayInputStream imageInputStream = new ByteArrayInputStream(imageBytes);
BufferedImage inputImage = null;
try {
inputImage = ImageIO.read(imageInputStream);
if(inputImage == null) {
log.error("Failed to generate BufferedImage from rendered icon, ImageIO returned null: " + icon.nameBase);
failedIcons.add(icon);
return;
}
} catch (IOException e2) {
log.error("Failed to generate BufferedImage from rendered icon: " + icon.nameBase + " - " + e2.getMessage());
failedIcons.add(icon);
return;
}
writeIcon(icon, outputWidth, outputHeight, inputImage);
try {
if (icon.disabledPath != null) {
BufferedImage desaturated16 = desaturator.filter(
grayFilter.filter(inputImage, null), null);
BufferedImage deconstrast = decontrast.filter(desaturated16, null);
ImageIO.write(deconstrast, "PNG", new File(icon.disabledPath, icon.nameBase + ".png"));
}
} catch (Exception e1) {
log.error("Failed to render disabled icon: " +
icon.nameBase, e1);
failedIcons.add(icon);
}
}
/**
* <p>Generates a Batik SVGDocument for the supplied IconEntry's input
* file.</p>
*
* @param icon the icon entry to generate an SVG document for
*
* @return a batik SVGDocument instance or null if one could not be generated
*/
private SVGDocument generateSVGDocument(IconEntry icon) {
// Load the document and find out the native height/width
// We reuse the document later for rasterization
SVGDocument svgDocument = null;
try {
FileInputStream iconDocumentStream = new FileInputStream(icon.inputPath);
String parser = XMLResourceDescriptor.getXMLParserClassName();
SAXSVGDocumentFactory f = new SAXSVGDocumentFactory(parser);
// What kind of URI is batik expecting here??? the docs don't say
svgDocument = f.createSVGDocument("file://" + icon.nameBase + ".svg", iconDocumentStream);
} catch (Exception e3) {
log.error("Error parsing SVG icon document: " + e3.getMessage());
failedIcons.add(icon);
return null;
}
return svgDocument;
}
/**
* <p>Resizes the supplied inputImage to the specified width and height, using
* lanczos resampling techniques.</p>
*
* @param icon the icon that's being resized
* @param width the desired output width after rescaling operations
* @param height the desired output height after rescaling operations
* @param sourceImage the source image to resource
*/
private void writeIcon(IconEntry icon, int width, int height, BufferedImage sourceImage) {
try {
String outputName = icon.nameBase;
if (outputScale != 1) {
String scaleId = outputScale == (double) (int) outputScale ? Integer.toString((int) outputScale): Double.toString(outputScale);
outputName += "@" + scaleId + "x";
}
outputName += ".png";
ImageIO.write(sourceImage, "PNG", new File(icon.outputPath, outputName));
} catch (Exception e1) {
log.error("Failed to resize rendered icon to output size: " +
icon.nameBase, e1);
failedIcons.add(icon);
}
}
/**
* <p>Handles concurrently rasterizing the icons to
* reduce the duration on multicore systems.</p>
*/
public void rasterizeAll() {
// The number of icons that haven't been distributed to
// callables
int remainingIcons = icons.size();
// The number of icons to distribute to a rendering callable
final int threadExecSize = Math.max(1, icons.size() / this.threads);
// The current offset to start a batch, as they're distributed
// between rendering callables
int batchOffset = 0;
// A list of callables used to render icons on multiple threads
// Each callable gets a set of icons to render
List<Callable<Object>> tasks = new ArrayList<Callable<Object>>(
this.threads);
// Distribute the rasterization operations between multiple threads
while (remainingIcons > 0) {
// The current start index for the current batch
final int batchStart = batchOffset;
// Increment the offset to reflect this batch (used for the next batch)
batchOffset += threadExecSize;
// Determine this batch size, used for batches that have less than
// threadExecSize at the end of the distribution operation
int batchSize = 0;
// Determine if we can fit a full batch in this callable
// or if we are at the end of gathered icons
if (remainingIcons > threadExecSize) {
batchSize = threadExecSize;
} else {
// We have less than a full batch worth of remaining icons
// just add them all
batchSize = remainingIcons;
}
// Deincrement the remaining Icons
remainingIcons -= threadExecSize;
// Used for access in the callable's scope
final int execCount = batchSize;
// Create the callable and add it to the task pool
Callable<Object> runnable = new Callable<Object>() {
public Object call() throws Exception {
// The jhlabs filters are not thread safe, so provide one set per thread
GrayscaleFilter grayFilter = new GrayscaleFilter();
HSBAdjustFilter desaturator = new HSBAdjustFilter();
desaturator.setSFactor(0.0f);
ContrastFilter decontrast = new ContrastFilter();
decontrast.setBrightness(2.9f);
decontrast.setContrast(0.2f);
// Rasterize this batch
for (int count = 0; count < execCount; count++) {
rasterize(icons.get(batchStart + count), grayFilter, desaturator, decontrast);
}
// Update the render counter
counter.getAndAdd(execCount);
log.info("Finished rendering batch, index: " + batchStart);
return null;
}
};
tasks.add(runnable);
}
// Execute the rasterization operations that
// have been added to the pool
try {
execPool.invokeAll(tasks);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
// Print info about failed render operations, so they can be fixed
log.info("Failed Icon Count: " + failedIcons.size());
for (IconEntry icon : failedIcons) {
log.info("Failed Icon: " + icon.nameBase);
}
}
/**
* Use batik to rasterize the input SVG into a raster image at the specified
* image dimensions.
*
* @param width the width to render the icons at
* @param height the height to render the icon at
* @param input the SVG transcoder input
* @param stream the stream to write the PNG data to
*/
public boolean renderIcon(final String iconName, int width, int height,
TranscoderInput tinput, OutputStream stream) {
PNGTranscoder transcoder = new PNGTranscoder() {
protected ImageRenderer createRenderer() {
ImageRenderer renderer = super.createRenderer();
RenderingHints renderHints = renderer.getRenderingHints();
renderHints.add(new RenderingHints(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_OFF));
renderHints.add(new RenderingHints(RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY));
renderHints.add(new RenderingHints(RenderingHints.KEY_DITHERING,
RenderingHints.VALUE_DITHER_DISABLE));
renderHints.add(new RenderingHints(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BICUBIC));
renderHints.add(new RenderingHints(RenderingHints.KEY_ALPHA_INTERPOLATION,
RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY));
renderHints.add(new RenderingHints(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON));
renderHints.add(new RenderingHints(RenderingHints.KEY_COLOR_RENDERING,
RenderingHints.VALUE_COLOR_RENDER_QUALITY));
renderHints.add(new RenderingHints(RenderingHints.KEY_STROKE_CONTROL,
RenderingHints.VALUE_STROKE_PURE));
renderHints.add(new RenderingHints(RenderingHints.KEY_FRACTIONALMETRICS,
RenderingHints.VALUE_FRACTIONALMETRICS_ON));
renderer.setRenderingHints(renderHints);
return renderer;
}
};
transcoder.addTranscodingHint(PNGTranscoder.KEY_WIDTH, new Float(width));
transcoder.addTranscodingHint(PNGTranscoder.KEY_HEIGHT, new Float(height));
transcoder.setErrorHandler(new ErrorHandler() {
public void warning(TranscoderException arg0)
throws TranscoderException {
log.error("Icon: " + iconName + " - WARN: " + arg0.getMessage());
}
public void fatalError(TranscoderException arg0)
throws TranscoderException {
log.error("Icon: " + iconName + " - FATAL: " + arg0.getMessage());
}
public void error(TranscoderException arg0)
throws TranscoderException {
log.error("Icon: " + iconName + " - ERROR: " + arg0.getMessage());
}
});
// Transcode the SVG document input to a PNG via the output stream
TranscoderOutput output = new TranscoderOutput(stream);
try {
transcoder.transcode(tinput, output);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
try {
stream.close();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
/**
* <p>Initializes rasterizer defaults</p>
*
* @param threads the number of threads to render with
* @param scale multiplier to use with icon output dimensions
*/
private void init(int threads, double scale) {
this.threads = threads;
this.outputScale = Math.max(1, scale);
icons = new ArrayList<IconEntry>();
execPool = Executors.newFixedThreadPool(threads);
counter = new AtomicInteger();
}
/**
* @see AbstractMojo#execute()
*/
public void execute() throws MojoExecutionException, MojoFailureException {
log = getLog();
// Default to 2x the number of processor cores but allow override via jvm arg
int threads = Math.max(1, Runtime.getRuntime().availableProcessors() * 2);
String threadStr = System.getProperty(RENDERTHREADS);
if (threadStr != null) {
try {
threads = Integer.parseInt(threadStr);
} catch (Exception e) {
e.printStackTrace();
System.out
.println("Could not parse thread count, using default thread count");
}
}
// if high res is enabled, the icons output size will be scaled by iconScale
// Defaults to 1, meaning native size
double iconScale = 1;
String iconScaleStr = System.getProperty(ECLIPSE_SVG_SCALE);
if (iconScaleStr != null) {
iconScale = Double.parseDouble(iconScaleStr);
if (iconScale != 1 && iconScale != 1.5 && iconScale != 2) {
log.warn("Unusual scale factor: " + iconScaleStr + " (@" + iconScale + "x)");
}
}
// Track the time it takes to render the entire set
long totalStartTime = System.currentTimeMillis();
// initialize defaults (the old renderer was instantiated via constructor)
init(threads, iconScale);
String workingDirectory = System.getProperty("user.dir");
File outputDir = new File(workingDirectory + (iconScale == 1 ? "/eclipse-png/" : "/eclipse-png-highdpi/"));
File iconDirectoryRoot = new File("eclipse-svg/");
// Search each subdir in the root dir for svg icons
for (File file : iconDirectoryRoot.listFiles()) {
if(!file.isDirectory()) {
continue;
}
String dirName = file.getName();
// Where to place the rendered icon
File outputBase = new File(outputDir, (iconScale == 1 ? dirName : dirName + ".highdpi"));
if (iconScale != 1) {
createFragmentFiles(outputBase, dirName);
}
IconGatherer.gatherIcons(icons, "svg", file, file, outputBase, true);
}
log.info("Working directory: " + outputDir.getAbsolutePath());
log.info("SVG Icon Directory: " + iconDirectoryRoot.getAbsolutePath());
log.info("Rendering icons with " + threads + " threads, scaling output to " + iconScale + "x");
long startTime = System.currentTimeMillis();
// Render the icons
rasterizeAll();
// Print summary of operations
int iconRendered = getIconsRendered();
int failedIcons = getFailedIcons();
int fullIconCount = iconRendered - failedIcons;
log.info(fullIconCount + " Icons Rendered");
log.info(failedIcons + " Icons Failed");
log.info("Took: " + (System.currentTimeMillis() - startTime) + " ms.");
log.info("Rasterization operations completed, Took: "
+ (System.currentTimeMillis() - totalStartTime) + " ms.");
}
private void createFragmentFiles(File outputBase, String dirName) {
createFile(new File(outputBase, "build.properties"), "bin.includes = META-INF/,icons/,.\n");
createFile(new File(outputBase, ".project"), "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<projectDescription>\n" +
" <name>" + dirName + ".highdpi</name>\n" +
" <comment></comment>\n" +
" <projects>\n" +
" </projects>\n" +
" <buildSpec>\n" +
" <buildCommand>\n" +
" <name>org.eclipse.pde.ManifestBuilder</name>\n" +
" <arguments>\n" +
" </arguments>\n" +
" </buildCommand>\n" +
" <buildCommand>\n" +
" <name>org.eclipse.pde.SchemaBuilder</name>\n" +
" <arguments>\n" +
" </arguments>\n" +
" </buildCommand>\n" +
" </buildSpec>\n" +
" <natures>\n" +
" <nature>org.eclipse.pde.PluginNature</nature>\n" +
" </natures>\n" +
"</projectDescription>\n");
createFile(new File(outputBase, "META-INF/MANIFEST.MF"), "Manifest-Version: 1.0\n" +
"Bundle-ManifestVersion: 2\n" +
"Bundle-Name: " + dirName + ".highdpi\n" +
"Bundle-SymbolicName: " + dirName + ".highdpi\n" +
"Bundle-Version: 0.1.0.qualifier\n" +
"Fragment-Host: " + dirName + "\n");
}
private void createFile(File file, String contents) {
try {
file.getParentFile().mkdirs();
FileWriter writer = new FileWriter(file);
writer.write(contents);
writer.close();
} catch (IOException e) {
log.error(e);
}
}
}