SpriteBuilder.java
/*
* SmartSprites Project
*
* Copyright (C) 2007-2009, Stanisław Osiński.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* - Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* - Redistributions in binary form must reproduce the above copyright notice, this
* list of conditions and the following disclaimer in the documentation and/or
* other materials provided with the distribution.
*
* - Neither the name of the SmartSprites Project nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* - We kindly request that you include in the end-user documentation provided with
* the redistribution and/or in the software itself an acknowledgement equivalent
* to the following: "This product includes software developed by the SmartSprites
* Project."
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.carrot2.labs.smartsprites;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.common.collect.Sets;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.carrot2.labs.smartsprites.message.LevelCounterMessageSink;
import org.carrot2.labs.smartsprites.message.Message.MessageType;
import org.carrot2.labs.smartsprites.message.MessageLog;
import org.carrot2.labs.smartsprites.resource.FileSystemResourceHandler;
import org.carrot2.labs.smartsprites.resource.ResourceHandler;
import org.carrot2.util.FileUtils;
import org.carrot2.util.PathUtils;
import org.carrot2.util.StringUtils;
/**
* Performs all stages of sprite building. This class is not thread-safe.
*/
public class SpriteBuilder {
/** Properties we need to watch for in terms of overriding the generated ones. */
private static final HashSet<String> OVERRIDING_PROPERTIES = Sets.newHashSet("background-position",
"background-image");
/** This builder's configuration. */
public final SmartSpritesParameters parameters;
/** This builder's message log. */
private final MessageLog messageLog;
/** Directive occurrence collector for this builder. */
private final SpriteDirectiveOccurrenceCollector spriteDirectiveOccurrenceCollector;
/** SpriteImageBuilder for this builder. */
private final SpriteImageBuilder spriteImageBuilder;
/** Resource handler. */
private ResourceHandler resourceHandler;
/**
* Creates a {@link SpriteBuilder} with the provided parameters and log.
*
* @param parameters
* the parameters
* @param messageLog
* the message log
*/
public SpriteBuilder(SmartSpritesParameters parameters, MessageLog messageLog) {
this(parameters, messageLog, new FileSystemResourceHandler(parameters.getDocumentRootDir(),
parameters.getCssFileEncoding(), messageLog));
}
/**
* Creates a {@link SpriteBuilder} with the provided parameters and log.
*
* @param parameters
* the parameters
* @param messageLog
* the message log
* @param resourceHandler
* the resource handler
*/
public SpriteBuilder(SmartSpritesParameters parameters, MessageLog messageLog, ResourceHandler resourceHandler) {
this.messageLog = messageLog;
this.parameters = parameters;
this.resourceHandler = resourceHandler;
spriteDirectiveOccurrenceCollector = new SpriteDirectiveOccurrenceCollector(messageLog, resourceHandler);
spriteImageBuilder = new SpriteImageBuilder(parameters, messageLog, resourceHandler);
}
/**
* Performs processing for this builder's parameters. This method resolves all paths against the local file system.
*
* @throws IOException
* Signals that an I/O exception has occurred.
*/
public void buildSprites() throws IOException {
if (!parameters.validate(messageLog)) {
return;
}
final Collection<String> filePaths;
if (parameters.getCssFiles() != null && !parameters.getCssFiles().isEmpty()) {
// Take directly provided css fle paths
filePaths = parameters.getCssFiles();
// If root dir is provided, filter out those files that are outside root dir
if (StringUtils.isNotBlank(parameters.getOutputDir())) {
filterFilesOutsideRootDir(filePaths);
}
// Make sure the files exist and are really files
for (Iterator<String> it = filePaths.iterator(); it.hasNext();) {
final String path = it.next();
final File file = new File(path);
if (file.exists()) {
if (!file.isFile()) {
messageLog.warning(MessageType.CSS_PATH_IS_NOT_A_FILE, path);
it.remove();
}
} else {
messageLog.warning(MessageType.CSS_FILE_DOES_NOT_EXIST, path);
it.remove();
}
}
} else {
// Take all css files from the root dir
final List<File> files = Lists.newArrayList(org.apache.commons.io.FileUtils
.listFiles(parameters.getRootDirFile(), new String[] { "css" }, true));
Collections.sort(files, Comparator.comparing(File::getAbsolutePath));
filePaths = new ArrayList<>();
for (File file : files) {
filePaths.add(file.getPath());
}
}
buildSprites(filePaths);
}
/**
* Filter files outside root dir.
*
* @param filePaths
* the file paths
*
* @throws IOException
* Signals that an I/O exception has occurred.
*/
private void filterFilesOutsideRootDir(Collection<String> filePaths) throws IOException {
for (Iterator<String> it = filePaths.iterator(); it.hasNext();) {
final String filePath = it.next();
if (!FileUtils.isFileInParent(new File(filePath), parameters.getRootDirFile())) {
it.remove();
messageLog.warning(MessageType.IGNORING_CSS_FILE_OUTSIDE_OF_ROOT_DIR, filePath);
}
}
}
/**
* Performs processing from the list of file paths for this builder's parameters.
*
* @param filePaths
* paths of CSS files to process. Non-absolute paths will be taken relative to the current working
* directory. Both platform-specific and '/' as the file separator are supported.
*
* @throws IOException
* Signals that an I/O exception has occurred.
*/
public void buildSprites(Collection<String> filePaths) throws IOException {
final long start = System.currentTimeMillis();
final LevelCounterMessageSink levelCounter = new LevelCounterMessageSink();
messageLog.addMessageSink(levelCounter);
// Collect sprite declarations from all css files
final Multimap<String, SpriteImageOccurrence> spriteImageOccurrencesByFile = spriteDirectiveOccurrenceCollector
.collectSpriteImageOccurrences(filePaths);
// Merge them, checking for duplicates
final Map<String, SpriteImageOccurrence> spriteImageOccurrencesBySpriteId = spriteDirectiveOccurrenceCollector
.mergeSpriteImageOccurrences(spriteImageOccurrencesByFile);
final Map<String, SpriteImageDirective> spriteImageDirectivesBySpriteId = new LinkedHashMap<>();
for (Map.Entry<String, SpriteImageOccurrence> entry : spriteImageOccurrencesBySpriteId.entrySet()) {
spriteImageDirectivesBySpriteId.put(entry.getKey(), entry.getValue().spriteImageDirective);
}
// Collect sprite references from all css files
final Multimap<String, SpriteReferenceOccurrence> spriteEntriesByFile = spriteDirectiveOccurrenceCollector
.collectSpriteReferenceOccurrences(filePaths, spriteImageDirectivesBySpriteId);
// Now merge and regroup all files by sprite-id
final Multimap<String, SpriteReferenceOccurrence> spriteReferenceOccurrencesBySpriteId = SpriteDirectiveOccurrenceCollector
.mergeSpriteReferenceOccurrences(spriteEntriesByFile);
// Build the sprite images
messageLog.setCssFile(null);
final Multimap<String, SpriteReferenceReplacement> spriteReplacementsByFile = spriteImageBuilder
.buildSpriteImages(spriteImageOccurrencesBySpriteId, spriteReferenceOccurrencesBySpriteId);
// Rewrite the CSS
rewriteCssFiles(spriteImageOccurrencesByFile, spriteReplacementsByFile);
final long stop = System.currentTimeMillis();
if (levelCounter.getWarnCount() > 0) {
messageLog.status(MessageType.PROCESSING_COMPLETED_WITH_WARNINGS, stop - start,
levelCounter.getWarnCount());
} else {
messageLog.status(MessageType.PROCESSING_COMPLETED, stop - start);
}
}
/**
* Rewrites the original files to refer to the generated sprite images.
*
* @param spriteImageOccurrencesByFile
* the sprite image occurrences by file
* @param spriteReplacementsByFile
* the sprite replacements by file
*
* @throws IOException
* Signals that an I/O exception has occurred.
*/
private void rewriteCssFiles(final Multimap<String, SpriteImageOccurrence> spriteImageOccurrencesByFile,
final Multimap<String, SpriteReferenceReplacement> spriteReplacementsByFile) throws IOException {
if (spriteReplacementsByFile.isEmpty()) {
// If nothing to replace, still, copy the original file, so that there
// is some output file.
for (final Map.Entry<String, Collection<SpriteImageOccurrence>> entry : spriteImageOccurrencesByFile.asMap()
.entrySet()) {
final String cssFile = entry.getKey();
createProcessedCss(cssFile, SpriteImageBuilder.getSpriteImageOccurrencesByLineNumber(
spriteImageOccurrencesByFile.get(cssFile)), new HashMap<>());
}
} else {
for (final Map.Entry<String, Collection<SpriteReferenceReplacement>> entry : spriteReplacementsByFile
.asMap().entrySet()) {
final String cssFile = entry.getKey();
final Map<Integer, SpriteReferenceReplacement> spriteReplacementsByLineNumber = SpriteImageBuilder
.getSpriteReplacementsByLineNumber(entry.getValue());
createProcessedCss(cssFile, SpriteImageBuilder.getSpriteImageOccurrencesByLineNumber(
spriteImageOccurrencesByFile.get(cssFile)), spriteReplacementsByLineNumber);
}
}
}
/**
* Rewrites one CSS file to refer to the generated sprite images.
*
* @param originalCssFile
* the original css file
* @param spriteImageOccurrencesByLineNumber
* the sprite image occurrences by line number
* @param spriteReplacementsByLineNumber
* the sprite replacements by line number
*
* @throws IOException
* Signals that an I/O exception has occurred.
*/
private void createProcessedCss(String originalCssFile,
Map<Integer, SpriteImageOccurrence> spriteImageOccurrencesByLineNumber,
Map<Integer, SpriteReferenceReplacement> spriteReplacementsByLineNumber) throws IOException {
final String processedCssFile = getProcessedCssFile(originalCssFile);
messageLog.setCssFile(null);
messageLog.info(MessageType.CREATING_CSS_STYLE_SHEET, processedCssFile);
messageLog.info(MessageType.READING_CSS, originalCssFile);
messageLog.info(MessageType.WRITING_CSS, processedCssFile);
String originalCssLine;
int originalCssLineNumber = -1;
int lastReferenceReplacementLine = -1;
boolean markSpriteImages = parameters.isMarkSpriteImages();
// Generate UID for sprite file
try (BufferedReader originalCssReader = new BufferedReader(
resourceHandler.getResourceAsReader(originalCssFile));
BufferedWriter processedCssWriter = new BufferedWriter(
resourceHandler.getResourceAsWriter(processedCssFile))) {
messageLog.setCssFile(originalCssFile);
originalCssFile = originalCssFile.replace(File.separatorChar, '/');
while ((originalCssLine = originalCssReader.readLine()) != null) {
originalCssLineNumber++;
messageLog.setLine(originalCssLineNumber);
if (originalCssLine.contains("}")) {
lastReferenceReplacementLine = -1;
}
final SpriteImageOccurrence spriteImageOccurrence = spriteImageOccurrencesByLineNumber
.get(originalCssLineNumber);
final SpriteReferenceReplacement spriteReferenceReplacement = spriteReplacementsByLineNumber
.get(originalCssLineNumber);
if (spriteImageOccurrence != null) {
// Ignore line with directive
continue;
}
if (spriteReferenceReplacement != null) {
final boolean important = spriteReferenceReplacement.spriteReferenceOccurrence.important;
lastReferenceReplacementLine = originalCssLineNumber;
processedCssWriter.write(" background-image: url('"
+ getRelativeToReplacementLocation(spriteReferenceReplacement.spriteImage.resolvedPath,
originalCssFile, spriteReferenceReplacement)
+ "')" + (important ? " !important" : "") + ";"
+ (markSpriteImages ? " /** sprite:sprite */" : "") + "\n");
processedCssWriter
.write(" background-position: " + spriteReferenceReplacement.horizontalPositionString + " "
+ spriteReferenceReplacement.verticalPositionString
+ (important ? " !important" : "") + ";\n");
// If the sprite scale is not 1, write out a background-size directive
final float scale = spriteReferenceReplacement.spriteImage.scaleRatio;
if (scale != 1.0f) {
processedCssWriter.write(" background-size: "
+ Math.round(spriteReferenceReplacement.spriteImage.spriteWidth / scale) + "px "
+ Math.round(spriteReferenceReplacement.spriteImage.spriteHeight / scale) + "px;\n");
}
continue;
}
if (lastReferenceReplacementLine >= 0) {
for (final String property : OVERRIDING_PROPERTIES) {
if (originalCssLine.contains(property)) {
messageLog.warning(MessageType.OVERRIDING_PROPERTY_FOUND, property,
lastReferenceReplacementLine);
}
}
}
// Just write the original line
processedCssWriter.write(originalCssLine + "\n");
}
messageLog.setCssFile(null);
}
}
/**
* Returns the sprite image's imagePath relative to the CSS in which we're making replacements. The imagePath is
* relative to the CSS which declared the sprite image. As it may happen that the image is referenced in another CSS
* file, we must make sure the paths are correctly translated.
*
* @param imagePath
* the image path
* @param originalCssFile
* the original css file
* @param spriteReferenceReplacement
* the sprite reference replacement
*
* @return the relative to replacement location
*/
private String getRelativeToReplacementLocation(String imagePath, String originalCssFile,
final SpriteReferenceReplacement spriteReferenceReplacement) {
final String declaringCssPath = spriteReferenceReplacement.spriteImage.spriteImageOccurrence.cssFile
.replace(File.separatorChar, '/');
final String declarationReplacementRelativePath = PathUtils
.getRelativeFilePath(originalCssFile.substring(0, originalCssFile.lastIndexOf('/')),
declaringCssPath.substring(0, declaringCssPath.lastIndexOf('/')))
.replace(File.separatorChar, '/');
return FileUtils.canonicalize(
(Strings.isNullOrEmpty(declarationReplacementRelativePath) || originalCssFile.equals(declaringCssPath)
? "" : declarationReplacementRelativePath + '/') + imagePath,
"/");
}
/**
* Gets the name of the processed CSS file.
*
* @param originalCssFile
* the original css file
*
* @return the processed css file
*/
String getProcessedCssFile(String originalCssFile) {
final int lastDotIndex = originalCssFile.lastIndexOf('.');
final String processedCssFile;
if (lastDotIndex >= 0) {
processedCssFile = originalCssFile.substring(0, lastDotIndex) + parameters.getCssFileSuffix()
+ originalCssFile.substring(lastDotIndex);
} else {
processedCssFile = originalCssFile + parameters.getCssFileSuffix();
}
if (parameters.hasOutputDir()) {
return FileUtils.changeRoot(processedCssFile, parameters.getRootDir(), parameters.getOutputDir());
}
return processedCssFile;
}
}