DynamicMediaPath.java

  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. import java.net.URLEncoder;
  22. import java.nio.charset.StandardCharsets;

  23. import org.apache.commons.lang3.StringUtils;
  24. import org.jetbrains.annotations.NotNull;
  25. import org.jetbrains.annotations.Nullable;
  26. import org.slf4j.Logger;
  27. import org.slf4j.LoggerFactory;

  28. import io.wcm.handler.media.CropDimension;
  29. import io.wcm.handler.media.Dimension;
  30. import io.wcm.handler.media.format.Ratio;
  31. import io.wcm.handler.media.impl.ImageQualityPercentage;
  32. import io.wcm.handler.mediasource.dam.impl.DamContext;
  33. import io.wcm.wcm.commons.contenttype.ContentType;

  34. /**
  35.  * Build part of dynamic media/scene7 URL to render renditions.
  36.  */
  37. public final class DynamicMediaPath {

  38.   /**
  39.    * Fixed path part for dynamic media image serving API for serving images.
  40.    */
  41.   @SuppressWarnings("java:S1075") // not a file path
  42.   private static final String IMAGE_SERVER_PATH = "/is/image/";

  43.   /**
  44.    * Fixed path part for dynamic media image serving API for serving static content.
  45.    */
  46.   @SuppressWarnings("java:S1075") // not a file path
  47.   private static final String CONTENT_SERVER_PATH = "/is/content/";

  48.   /**
  49.    * Suffix is appended to static content dynamic media URLs that should be served with
  50.    * Content-Disposition: attachment header.
  51.    * This is configured via a custom ruleset, see https://wcm.io/handler/media/dynamic-media.html
  52.    */
  53.   public static final String DOWNLOAD_SUFFIX = "?cdh=attachment";

  54.   private static final Logger log = LoggerFactory.getLogger(DynamicMediaPath.class);

  55.   private DynamicMediaPath() {
  56.     // static methods only
  57.   }

  58.   /**
  59.    * Build media path for serving static content via dynamic media/scene7.
  60.    * @param damContext DAM context objects
  61.    * @param contentDispositionAttachment Whether to send content disposition: attachment header for downloads
  62.    * @return Media path
  63.    */
  64.   public static @NotNull String buildContent(@NotNull DamContext damContext, boolean contentDispositionAttachment) {
  65.     StringBuilder result = new StringBuilder();
  66.     result.append(CONTENT_SERVER_PATH).append(encodeDynamicMediaObject(damContext));
  67.     if (contentDispositionAttachment) {
  68.       result.append(DOWNLOAD_SUFFIX);
  69.     }
  70.     return result.toString();
  71.   }

  72.   /**
  73.    * Build media path for rendering image via dynamic media/scene7.
  74.    * @param damContext DAM context objects
  75.    * @return Media path
  76.    */
  77.   public static @NotNull String buildImage(@NotNull DamContext damContext) {
  78.     return IMAGE_SERVER_PATH + encodeDynamicMediaObject(damContext);
  79.   }

  80.   /**
  81.    * Build media path for rendering image with dynamic media/scene7.
  82.    * @param damContext DAM context objects
  83.    * @param width Width
  84.    * @param height Height
  85.    * @return Media path
  86.    */
  87.   public static @Nullable String buildImage(@NotNull DamContext damContext, long width, long height) {
  88.     return buildImage(damContext, width, height, null, null);
  89.   }

  90.   /**
  91.    * Build media path for rendering image with dynamic media/scene7.
  92.    * @param damContext DAM context objects
  93.    * @param width Width
  94.    * @param height Height
  95.    * @param cropDimension Crop dimension
  96.    * @param rotation Rotation
  97.    * @return Media path
  98.    */
  99.   public static @Nullable String buildImage(@NotNull DamContext damContext, long width, long height,
  100.       @Nullable CropDimension cropDimension, @Nullable Integer rotation) {
  101.     Dimension dimension = calcWidthHeight(damContext, width, height);

  102.     StringBuilder result = new StringBuilder();
  103.     result.append(IMAGE_SERVER_PATH).append(encodeDynamicMediaObject(damContext));

  104.     // check for smart cropping when no cropping was applied by default, or auto-crop is enabled
  105.     if (SmartCrop.canApply(cropDimension, rotation)) {
  106.       // check for matching image profile and use predefined cropping preset if match found
  107.       NamedDimension smartCropDef = SmartCrop.getDimensionForWidthHeight(damContext.getImageProfile(), width, height);
  108.       if (smartCropDef != null) {
  109.         if (damContext.isDynamicMediaValidateSmartCropRenditionSizes()
  110.             && !SmartCrop.isMatchingSize(damContext.getAsset(), damContext.getResourceResolver(), smartCropDef, width, height)) {
  111.           // smart crop should be applied, but selected area is too small, treat as invalid
  112.           logResult(damContext, "<too small for " + width + "x" + height + ">");
  113.           return null;
  114.         }
  115.         result.append("%3A").append(smartCropDef.getName()).append("?");
  116.         appendWidthHeigtFormatQuality(result, dimension, damContext);
  117.         logResult(damContext, result);
  118.         return result.toString();
  119.       }
  120.     }

  121.     result.append("?");
  122.     if (cropDimension != null) {
  123.       result.append("crop=").append(cropDimension.getCropStringWidthHeight()).append("&");
  124.     }
  125.     if (rotation != null) {
  126.       result.append("rotate=").append(rotation).append("&");
  127.     }
  128.     appendWidthHeigtFormatQuality(result, dimension, damContext);
  129.     logResult(damContext, result);
  130.     return result.toString();
  131.   }

  132.   private static void appendWidthHeigtFormatQuality(@NotNull StringBuilder result, @NotNull Dimension dimension, @NotNull DamContext damContext) {
  133.     result.append("wid=").append(dimension.getWidth()).append("&")
  134.         .append("hei=").append(dimension.getHeight()).append("&")
  135.         // cropping/width/height is pre-calculated to fit with original ratio, make sure there are no 1px background lines visible
  136.         .append("fit=stretch");
  137.     if (isPNG(damContext)) {
  138.       // if original image is PNG image, make sure alpha channel is preserved
  139.       result.append("&fmt=png-alpha");
  140.     }
  141.     else if (damContext.isDynamicMediaSetImageQuality()) {
  142.       // it not PNG lossy format is used, apply image quality setting
  143.       result.append("&qlt=").append(ImageQualityPercentage.getAsInteger(damContext.getMediaArgs(), damContext.getMediaHandlerConfig()));
  144.     }
  145.   }

  146.   private static void logResult(@NotNull DamContext damContext, @NotNull CharSequence result) {
  147.     if (log.isTraceEnabled()) {
  148.       log.trace("Build dynamic media path for {}: {}", damContext.getAsset().getPath(), result);
  149.     }
  150.   }

  151.   /**
  152.    * Checks if width or height is bigger than the allowed max. width/height.
  153.    * Reduces both to the max limit keeping aspect ration is required.
  154.    * @param width With
  155.    * @param height Height
  156.    * @return Dimension with capped width/height
  157.    */
  158.   private static Dimension calcWidthHeight(@NotNull DamContext damContext, long width, long height) {
  159.     Dimension sizeLimit = damContext.getDynamicMediaImageSizeLimit();
  160.     if (width > sizeLimit.getWidth()) {
  161.       double ratio = Ratio.get(width, height);
  162.       long newWidth = sizeLimit.getWidth();
  163.       long newHeight = Math.round(newWidth / ratio);
  164.       return calcWidthHeight(damContext, newWidth, newHeight);
  165.     }
  166.     if (height > sizeLimit.getHeight()) {
  167.       double ratio = Ratio.get(width, height);
  168.       long newHeight = sizeLimit.getHeight();
  169.       long newWidth = Math.round(newHeight * ratio);
  170.       return new Dimension(newWidth, newHeight);
  171.     }
  172.     return new Dimension(width, height);
  173.   }

  174.   /**
  175.    * Splits dynamic media folder and file name and URL-encodes them separately (may contain spaces or special chars).
  176.    * @param damContext DAM context
  177.    * @return Encoded path
  178.    */
  179.   private static String encodeDynamicMediaObject(@NotNull DamContext damContext) {
  180.     String[] pathParts = StringUtils.split(damContext.getDynamicMediaObject(), "/");
  181.     for (int i = 0; i < pathParts.length; i++) {
  182.       pathParts[i] = URLEncoder.encode(pathParts[i], StandardCharsets.UTF_8);
  183.       // replace "+" with %20 in URL paths
  184.       pathParts[i] = StringUtils.replace(pathParts[i], "+", "%20");
  185.     }
  186.     return StringUtils.join(pathParts, "/");
  187.   }

  188.   private static boolean isPNG(@NotNull DamContext damContext) {
  189.     return StringUtils.equals(damContext.getAsset().getMimeType(), ContentType.PNG);
  190.   }

  191. }