SmartCrop.java

  1. /*
  2.  * #%L
  3.  * wcm.io
  4.  * %%
  5.  * Copyright (C) 2022 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 static com.day.cq.commons.jcr.JcrConstants.JCR_CONTENT;
  22. import static com.day.cq.dam.api.DamConstants.RENDITIONS_FOLDER;

  23. import java.util.Arrays;

  24. import org.apache.sling.api.resource.Resource;
  25. import org.apache.sling.api.resource.ResourceResolver;
  26. import org.apache.sling.api.resource.ValueMap;
  27. import org.jetbrains.annotations.NotNull;
  28. import org.jetbrains.annotations.Nullable;
  29. import org.slf4j.Logger;
  30. import org.slf4j.LoggerFactory;

  31. import com.day.cq.dam.api.Asset;

  32. import io.wcm.handler.media.CropDimension;
  33. import io.wcm.handler.media.Dimension;
  34. import io.wcm.handler.media.format.Ratio;
  35. import io.wcm.handler.mediasource.dam.AssetRendition;

  36. /**
  37.  * Apply Dynamic Media Smart Cropping.
  38.  */
  39. public final class SmartCrop {

  40.   /**
  41.    * Normalized width (double value 0..1 as percentage of original image).
  42.    */
  43.   public static final String PN_NORMALIZED_WIDTH = "normalizedWidth";

  44.   /**
  45.    * Normalized height (double value 0..1 as percentage of original image).
  46.    */
  47.   public static final String PN_NORMALIZED_HEIGHT = "normalizedHeight";

  48.   /**
  49.    * Left margin (double value 0..1 as percentage of original image).
  50.    */
  51.   public static final String PN_LEFT = "left";

  52.   /**
  53.    * Top margin (double value 0..1 as percentage of original image).
  54.    */
  55.   public static final String PN_TOP = "top";

  56.   private static final double MIN_NORMALIZED_WIDTH_HEIGHT = 0.0001;
  57.   private static final Logger log = LoggerFactory.getLogger(SmartCrop.class);

  58.   private SmartCrop() {
  59.     // static methods only
  60.   }

  61.   /**
  62.    * Smart cropping can be applied when no manual cropping was applied, or auto cropping is enabled.
  63.    * Additionally, combination with rotation is not allowed.
  64.    * @param cropDimension Manual crop definition
  65.    * @param rotation Rotation
  66.    * @return true if Smart Cropping can be applied
  67.    */
  68.   public static boolean canApply(@Nullable CropDimension cropDimension, @Nullable Integer rotation) {
  69.     return (cropDimension == null || cropDimension.isAutoCrop()) && rotation == null;
  70.   }

  71.   /**
  72.    * Checks DM image profile for a smart cropping definition matching the ratio of the requested ratio.
  73.    * @param imageProfile Image profile from DAM context (null if no is defined)
  74.    * @param requestedRatio Requested ratio
  75.    * @return Named dimension or null. The provided width/height can usually be ignored, because they
  76.    *         are the width/height from the image profile which only describe the aspect ratio, but not
  77.    *         any width/height values used in reality.
  78.    */
  79.   public static @Nullable NamedDimension getDimensionForRatio(@Nullable ImageProfile imageProfile, double requestedRatio) {
  80.     if (imageProfile == null) {
  81.       return null;
  82.     }
  83.     return imageProfile.getSmartCropDefinitions().stream()
  84.         .filter(def -> Ratio.matches(Ratio.get(def), requestedRatio))
  85.         .findFirst().orElse(null);
  86.   }

  87.   /**
  88.    * Checks DM image profile for a smart cropping definition matching the ratio of the requested width/height.
  89.    * @param imageProfile Image profile from DAM context (null if no is defined)
  90.    * @param width Width
  91.    * @param height Height
  92.    * @return Smart cropping definition with requested width/height - or null if no match
  93.    */
  94.   public static @Nullable NamedDimension getDimensionForWidthHeight(@Nullable ImageProfile imageProfile, long width, long height) {
  95.     Double requestedRatio = Ratio.get(width, height);
  96.     NamedDimension matchingDimension = getDimensionForRatio(imageProfile, requestedRatio);
  97.     if (matchingDimension != null) {
  98.       // create new named dimension with actual requested width/height
  99.       return new NamedDimension(matchingDimension.getName(), width, height);
  100.     }
  101.     else {
  102.       return null;
  103.     }
  104.   }

  105.   /**
  106.    * Gets the actual smart-cropped dimension for the given asset and smart cropping definition (aspect ratio).
  107.    * @param asset Asset
  108.    * @param resourceResolver Resource resolver
  109.    * @param smartCropDef Smart cropping definition from image profile
  110.    * @return Actual dimension of the smart cropping area or null if not found
  111.    */
  112.   @SuppressWarnings("java:S1075") // no filesystem paths
  113.   public static @Nullable CropDimension getCropDimensionForAsset(@NotNull Asset asset,
  114.       @NotNull ResourceResolver resourceResolver, @NotNull NamedDimension smartCropDef) {
  115.     // at this path smart cropping parameters may be stored for each ratio (esp. if manual cropping was applied)
  116.     String smartCropRenditionPath = asset.getPath()
  117.         + "/" + JCR_CONTENT
  118.         + "/" + RENDITIONS_FOLDER
  119.         + "/" + smartCropDef.getName()
  120.         + "/" + JCR_CONTENT;
  121.     Resource smartCropRendition = resourceResolver.getResource(smartCropRenditionPath);
  122.     if (smartCropRendition == null) {
  123.       // on AEMaaCS this path should always exist, in AEMaaCS SDK it seems to be created only when manual cropping
  124.       // is applied in the Assets UI
  125.       return null;
  126.     }
  127.     ValueMap props = smartCropRendition.getValueMap();
  128.     double leftPercentage = props.get(PN_LEFT, 0d);
  129.     double topPercentage = props.get(PN_TOP, 0d);
  130.     double widthPercentage = props.get(PN_NORMALIZED_WIDTH, 0d);
  131.     double heightPercentage = props.get(PN_NORMALIZED_HEIGHT, 0d);
  132.     Dimension originalDimension = AssetRendition.getDimension(asset.getOriginal());
  133.     if (originalDimension == null
  134.         || !isValidTopLeft(leftPercentage, topPercentage)
  135.         || !isValidWidthHeight(widthPercentage, heightPercentage)) {
  136.       // ignore smart cropping rendition with invalid dimension
  137.       return null;
  138.     }

  139.     // calculate actual cropping dimension
  140.     long originalWidth = originalDimension.getWidth();
  141.     long originalHeight = originalDimension.getHeight();
  142.     long left = Math.round(originalWidth * leftPercentage);
  143.     long top = Math.round(originalHeight * topPercentage);
  144.     long width = Math.round(originalWidth * widthPercentage);
  145.     long height = Math.round(originalHeight * heightPercentage);

  146.     // it may happen that DM provides inconsistent normalizedWidth/normalizedHeight values which results
  147.     // in renditions not matching the ratio of the cropping definition. In that case use only the one from the
  148.     // the two which results in the smaller rendition and calculate the missing value from the other
  149.     double expectedRatio = Ratio.get(smartCropDef.getWidth(), smartCropDef.getHeight());
  150.     double actualRatio = Ratio.get(width, height);
  151.     if (!Ratio.matches(expectedRatio, actualRatio)) {
  152.       if (actualRatio > expectedRatio) {
  153.         width = Math.round(height * expectedRatio);
  154.       }
  155.       else {
  156.         height = Math.round(width / expectedRatio);
  157.       }
  158.     }

  159.     return new CropDimension(left, top, width, height, true);
  160.   }

  161.   /**
  162.    * Verifies that the actual image area picked in smart cropping (either automatic or manual) results in
  163.    * a rendition size that fulfills at least the requested width/height.
  164.    * @param asset DAM asset
  165.    * @param resourceResolver Resource resolve
  166.    * @param smartCropDef Smart cropping dimension
  167.    * @param width Requested width
  168.    * @param height Requested height
  169.    * @return true if size is matching, or no width/height information for the cropped area is available
  170.    */
  171.   public static boolean isMatchingSize(@NotNull Asset asset, @NotNull ResourceResolver resourceResolver,
  172.       @NotNull NamedDimension smartCropDef, long width, long height) {
  173.     CropDimension cropDimension = getCropDimensionForAsset(asset, resourceResolver, smartCropDef);
  174.     if (cropDimension == null) {
  175.       // smart cropping rendition is not found in repository or it contains invalid values,
  176.       // we assume the size should be fine and skip further checking
  177.       return true;
  178.     }

  179.     // check if smart cropping area is large enough
  180.     long croppedWidth = cropDimension.getWidth();
  181.     long croppedHeight = cropDimension.getHeight();
  182.     boolean isMatchingSize = (cropDimension.getWidth() >= width && croppedHeight >= height);
  183.     if (!isMatchingSize) {
  184.       log.debug("Smart cropping area '{}' for asset {} is too small ({} x {}) for requested size {} x {}.",
  185.           smartCropDef.getName(), asset.getPath(), croppedWidth, croppedHeight, width, height);
  186.     }
  187.     return isMatchingSize;
  188.   }

  189.   private static boolean isValidTopLeft(double... numbers) {
  190.     return Arrays.stream(numbers).allMatch(value -> value >= 0 && value <= 1);
  191.   }

  192.   private static boolean isValidWidthHeight(double... numbers) {
  193.     return Arrays.stream(numbers).allMatch(value -> value >= MIN_NORMALIZED_WIDTH_HEIGHT && value <= 1);
  194.   }

  195. }