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()).append("&")
151         .append("hei=").append(dimension.getHeight()).append("&")
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 (isPNG(damContext)) {
155       // if original image is PNG image, make sure alpha channel is preserved
156       result.append("&fmt=png-alpha");
157     }
158     else if (damContext.isDynamicMediaSetImageQuality()) {
159       // it not PNG lossy format is used, apply image quality setting
160       result.append("&qlt=").append(ImageQualityPercentage.getAsInteger(damContext.getMediaArgs(), damContext.getMediaHandlerConfig()));
161     }
162   }
163 
164   private static void logResult(@NotNull DamContext damContext, @NotNull CharSequence result) {
165     if (log.isTraceEnabled()) {
166       log.trace("Build dynamic media path for {}: {}", damContext.getAsset().getPath(), result);
167     }
168   }
169 
170   /**
171    * Checks if width or height is bigger than the allowed max. width/height.
172    * Reduces both to the max limit keeping aspect ration is required.
173    * @param width With
174    * @param height Height
175    * @return Dimension with capped width/height
176    */
177   private static Dimension calcWidthHeight(@NotNull DamContext damContext, long width, long height) {
178     Dimension sizeLimit = damContext.getDynamicMediaImageSizeLimit();
179     if (width > sizeLimit.getWidth()) {
180       double ratio = Ratio.get(width, height);
181       long newWidth = sizeLimit.getWidth();
182       long newHeight = Math.round(newWidth / ratio);
183       return calcWidthHeight(damContext, newWidth, newHeight);
184     }
185     if (height > sizeLimit.getHeight()) {
186       double ratio = Ratio.get(width, height);
187       long newHeight = sizeLimit.getHeight();
188       long newWidth = Math.round(newHeight * ratio);
189       return new Dimension(newWidth, newHeight);
190     }
191     return new Dimension(width, height);
192   }
193 
194   /**
195    * Splits dynamic media folder and file name and URL-encodes them separately (may contain spaces or special chars).
196    * @param damContext DAM context
197    * @return Encoded path
198    */
199   private static String encodeDynamicMediaObject(@NotNull DamContext damContext) {
200     String[] pathParts = StringUtils.split(damContext.getDynamicMediaObject(), "/");
201     for (int i = 0; i < pathParts.length; i++) {
202       pathParts[i] = URLEncoder.encode(pathParts[i], StandardCharsets.UTF_8);
203       // replace "+" with %20 in URL paths
204       pathParts[i] = StringUtils.replace(pathParts[i], "+", "%20");
205     }
206     return StringUtils.join(pathParts, "/");
207   }
208 
209   private static boolean isPNG(@NotNull DamContext damContext) {
210     return StringUtils.equals(damContext.getAsset().getMimeType(), ContentType.PNG);
211   }
212 
213 }