View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.thumbnail.impl;
17  
18  import java.awt.Image;
19  import java.awt.Rectangle;
20  import java.awt.image.BufferedImage;
21  import java.io.File;
22  import java.io.IOException;
23  import java.nio.file.Files;
24  import java.nio.file.Path;
25  import java.util.Iterator;
26  import java.util.Map;
27  
28  import javax.imageio.ImageIO;
29  import javax.imageio.ImageReadParam;
30  import javax.imageio.ImageReader;
31  import javax.imageio.stream.ImageInputStream;
32  
33  import org.apache.logging.log4j.LogManager;
34  import org.apache.logging.log4j.Logger;
35  import org.codelibs.core.lang.StringUtil;
36  import org.codelibs.core.misc.Tuple3;
37  import org.codelibs.fess.crawler.entity.ResponseData;
38  import org.codelibs.fess.mylasta.direction.FessConfig;
39  import org.codelibs.fess.util.ComponentUtil;
40  import org.codelibs.fess.util.DocumentUtil;
41  
42  /**
43   * HTML tag-based thumbnail generator that creates thumbnails from image content
44   * referenced in HTML documents. This generator extracts images from HTML content
45   * and processes them to create thumbnail images based on configured dimensions
46   * and format settings.
47   *
48   * <p>The generator validates image MIME types, processes image data through
49   * ImageIO operations, and applies scaling and cropping to generate thumbnails
50   * that meet the specified size requirements.</p>
51   *
52   */
53  public class HtmlTagBasedGenerator extends BaseThumbnailGenerator {
54  
55      private static final Logger logger = LogManager.getLogger(HtmlTagBasedGenerator.class);
56  
57      /**
58       * Default constructor for HtmlTagBasedGenerator.
59       */
60      public HtmlTagBasedGenerator() {
61          super();
62      }
63  
64      /**
65       * Destroys this thumbnail generator and releases any resources.
66       * This implementation is empty as no cleanup is required.
67       */
68      @Override
69      public void destroy() {
70      }
71  
72      /**
73       * Creates a thumbnail generation task for the specified document.
74       *
75       * @param path the file path or URL of the document
76       * @param docMap the document metadata map containing field values
77       * @return a tuple containing the generator name, thumbnail ID, and path
78       */
79      @Override
80      public Tuple3<String, String, String> createTask(final String path, final Map<String, Object> docMap) {
81          final FessConfig fessConfig = ComponentUtil.getFessConfig();
82          final String thumbnailId = DocumentUtil.getValue(docMap, fessConfig.getIndexFieldId(), String.class);
83          final Tuple3<String, String, String> task = new Tuple3<>(getName(), thumbnailId, path);
84          if (logger.isDebugEnabled()) {
85              logger.debug("Create thumbnail task: {}", task);
86          }
87          return task;
88      }
89  
90      /**
91       * Generates a thumbnail image and saves it to the specified output file.
92       *
93       * <p>This method processes the image content associated with the thumbnail ID,
94       * validates the image format, applies scaling and cropping transformations,
95       * and writes the resulting thumbnail to the output file.</p>
96       *
97       * @param thumbnailId the unique identifier for the thumbnail
98       * @param outputFile the file where the generated thumbnail will be saved
99       * @return true if the thumbnail was successfully generated, false otherwise
100      */
101     @Override
102     public boolean generate(final String thumbnailId, final File outputFile) {
103         if (logger.isDebugEnabled()) {
104             logger.debug("Generate Thumbnail: {}", thumbnailId);
105         }
106 
107         if (outputFile.exists()) {
108             if (logger.isDebugEnabled()) {
109                 logger.debug("The thumbnail file exists: {}", outputFile.getAbsolutePath());
110             }
111             return true;
112         }
113 
114         final File parentFile = outputFile.getParentFile();
115         final Path parentPath = parentFile.toPath();
116         try {
117             Files.createDirectories(parentPath);
118         } catch (final IOException e) {
119             logger.warn("Failed to create parent directory: {}", parentFile.getAbsolutePath(), e);
120             return false;
121         }
122 
123         return process(thumbnailId, responseData -> {
124             if (!isImageMimeType(responseData)) {
125                 if (logger.isDebugEnabled()) {
126                     logger.debug("Thumbnail is not image: {} : {}", thumbnailId, responseData.getUrl());
127                 }
128                 updateThumbnailField(thumbnailId, StringUtil.EMPTY);
129                 return false;
130             }
131             boolean created = false;
132             try (ImageInputStream input = ImageIO.createImageInputStream(responseData.getResponseBody())) {
133                 switch (saveImage(input, outputFile)) {
134                 case OK:
135                     created = true;
136                     break;
137                 case FAILED:
138                     logger.warn("Failed to create thumbnail: {} -> {}", thumbnailId, responseData.getUrl());
139                     break;
140                 case INVALID_SIZE:
141                     logger.info("Unmatched thumbnail size: {} -> {}", thumbnailId, responseData.getUrl());
142                     break;
143                 case NO_IMAGE:
144                     if (logger.isDebugEnabled()) {
145                         logger.debug("No thumbnail: {} -> {}", thumbnailId, responseData.getUrl());
146                     }
147                     break;
148                 default:
149                     logger.error("Unknown thumbnail result: {} -> {}", thumbnailId, responseData.getUrl());
150                     break;
151                 }
152             } catch (final Throwable t) {
153                 logger.warn("Failed to create thumbnail: {} -> {} ({}:{})", thumbnailId, responseData.getUrl(),
154                         t.getClass().getCanonicalName(), t.getMessage());
155                 if (logger.isDebugEnabled()) {
156                     logger.debug("Details for failed thumbnail creation.", t);
157                 }
158             } finally {
159                 if (!created) {
160                     updateThumbnailField(thumbnailId, StringUtil.EMPTY);
161                     try {
162                         Files.deleteIfExists(outputFile.toPath());
163                     } catch (final IOException e) {
164                         logger.warn("Failed to delete output file: {}", outputFile.getAbsolutePath(), e);
165                     }
166                 }
167             }
168             return outputFile.exists();
169         });
170 
171     }
172 
173     /**
174      * Checks if the response data contains an image MIME type that can be processed
175      * for thumbnail generation.
176      *
177      * @param responseData the response data containing MIME type information
178      * @return true if the MIME type represents a supported image format, false otherwise
179      */
180     protected boolean isImageMimeType(final ResponseData responseData) {
181         final String mimeType = responseData.getMimeType();
182         if (mimeType == null) {
183             return true;
184         }
185 
186         return switch (mimeType) {
187         case "image/png", "image/gif", "image/jpeg", "image/bmp" -> true;
188         default -> false;
189         };
190     }
191 
192     /**
193      * Processes and saves an image from the input stream to the output file as a thumbnail.
194      *
195      * <p>This method reads image data, validates dimensions, applies subsampling and scaling
196      * transformations based on configuration settings, and writes the processed thumbnail
197      * to the specified output file.</p>
198      *
199      * @param input the image input stream containing the source image data
200      * @param outputFile the file where the processed thumbnail will be saved
201      * @return the result of the image processing operation
202      * @throws IOException if an error occurs during image reading or writing operations
203      */
204     protected Result saveImage(final ImageInputStream input, final File outputFile) throws IOException {
205         final FessConfig fessConfig = ComponentUtil.getFessConfig();
206         final Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
207         if (readers.hasNext()) {
208             final ImageReader reader = readers.next();
209             try {
210                 reader.setInput(input);
211                 final ImageReadParam param = reader.getDefaultReadParam();
212                 final int width = reader.getWidth(0);
213                 final int height = reader.getHeight(0);
214                 if (width <= 0 || height <= 0) {
215                     return Result.NO_IMAGE;
216                 }
217                 if (!fessConfig.validateThumbnailSize(width, height)) {
218                     return Result.INVALID_SIZE;
219                 }
220                 final int samplingWidth = width / fessConfig.getThumbnailHtmlImageThumbnailWidthAsInteger();
221                 final int samplingHeight = height / fessConfig.getThumbnailHtmlImageThumbnailHeightAsInteger();
222                 param.setSourceSubsampling(samplingWidth <= 0 ? 1 : samplingWidth, samplingHeight <= 0 ? 1 : samplingHeight, 0, 0);
223                 param.setSourceRegion(new Rectangle(width, height > width ? width : height));
224                 final BufferedImage image = reader.read(0, param);
225                 final int thumbnailWidth = fessConfig.getThumbnailHtmlImageThumbnailWidthAsInteger();
226                 final int thumbnailHeight = (int) ((height > width ? width : height)
227                         * fessConfig.getThumbnailHtmlImageThumbnailWidthAsInteger().floatValue() / width);
228                 final BufferedImage thumbnail = new BufferedImage(thumbnailWidth, thumbnailHeight, image.getType());
229                 thumbnail.getGraphics()
230                         .drawImage(image.getScaledInstance(thumbnailWidth, thumbnailHeight, Image.SCALE_AREA_AVERAGING), 0, 0,
231                                 thumbnailWidth, thumbnailHeight, null);
232                 ImageIO.write(thumbnail, fessConfig.getThumbnailHtmlImageFormat(), outputFile);
233                 image.flush();
234                 return Result.OK;
235             } finally {
236                 reader.dispose();
237             }
238         }
239         return Result.FAILED;
240     }
241 
242     /**
243      * Enumeration representing the possible results of thumbnail image processing.
244      */
245     protected enum Result {
246         /** Thumbnail was successfully generated */
247         OK,
248         /** Thumbnail generation failed due to processing errors */
249         FAILED,
250         /** Image dimensions do not meet validation requirements */
251         INVALID_SIZE,
252         /** No valid image data was found in the input */
253         NO_IMAGE;
254     }
255 }