View Javadoc
1   /*
2    * #%L
3    * wcm.io
4    * %%
5    * Copyright (C) 2020 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.dam.impl.dynamicmedia;
21  
22  import java.net.URLEncoder;
23  import java.nio.charset.StandardCharsets;
24  
25  import org.apache.commons.lang3.StringUtils;
26  import org.jetbrains.annotations.NotNull;
27  import org.jetbrains.annotations.Nullable;
28  import org.slf4j.Logger;
29  import org.slf4j.LoggerFactory;
30  
31  import io.wcm.handler.media.CropDimension;
32  import io.wcm.handler.media.Dimension;
33  import io.wcm.handler.media.format.Ratio;
34  import io.wcm.handler.media.impl.ImageQualityPercentage;
35  import io.wcm.handler.mediasource.dam.impl.DamContext;
36  import io.wcm.wcm.commons.contenttype.ContentType;
37  
38  /**
39   * Build part of dynamic media/scene7 URL to render renditions.
40   */
41  public final class DynamicMediaPath {
42  
43    /**
44     * Fixed path part for dynamic media image serving API for serving images.
45     */
46    @SuppressWarnings("java:S1075") // not a file path
47    private static final String IMAGE_SERVER_PATH = "/is/image/";
48  
49    /**
50     * Fixed path part for dynamic media image serving API for serving static content.
51     */
52    @SuppressWarnings("java:S1075") // not a file path
53    private static final String CONTENT_SERVER_PATH = "/is/content/";
54  
55    /**
56     * Suffix is appended to static content dynamic media URLs that should be served with
57     * Content-Disposition: attachment header.
58     * This is configured via a custom ruleset, see https://wcm.io/handler/media/dynamic-media.html
59     */
60    public static final String DOWNLOAD_SUFFIX = "?cdh=attachment";
61  
62    private static final Logger log = LoggerFactory.getLogger(DynamicMediaPath.class);
63  
64    private DynamicMediaPath() {
65      // static methods only
66    }
67  
68    /**
69     * Build media path for serving static content via dynamic media/scene7.
70     * @param damContext DAM context objects
71     * @param contentDispositionAttachment Whether to send content disposition: attachment header for downloads
72     * @return Media path
73     */
74    public static @NotNull String buildContent(@NotNull DamContext damContext, boolean contentDispositionAttachment) {
75      StringBuilder result = new StringBuilder();
76      result.append(CONTENT_SERVER_PATH).append(encodeDynamicMediaObject(damContext));
77      if (contentDispositionAttachment) {
78        result.append(DOWNLOAD_SUFFIX);
79      }
80      return result.toString();
81    }
82  
83    /**
84     * Build media path for rendering image via dynamic media/scene7.
85     * @param damContext DAM context objects
86     * @return Media path
87     */
88    public static @NotNull String buildImage(@NotNull DamContext damContext) {
89      return IMAGE_SERVER_PATH + encodeDynamicMediaObject(damContext);
90    }
91  
92    /**
93     * Build media path for rendering image with dynamic media/scene7.
94     * @param damContext DAM context objects
95     * @param width Width
96     * @param height Height
97     * @return Media path
98     */
99    public static @Nullable String buildImage(@NotNull DamContext damContext, long width, long height) {
100     return buildImage(damContext, width, height, null, null);
101   }
102 
103   /**
104    * Build media path for rendering image with dynamic media/scene7.
105    * @param damContext DAM context objects
106    * @param width Width
107    * @param height Height
108    * @param cropDimension Crop dimension
109    * @param rotation Rotation
110    * @return Media path
111    */
112   public static @Nullable String buildImage(@NotNull DamContext damContext, long width, long height,
113       @Nullable CropDimension cropDimension, @Nullable Integer rotation) {
114     Dimension dimension = calcWidthHeight(damContext, width, height);
115 
116     StringBuilder result = new StringBuilder();
117     result.append(IMAGE_SERVER_PATH).append(encodeDynamicMediaObject(damContext));
118 
119     // check for smart cropping when no cropping was applied by default, or auto-crop is enabled
120     if (SmartCrop.canApply(cropDimension, rotation)) {
121       // check for matching image profile and use predefined cropping preset if match found
122       NamedDimension smartCropDef = SmartCrop.getDimensionForWidthHeight(damContext.getImageProfile(), width, height);
123       if (smartCropDef != null) {
124         if (damContext.isDynamicMediaValidateSmartCropRenditionSizes()
125             && !SmartCrop.isMatchingSize(damContext.getAsset(), damContext.getResourceResolver(), smartCropDef, width, height)) {
126           // smart crop should be applied, but selected area is too small, treat as invalid
127           logResult(damContext, "<too small for " + width + "x" + height + ">");
128           return null;
129         }
130         result.append("%3A").append(smartCropDef.getName()).append("?");
131         appendWidthHeigtFormatQuality(result, dimension, damContext);
132         logResult(damContext, result);
133         return result.toString();
134       }
135     }
136 
137     result.append("?");
138     if (cropDimension != null) {
139       result.append("crop=").append(cropDimension.getCropStringWidthHeight()).append("&");
140     }
141     if (rotation != null) {
142       result.append("rotate=").append(rotation).append("&");
143     }
144     appendWidthHeigtFormatQuality(result, dimension, damContext);
145     logResult(damContext, result);
146     return result.toString();
147   }
148 
149   private static void appendWidthHeigtFormatQuality(@NotNull StringBuilder result, @NotNull Dimension dimension, @NotNull DamContext damContext) {
150     result.append("wid=").append(dimension.getWidth())
151         .append("&hei=").append(dimension.getHeight())
152         // cropping/width/height is pre-calculated to fit with original ratio, make sure there are no 1px background lines visible
153         .append("&fit=stretch");
154     // if original image may have an alpha channel, make sure it's preserved in the output format
155     if (mayHaveAlphaChannel(damContext)) {
156       applyFmt(result, damContext.getDynamicMediaDefaultFmtAlpha());
157     }
158     else {
159       applyFmt(result, damContext.getDynamicMediaDefaultFmt());
160     }
161     if (damContext.isDynamicMediaSetImageQuality() && !isLosslessImageFormat(damContext)) {
162       // it not PNG lossy format is used, apply image quality setting
163       result.append("&qlt=").append(ImageQualityPercentage.getAsInteger(damContext.getMediaArgs(), damContext.getMediaHandlerConfig()));
164     }
165   }
166 
167   private static void applyFmt(@NotNull StringBuilder result, @NotNull String fmt) {
168     if (StringUtils.isNotBlank(fmt)) {
169       result.append("&fmt=").append(fmt);
170     }
171   }
172 
173   private static void logResult(@NotNull DamContext damContext, @NotNull CharSequence result) {
174     if (log.isTraceEnabled()) {
175       log.trace("Build dynamic media path for {}: {}", damContext.getAsset().getPath(), result);
176     }
177   }
178 
179   /**
180    * Checks if width or height is bigger than the allowed max. width/height.
181    * Reduces both to the max limit keeping aspect ration is required.
182    * @param width With
183    * @param height Height
184    * @return Dimension with capped width/height
185    */
186   private static Dimension calcWidthHeight(@NotNull DamContext damContext, long width, long height) {
187     Dimension sizeLimit = damContext.getDynamicMediaImageSizeLimit();
188     if (width > sizeLimit.getWidth()) {
189       double ratio = Ratio.get(width, height);
190       long newWidth = sizeLimit.getWidth();
191       long newHeight = Math.round(newWidth / ratio);
192       return calcWidthHeight(damContext, newWidth, newHeight);
193     }
194     if (height > sizeLimit.getHeight()) {
195       double ratio = Ratio.get(width, height);
196       long newHeight = sizeLimit.getHeight();
197       long newWidth = Math.round(newHeight * ratio);
198       return new Dimension(newWidth, newHeight);
199     }
200     return new Dimension(width, height);
201   }
202 
203   /**
204    * Splits dynamic media folder and file name and URL-encodes them separately (may contain spaces or special chars).
205    * @param damContext DAM context
206    * @return Encoded path
207    */
208   private static String encodeDynamicMediaObject(@NotNull DamContext damContext) {
209     String[] pathParts = StringUtils.split(damContext.getDynamicMediaObject(), "/");
210     for (int i = 0; i < pathParts.length; i++) {
211       pathParts[i] = URLEncoder.encode(pathParts[i], StandardCharsets.UTF_8);
212       // replace "+" with %20 in URL paths
213       pathParts[i] = StringUtils.replace(pathParts[i], "+", "%20");
214     }
215     return StringUtils.join(pathParts, "/");
216   }
217 
218   /**
219    * Checks if the asset is an image format that may have an alpha channel.
220    * @param damContext DAM context
221    * @return true if the asset may have an alpha channel
222    */
223   private static boolean mayHaveAlphaChannel(@NotNull DamContext damContext) {
224     String mimeType = damContext.getAsset().getMimeType();
225     return StringUtils.equalsAny(mimeType, ContentType.PNG, ContentType.WEBP);
226   }
227 
228   /**
229    * Checks if the asset is a lossless image format.
230    * @param damContext DAM context
231    * @return true if the asset has a lossless image format
232    */
233   private static boolean isLosslessImageFormat(@NotNull DamContext damContext) {
234     String mimeType = damContext.getAsset().getMimeType();
235     return StringUtils.equals(mimeType, ContentType.PNG);
236   }
237 
238 }