View Javadoc
1   /*
2    * #%L
3    * wcm.io
4    * %%
5    * Copyright (C) 2014 wcm.io
6    * %%
7    * Licensed under the Apache License, Version 2.0 (the "License");
8    * you may not use this file except in compliance with the License.
9    * You may obtain a copy of the License at
10   *
11   *      http://www.apache.org/licenses/LICENSE-2.0
12   *
13   * Unless required by applicable law or agreed to in writing, software
14   * distributed under the License is distributed on an "AS IS" BASIS,
15   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16   * See the License for the specific language governing permissions and
17   * limitations under the License.
18   * #L%
19   */
20  package io.wcm.handler.mediasource.inline;
21  
22  import java.io.IOException;
23  import java.io.InputStream;
24  import java.util.ArrayList;
25  import java.util.Date;
26  import java.util.List;
27  import java.util.Objects;
28  
29  import javax.jcr.Node;
30  import javax.jcr.Property;
31  import javax.jcr.RepositoryException;
32  
33  import org.apache.commons.io.FilenameUtils;
34  import org.apache.commons.io.IOUtils;
35  import org.apache.commons.lang3.StringUtils;
36  import org.apache.sling.api.adapter.Adaptable;
37  import org.apache.sling.api.adapter.SlingAdaptable;
38  import org.apache.sling.api.resource.Resource;
39  import org.apache.sling.api.resource.ValueMap;
40  import org.jetbrains.annotations.NotNull;
41  import org.jetbrains.annotations.Nullable;
42  
43  import com.day.cq.commons.jcr.JcrConstants;
44  import com.day.image.Layer;
45  
46  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
47  import io.wcm.handler.media.CropDimension;
48  import io.wcm.handler.media.Dimension;
49  import io.wcm.handler.media.Media;
50  import io.wcm.handler.media.MediaArgs;
51  import io.wcm.handler.media.MediaFileType;
52  import io.wcm.handler.media.Rendition;
53  import io.wcm.handler.media.UriTemplate;
54  import io.wcm.handler.media.UriTemplateType;
55  import io.wcm.handler.media.format.MediaFormat;
56  import io.wcm.handler.media.format.Ratio;
57  import io.wcm.handler.media.format.impl.MediaFormatSupport;
58  import io.wcm.handler.media.impl.ImageFileServlet;
59  import io.wcm.handler.media.impl.ImageFileServletSelector;
60  import io.wcm.handler.media.impl.ImageTransformation;
61  import io.wcm.handler.media.impl.JcrBinary;
62  import io.wcm.handler.media.impl.MediaFileServletConstants;
63  import io.wcm.handler.media.spi.MediaHandlerConfig;
64  import io.wcm.handler.mediasource.ngdm.impl.MediaArgsDimension;
65  import io.wcm.handler.url.UrlHandler;
66  import io.wcm.sling.commons.adapter.AdaptTo;
67  import io.wcm.wcm.commons.caching.ModificationDate;
68  
69  /**
70   * {@link Rendition} implementation for inline media objects stored in a node in a content page.
71   */
72  final class InlineRendition extends SlingAdaptable implements Rendition {
73  
74    private final Adaptable adaptable;
75    private final Resource resource;
76    private final MediaArgs mediaArgs;
77    private final MediaHandlerConfig mediaHandlerConfig;
78    private final String fileName;
79    private final String fileExtension;
80    private final String originalFileExtension;
81    private final Dimension imageDimension;
82    private final Dimension maxImageDimension;
83    private final String url;
84    private CropDimension cropDimension;
85    private final Integer rotation;
86    private MediaFormat resolvedMediaFormat;
87    private boolean fallback;
88  
89    /**
90     * Special dimension instance that marks "scaling is required but not possible"
91     */
92    private static final Dimension SCALING_NOT_POSSIBLE_DIMENSION = new Dimension(-1, -1);
93  
94    /**
95     * @param resource Binary resource
96     * @param media Media metadata
97     * @param mediaHandlerConfig Media handler config
98     * @param mediaArgs Media args
99     * @param fileName File name
100    */
101   @SuppressWarnings("java:S3776") // ignore complexity
102   InlineRendition(Resource resource, Media media, MediaArgs mediaArgs, MediaHandlerConfig mediaHandlerConfig,
103       String fileName, Adaptable adaptable) {
104     this.resource = resource;
105     this.mediaArgs = mediaArgs;
106     this.mediaHandlerConfig = mediaHandlerConfig;
107     this.adaptable = adaptable;
108 
109     this.rotation = media.getRotation();
110     this.cropDimension = media.getCropDimension();
111 
112     // detect image dimension
113     this.originalFileExtension = FilenameUtils.getExtension(fileName);
114 
115     // check if scaling is possible
116     boolean isImage = MediaFileType.isImage(this.originalFileExtension);
117     boolean isVectorImage = MediaFileType.isVectorImage(this.originalFileExtension);
118 
119     Dimension dimension = null;
120     Dimension maxDimension = null;
121     Dimension scaledDimension = null;
122     String processedFileName = fileName;
123     if (isImage) {
124       // get dimension from image binary
125       List<Dimension> dimensionCandidates = getImageOrCroppedDimensions();
126       for (int i = 0; i < dimensionCandidates.size(); i++) {
127         dimension = dimensionCandidates.get(i);
128         maxDimension = dimension;
129         if (isVectorImage && (this.rotation != null || this.cropDimension != null)) {
130           // transformation not possible for vector images
131           scaledDimension = SCALING_NOT_POSSIBLE_DIMENSION;
132         }
133         else {
134           // check if scaling is required
135           scaledDimension = getScaledDimension(dimension);
136           if (scaledDimension != null && isValidScalingDimension(scaledDimension)) {
137             // overwrite image dimension of {@link Rendition} instance with scaled dimensions
138             dimension = scaledDimension;
139             // extension may have to be changed because scaling case produce different file format
140             if (!isVectorImage) {
141               processedFileName = ImageFileServlet.getImageFileName(processedFileName,
142                   mediaArgs.getEnforceOutputFileExtension());
143             }
144           }
145         }
146         if (isValidScalingDimension(scaledDimension)) {
147           if (i > 0) {
148             // fallback (original) image dimension is used - clear ignored cropping parameters
149             this.cropDimension = null;
150             this.fallback = true;
151           }
152           break;
153         }
154       }
155       if (!isValidScalingDimension(scaledDimension) && mediaArgs.isAutoCrop() && !isVectorImage && dimension != null) {
156         // scaling is required, but not match with inline media - try auto cropping (if enabled)
157         InlineAutoCropping autoCropping = new InlineAutoCropping(dimension, mediaArgs);
158         List<CropDimension> autoCropDimensions = autoCropping.calculateAutoCropDimensions();
159         for (CropDimension autoCropDimension : autoCropDimensions) {
160           scaledDimension = getScaledDimension(autoCropDimension);
161           maxDimension = autoCropDimension;
162           if (scaledDimension == null) {
163             scaledDimension = autoCropDimension;
164           }
165           if (isValidScalingDimension(scaledDimension)) {
166             // overwrite image dimension of {@link Rendition} instance with scaled dimensions
167             dimension = scaledDimension;
168             this.cropDimension = autoCropDimension;
169             // extension may have to be changed because scaling case produce different file format
170             if (!isVectorImage) {
171               processedFileName = ImageFileServlet.getImageFileName(processedFileName,
172                   mediaArgs.getEnforceOutputFileExtension());
173             }
174             break;
175           }
176         }
177       }
178     }
179     this.fileName = processedFileName;
180     this.fileExtension = FilenameUtils.getExtension(processedFileName);
181     this.imageDimension = dimension;
182     this.maxImageDimension = maxDimension;
183 
184     // build media url (it is null if no rendition is available for the given media args)
185     this.url = buildMediaUrl(scaledDimension);
186 
187     // set first media format as resolved format - because only the first is supported
188     MediaFormat firstMediaFormat = MediaArgsDimension.getFirstMediaFormat(mediaArgs);
189     if (url != null && firstMediaFormat != null) {
190       this.resolvedMediaFormat = firstMediaFormat;
191     }
192   }
193 
194   private boolean isValidScalingDimension(@Nullable Dimension dimension) {
195     return dimension == null || !dimension.equals(SCALING_NOT_POSSIBLE_DIMENSION);
196   }
197 
198   /**
199    * Gets a list of possible dimensions for media processing. If cropping parameters are given
200    * the list contains the cropping dimension and the original image dimension; if not only the latter.
201    * If the original image is not an image at all, an empty list is returned.
202    * @return Dimension
203    */
204   private List<Dimension> getImageOrCroppedDimensions() {
205     List<Dimension> dimensions = new ArrayList<>();
206 
207     Dimension originalDimension = getImageDimension();
208     if (originalDimension != null) {
209       if (this.cropDimension != null) {
210         dimensions.add(this.cropDimension);
211       }
212       dimensions.add(originalDimension);
213     }
214 
215     return dimensions;
216   }
217 
218   /**
219    * Gets the dimension of the uploaded image (if the binary is an image file at all).
220    * @return Dimension
221    */
222   private Dimension getImageDimension() {
223     Dimension dimension = null;
224 
225     // if binary is image try to calculate dimensions by loading it into a layer
226     Layer layer = this.resource.adaptTo(Layer.class);
227     if (layer != null) {
228       dimension = new Dimension(layer.getWidth(), layer.getHeight());
229     }
230 
231     return dimension;
232   }
233 
234   /**
235    * Checks if the current binary is an image and has to be scaled. In this case the destination dimension is returned.
236    * @return Scaled destination or null if no scaling is required. If a destination object with both
237    *         width and height set to -1 is returned, a scaling is required but not possible with the given source
238    *         object.
239    */
240   private @Nullable Dimension getScaledDimension(@NotNull Dimension originalDimension) {
241 
242     // check if image has to be rescaled
243     Dimension requestedDimension = MediaArgsDimension.getRequestedDimension(mediaArgs);
244     double requestedRatio = MediaArgsDimension.getRequestedRatio(mediaArgs);
245     double imageRatio = Ratio.get(originalDimension);
246     if (requestedRatio > 0 && !Ratio.matches(requestedRatio, imageRatio)) {
247       return SCALING_NOT_POSSIBLE_DIMENSION;
248     }
249 
250     boolean scaleWidth = (requestedDimension.getWidth() > 0
251         && requestedDimension.getWidth() != originalDimension.getWidth());
252     boolean scaleHeight = (requestedDimension.getHeight() > 0
253         && requestedDimension.getHeight() != originalDimension.getHeight());
254     if (scaleWidth || scaleHeight) {
255       long requestedWidth = requestedDimension.getWidth();
256       long requestedHeight = requestedDimension.getHeight();
257 
258       // calculate missing width/height from ratio if not specified
259       if (requestedWidth == 0 && requestedHeight > 0) {
260         requestedWidth = Math.round(requestedHeight * imageRatio);
261       }
262       else if (requestedWidth > 0 && requestedHeight == 0) {
263         requestedHeight = Math.round(requestedWidth / imageRatio);
264       }
265 
266       // calculate requested ratio
267       requestedRatio = Ratio.get(requestedWidth, requestedHeight);
268 
269       // if ratio does not match, or requested width/height is larger than the original image scaling is not possible
270       if (!Ratio.matches(imageRatio, requestedRatio)
271           || (originalDimension.getWidth() < requestedWidth)
272           || (originalDimension.getHeight() < requestedHeight)) {
273         return SCALING_NOT_POSSIBLE_DIMENSION;
274       }
275       else {
276         return new Dimension(requestedWidth, requestedHeight);
277       }
278 
279     }
280 
281     return null;
282   }
283 
284   /**
285    * Build media URL for this rendition - either "native" URL to repository or virtual url to rescaled version.
286    * @return Media URL - null if no rendition is available
287    */
288   private String buildMediaUrl(Dimension scaledDimension) {
289 
290     // check for file extension filtering
291     if (!isMatchingFileExtension()) {
292       return null;
293     }
294 
295     // check if image has to be rescaled
296     if (scaledDimension != null) {
297 
298       // check if scaling is not possible
299       if (scaledDimension.equals(SCALING_NOT_POSSIBLE_DIMENSION)) {
300         return null;
301       }
302 
303       // otherwise generate scaled image URL
304       return buildScaledMediaUrl(scaledDimension, this.cropDimension);
305     }
306 
307     // if no scaling but cropping or rotation required build scaled image URL
308     if (this.cropDimension != null || this.rotation != null) {
309       return buildScaledMediaUrl(this.cropDimension != null ? this.cropDimension : this.imageDimension, this.cropDimension);
310     }
311 
312     if (mediaArgs.isContentDispositionAttachment()) {
313       // if not scaling and no cropping required but special content disposition headers required build download url
314       return buildDownloadMediaUrl();
315     }
316     else if (MediaFileType.isBrowserImage(getFileExtension()) || !MediaFileType.isImage(getFileExtension())) {
317       if (enforceVirtualRendition()) {
318         // enforce virtual rendition instead of native media URL
319         return buildScaledMediaUrl(this.imageDimension, null);
320       }
321       else {
322         // if no scaling and no cropping required build native media URL
323         return buildNativeMediaUrl();
324       }
325     }
326     else {
327       // image rendition uses a file extension that cannot be displayed in browser directly - render via ImageFileServlet
328       return buildScaledMediaUrl(this.imageDimension, null);
329     }
330   }
331 
332   private boolean enforceVirtualRendition() {
333     if (MediaFileType.isImage(getFileExtension()) && !MediaFileType.isVectorImage(getFileExtension())) {
334       if (mediaHandlerConfig.enforceVirtualRenditions()) {
335         return true;
336       }
337       if (mediaArgs.getEnforceOutputFileExtension() != null) {
338         return !StringUtils.equalsIgnoreCase(getFileExtension(), mediaArgs.getEnforceOutputFileExtension());
339       }
340     }
341     return false;
342   }
343 
344   /**
345    * Builds "native" URL that returns the binary data directly from the repository.
346    * @return Media URL
347    */
348   private String buildNativeMediaUrl() {
349     String path = null;
350 
351     Resource parentResource = this.resource.getParent();
352     if (parentResource != null && JcrBinary.isNtFile(parentResource)) {
353       // if parent resource is nt:file and its node name equals the detected filename, directly use the nt:file node path
354       if (StringUtils.equals(parentResource.getName(), getFileName())) {
355         path = parentResource.getPath();
356       }
357       // otherwise use nt:file node path with custom filename
358       else {
359         path = parentResource.getPath() + "./" + getFileName();
360       }
361     }
362     else {
363       // nt:resource node does not have a nt:file parent, use its path directly
364       path = this.resource.getPath() + "./" + getFileName();
365     }
366 
367     // build externalized URL
368     UrlHandler urlHandler = AdaptTo.notNull(this.adaptable, UrlHandler.class);
369     return urlHandler.get(path).urlMode(this.mediaArgs.getUrlMode()).buildExternalResourceUrl(this.resource);
370   }
371 
372   /**
373    * Builds URL to rescaled version of the binary image.
374    * @return Media URL
375    */
376   @SuppressFBWarnings("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE")
377   @SuppressWarnings("java:S1075") // not a file path
378   private String buildScaledMediaUrl(@NotNull Dimension dimension, @Nullable CropDimension mediaUrlCropDimension) {
379 
380     if (isVectorImage()) {
381       // vector images are scaled in browser, so use native URL
382       return buildNativeMediaUrl();
383     }
384 
385     String resourcePath = this.resource.getPath();
386 
387     // if parent resource is a nt:file resource, use this one as path for scaled image
388     Resource parentResource = this.resource.getParent();
389     if (parentResource != null && JcrBinary.isNtFile(parentResource)) {
390       resourcePath = parentResource.getPath();
391     }
392 
393     // URL to render scaled image via {@link InlineRenditionServlet}
394     String path = resourcePath
395         + "." + ImageFileServletSelector.build(dimension.getWidth(), dimension.getHeight(),
396             mediaUrlCropDimension, this.rotation, this.mediaArgs.getImageQualityPercentage(),
397             this.mediaArgs.isContentDispositionAttachment())
398         + "." + MediaFileServletConstants.EXTENSION + "/"
399         // replace extension based on the format supported by ImageFileServlet for rendering for this rendition
400         + ImageFileServlet.getImageFileName(getFileName(),
401             mediaArgs.getEnforceOutputFileExtension());
402 
403     // build externalized URL
404     UrlHandler urlHandler = AdaptTo.notNull(this.adaptable, UrlHandler.class);
405     return urlHandler.get(path).urlMode(this.mediaArgs.getUrlMode()).buildExternalResourceUrl(this.resource);
406   }
407 
408   /**
409    * Builds URL to rescaled version of the binary image.
410    * @return Media URL
411    */
412   @SuppressWarnings("java:S1075") // not a file path
413   private String buildDownloadMediaUrl() {
414     String resourcePath = this.resource.getPath();
415 
416     // if parent resource is a nt:file resource, use this one as path for scaled image
417     Resource parentResource = this.resource.getParent();
418     if (parentResource != null && JcrBinary.isNtFile(parentResource)) {
419       resourcePath = parentResource.getPath();
420     }
421 
422     // URL to render scaled image via {@link InlineRenditionServlet}
423     String path = resourcePath + "." + MediaFileServletConstants.SELECTOR
424         + "." + MediaFileServletConstants.SELECTOR_DOWNLOAD
425         + "." + MediaFileServletConstants.EXTENSION + "/" + getFileName();
426 
427     // build externalized URL
428     UrlHandler urlHandler = AdaptTo.notNull(this.adaptable, UrlHandler.class);
429     return urlHandler.get(path).urlMode(this.mediaArgs.getUrlMode()).buildExternalResourceUrl(this.resource);
430   }
431 
432   /**
433    * Checks if the file extension of the current binary matches with the requested extensions from the media args.
434    * @return true if file extension matches
435    */
436   private boolean isMatchingFileExtension() {
437     String[] extensions = MediaFormatSupport.getRequestedFileExtensions(mediaArgs);
438     if (extensions == null) {
439       // constraints for filtering file extensions are not fulfilled - not matching possible
440       return false;
441     }
442     if (extensions.length == 0) {
443       return true;
444     }
445     for (String extension : extensions) {
446       if (StringUtils.equalsIgnoreCase(extension, this.originalFileExtension)) {
447         return true;
448       }
449     }
450     return false;
451   }
452 
453   @Override
454   public String getUrl() {
455     return this.url;
456   }
457 
458   @Override
459   public String getPath() {
460     return this.resource.getPath();
461   }
462 
463   @Override
464   public String getFileName() {
465     if (this.url != null) {
466       return FilenameUtils.getName(this.url);
467     }
468     return this.fileName;
469   }
470 
471   @Override
472   public String getFileExtension() {
473     if (this.url != null) {
474       return FilenameUtils.getExtension(this.url);
475     }
476     return StringUtils.defaultString(this.fileExtension, this.originalFileExtension);
477   }
478 
479   @Override
480   @SuppressWarnings("java:S112") // allow runtime exception
481   public long getFileSize() {
482     Node node = this.resource.adaptTo(Node.class);
483     if (node != null) {
484       try {
485         Property data = node.getProperty(JcrConstants.JCR_DATA);
486         return data.getBinary().getSize();
487       }
488       catch (RepositoryException ex) {
489         throw new RuntimeException("Unable to detect binary file size for " + this.resource.getPath(), ex);
490       }
491     }
492     else {
493       // fallback to Sling API if JCR node is not present (inefficient - but this should happen only in unit tests)
494       try {
495         InputStream is = this.resource.getValueMap().get(JcrConstants.JCR_DATA, InputStream.class);
496         return IOUtils.toByteArray(is).length;
497       }
498       catch (IOException ex) {
499         throw new RuntimeException("Unable to detect binary file size for " + this.resource.getPath(), ex);
500       }
501     }
502   }
503 
504   @Override
505   public String getMimeType() {
506     return JcrBinary.getMimeType(this.resource);
507   }
508 
509   @Override
510   public Date getModificationDate() {
511     return ModificationDate.get(this.resource);
512   }
513 
514   @Override
515   public MediaFormat getMediaFormat() {
516     return resolvedMediaFormat;
517   }
518 
519   @Override
520   @SuppressWarnings("null")
521   public ValueMap getProperties() {
522     return this.resource.getValueMap();
523   }
524 
525   @Override
526   public boolean isImage() {
527     return MediaFileType.isImage(getFileExtension());
528   }
529 
530   @Override
531   public boolean isBrowserImage() {
532     return MediaFileType.isBrowserImage(getFileExtension());
533   }
534 
535   @Override
536   public boolean isVectorImage() {
537     return MediaFileType.isVectorImage(getFileExtension());
538   }
539 
540   @Override
541   public boolean isDownload() {
542     return !isImage();
543   }
544 
545   @Override
546   public long getWidth() {
547     if (imageDimension != null) {
548       return ImageTransformation.rotateMapDimension(imageDimension, rotation).getWidth();
549     }
550     else {
551       return 0;
552     }
553   }
554 
555   @Override
556   public long getHeight() {
557     if (imageDimension != null) {
558       return ImageTransformation.rotateMapDimension(imageDimension, rotation).getHeight();
559     }
560     else {
561       return 0;
562     }
563   }
564 
565   @Override
566   public boolean isFallback() {
567     return fallback;
568   }
569 
570   @Override
571   public @NotNull UriTemplate getUriTemplate(@NotNull UriTemplateType type) {
572     if (type == UriTemplateType.CROP_CENTER) {
573       throw new IllegalArgumentException("CROP_CENTER not supported for rendition URI templates.");
574     }
575     if (!isImage() || isVectorImage()) {
576       throw new UnsupportedOperationException("Unable to build URI template for " + resource.getPath());
577     }
578     if (this.maxImageDimension == null) {
579       throw new IllegalStateException("Unable to detect dimension for inline image: " + resource.getPath());
580     }
581 
582     Dimension dimension = ImageTransformation.rotateMapDimension(maxImageDimension, rotation);
583     return new InlineUriTemplate(type, dimension, this.resource, fileName,
584         this.cropDimension, this.rotation, mediaArgs, adaptable);
585   }
586 
587   @Override
588   @SuppressWarnings({ "unchecked", "null" })
589   public <AdapterType> AdapterType adaptTo(Class<AdapterType> type) {
590     if (type == Resource.class) {
591       return (AdapterType)this.resource;
592     }
593     else if (type == Layer.class && isImage()) {
594       return (AdapterType)getLayer();
595     }
596     else if (type == InputStream.class) {
597       return (AdapterType)this.resource.adaptTo(InputStream.class);
598     }
599     return super.adaptTo(type);
600   }
601 
602   private Layer getLayer() {
603     Layer layer = this.resource.adaptTo(Layer.class);
604     if (layer != null) {
605       if (cropDimension != null) {
606         layer.crop(cropDimension.getRectangle());
607       }
608       if (rotation != null) {
609         layer.rotate(rotation);
610       }
611       long width = getWidth();
612       long height = getHeight();
613       if (width <= layer.getWidth() && height <= layer.getHeight()) {
614         layer.resize((int)width, (int)height);
615       }
616     }
617     return layer;
618   }
619 
620   @Override
621   public String toString() {
622     return Objects.toString(url, "#invalid");
623   }
624 
625 }