• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hazendaz / smartsprites / 266

03 May 2025 07:20PM UTC coverage: 84.619%. Remained the same
266

push

github

hazendaz
Use path over file and fix test that would now break with modern usage (had extra /)

533 of 658 branches covered (81.0%)

Branch coverage included in aggregate %.

1277 of 1481 relevant lines covered (86.23%)

0.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.7
/src/main/java/org/carrot2/labs/smartsprites/SpriteBuilder.java
1
/*
2
 * SmartSprites Project
3
 *
4
 * Copyright (C) 2007-2009, Stanisław Osiński.
5
 * All rights reserved.
6
 *
7
 * Redistribution and use in source and binary forms, with or without modification,
8
 * are permitted provided that the following conditions are met:
9
 *
10
 * - Redistributions of  source code must  retain the above  copyright notice, this
11
 *   list of conditions and the following disclaimer.
12
 *
13
 * - Redistributions in binary form must reproduce the above copyright notice, this
14
 *   list of conditions and the following  disclaimer in  the documentation  and/or
15
 *   other materials provided with the distribution.
16
 *
17
 * - Neither the name of the SmartSprites Project nor the names of its contributors
18
 *   may  be used  to endorse  or  promote  products derived   from  this  software
19
 *   without specific prior written permission.
20
 *
21
 * - We kindly request that you include in the end-user documentation provided with
22
 *   the redistribution and/or in the software itself an acknowledgement equivalent
23
 *   to  the  following: "This product includes software developed by the SmartSprites
24
 *   Project."
25
 *
26
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"  AND
27
 * ANY EXPRESS OR  IMPLIED WARRANTIES, INCLUDING,  BUT NOT LIMITED  TO, THE IMPLIED
28
 * WARRANTIES  OF  MERCHANTABILITY  AND  FITNESS  FOR  A  PARTICULAR  PURPOSE   ARE
29
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE  FOR
30
 * ANY DIRECT, INDIRECT, INCIDENTAL,  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL  DAMAGES
31
 * (INCLUDING, BUT  NOT LIMITED  TO, PROCUREMENT  OF SUBSTITUTE  GOODS OR SERVICES;
32
 * LOSS OF USE, DATA, OR PROFITS;  OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND  ON
33
 * ANY  THEORY  OF  LIABILITY,  WHETHER  IN  CONTRACT,  STRICT  LIABILITY,  OR TORT
34
 * (INCLUDING NEGLIGENCE OR OTHERWISE)  ARISING IN ANY WAY  OUT OF THE USE  OF THIS
35
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36
 */
37
package org.carrot2.labs.smartsprites;
38

39
import com.google.common.base.Strings;
40
import com.google.common.collect.Lists;
41
import com.google.common.collect.Multimap;
42
import com.google.common.collect.Sets;
43

44
import java.io.BufferedReader;
45
import java.io.BufferedWriter;
46
import java.io.File;
47
import java.io.IOException;
48
import java.nio.file.Path;
49
import java.util.ArrayList;
50
import java.util.Collection;
51
import java.util.Collections;
52
import java.util.Comparator;
53
import java.util.HashMap;
54
import java.util.HashSet;
55
import java.util.Iterator;
56
import java.util.LinkedHashMap;
57
import java.util.List;
58
import java.util.Map;
59

60
import org.carrot2.labs.smartsprites.message.LevelCounterMessageSink;
61
import org.carrot2.labs.smartsprites.message.Message.MessageType;
62
import org.carrot2.labs.smartsprites.message.MessageLog;
63
import org.carrot2.labs.smartsprites.resource.FileSystemResourceHandler;
64
import org.carrot2.labs.smartsprites.resource.ResourceHandler;
65
import org.carrot2.util.FileUtils;
66
import org.carrot2.util.PathUtils;
67
import org.carrot2.util.StringUtils;
68

69
/**
70
 * Performs all stages of sprite building. This class is not thread-safe.
71
 */
