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

hazendaz / smartsprites / 555

21 May 2026 03:26PM UTC coverage: 87.799%. Remained the same
555

push

github

hazendaz
[tests] Fix tests that were looking at size of original license before spdx change

557 of 670 branches covered (83.13%)

Branch coverage included in aggregate %.

1350 of 1502 relevant lines covered (89.88%)

0.9 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
 * SPDX-License-Identifier: BSD-3-Clause
3
 * See LICENSE file for details.
4
 *
5
 * Copyright 2021-2026 Hazendaz
6
 * Copyright (C) 2007-2009, Stanisław Osiński.
7
 */
8
package org.carrot2.labs.smartsprites;
9

10
import com.google.common.base.Strings;
11
import com.google.common.collect.Lists;
12
import com.google.common.collect.Multimap;
13
import com.google.common.collect.Sets;
14

15
import java.io.BufferedReader;
16
import java.io.BufferedWriter;
17
import java.io.File;
18
import java.io.IOException;
19
import java.nio.file.Path;
20
import java.util.ArrayList;
21
import java.util.Collection;
22
import java.util.Comparator;
23
import java.util.HashMap;
24
import java.util.HashSet;
25
import java.util.Iterator;
26
import java.util.LinkedHashMap;
27
import java.util.List;
28
import java.util.Map;
29

30
import org.carrot2.labs.smartsprites.message.LevelCounterMessageSink;
31
import org.carrot2.labs.smartsprites.message.Message.MessageType;
32
import org.carrot2.labs.smartsprites.message.MessageLog;
33
import org.carrot2.labs.smartsprites.resource.FileSystemResourceHandler;
34
import org.carrot2.labs.smartsprites.resource.ResourceHandler;
35
import org.carrot2.util.FileUtils;
36
import org.carrot2.util.PathUtils;
37
import org.carrot2.util.StringUtils;
38

39
/**
40
 * Performs all stages of sprite building. This class is not thread-safe.
41
 */
42
public class SpriteBuilder {
43
    /** Properties we need to watch for in terms of overriding the generated ones. */
44
    private static final HashSet<String> OVERRIDING_PROPERTIES = Sets.newHashSet("background-position",
1✔
45
            "background-image");
46

47
    /** This builder's configuration. */
48
    public final SmartSpritesParameters parameters;
49

50
    /** This builder's message log. */
51
    private final MessageLog messageLog;
52

53
    /** Directive occurrence collector for this builder. */
54
    private final SpriteDirectiveOccurrenceCollector spriteDirectiveOccurrenceCollector;
55

56
    /** SpriteImageBuilder for this builder. */
57
    private final SpriteImageBuilder spriteImageBuilder;
58

59
    /** Resource handler. */
60
    private ResourceHandler resourceHandler;
61

62
    /**
63
     * Creates a {@link SpriteBuilder} with the provided parameters and log.
64
     *
65
     * @param parameters
66
     *            the parameters
67
     * @param messageLog
68
     *            the message log
69
     */
70
    public SpriteBuilder(SmartSpritesParameters parameters, MessageLog messageLog) {
71
        this(parameters, messageLog, new FileSystemResourceHandler(parameters.getDocumentRootDir(),
1✔
72
                parameters.getCssFileEncoding(), messageLog));
1✔
73
    }
1✔
74

75
    /**
76
     * Creates a {@link SpriteBuilder} with the provided parameters and log.
77
     *
78
     * @param parameters
79
     *            the parameters
80
     * @param messageLog
81
     *            the message log
82
     * @param resourceHandler
83
     *            the resource handler
84
     */
85
    public SpriteBuilder(SmartSpritesParameters parameters, MessageLog messageLog, ResourceHandler resourceHandler) {
1✔
86
        this.messageLog = messageLog;
1✔
87
        this.parameters = parameters;
1✔
88
        this.resourceHandler = resourceHandler;
1✔
89
        spriteDirectiveOccurrenceCollector = new SpriteDirectiveOccurrenceCollector(messageLog, resourceHandler);
1✔
90
        spriteImageBuilder = new SpriteImageBuilder(parameters, messageLog, resourceHandler);
1✔
91
    }
1✔
92

93
    /**
94
     * Performs processing for this builder's parameters. This method resolves all paths against the local file system.
95
     *
96
     * @throws IOException
97
     *             Signals that an I/O exception has occurred.
98
     */
99
    public void buildSprites() throws IOException {
100
        if (!parameters.validate(messageLog)) {
1!
101
            return;
×
102
        }
103

104
        final Collection<String> filePaths;
105
        if (parameters.getCssFiles() != null && !parameters.getCssFiles().isEmpty()) {
1!
106
            // Take directly provided css fle paths
107
            filePaths = parameters.getCssFiles();
1✔
108

109
            // If root dir is provided, filter out those files that are outside root dir
110
            if (StringUtils.isNotBlank(parameters.getOutputDir())) {
1✔
111
                filterFilesOutsideRootDir(filePaths);
1✔
112
            }
113

114
            // Make sure the files exist and are really files
115
            for (Iterator<String> it = filePaths.iterator(); it.hasNext();) {
1✔
116
                final String path = it.next();
1✔
117
                final File file = Path.of(path).toFile();
1✔
118
                if (file.exists()) {
1✔
119
                    if (!file.isFile()) {
1✔
120
                        messageLog.warning(MessageType.CSS_PATH_IS_NOT_A_FILE, path);
1✔
121
                        it.remove();
1✔
122
                    }
123
                } else {
124
                    messageLog.warning(MessageType.CSS_FILE_DOES_NOT_EXIST, path);
1✔
125
                    it.remove();
1✔
126
                }
127
            }
1✔
128
        } else {
129
            // Take all css files from the root dir
130
            final List<File> files = Lists.newArrayList(org.apache.commons.io.FileUtils
1✔
131
                    .listFiles(parameters.getRootDirFile(), new String[] { "css" }, true));
1✔
132
            files.sort(Comparator.comparing(File::getAbsolutePath));
1✔
133

134
            filePaths = new ArrayList<>();
1✔
135
            for (File file : files) {
1✔
136
                filePaths.add(file.getPath());
1✔
137
            }
1✔
138
        }
139

140
        buildSprites(filePaths);
1✔
141
    }
1✔
142

143
    /**
144
     * Filter files outside root dir.
145
     *
146
     * @param filePaths
147
     *            the file paths
148
     *
149
     * @throws IOException
150
     *             Signals that an I/O exception has occurred.
151
     */
152
    private void filterFilesOutsideRootDir(Collection<String> filePaths) throws IOException {
153
        for (Iterator<String> it = filePaths.iterator(); it.hasNext();) {
1✔
154
            final String filePath = it.next();
1✔
155
            if (!FileUtils.isFileInParent(Path.of(filePath).toFile(), parameters.getRootDirFile())) {
1✔
156
                it.remove();
1✔
157
                messageLog.warning(MessageType.IGNORING_CSS_FILE_OUTSIDE_OF_ROOT_DIR, filePath);
1✔
158
            }
159
        }
1✔
160
    }
1✔
161

162
    /**
163
     * Performs processing from the list of file paths for this builder's parameters.
164
     *
165
     * @param filePaths
166
     *            paths of CSS files to process. Non-absolute paths will be taken relative to the current working
167
     *            directory. Both platform-specific and '/' as the file separator are supported.
168
     *
169
     * @throws IOException
170
     *             Signals that an I/O exception has occurred.
171
     */
172
    public void buildSprites(Collection<String> filePaths) throws IOException {
173
        final long start = System.currentTimeMillis();
1✔
174

175
        final LevelCounterMessageSink levelCounter = new LevelCounterMessageSink();
1✔
176
        messageLog.addMessageSink(levelCounter);
1✔
177

178
        // Collect sprite declarations from all css files
179
        final Multimap<String, SpriteImageOccurrence> spriteImageOccurrencesByFile = spriteDirectiveOccurrenceCollector
1✔
180
                .collectSpriteImageOccurrences(filePaths);
1✔
181

182
        // Merge them, checking for duplicates
183
        final Map<String, SpriteImageOccurrence> spriteImageOccurrencesBySpriteId = spriteDirectiveOccurrenceCollector
1✔
184
                .mergeSpriteImageOccurrences(spriteImageOccurrencesByFile);
1✔
185
        final Map<String, SpriteImageDirective> spriteImageDirectivesBySpriteId = new LinkedHashMap<>();
1✔
186
        for (Map.Entry<String, SpriteImageOccurrence> entry : spriteImageOccurrencesBySpriteId.entrySet()) {
1✔
187
            spriteImageDirectivesBySpriteId.put(entry.getKey(), entry.getValue().spriteImageDirective);
1✔
188
        }
1✔
189

190
        // Collect sprite references from all css files
191
        final Multimap<String, SpriteReferenceOccurrence> spriteEntriesByFile = spriteDirectiveOccurrenceCollector
1✔
192
                .collectSpriteReferenceOccurrences(filePaths, spriteImageDirectivesBySpriteId);
1✔
193

194
        // Now merge and regroup all files by sprite-id
195
        final Multimap<String, SpriteReferenceOccurrence> spriteReferenceOccurrencesBySpriteId = SpriteDirectiveOccurrenceCollector
1✔
196
                .mergeSpriteReferenceOccurrences(spriteEntriesByFile);
1✔
197

198
        // Build the sprite images
199
        messageLog.setCssFile(null);
1✔
200
        final Multimap<String, SpriteReferenceReplacement> spriteReplacementsByFile = spriteImageBuilder
1✔
201
                .buildSpriteImages(spriteImageOccurrencesBySpriteId, spriteReferenceOccurrencesBySpriteId);
1✔
202

203
        // Rewrite the CSS
204
        rewriteCssFiles(spriteImageOccurrencesByFile, spriteReplacementsByFile);
1✔
205

206
        final long stop = System.currentTimeMillis();
1✔
207

208
        if (levelCounter.getWarnCount() > 0) {
1✔
209
            messageLog.status(MessageType.PROCESSING_COMPLETED_WITH_WARNINGS, stop - start,
1✔
210
                    levelCounter.getWarnCount());
1✔
211
        } else {
212
            messageLog.status(MessageType.PROCESSING_COMPLETED, stop - start);
1✔
213
        }
214
    }
1✔
215

216
    /**
217
     * Rewrites the original files to refer to the generated sprite images.
218
     *
219
     * @param spriteImageOccurrencesByFile
220
     *            the sprite image occurrences by file
221
     * @param spriteReplacementsByFile
222
     *            the sprite replacements by file
223
     *
224
     * @throws IOException
225
     *             Signals that an I/O exception has occurred.
226
     */
227
    private void rewriteCssFiles(final Multimap<String, SpriteImageOccurrence> spriteImageOccurrencesByFile,
228
            final Multimap<String, SpriteReferenceReplacement> spriteReplacementsByFile) throws IOException {
229
        if (spriteReplacementsByFile.isEmpty()) {
1✔
230
            // If nothing to replace, still, copy the original file, so that there
231
            // is some output file.
232
            for (final Map.Entry<String, Collection<SpriteImageOccurrence>> entry : spriteImageOccurrencesByFile.asMap()
1✔
233
                    .entrySet()) {
1✔
234
                final String cssFile = entry.getKey();
1✔
235

236
                createProcessedCss(cssFile, SpriteImageBuilder.getSpriteImageOccurrencesByLineNumber(
1✔
237
                        spriteImageOccurrencesByFile.get(cssFile)), new HashMap<>());
1✔
238
            }
1✔
239
        } else {
240
            for (final Map.Entry<String, Collection<SpriteReferenceReplacement>> entry : spriteReplacementsByFile
1✔
241
                    .asMap().entrySet()) {
1✔
242
                final String cssFile = entry.getKey();
1✔
243
                final Map<Integer, SpriteReferenceReplacement> spriteReplacementsByLineNumber = SpriteImageBuilder
1✔
244
                        .getSpriteReplacementsByLineNumber(entry.getValue());
1✔
245

246
                createProcessedCss(cssFile, SpriteImageBuilder.getSpriteImageOccurrencesByLineNumber(
1✔
247
                        spriteImageOccurrencesByFile.get(cssFile)), spriteReplacementsByLineNumber);
1✔
248
            }
1✔
249
        }
250
    }
1✔
251

252
    /**
253
     * Rewrites one CSS file to refer to the generated sprite images.
254
     *
255
     * @param originalCssFile
256
     *            the original css file
257
     * @param spriteImageOccurrencesByLineNumber
258
     *            the sprite image occurrences by line number
259
     * @param spriteReplacementsByLineNumber
260
     *            the sprite replacements by line number
261
     *
262
     * @throws IOException
263
     *             Signals that an I/O exception has occurred.
264
     */
265
    private void createProcessedCss(String originalCssFile,
266
            Map<Integer, SpriteImageOccurrence> spriteImageOccurrencesByLineNumber,
267
            Map<Integer, SpriteReferenceReplacement> spriteReplacementsByLineNumber) throws IOException {
268
        final String processedCssFile = getProcessedCssFile(originalCssFile);
1✔
269
        messageLog.setCssFile(null);
1✔
270
        messageLog.info(MessageType.CREATING_CSS_STYLE_SHEET, processedCssFile);
1✔
271
        messageLog.info(MessageType.READING_CSS, originalCssFile);
1✔
272
        messageLog.info(MessageType.WRITING_CSS, processedCssFile);
1✔
273

274
        String originalCssLine;
275
        int originalCssLineNumber = -1;
1✔
276
        int lastReferenceReplacementLine = -1;
1✔
277

278
        boolean markSpriteImages = parameters.isMarkSpriteImages();
1✔
279

280
        // Generate UID for sprite file
281
        try (BufferedReader originalCssReader = new BufferedReader(
1✔
282
                resourceHandler.getResourceAsReader(originalCssFile));
1✔
283
                BufferedWriter processedCssWriter = new BufferedWriter(
1✔
284
                        resourceHandler.getResourceAsWriter(processedCssFile))) {
1✔
285
            messageLog.setCssFile(originalCssFile);
1✔
286

287
            originalCssFile = originalCssFile.replace(File.separatorChar, '/');
1✔
288

289
            while ((originalCssLine = originalCssReader.readLine()) != null) {
1✔
290
                originalCssLineNumber++;
1✔
291
                messageLog.setLine(originalCssLineNumber);
1✔
292

293
                if (originalCssLine.contains("}")) {
1✔
294
                    lastReferenceReplacementLine = -1;
1✔
295
                }
296

297
                final SpriteImageOccurrence spriteImageOccurrence = spriteImageOccurrencesByLineNumber
1✔
298
                        .get(originalCssLineNumber);
1✔
299
                final SpriteReferenceReplacement spriteReferenceReplacement = spriteReplacementsByLineNumber
1✔
300
                        .get(originalCssLineNumber);
1✔
301

302
                if (spriteImageOccurrence != null) {
1✔
303
                    // Ignore line with directive
304
                    continue;
1✔
305
                }
306

307
                if (spriteReferenceReplacement != null) {
1✔
308
                    final boolean important = spriteReferenceReplacement.spriteReferenceOccurrence.important;
1✔
309
                    lastReferenceReplacementLine = originalCssLineNumber;
1✔
310

311
                    processedCssWriter.write("  background-image: url('"
1✔
312
                            + getRelativeToReplacementLocation(spriteReferenceReplacement.spriteImage.resolvedPath,
1✔
313
                                    originalCssFile, spriteReferenceReplacement)
314
                            + "')" + (important ? " !important" : "") + ";"
1✔
315
                            + (markSpriteImages ? " /** sprite:sprite */" : "") + "\n");
1!
316

317
                    processedCssWriter
1✔
318
                            .write("  background-position: " + spriteReferenceReplacement.horizontalPositionString + " "
1✔
319
                                    + spriteReferenceReplacement.verticalPositionString
320
                                    + (important ? " !important" : "") + ";\n");
1✔
321

322
                    // If the sprite scale is not 1, write out a background-size directive
323
                    final float scale = spriteReferenceReplacement.spriteImage.scaleRatio;
1✔
324
                    if (scale != 1.0f) {
1✔
325
                        processedCssWriter.write("  background-size: "
1✔
326
                                + Math.round(spriteReferenceReplacement.spriteImage.spriteWidth / scale) + "px "
1✔
327
                                + Math.round(spriteReferenceReplacement.spriteImage.spriteHeight / scale) + "px;\n");
1✔
328
                    }
329

330
                    continue;
331
                }
332

333
                if (lastReferenceReplacementLine >= 0) {
1✔
334
                    for (final String property : OVERRIDING_PROPERTIES) {
1✔
335
                        if (originalCssLine.contains(property)) {
1✔
336
                            messageLog.warning(MessageType.OVERRIDING_PROPERTY_FOUND, property,
1✔
337
                                    lastReferenceReplacementLine);
1✔
338
                        }
339
                    }
1✔
340
                }
341

342
                // Just write the original line
343
                processedCssWriter.write(originalCssLine + "\n");
1✔
344
            }
1✔
345

346
            messageLog.setCssFile(null);
1✔
347
        }
348
    }
1✔
349

350
    /**
351
     * Returns the sprite image's imagePath relative to the CSS in which we're making replacements. The imagePath is
352
     * relative to the CSS which declared the sprite image. As it may happen that the image is referenced in another CSS
353
     * file, we must make sure the paths are correctly translated.
354
     *
355
     * @param imagePath
356
     *            the image path
357
     * @param originalCssFile
358
     *            the original css file
359
     * @param spriteReferenceReplacement
360
     *            the sprite reference replacement
361
     *
362
     * @return the relative to replacement location
363
     */
364
    private String getRelativeToReplacementLocation(String imagePath, String originalCssFile,
365
            final SpriteReferenceReplacement spriteReferenceReplacement) {
366
        final String declaringCssPath = spriteReferenceReplacement.spriteImage.spriteImageOccurrence.cssFile
1✔
367
                .replace(File.separatorChar, '/');
1✔
368
        final String declarationReplacementRelativePath = PathUtils
1✔
369
                .getRelativeFilePath(originalCssFile.substring(0, originalCssFile.lastIndexOf('/')),
1✔
370
                        declaringCssPath.substring(0, declaringCssPath.lastIndexOf('/')))
1✔
371
                .replace(File.separatorChar, '/');
1✔
372
        return FileUtils.canonicalize(
1✔
373
                (Strings.isNullOrEmpty(declarationReplacementRelativePath) || originalCssFile.equals(declaringCssPath)
1!
374
                        ? ""
1✔
375
                        : declarationReplacementRelativePath + '/') + imagePath,
1✔
376
                "/");
377
    }
378

379
    /**
380
     * Gets the name of the processed CSS file.
381
     *
382
     * @param originalCssFile
383
     *            the original css file
384
     *
385
     * @return the processed css file
386
     */
387
    String getProcessedCssFile(String originalCssFile) {
388
        final int lastDotIndex = originalCssFile.lastIndexOf('.');
1✔
389
        final String processedCssFile;
390
        if (lastDotIndex >= 0) {
1!
391
            processedCssFile = originalCssFile.substring(0, lastDotIndex) + parameters.getCssFileSuffix()
1✔
392
                    + originalCssFile.substring(lastDotIndex);
1✔
393
        } else {
394
            processedCssFile = originalCssFile + parameters.getCssFileSuffix();
×
395
        }
396

397
        if (parameters.hasOutputDir()) {
1✔
398
            return FileUtils.changeRoot(processedCssFile, parameters.getRootDir(), parameters.getOutputDir());
1✔
399
        }
400
        return processedCssFile;
1✔
401
    }
402
}
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