DefaultRenditionHandler.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2014 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;

import static io.wcm.handler.media.format.impl.MediaFormatSupport.getRequestedFileExtensions;
import static io.wcm.handler.media.format.impl.MediaFormatSupport.visitMediaFormats;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;

import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
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 com.day.cq.dam.api.Rendition;

import io.wcm.handler.media.CropDimension;
import io.wcm.handler.media.MediaArgs;
import io.wcm.handler.media.MediaArgs.MediaFormatOption;
import io.wcm.handler.media.MediaFileType;
import io.wcm.handler.media.format.MediaFormat;
import io.wcm.handler.media.format.MediaFormatHandler;
import io.wcm.handler.media.format.Ratio;
import io.wcm.handler.media.format.impl.MediaFormatVisitor;
import io.wcm.handler.mediasource.dam.AemRenditionType;
import io.wcm.handler.mediasource.dam.AssetRendition;
import io.wcm.handler.mediasource.dam.impl.dynamicmedia.NamedDimension;
import io.wcm.handler.mediasource.dam.impl.dynamicmedia.SmartCrop;

/**
 * Handles resolving DAM renditions and resizing for media handler.
 */
class DefaultRenditionHandler implements RenditionHandler {

  private Set<RenditionMetadata> renditions;
  private final RenditionMetadata originalRendition;
  private final DamContext damContext;

  private final Logger log = LoggerFactory.getLogger(getClass());

  /**
   * @param damContext DAM context objects
   */
  DefaultRenditionHandler(DamContext damContext) {
    this.damContext = damContext;

    Rendition damOriginalRendition = damContext.getAsset().getOriginal();
    originalRendition = damOriginalRendition != null ? new RenditionMetadata(damOriginalRendition) : null;
  }

  protected RenditionMetadata getOriginalRendition() {
    return this.originalRendition;
  }

  /**
   * @return All renditions that are available for this asset
   */
  Set<RenditionMetadata> getAvailableRenditions(MediaArgs mediaArgs) {
    if (this.renditions == null) {
      // gather rendition infos of all renditions and sort them by size (smallest or virtual crop rendition first)
      Set<RenditionMetadata> candidates = new TreeSet<>();
      for (Rendition rendition : damContext.getAsset().getRenditions()) {
        addRendition(candidates, rendition, mediaArgs);
      }

      // special handling for dynamic media
      if (damContext.isDynamicMediaEnabled() && damContext.isDynamicMediaAsset()) {
        // check if there are matching smart crop renditions for the requested media format(s)
        // and return those instead of the original rendition for further processing
        String fileExtension = FilenameUtils.getExtension(damContext.getAsset().getName());
        if (damContext.isDynamicMediaValidateSmartCropRenditionSizes()
            && MediaFileType.isImage(fileExtension) && !MediaFileType.isVectorImage(fileExtension)) {
          List<CropDimension> cropDimensions = getDynamicMediaCropDimensions(mediaArgs);
          if (!cropDimensions.isEmpty()) {
            candidates.addAll(cropDimensions.stream()
                .map(cropDimension -> new VirtualTransformedRenditionMetadata(originalRendition.getRendition(),
                    cropDimension.getWidth(), cropDimension.getHeight(), mediaArgs.getEnforceOutputFileExtension(), cropDimension,
                    null, mediaArgs.getImageQualityPercentage()))
                .collect(Collectors.toList()));
          }
        }
      }

      candidates = postProcessCandidates(candidates, mediaArgs);
      this.renditions = Collections.unmodifiableSet(candidates);
    }
    return this.renditions;
  }

  /**
   * Provides an option to post process the list of candidates. Can be overridden in subclasses
   * @param candidates Candidates
   * @param mediaArgs Media args
   * @return {@link Set} of {@link RenditionMetadata}
   */
  protected Set<RenditionMetadata> postProcessCandidates(Set<RenditionMetadata> candidates, MediaArgs mediaArgs) {
    return candidates;
  }