72
public class SpriteBuilder {
73
    /** Properties we need to watch for in terms of overriding the generated ones. */
74
    private static final HashSet<String> OVERRIDING_PROPERTIES = Sets.newHashSet("background-position",
1✔
75
            "background-image");
76

77
    /** This builder's configuration. */
78
    public final SmartSpritesParameters parameters;
79

80
    /** This builder's message log. */
81
    private final MessageLog messageLog;
82

83
    /** Directive occurrence collector for this builder. */
84
    private final SpriteDirectiveOccurrenceCollector spriteDirectiveOccurrenceCollector;
85

86
    /** SpriteImageBuilder for this builder. */
87
    private final SpriteImageBuilder spriteImageBuilder;
88

89
    /** Resource handler. */
90
    private ResourceHandler resourceHandler;
91

92
    /**
93
     * Creates a {@link SpriteBuilder} with the provided parameters and log.
94
     *
95
     * @param parameters
96
     *            the parameters
97
     * @param messageLog
98
     *            the message log
99
     */
100
    public SpriteBuilder(SmartSpritesParameters parameters, MessageLog messageLog) {
101
        this(parameters, messageLog, new FileSystemResourceHandler(parameters.getDocumentRootDir(),
1✔
102
                parameters.getCssFileEncoding(), messageLog));
1✔
103
    }
1✔
104

105
    /**
106
     * Creates a {@link SpriteBuilder} with the provided parameters and log.
107
     *
108
     * @param parameters
109
     *            the parameters
110
     * @param messageLog
111
     *            the message log
112
     * @param resourceHandler
113
     *            the resource handler
114
     */
115
    public SpriteBuilder(SmartSpritesParameters parameters, MessageLog messageLog, ResourceHandler resourceHandler) {
1✔
116
        this.messageLog = messageLog;
1✔
117
        this.parameters = parameters;
1✔
118
        this.resourceHandler = resourceHandler;
1✔
119
        spriteDirectiveOccurrenceCollector = new SpriteDirectiveOccurrenceCollector(messageLog, resourceHandler);
1✔
120
        spriteImageBuilder = new SpriteImageBuilder(parameters, messageLog, resourceHandler);
1✔
121
    }
1✔
122

123
    /**
124
     * Performs processing for this builder's parameters. This method resolves all paths against the local file system.
125
     *
126
     * @throws IOException
127
     *             Signals that an I/O exception has occurred.
128
     */
129
    public void buildSprites() throws IOException {
130
        if (!parameters.validate(messageLog)) {
1!
131
            return;
×
132
        }
133

134
        final Collection<String> filePaths;
135
        if (parameters.getCssFiles() != null && !parameters.getCssFiles().isEmpty()) {
1!
136
            // Take directly provided css fle paths
137
            filePaths = parameters.getCssFiles();
1✔
138

139
            // If root dir is provided, filter out those files that are outside root dir
140
            if (StringUtils.isNotBlank(parameters.getOutputDir())) {
1✔
141
                filterFilesOutsideRootDir(filePaths);
1✔
142
            }
143

144
            // Make sure the files exist and are really files
145
            for (Iterator<String> it = filePaths.iterator(); it.hasNext();) {
1✔
146
                final String path = it.next();
1✔
147
                final File file = Path.of(path).toFile();
1✔
148
                if (file.exists()) {
1✔
149
                    if (!file.isFile()) {
1✔
150
                        messageLog.warning(MessageType.CSS_PATH_IS_NOT_A_FILE, path);
1✔
151
                        it.remove();
1✔
152
                    }
153
                } else {
154
                    messageLog.warning(MessageType.CSS_FILE_DOES_NOT_EXIST, path);
1✔
155
                    it.remove();
1✔
156
                }
157
            }
1✔
158
        } else {
159
            // Take all css files from the root dir
160
            final List<File> files = Lists.newArrayList(org.apache.commons.io.FileUtils
1✔
161
                    .listFiles(parameters.getRootDirFile(), new String[] { "css" }, true));
1✔
162
            Collections.sort(files, Comparator.comparing(File::getAbsolutePath));
1✔
163

164
            filePaths = new ArrayList<>();
1✔
165
            for (File file : files) {
1✔
166
                filePaths.add(file.getPath());
1✔
167
            }
1✔
168
        }
169

170
        buildSprites(filePaths);
1✔
171
    }
1✔
172

173
    /**
174
     * Filter files outside root dir.
175
     *
176
     * @param filePaths
177
     *            the file paths
178
     *
179
     * @throws IOException
180
     *             Signals that an I/O exception has occurred.
181
     */
182
    private void filterFilesOutsideRootDir(Collection<String> filePaths) throws IOException {
183
        for (Iterator<String> it = filePaths.iterator(); it.hasNext();) {
1✔
184
            final String filePath = it.next();
1✔
185
            if (!FileUtils.isFileInParent(Path.of(filePath).toFile(), parameters.getRootDirFile())) {
1✔
186
                it.remove();
1✔
187
                messageLog.warning(MessageType.IGNORING_CSS_FILE_OUTSIDE_OF_ROOT_DIR, filePath);
1✔
188
            }
189
        }
1✔
190
    }
1✔
191

192
    /**
193
     * Performs processing from the list of file paths for this builder's parameters.
194
     *
195
     * @param filePaths
196
     *            paths of CSS files to process. Non-absolute paths will be taken relative to the current working
197
     *            directory. Both platform-specific and '/' as the file separator are supported.
198
     *
199
     * @throws IOException
200
     *             Signals that an I/O exception has occurred.
201
     */
202
    public void buildSprites(Collection<String> filePaths) throws IOException {
203
        final long start = System.currentTimeMillis();
1✔
204

205
        final LevelCounterMessageSink levelCounter = new LevelCounterMessageSink();
1✔
206
        messageLog.addMessageSink(levelCounter);
1✔
207

208
        // Collect sprite declarations from all css files
209
        final Multimap<String, SpriteImageOccurrence> spriteImageOccurrencesByFile = spriteDirectiveOccurrenceCollector
1✔
210
                .collectSpriteImageOccurrences(filePaths);
1✔
211

212
        // Merge them, checking for duplicates
213
        final Map<String, SpriteImageOccurrence> spriteImageOccurrencesBySpriteId = spriteDirectiveOccurrenceCollector
1✔
214
                .mergeSpriteImageOccurrences(spriteImageOccurrencesByFile);
1✔
215
        final Map<String, SpriteImageDirective> spriteImageDirectivesBySpriteId = new LinkedHashMap<>();
1✔
216
        for (Map.Entry<String, SpriteImageOccurrence> entry : spriteImageOccurrencesBySpriteId.entrySet()) {
1✔
217
            spriteImageDirectivesBySpriteId.put(entry.getKey(), entry.getValue().spriteImageDirective);
1✔
218
        }
1✔
219

220
        // Collect sprite references from all css files
221
        final Multimap<String, SpriteReferenceOccurrence> spriteEntriesByFile = spriteDirectiveOccurrenceCollector
1✔
222
                .collectSpriteReferenceOccurrences(filePaths, spriteImageDirectivesBySpriteId);
1✔
223

224
        // Now merge and regroup all files by sprite-id
225
        final Multimap<String, SpriteReferenceOccurrence> spriteReferenceOccurrencesBySpriteId = SpriteDirectiveOccurrenceCollector
1✔
226
                .mergeSpriteReferenceOccurrences(spriteEntriesByFile);
1✔
227

228
        // Build the sprite images
229
        messageLog.setCssFile(null);
1✔
230
        final Multimap<String, SpriteReferenceReplacement> spriteReplacementsByFile = spriteImageBuilder
1✔
231
                .buildSpriteImages(spriteImageOccurrencesBySpriteId, spriteReferenceOccurrencesBySpriteId);
1✔
232

233
        // Rewrite the CSS
234
        rewriteCssFiles(spriteImageOccurrencesByFile, spriteReplacementsByFile);
1✔
235

236
        final long stop = System.currentTimeMillis();
1✔
237

238
        if (levelCounter.getWarnCount() > 0) {
1✔
239
            messageLog.status(MessageType.PROCESSING_COMPLETED_WITH_WARNINGS, stop - start,
1✔
240
                    levelCounter.getWarnCount());
1✔
241
        } else {
242
            messageLog.status(MessageType.PROCESSING_COMPLETED, stop - start);
1✔
243
        }
244
    }
1✔
245

246
    /**
247
     * Rewrites the original files to refer to the generated sprite images.
248
     *
249
     * @param spriteImageOccurrencesByFile
250
     *            the sprite image occurrences by file
251
     * @param spriteReplacementsByFile
252
     *            the sprite replacements by file
253
     *
254
     * @throws IOException
255
     *             Signals that an I/O exception has occurred.
256
     */
257
    private void rewriteCssFiles(final Multimap<String, SpriteImageOccurrence> spriteImageOccurrencesByFile,
258
            final Multimap<String, SpriteReferenceReplacement> spriteReplacementsByFile) throws IOException {
259
        if (spriteReplacementsByFile.isEmpty()) {
1✔
260
            // If nothing to replace, still, copy the original file, so that there
261
            // is some output file.
262
            for (final Map.Entry<String, Collection<SpriteImageOccurrence>> entry : spriteImageOccurrencesByFile.asMap()
1✔
263
                    .entrySet()) {
1✔
264
                final String cssFile = entry.getKey();
1✔
265

266
                createProcessedCss(cssFile, SpriteImageBuilder.getSpriteImageOccurrencesByLineNumber(
1✔
267
                        spriteImageOccurrencesByFile.get(cssFile)), new HashMap<>());
1✔
268
            }
1✔
269
        } else {
270
            for (final Map.Entry<String, Collection<SpriteReferenceReplacement>> entry : spriteReplacementsByFile
1✔
271
                    .asMap().entrySet()) {
1✔
272
                final String cssFile = entry.getKey();
1✔
273
                final Map<Integer, SpriteReferenceReplacement> spriteReplacementsByLineNumber = SpriteImageBuilder
1✔
274
                        .getSpriteReplacementsByLineNumber(entry.getValue());
1✔
275

276
                createProcessedCss(cssFile, SpriteImageBuilder.getSpriteImageOccurrencesByLineNumber(
1✔
277
                        spriteImageOccurrencesByFile.get(cssFile)), spriteReplacementsByLineNumber);
1✔
278
            }
1✔
279
        }
280
    }
1✔
281

282
    /**
283
     * Rewrites one CSS file to refer to the generated sprite images.
284
     *
285
     * @param originalCssFile
286
     *            the original css file
287
     * @param spriteImageOccurrencesByLineNumber
288
     *            the sprite image occurrences by line number
289
     * @param spriteReplacementsByLineNumber
290
     *            the sprite replacements by line number
291
     *
292
     * @throws IOException
293
     *             Signals that an I/O exception has occurred.
294
     */
295
    private void createProcessedCss(String originalCssFile,
296
            Map<Integer, SpriteImageOccurrence> spriteImageOccurrencesByLineNumber,
297
            Map<Integer, SpriteReferenceReplacement> spriteReplacementsByLineNumber) throws IOException {
298
        final String processedCssFile = getProcessedCssFile(originalCssFile);
1✔
299
        messageLog.setCssFile(null);
1✔
300
        messageLog.info(MessageType.CREATING_CSS_STYLE_SHEET, processedCssFile);
1✔
301
        messageLog.info(MessageType.READING_CSS, originalCssFile);
1✔
302
        messageLog.info(MessageType.WRITING_CSS, processedCssFile);
1✔
303

304
        String originalCssLine;
305
        int originalCssLineNumber = -1;
1✔
306
        int lastReferenceReplacementLine = -1;
1✔
307

308
        boolean markSpriteImages = parameters.isMarkSpriteImages();
1✔
309

310
        // Generate UID for sprite file
311
        try (BufferedReader originalCssReader = new BufferedReader(
1✔
312
                resourceHandler.getResourceAsReader(originalCssFile));
1✔
313
                BufferedWriter processedCssWriter = new BufferedWriter(
1✔
314
                        resourceHandler.getResourceAsWriter(processedCssFile))) {
1✔
315
            messageLog.setCssFile(originalCssFile);
1✔
316

317
            originalCssFile = originalCssFile.replace(File.separatorChar, '/');
1✔
318

319
            while ((originalCssLine = originalCssReader.readLine()) != null) {
1✔
320
                originalCssLineNumber++;
1✔
321
                messageLog.setLine(originalCssLineNumber);
1✔
322

323
                if (originalCssLine.contains("}")) {
1✔
324
                    lastReferenceReplacementLine = -1;
1✔
325
                }
326

327
                final SpriteImageOccurrence spriteImageOccurrence = spriteImageOccurrencesByLineNumber
1✔
328
                        .get(originalCssLineNumber);
1✔
329
                final SpriteReferenceReplacement spriteReferenceReplacement = spriteReplacementsByLineNumber
1✔
330
                        .get(originalCssLineNumber);
1✔
331

332
                if (spriteImageOccurrence != null) {
1✔
333
                    // Ignore line with directive
334
                    continue;
1✔
335
                }
336

337
                if (spriteReferenceReplacement != null) {
1✔
338
                    final boolean important = spriteReferenceReplacement.spriteReferenceOccurrence.important;
1✔
339
                    lastReferenceReplacementLine = originalCssLineNumber;
1✔
340

341
                    processedCssWriter.write("  background-image: url('"
1✔
342
                            + getRelativeToReplacementLocation(spriteReferenceReplacement.spriteImage.resolvedPath,
1✔
343
                                    originalCssFile, spriteReferenceReplacement)
344
                            + "')" + (important ? " !important" : "") + ";"
1✔
345
                            + (markSpriteImages ? " /** sprite:sprite */" : "") + "\n");
1!
346

347
                    processedCssWriter
1✔
348
                            .write("  background-position: " + spriteReferenceReplacement.horizontalPositionString + " "
1✔
349
                                    + spriteReferenceReplacement.verticalPositionString
350
                                    + (important ? " !important" : "") + ";\n");
1✔
351

352
                    // If the sprite scale is not 1, write out a background-size directive
353
                    final float scale = spriteReferenceReplacement.spriteImage.scaleRatio;
1✔
354
                    if (scale != 1.0f) {
1✔
355
                        processedCssWriter.write("  background-size: "
1✔
356
                                + Math.round(spriteReferenceReplacement.spriteImage.spriteWidth / scale) + "px "
1✔
357
                                + Math.round(spriteReferenceReplacement.spriteImage.spriteHeight / scale) + "px;\n");
1✔
358
                    }
359

360
                    continue;
361
                }
362

363
                if (lastReferenceReplacementLine >= 0) {
1✔
364
                    for (final String property : OVERRIDING_PROPERTIES) {
1✔
365
                        if (originalCssLine.contains(property)) {
1✔
366
                            messageLog.warning(MessageType.OVERRIDING_PROPERTY_FOUND, property,
1✔
367
                                    lastReferenceReplacementLine);
1✔
368
                        }
369
                    }
1✔
370
                }
371

372
                // Just write the original line
373
                processedCssWriter.write(originalCssLine + "\n");
1✔
374
            }
1✔
375

376
            messageLog.setCssFile(null);
1✔
377
        }
378
    }
1✔
379

380
    /**
381
     * Returns the sprite image's imagePath relative to the CSS in which we're making replacements. The imagePath is
382
     * relative to the CSS which declared the sprite image. As it may happen that the image is referenced in another CSS
383
     * file, we must make sure the paths are correctly translated.
384
     *
385
     * @param imagePath
386
     *            the image path
387
     * @param originalCssFile
388
     *            the original css file
389
     * @param spriteReferenceReplacement
390
     *            the sprite reference replacement
391
     *
392
     * @return the relative to replacement location
393
     */
394
    private String getRelativeToReplacementLocation(String imagePath, String originalCssFile,
395
            final SpriteReferenceReplacement spriteReferenceReplacement) {
396
        final String declaringCssPath = spriteReferenceReplacement.spriteImage.spriteImageOccurrence.cssFile
1✔
397
                .replace(File.separatorChar, '/');
1✔
398
        final String declarationReplacementRelativePath = PathUtils
1✔
399
                .getRelativeFilePath(originalCssFile.substring(0, originalCssFile.lastIndexOf('/')),
1✔
400
                        declaringCssPath.substring(0, declaringCssPath.lastIndexOf('/')))
1✔
401
                .replace(File.separatorChar, '/');
1✔
402
        return FileUtils.canonicalize(
1✔
403
                (Strings.isNullOrEmpty(declarationReplacementRelativePath) || originalCssFile.equals(declaringCssPath)
1!
404
                        ? ""
1✔
405
                        : declarationReplacementRelativePath + '/') + imagePath,
1✔
406
                "/");
407
    }
408

409
    /**
410
     * Gets the name of the processed CSS file.
411
     *
412
     * @param originalCssFile
413
     *            the original css file
414
     *
415
     * @return the processed css file
416
     */
417
    String getProcessedCssFile(String originalCssFile) {
418
        final int lastDotIndex = originalCssFile.lastIndexOf('.');
1✔
419
        final String processedCssFile;
420
        if (lastDotIndex >= 0) {
1!
421
            processedCssFile = originalCssFile.substring(0, lastDotIndex) + parameters.getCssFileSuffix()
1✔
422
                    + originalCssFile.substring(lastDotIndex);
1✔
423
        } else {
424
            processedCssFile = originalCssFile + parameters.getCssFileSuffix();
×
425
        }
426

427
        if (parameters.hasOutputDir()) {
1✔
428
            return FileUtils.changeRoot(processedCssFile, parameters.getRootDir(), parameters.getOutputDir());
1✔
429
        }
430
        return processedCssFile;
1✔
431
    }
432
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc