SmartCrop.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2022 wcm.io
 * %%
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * #L%
 */
package io.wcm.handler.mediasource.dam.impl.dynamicmedia;

import static com.day.cq.commons.jcr.JcrConstants.JCR_CONTENT;
import static com.day.cq.dam.api.DamConstants.RENDITIONS_FOLDER;

import java.util.Arrays;

import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ValueMap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

import io.wcm.handler.media.CropDimension;
import io.wcm.handler.media.Dimension;
import io.wcm.handler.media.format.Ratio;
import io.wcm.handler.mediasource.dam.AssetRendition;

/**
 * Apply Dynamic Media Smart Cropping.
 */
public final class SmartCrop {

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

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

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

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

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

  private SmartCrop() {
    // static methods only
  }

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

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

  /**
   * Checks DM image profile for a smart cropping definition matching the ratio of the requested width/height.
   * @param imageProfile Image profile from DAM context (null if no is defined)
   * @param width Width
   * @param height Height
   * @return Smart cropping definition with requested width/height - or null if no match
   */
  public static @Nullable NamedDimension getDimensionForWidthHeight(@Nullable ImageProfile imageProfile, long width, long height) {
    Double requestedRatio = Ratio.get(width, height);
    NamedDimension matchingDimension = getDimensionForRatio(imageProfile, requestedRatio);
    if (matchingDimension != null) {
      // create new named dimension with actual requested width/height
      return new NamedDimension(matchingDimension.getName(), width, height);
    }
    else {
      return null;
    }
  }

  /**
   * Gets the actual smart-cropped dimension for the given asset and smart cropping definition (aspect ratio).
   * @param asset Asset
   * @param resourceResolver Resource resolver
   * @param smartCropDef Smart cropping definition from image profile
   * @return Actual dimension of the smart cropping area or null if not found
   */
  @SuppressWarnings("java:S1075") // no filesystem paths
  public static @Nullable CropDimension getCropDimensionForAsset(@NotNull Asset asset,
      @NotNull ResourceResolver resourceResolver, @NotNull NamedDimension smartCropDef) {
    // at this path smart cropping parameters may be stored for each ratio (esp. if manual cropping was applied)
    String smartCropRenditionPath = asset.getPath()
        + "/" + JCR_CONTENT
        + "/" + RENDITIONS_FOLDER
        + "/" + smartCropDef.getName()
        + "/" + JCR_CONTENT;
    Resource smartCropRendition = resourceResolver.getResource(smartCropRenditionPath);
    if (smartCropRendition == null) {
      // on AEMaaCS this path should always exist, in AEMaaCS SDK it seems to be created only when manual cropping
      // is applied in the Assets UI
      return null;
    }
    ValueMap props = smartCropRendition.getValueMap();
    double leftPercentage = props.get(PN_LEFT, 0d);
    double topPercentage = props.get(PN_TOP, 0d);
    double widthPercentage = props.get(PN_NORMALIZED_WIDTH, 0d);
    double heightPercentage = props.get(PN_NORMALIZED_HEIGHT, 0d);
    Dimension originalDimension = AssetRendition.getDimension(asset.getOriginal());
    if (originalDimension == null
        || !isValidTopLeft(leftPercentage, topPercentage)
        || !isValidWidthHeight(widthPercentage, heightPercentage)) {
      // ignore smart cropping rendition with invalid dimension
      return null;
    }

    // calculate actual cropping dimension
    long originalWidth = originalDimension.getWidth();
    long originalHeight = originalDimension.getHeight();
    long left = Math.round(originalWidth * leftPercentage);
    long top = Math.round(originalHeight * topPercentage);
    long width = Math.round(originalWidth * widthPercentage);
    long height = Math.round(originalHeight * heightPercentage);

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

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

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

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

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

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

}