  /**
   * adds rendition to the list of candidates, if it should be available for resolving
   * @param candidates Candidates
   * @param rendition Rendition
   */
  private void addRendition(Set<RenditionMetadata> candidates, Rendition rendition, MediaArgs mediaArgs) {
    AemRenditionType aemRenditionType = AemRenditionType.forRendition(rendition);
    if (aemRenditionType != null && !getIncludeAssetAemRenditions(mediaArgs).contains(aemRenditionType)) {
      // ignore all other AEM-generated renditions unless allowed via mediaargs
      return;
    }

    if (!AssetRendition.isOriginal(rendition)
        && ((damContext.isDynamicMediaEnabled() && damContext.isDynamicMediaAsset()) || damContext.isWebOptimizedImageDeliveryEnabled())) {
      // skip all non-original renditions for dynamic media and web-optimized delivery - they are not supported
      return;
    }

    RenditionMetadata renditionMetadata = createRenditionMetadata(rendition);
    candidates.add(renditionMetadata);
  }

  /**
   * Get combined set of allowed AEM-generated rendition types.
   * @param mediaArgs Media args
   * @return All allowed AEM-generated rendition types
   */
  @SuppressWarnings("deprecation")
  private @NotNull Set<AemRenditionType> getIncludeAssetAemRenditions(MediaArgs mediaArgs) {
    Set<AemRenditionType> fromMediaArgs = mediaArgs.getIncludeAssetAemRenditions();
    if (fromMediaArgs == null) {
      fromMediaArgs = Set.of();
    }
    Set<AemRenditionType> result = new HashSet<>(fromMediaArgs);
    // check deprecated flags for web renditions and asset thumbnails
    Boolean includeAssetWebRenditions = mediaArgs.isIncludeAssetWebRenditions();
    if (includeAssetWebRenditions != null) {
      if (includeAssetWebRenditions) {
        result.add(AemRenditionType.WEB_RENDITION);
      }
      else {
        result.remove(AemRenditionType.WEB_RENDITION);
      }
    }
    Boolean includeAssetThumbnails = mediaArgs.isIncludeAssetThumbnails();
    if (includeAssetThumbnails != null) {
      if (includeAssetThumbnails) {
        result.add(AemRenditionType.THUMBNAIL_RENDITION);
      }
      else {
        result.remove(AemRenditionType.THUMBNAIL_RENDITION);
      }
    }
    return result;
  }

  /**
   * Try to get actual smart crop dimensions for the requested ratio(s) for the current asset.
   * @param mediaArgs Media Args with requested media formats
   * @return Cropping dimensions or empty list if not found
   */
  private @NotNull List<CropDimension> getDynamicMediaCropDimensions(MediaArgs mediaArgs) {
    MediaFormatOption[] mediaFormatOptions = mediaArgs.getMediaFormatOptions();
    if (mediaFormatOptions == null) {
      return Collections.emptyList();
    }
    List<CropDimension> result = new ArrayList<>();
    for (MediaFormatOption mediaFormatOption : mediaFormatOptions) {
      MediaFormat mediaFormat = mediaFormatOption.getMediaFormat();
      if (mediaFormat != null && mediaFormat.hasRatio()) {
        NamedDimension smartCropDef = SmartCrop.getDimensionForRatio(damContext.getImageProfile(), mediaFormat.getRatio());
        if (smartCropDef != null) {
          CropDimension cropDimension = SmartCrop.getCropDimensionForAsset(damContext.getAsset(), damContext.getResourceResolver(), smartCropDef);
          if (cropDimension != null) {
            result.add(cropDimension);
          }
        }
      }
    }
    return result;
  }

  /**
   * Create rendition metadata for given rendition. May be overridden by subclasses.
   * @param rendition Rendition
   * @return Rendition metadata
   */
  protected RenditionMetadata createRenditionMetadata(Rendition rendition) {
    return new RenditionMetadata(rendition);
  }

