View Javadoc
1   package org.sentrysoftware.maven.skin;
2   
3   /*-
4    * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
5    * Sentry Maven Skin Tools
6    * ჻჻჻჻჻჻
7    * Copyright (C) 2017 - 2026 Sentry Software
8    * ჻჻჻჻჻჻
9    * Licensed under the Apache License, Version 2.0 (the "License");
10   * you may not use this file except in compliance with the License.
11   * You may obtain a copy of the License at
12   *
13   *      http://www.apache.org/licenses/LICENSE-2.0
14   *
15   * Unless required by applicable law or agreed to in writing, software
16   * distributed under the License is distributed on an "AS IS" BASIS,
17   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18   * See the License for the specific language governing permissions and
19   * limitations under the License.
20   * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
21   */
22  
23  import java.awt.Image;
24  import java.awt.image.BufferedImage;
25  import java.io.File;
26  import java.io.IOException;
27  import java.nio.file.Path;
28  import java.nio.file.Paths;
29  import java.util.ArrayList;
30  import java.util.List;
31  import java.util.regex.Pattern;
32  import java.util.stream.Collectors;
33  
34  import javax.imageio.IIOImage;
35  import javax.imageio.ImageIO;
36  import javax.imageio.ImageWriteParam;
37  import javax.imageio.ImageWriter;
38  import javax.imageio.spi.IIORegistry;
39  import javax.imageio.stream.FileImageOutputStream;
40  
41  import org.apache.velocity.tools.config.DefaultKey;
42  import org.jsoup.nodes.Element;
43  
44  import com.luciad.imageio.webp.WebPImageReaderSpi;
45  import com.luciad.imageio.webp.WebPImageWriterSpi;
46  import com.luciad.imageio.webp.WebPWriteParam;
47  
48  /**
49   * Set of tools for handling images
50   */
51  @DefaultKey("imageTool")
52  public class ImageTool {
53  
54  	static {
55  		// First, register the WEBP IOImage Writer and Reader
56  		// (Note: this should be done automatically by IOImage, but it doesn't work
57  		// with Plexus and Maven because of their specific ClassLoader)
58  		IIORegistry iioRegistry = IIORegistry.getDefaultInstance();
59  		iioRegistry.registerServiceProvider(new WebPImageWriterSpi());
60  		iioRegistry.registerServiceProvider(new WebPImageReaderSpi());
61  
62  	}
63  
64  	/**
65  	 * Patterns that matches with absolute URLs, like:
66  	 * <ul>
67  	 * <li>http://google.com
68  	 * <li>ftp://cia.gov
69  	 * <li>//sentrysoftware.com
70  	 */
71  	private static final Pattern ABSOLUTE_URL_PATTERN = Pattern.compile("^(?:[a-z]+:)?//", Pattern.CASE_INSENSITIVE);
72  
73  	/**
74  	 * Create a new instance
75  	 */
76  	public ImageTool() {
77  		/* Do nothing */
78  	}
79  
80  	/**
81  	 * Returns whether specified path is absolute or not.
82  	 * <ul>
83  	 * <li>http://google.com => absolute
84  	 * <li>ftp://cia.gov => absolute
85  	 * <li>//sentrysoftware.com => absolute
86  	 * <li>path/file => relative
87  	 *
88  	 * @param path Path to test
89  	 * @return whether specified path is absolute or not
90  	 */
91  	protected static boolean isAbsoluteUrl(final String path) {
92  		return ABSOLUTE_URL_PATTERN.matcher(path).find();
93  	}
94  
95  	/**
96  	 * Check the image links in the document and make sure they refer to a file that
97  	 * actually exists.
98  	 *
99  	 * @param body the HTML content
100 	 * @param basedir Actual root directory of the site on the file system
101 	 * @param currentDocument Logical path of the document being parsed (e.g.
102 	 *        "index.html", or "subdir/subpage.html")
103 	 * @return the updated HTML content
104 	 * @throws IOException when an image cannot be read or converted
105 	 */
106 	public Element checkImageLinks(
107 			final Element body,
108 			final String basedir,
109 			final String currentDocument)
110 			throws IOException {
111 
112 		// Initialization
113 		List<String> errorList = new ArrayList<String>();
114 
115 		// basedir path
116 		Path basedirPath = Paths.get(basedir).toAbsolutePath();
117 
118 		// First, calculate the real path of the current document
119 		Path documentPath = Paths.get(basedir, currentDocument);
120 
121 		Path parentPath = documentPath.getParent();
122 		if (parentPath == null) {
123 			throw new IOException("Couldn't get the parent path of " + currentDocument);
124 		}
125 
126 		// Select all images
127 		List<Element> elements = body.select("img");
128 
129 		// For each image
130 		for (Element element : elements) {
131 
132 			// Get the SRC attribute (the path)
133 			String imageSrc = element.attr("src");
134 			if (imageSrc.isEmpty()) {
135 				continue;
136 			}
137 
138 			// Skip absolute URLs
139 			if (isAbsoluteUrl(imageSrc)) {
140 				continue;
141 			}
142 
143 			// Calculate the path to the actual picture file
144 			Path sourcePath = documentPath.resolveSibling(imageSrc);
145 			File sourceFile = sourcePath.toFile();
146 
147 			// Skip external URLs
148 			if (!sourcePath.toAbsolutePath().startsWith(basedirPath)) {
149 				continue;
150 			}
151 
152 			// Recalculate the relative link and see whether the original matches
153 			// the recalculated one. If not, it means there is a problem in the case.
154 			Path recalculatedPath = parentPath.toRealPath().relativize(sourcePath.toRealPath());
155 			String sourcePathSlashString = sourcePath.toString().replace('\\', '/');
156 			String recalculatedPathSlashString = recalculatedPath.toString().replace('\\', '/');
157 			if (!recalculatedPathSlashString.endsWith(sourcePathSlashString)
158 					&& !sourcePathSlashString.endsWith(recalculatedPathSlashString)) {
159 				errorList
160 						.add(
161 								"Referenced image " + imageSrc + " in " + currentDocument + " doesn't match case of actual file "
162 										+ recalculatedPath);
163 			}
164 
165 			// Sanity check
166 			if (!sourceFile.isFile()) {
167 				errorList.add("Referenced image " + imageSrc + " in " + currentDocument + " doesn't exist");
168 			}
169 
170 		}
171 
172 		// Some errors, show them all
173 		if (!errorList.isEmpty()) {
174 			throw new IOException(errorList.stream().collect(Collectors.joining("\n")));
175 		}
176 
177 		return body;
178 
179 	}
180 
181 	/**
182 	 * Returns the extension of the file
183 	 * <p>
184 	 *
185 	 * @param file File
186 	 * @return the extension of the file
187 	 */
188 	protected static String getExtension(final File file) {
189 		String name = file.getName();
190 		int dotIndex = name.lastIndexOf('.');
191 		if (dotIndex > -1) {
192 			return name.substring(dotIndex + 1);
193 		}
194 		return "";
195 	}
196 
197 	/**
198 	 * Returns the name of the file without its extension
199 	 * <p>
200 	 *
201 	 * @param file File
202 	 * @return the name of the file without its extension
203 	 */
204 	protected static String getNameWithoutExtension(final File file) {
205 		String name = file.getName();
206 		int dotIndex = name.lastIndexOf('.');
207 		if (dotIndex > -1) {
208 			return name.substring(0, dotIndex);
209 		}
210 		return name;
211 	}
212 
213 	/**
214 	 * Create a thumbnail image file from the specified image file.
215 	 * <p>
216 	 *
217 	 * @param sourceFile File instance of the source image
218 	 * @param thumbnailMark Suffix to be appended to the source file name to build the thumbnail file
219 	 * @param maxWidth Maximum width of the thumbnail, 0 if no maximum width
220 	 * @param maxHeight Maximum height of the thumbnail, 0 if no maximum height
221 	 * @return File instance of the thumbail image
222 	 * @throws IOException when cannot read the source image, or write the thumbnail file
223 	 */
224 	protected static File createThumbnail(
225 			final File sourceFile,
226 			final String thumbnailMark,
227 			final int maxWidth,
228 			final int maxHeight)
229 			throws IOException {
230 
231 		// Sanity check
232 		if (!sourceFile.isFile()) {
233 			throw new IOException(sourceFile.getAbsolutePath() + " does not exist");
234 		}
235 
236 		// Destination
237 		File destination = new File(sourceFile.getParent(), getNameWithoutExtension(sourceFile) + thumbnailMark + ".jpg");
238 
239 		// Do we need to do anything? (if destination is newer than source, we skip)
240 		if (Helper.getLastModifiedTime(sourceFile) < Helper.getLastModifiedTime(destination)) {
241 			return destination;
242 		}
243 
244 		// Read the specified image
245 		BufferedImage sourceImage = ImageIO.read(sourceFile);
246 		String imageType = getExtension(sourceFile).toLowerCase();
247 
248 		// Calculate the dimensions of the resulting thumbnail
249 		int targetWidth = sourceImage.getWidth();
250 		int targetHeight = sourceImage.getHeight();
251 
252 		if (maxWidth > 0 && targetWidth > maxWidth) {
253 			targetHeight = targetHeight * maxWidth / targetWidth;
254 			targetWidth = maxWidth;
255 		}
256 		if (maxHeight > 0 && targetHeight > maxHeight) {
257 			targetWidth = targetWidth * maxHeight / targetHeight;
258 			targetHeight = maxHeight;
259 		}
260 
261 		// Rescale
262 		Image resultingImage = sourceImage.getScaledInstance(targetWidth, targetHeight, Image.SCALE_SMOOTH);
263 		BufferedImage outputImage = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
264 		outputImage.getGraphics().drawImage(resultingImage, 0, 0, null);
265 
266 		// Write the thumbnail file
267 		ImageIO.write(outputImage, imageType, destination);
268 
269 		return destination;
270 
271 	}
272 
273 	/**
274 	 * Saves the specified image file as a WEBP image.
275 	 * <p>
276 	 *
277 	 * @param sourceFile image file to convert to WEBP
278 	 * @return a File instance of the converted image, or null if the file was already a WEBP
279 	 * @throws IOException when cannot read the image file
280 	 */
281 	protected static File saveImageFileAsWebp(final File sourceFile) throws IOException {
282 
283 		// Sanity check
284 		if (!sourceFile.isFile()) {
285 			throw new IOException(sourceFile.getAbsolutePath() + " does not exist");
286 		}
287 
288 		// Output file
289 		String webpImagePath = getNameWithoutExtension(sourceFile) + ".webp";
290 		File webpFile = new File(sourceFile.getParent(), webpImagePath);
291 
292 		// Do we need to do anything? (if destination is newer than source, we skip)
293 		if (Helper.getLastModifiedTime(sourceFile) < Helper.getLastModifiedTime(webpFile)) {
294 			return webpFile;
295 		}
296 
297 		// Read the specified image
298 		BufferedImage sourceImage = ImageIO.read(sourceFile);
299 		if (sourceImage == null) {
300 			return null;
301 		}
302 
303 		// Image type (skip if webp)
304 		String imageType = getExtension(sourceFile).toLowerCase();
305 		if ("webp".equals(imageType)) {
306 			return null;
307 		}
308 
309 		// Obtain a WebP ImageWriter instance
310 		ImageWriter writer = ImageIO.getImageWritersBySuffix("webp").next();
311 
312 		// Configure encoding parameters: LOSSY for jpeg and jpg, LOSSLESS otherwise
313 		WebPWriteParam writeParam = new WebPWriteParam(writer.getLocale());
314 		writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
315 		if ("jpeg".equals(imageType) || "jpg".equals(imageType)) {
316 			writeParam.setCompressionType(writeParam.getCompressionTypes()[WebPWriteParam.LOSSY_COMPRESSION]);
317 		} else {
318 			writeParam.setCompressionType(writeParam.getCompressionTypes()[WebPWriteParam.LOSSLESS_COMPRESSION]);
319 		}
320 
321 		// Configure the output on the ImageWriter
322 		writer.setOutput(new FileImageOutputStream(webpFile));
323 
324 		// Write the WEBP image
325 		writer.write(null, new IIOImage(sourceImage, null, null), writeParam);
326 
327 		// Return the file
328 		return webpFile;
329 
330 	}
331 
332 	/**
333 	 * Upgrades all images in the specified HTML document to WEBP.
334 	 *
335 	 * @param body the HTML content
336 	 * @param selector CSS selector to select all images to upgrade
337 	 *        ("img.screenshot" will process all &lt;IMG
338 	 *        class="screenshot"&gt; elements)
339 	 * @param basedir Actual root directory of the site on the file system
340 	 * @param currentDocument Logical path of the document being parsed (e.g.
341 	 *        "index.html", or "subdir/subpage.html")
342 	 * @return the updated HTML content
343 	 * @throws IOException when an image cannot be read or converted
344 	 */
345 	public Element convertImagesToWebp(
346 			final Element body,
347 			final String selector,
348 			final String basedir,
349 			final String currentDocument)
350 			throws IOException {
351 
352 		// basedir path
353 		Path basedirPath = Paths.get(basedir).toAbsolutePath();
354 
355 		// First, calculate the real path of the current document
356 		Path documentPath = Paths.get(basedir, currentDocument);
357 
358 		Path parentPath = documentPath.getParent();
359 		if (parentPath == null) {
360 			throw new IOException("Couldn't get parent path of " + currentDocument);
361 		}
362 
363 		// Select all images
364 		List<Element> elements = body.select(selector);
365 
366 		// For each image
367 		for (Element element : elements) {
368 
369 			// Get the SRC attribute (the path)
370 			String imageSrc = element.attr("src");
371 			if (imageSrc.isEmpty()) {
372 				continue;
373 			}
374 
375 			// Skip absolute URLs
376 			if (isAbsoluteUrl(imageSrc)) {
377 				continue;
378 			}
379 
380 			// Calculate the path to the actual picture file
381 			Path sourcePath = documentPath.resolveSibling(imageSrc);
382 			File sourceFile = sourcePath.toFile();
383 
384 			// Skip external URLs
385 			if (!sourcePath.toAbsolutePath().startsWith(basedirPath)) {
386 				continue;
387 			}
388 
389 			// Sanity check
390 			if (!sourceFile.isFile()) {
391 				throw new IOException(sourceFile.getAbsolutePath() + " (referenced as " + imageSrc + ") does not exist");
392 			}
393 
394 			// Save as webp
395 			File webpFile = saveImageFileAsWebp(sourceFile);
396 			if (webpFile == null) {
397 				continue;
398 			}
399 
400 			// Calculate the src path of the webp image
401 			String webpSrc = parentPath.relativize(webpFile.toPath()).toString().replace('\\', '/');
402 
403 			// Now wrap the IMG element with <picture> and <source srcset="...webp">
404 			element
405 					.wrap("<picture>")
406 					.parent()
407 					.prependElement("source")
408 					.attr("srcset", webpSrc)
409 					.attr("type", "image/webp");
410 
411 		}
412 
413 		return body;
414 
415 	}
416 
417 	/**
418 	 * Explicitly states the width and height of each image in the specified document.
419 	 *
420 	 * @param body the HTML content
421 	 * @param selector CSS selector to select all images to upgrade
422 	 *        ("img.screenshot" will process all &lt;IMG
423 	 *        class="screenshot"&gt; elements)
424 	 * @param basedir Actual root directory of the site on the file system
425 	 * @param currentDocument Logical path of the document being parsed (e.g.
426 	 *        "index.html", or "subdir/subpage.html")
427 	 * @return the updated HTML content
428 	 * @throws IOException when an image cannot be read or converted
429 	 */
430 	public Element explicitImageSize(
431 			final Element body,
432 			final String selector,
433 			final String basedir,
434 			final String currentDocument)
435 			throws IOException {
436 
437 		// basedir path
438 		Path basedirPath = Paths.get(basedir).toAbsolutePath();
439 
440 		// First, calculate the real path of the current document
441 		Path documentPath = Paths.get(basedir, currentDocument);
442 
443 		// Select all images
444 		List<Element> elements = body.select(selector);
445 
446 		// For each image
447 		for (Element element : elements) {
448 
449 			// Get the SRC attribute (the path)
450 			String imageSrc = element.attr("src");
451 			if (imageSrc.isEmpty()) {
452 				continue;
453 			}
454 
455 			// Skip absolute URLs
456 			if (isAbsoluteUrl(imageSrc)) {
457 				continue;
458 			}
459 
460 			// If size and height are already specified, skip
461 			if (element.attr("style").matches("(^|\\b)(width:|height:)")
462 					|| !element.attr("height").isEmpty()
463 					|| !element.attr("width").isEmpty()) {
464 				continue;
465 			}
466 
467 			// Calculate the path to the actual picture file
468 			Path sourcePath = documentPath.resolveSibling(imageSrc);
469 			File sourceFile = sourcePath.toFile();
470 
471 			// Skip external URLs
472 			if (!sourcePath.toAbsolutePath().startsWith(basedirPath)) {
473 				continue;
474 			}
475 
476 			// Sanity check
477 			if (!sourceFile.isFile()) {
478 				throw new IOException(sourceFile.getAbsolutePath() + " (referenced as " + imageSrc + ") does not exist");
479 			}
480 
481 			// Read the image
482 			BufferedImage sourceImage = ImageIO.read(sourceFile);
483 			if (sourceImage == null) {
484 				continue;
485 			}
486 
487 			// Now set the width and height attributes (and CSS)
488 			element
489 					.attr("width", String.valueOf(sourceImage.getWidth()))
490 					.attr("height", String.valueOf(sourceImage.getHeight()))
491 					.attr(
492 							"style",
493 							String
494 									.format(
495 											"width: %dpx; height: %dpx;%s",
496 											sourceImage.getWidth(),
497 											sourceImage.getHeight(),
498 											element.attr("style")));
499 
500 		}
501 
502 		return body;
503 
504 	}
505 
506 	/**
507 	 * For all images in the document, create the corresponding thumbnail, and wrap
508 	 * the picture elements with the specified template.
509 	 * <p>
510 	 * The specified template may reference the below "macros":
511 	 * <ul>
512 	 * <li><code>%imgWidth%</code>: the original image width
513 	 * <li><code>%imgHeight%</code>: the original image height
514 	 * <li><code>%imgAlt%</code>: the image alternate text (it's description)
515 	 * <li><code>%thumbWidth%</code>: the thumbnail image width
516 	 * <li><code>%thumbHeight%</code>: the thumbnail image height
517 	 * <li><code>%thumbSrc%</code>: the thumbnail image source path
518 	 * </ul>
519 	 *
520 	 * @param body the HTML content
521 	 * @param selector CSS selector to select all images to upgrade
522 	 *        ("img.screenshot" will process all &lt;IMG
523 	 *        class="screenshot"&gt; elements)
524 	 * @param basedir Actual root directory of the site on the file system
525 	 * @param currentDocument Logical path of the document being parsed (e.g.
526 	 *        "index.html", or "subdir/subpage.html")
527 	 * @param maxWidth Maximum width for the thumbnail (or 0 for no maximum)
528 	 * @param maxHeight Maximum height for the thumbnail (or 0 for no maximum)
529 	 * @param wrapTemplate HTML code wrap the image element with. This will be
530 	 *        typically used to create the thumbnail element. The HTML
531 	 *        can reference macros.
532 	 * @return the updated HTML content
533 	 * @throws IOException when an image cannot be read or converted
534 	 */
535 	public Element convertImagesToThumbnails(
536 			final Element body,
537 			final String selector,
538 			final String basedir,
539 			final String currentDocument,
540 			final int maxWidth,
541 			final int maxHeight,
542 			final String wrapTemplate)
543 			throws IOException {
544 
545 		// basedir path
546 		Path basedirPath = Paths.get(basedir).toAbsolutePath();
547 
548 		// First, calculate the real path of the current document
549 		Path documentPath = Paths.get(basedir, currentDocument);
550 
551 		Path parentPath = documentPath.getParent();
552 		if (parentPath == null) {
553 			throw new IOException("Couldn't get parent path of " + currentDocument);
554 		}
555 
556 		// Select all images
557 		List<Element> elements = body.select(selector);
558 
559 		// For each image
560 		for (Element element : elements) {
561 
562 			// Get the SRC attribute (the path)
563 			String imageSrc = element.attr("src");
564 			if (imageSrc.isEmpty()) {
565 				continue;
566 			}
567 
568 			// Skip absolute URLs
569 			if (isAbsoluteUrl(imageSrc)) {
570 				continue;
571 			}
572 
573 			// Get the ALT attribute (the description)
574 			String imageAlt = element.attr("alt");
575 
576 			// Calculate the path to the actual picture file
577 			Path sourcePath = documentPath.resolveSibling(imageSrc);
578 			File sourceFile = sourcePath.toFile();
579 
580 			// Skip external URLs
581 			if (!sourcePath.toAbsolutePath().startsWith(basedirPath)) {
582 				continue;
583 			}
584 
585 			// Sanity check
586 			if (!sourceFile.isFile()) {
587 				throw new IOException(sourceFile.getAbsolutePath() + " (referenced as " + imageSrc + ") does not exist");
588 			}
589 
590 			// Image size
591 			BufferedImage sourceImage = ImageIO.read(sourceFile);
592 			int sourceWidth = sourceImage.getWidth();
593 			int sourceHeight = sourceImage.getHeight();
594 
595 			// Create the thumbnail
596 			File thumbnailFile = createThumbnail(sourceFile, "-thumbnail", maxWidth, maxHeight);
597 
598 			// Read the thumbnail and get its size
599 			BufferedImage thumbnailImage = ImageIO.read(thumbnailFile);
600 			int thumbnailWidth = thumbnailImage.getWidth();
601 			int thumbnailHeight = thumbnailImage.getHeight();
602 
603 			// Calculate the src path of the webp image
604 			String thumbnailSrc = parentPath.relativize(thumbnailFile.toPath()).toString().replace('\\', '/');
605 
606 			// Replace macros in the wrap template
607 			String wrapHtml = wrapTemplate
608 					.replaceAll("%imgWidth%", String.valueOf(sourceWidth))
609 					.replaceAll("%imgHeight%", String.valueOf(sourceHeight))
610 					.replaceAll("%thumbWidth%", String.valueOf(thumbnailWidth))
611 					.replaceAll("%thumbHeight%", String.valueOf(thumbnailHeight))
612 					.replaceAll("%thumbSrc%", thumbnailSrc)
613 					.replaceAll("%imgAlt%", imageAlt);
614 
615 			// Now wrap the IMG element with template
616 			// If the IMG element is inside a PICTURE element, wrap the PICTURE element
617 			Element pictureElement = element;
618 			if ("PICTURE".equalsIgnoreCase(element.parent().tagName())) {
619 				pictureElement = element.parent();
620 			}
621 			pictureElement.wrap(wrapHtml);
622 
623 		}
624 
625 		return body;
626 
627 	}
628 
629 }