  /**
   * Get all renditions that match the requested list of file extension.
   * @param fileExtensions List of file extensions
   * @return Matching renditions
   */
  private Set<RenditionMetadata> getRendtionsMatchingFileExtensions(String[] fileExtensions, MediaArgs mediaArgs) {

    // if no file extension restriction get all renditions
    Set<RenditionMetadata> allRenditions = getAvailableRenditions(mediaArgs);
    if (fileExtensions == null || fileExtensions.length == 0) {
      return allRenditions;
    }

    // otherwise return those with matching extensions
    Set<RenditionMetadata> matchingRenditions = new TreeSet<>();
    for (RenditionMetadata rendition : allRenditions) {
      for (String fileExtension : fileExtensions) {
        if (StringUtils.equalsIgnoreCase(fileExtension, rendition.getFileExtension())) {
          matchingRenditions.add(rendition);
          break;
        }
      }
    }
    return matchingRenditions;
  }

  /**
   * Get rendition (probably virtual) for given media arguments.
   * @param mediaArgs Media arguments
   * @return Rendition or null if none is matching
   */
  @Override
  public RenditionMetadata getRendition(MediaArgs mediaArgs) {

    // get list of file extensions requested
    String[] requestedFileExtensions = getRequestedFileExtensions(mediaArgs);

    // if the array is null file extensions constraints are applied, but do not match to each other
    // - no rendition can fulfill these constraints
    if (requestedFileExtensions == null) {
      return null;
    }

    // check if a specific media size is requested
    boolean isSizeMatchingRequest = isSizeMatchingRequest(mediaArgs, requestedFileExtensions);

    // get rendition candidates matching for file extensions
    Set<RenditionMetadata> candidates = getRendtionsMatchingFileExtensions(requestedFileExtensions, mediaArgs);

    if (log.isTraceEnabled()) {
      log.trace("GetRendition: requestedFileExtensions={}, isSizeMatchingRequest={}, mediaArgs={}, candidates={}",
          StringUtils.join(requestedFileExtensions, ","), isSizeMatchingRequest, mediaArgs, candidates);
    }

    // if request does not contain any size restrictions return original image or first by filename matching rendition
    if (!isSizeMatchingRequest) {
      return getOriginalOrFirstRendition(candidates);
    }

    // original rendition is a image - check for matching rendition or build virtual one
    RenditionMetadata exactMatchRendition = getExactMatchRendition(candidates, mediaArgs);
    if (exactMatchRendition != null && !enforceVirtualRendition(exactMatchRendition, mediaArgs)) {
      return exactMatchRendition;
    }

    // get rendition virtual rendition downscaled from existing one
    RenditionMetadata virtualRendition = getVirtualRendition(candidates, mediaArgs);
    if (virtualRendition != null) {
      return virtualRendition;
    }

    // no match found
    return null;
  }

  protected boolean enforceVirtualRendition(RenditionMetadata rendition, MediaArgs mediaArgs) {
    if (rendition.isImage() && !rendition.isVectorImage()) {
      if (damContext.getMediaHandlerConfig().enforceVirtualRenditions()) {
        return true;
      }
      if (mediaArgs.getEnforceOutputFileExtension() != null) {
        return !StringUtils.equalsIgnoreCase(rendition.getFileExtension(), mediaArgs.getEnforceOutputFileExtension());
      }
    }
    return false;
  }

  /**
   * Checks if the media args contain any with/height restriction, that means a rendition matching
   * the given size constraints is requested. Additionally it is checked that at least one image file
   * extension is requested.
   * @param mediaArgs Media arguments
   * @return true if any size restriction was defined.
   */
  private boolean isSizeMatchingRequest(MediaArgs mediaArgs, String[] requestedFileExtensions) {

    // check that at least one image file extension is in the list of requested extensions
    boolean anyImageFileExtension = false;
    for (String fileExtension : requestedFileExtensions) {
      if (MediaFileType.isImage(fileExtension)) {
        anyImageFileExtension = true;
        break;
      }
    }
    if (!anyImageFileExtension && mediaArgs.getFixedWidth() == 0 && mediaArgs.getFixedHeight() == 0) {
      return false;
    }

    // check for size restriction
    if (mediaArgs.getFixedWidth() > 0 || mediaArgs.getFixedHeight() > 0) {
      return true;
    }
    Boolean isSizeMatchingMediaFormat = visitMediaFormats(mediaArgs, new MediaFormatVisitor<Boolean>() {
      @Override
      public @Nullable Boolean visit(@NotNull MediaFormat mediaFormat) {
        // check if any width or ratio restrictions are defined for the media format
        if (mediaFormat.getEffectiveMinWidth() > 0
            || mediaFormat.getEffectiveMaxWidth() > 0
            || mediaFormat.getEffectiveMinHeight() > 0
            || mediaFormat.getEffectiveMaxHeight() > 0
            || mediaFormat.getMinWidthHeight() > 0
            || mediaFormat.getRatio() > 0) {
          return true;
        }
        // alternatively check if responsive image is requested via image sizes or picture sources
        if (mediaArgs.getImageSizes() != null || mediaArgs.getPictureSources() != null) {
          return true;
        }
        return null;
      }
    });
    return isSizeMatchingMediaFormat != null && isSizeMatchingMediaFormat.booleanValue();
  }

  /**
   * Get rendition that matches exactly with the given media args requirements.
   * @param candidates Rendition candidates
   * @param mediaArgs Media args
   * @return Rendition or null if none found
   */
  @SuppressWarnings("java:S3776") // ignore complexity
  private RenditionMetadata getExactMatchRendition(final Set<RenditionMetadata> candidates, MediaArgs mediaArgs) {
    MediaFormat[] mediaFormats = mediaArgs.getMediaFormats();
    // check for fixed width and/or height request
    if (mediaArgs.getFixedWidth() > 0 || mediaArgs.getFixedHeight() > 0) {
      for (RenditionMetadata candidate : candidates) {
        if (candidate.matches(mediaArgs.getFixedWidth(), mediaArgs.getFixedHeight())) {
          return candidate;
        }
      }
    }

    // otherwise check for media format restriction
    else if (mediaFormats != null && mediaFormats.length > 0) {
      return visitMediaFormats(mediaArgs, new MediaFormatVisitor<RenditionMetadata>() {
        @Override
        public @Nullable RenditionMetadata visit(@NotNull MediaFormat mediaFormat) {
          for (RenditionMetadata candidate : candidates) {
            if (candidate.matches(mediaFormat.getEffectiveMinWidth(),
                mediaFormat.getEffectiveMinHeight(),
                mediaFormat.getEffectiveMaxWidth(),
                mediaFormat.getEffectiveMaxHeight(),
                mediaFormat.getMinWidthHeight(),
                mediaFormat.getRatio())) {
              candidate.setMediaFormat(mediaFormat);
              return candidate;
            }
          }
          return null;
        }
      });
    }

    // no restriction - return original or first rendition
    else {
      return getOriginalOrFirstRendition(candidates);
    }

    // none found
    return null;
  }

  /**
   * Returns original rendition - if it is contained in the candidate set. Otherwise first candidate is returned.
   * If a VirtualCropRenditionMetadata is present always the first one is returned.
   * @param candidates Candidates
   * @return Original or first rendition of candidates or null
   */
  private RenditionMetadata getOriginalOrFirstRendition(Set<RenditionMetadata> candidates) {
    if (this.originalRendition != null && candidates.contains(this.originalRendition)) {
      return this.originalRendition;
    }
    else if (!candidates.isEmpty()) {
      return candidates.iterator().next();
    }
    else {
      return null;
    }
  }

  /**
   * Check if a rendition is available from which the required format can be downscaled from and returns
   * a virtual rendition in this case.
   * @param candidates Candidates
   * @param mediaArgs Media args
   * @return Rendition or null
   */
  private RenditionMetadata getVirtualRendition(final Set<RenditionMetadata> candidates, MediaArgs mediaArgs) {

    // get from fixed width/height
    if (mediaArgs.getFixedWidth() > 0 || mediaArgs.getFixedHeight() > 0) {
      long destWidth = mediaArgs.getFixedWidth();
      long destHeight = mediaArgs.getFixedHeight();
      double destRatio = 0;
      if (destWidth > 0 && destHeight > 0) {
        destRatio = Ratio.get(destWidth, destHeight);
      }
      return getVirtualRendition(candidates, destWidth, destHeight, 0, destRatio,
          mediaArgs.getEnforceOutputFileExtension(), mediaArgs.getImageQualityPercentage());
    }

    // or from any media format
    return visitMediaFormats(mediaArgs, new MediaFormatVisitor<RenditionMetadata>() {
      @Override
      public @Nullable RenditionMetadata visit(@NotNull MediaFormat mediaFormat) {
        long destWidth = mediaFormat.getEffectiveMinWidth();
        long destHeight = mediaFormat.getEffectiveMinHeight();
        long minWidthHeight = mediaFormat.getMinWidthHeight();
        double destRatio = mediaFormat.getRatio();
        // try to find matching rendition, otherwise check for next media format
        RenditionMetadata rendition = getVirtualRendition(candidates, destWidth, destHeight, minWidthHeight, destRatio,
            mediaArgs.getEnforceOutputFileExtension(), mediaArgs.getImageQualityPercentage());
        if (rendition != null) {
          rendition.setMediaFormat(mediaFormat);
        }
        return rendition;
      }
    });
  }

  /**
   * Check if a rendition is available from which the required format can be downscaled from and returns
   * a virtual rendition in this case.
   * @param candidates Candidates
   * @param destWidth Destination width
   * @param destHeight Destination height
   * @param minWidthHeight Min. width/height (longest edge)
   * @param destRatio Destination ratio
   * @param enforceOutputFileExtension Enforce output file extension
   * @return Rendition or null
   */
  private RenditionMetadata getVirtualRendition(@NotNull Set<RenditionMetadata> candidates,
      long destWidth, long destHeight, long minWidthHeight, double destRatio,
      @Nullable String enforceOutputFileExtension, @Nullable Double imageQualityPercentage) {

    // if ratio is defined get first rendition with matching ratio and same or bigger size
    if (destRatio > 0) {
      for (RenditionMetadata candidate : candidates) {
        if (candidate.matches(destWidth, destHeight, 0, 0, minWidthHeight, destRatio)) {
          return getVirtualRendition(candidate, destWidth, destHeight, destRatio, enforceOutputFileExtension, imageQualityPercentage);
        }
      }
    }
    // otherwise get first rendition which is same or bigger in width and height
    else {
      for (RenditionMetadata candidate : candidates) {
        if (candidate.matches(destWidth, destHeight, 0, 0, minWidthHeight, 0d)) {
          return getVirtualRendition(candidate, destWidth, destHeight, 0d, enforceOutputFileExtension, imageQualityPercentage);
        }
      }
    }

    // none found
    return null;
  }

  /**
   * Get virtual rendition for given width/height/ratio.
   * @param rendition Rendition
   * @param widthValue Width
   * @param heightValue Height
   * @param ratioValue Ratio
   * @param enforceOutputFileExtension Enforce output file extension
   * @param imageQualityPercentage Image quality
   * @return Rendition or null
   */
  private RenditionMetadata getVirtualRendition(@NotNull RenditionMetadata rendition, long widthValue, long heightValue,
      double ratioValue, @Nullable String enforceOutputFileExtension, @Nullable Double imageQualityPercentage) {

    long width = widthValue;
    long height = heightValue;
    double ratio = ratioValue;

    // if ratio is missing: calculate from given rendition
    if (ratio < MediaFormatHandler.RATIO_TOLERANCE) {
      ratio = Ratio.get(rendition.getWidth(), rendition.getHeight());
    }

    // if height is missing - calculate from width
    if (height == 0 && width > 0) {
      height = Math.round(width / ratio);
    }

    // if width is missing - calculate from height
    if (width == 0 && height > 0) {
      width = Math.round(height * ratio);
    }

    // return virtual rendition
    if (width > 0 && height > 0) {
      if (rendition instanceof VirtualTransformedRenditionMetadata) {
        VirtualTransformedRenditionMetadata cropRendition = (VirtualTransformedRenditionMetadata)rendition;
        return new VirtualTransformedRenditionMetadata(cropRendition.getRendition(), width, height, enforceOutputFileExtension,
            cropRendition.getCropDimension(), cropRendition.getRotation(), imageQualityPercentage);
      }
      else {
        return new VirtualRenditionMetadata(rendition.getRendition(), width, height, enforceOutputFileExtension, imageQualityPercentage);
      }
    }
    else {
      return null;
    }
  }

  protected Asset getAsset() {
    return damContext.getAsset();
  }

}