InlineRendition.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.inline;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import javax.jcr.Node;
import javax.jcr.Property;
import javax.jcr.RepositoryException;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.adapter.Adaptable;
import org.apache.sling.api.adapter.SlingAdaptable;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ValueMap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import com.day.cq.commons.jcr.JcrConstants;
import com.day.image.Layer;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.wcm.handler.media.CropDimension;
import io.wcm.handler.media.Dimension;
import io.wcm.handler.media.Media;
import io.wcm.handler.media.MediaArgs;
import io.wcm.handler.media.MediaFileType;
import io.wcm.handler.media.Rendition;
import io.wcm.handler.media.UriTemplate;
import io.wcm.handler.media.UriTemplateType;
import io.wcm.handler.media.format.MediaFormat;
import io.wcm.handler.media.format.Ratio;
import io.wcm.handler.media.format.impl.MediaFormatSupport;
import io.wcm.handler.media.impl.ImageFileServlet;
import io.wcm.handler.media.impl.ImageFileServletSelector;
import io.wcm.handler.media.impl.ImageTransformation;
import io.wcm.handler.media.impl.JcrBinary;
import io.wcm.handler.media.impl.MediaFileServletConstants;
import io.wcm.handler.media.spi.MediaHandlerConfig;
import io.wcm.handler.mediasource.ngdm.impl.MediaArgsDimension;
import io.wcm.handler.url.UrlHandler;
import io.wcm.sling.commons.adapter.AdaptTo;
import io.wcm.wcm.commons.caching.ModificationDate;
/**
* {@link Rendition} implementation for inline media objects stored in a node in a content page.
*/
final class InlineRendition extends SlingAdaptable implements Rendition {
private final Adaptable adaptable;
private final Resource resource;
private final MediaArgs mediaArgs;
private final MediaHandlerConfig mediaHandlerConfig;
private final String fileName;
private final String fileExtension;
private final String originalFileExtension;
private final Dimension imageDimension;
private final Dimension maxImageDimension;
private final String url;
private CropDimension cropDimension;
private final Integer rotation;
private MediaFormat resolvedMediaFormat;
private boolean fallback;
/**
* Special dimension instance that marks "scaling is required but not possible"
*/
private static final Dimension SCALING_NOT_POSSIBLE_DIMENSION = new Dimension(-1, -1);
/**
* @param resource Binary resource
* @param media Media metadata
* @param mediaHandlerConfig Media handler config
* @param mediaArgs Media args
* @param fileName File name
*/
@SuppressWarnings("java:S3776") // ignore complexity
InlineRendition(Resource resource, Media media, MediaArgs mediaArgs, MediaHandlerConfig mediaHandlerConfig,
String fileName, Adaptable adaptable) {
this.resource = resource;
this.mediaArgs = mediaArgs;
this.mediaHandlerConfig = mediaHandlerConfig;
this.adaptable = adaptable;
this.rotation = media.getRotation();
this.cropDimension = media.getCropDimension();
// detect image dimension
this.originalFileExtension = FilenameUtils.getExtension(fileName);
// check if scaling is possible
boolean isImage = MediaFileType.isImage(this.originalFileExtension);
boolean isVectorImage = MediaFileType.isVectorImage(this.originalFileExtension);
Dimension dimension = null;
Dimension maxDimension = null;
Dimension scaledDimension = null;
String processedFileName = fileName;
if (isImage) {
// get dimension from image binary
List<Dimension> dimensionCandidates = getImageOrCroppedDimensions();
for (int i = 0; i < dimensionCandidates.size(); i++) {
dimension = dimensionCandidates.get(i);
maxDimension = dimension;
if (isVectorImage && (this.rotation != null || this.cropDimension != null)) {
// transformation not possible for vector images
scaledDimension = SCALING_NOT_POSSIBLE_DIMENSION;
}
else {
// check if scaling is required
scaledDimension = getScaledDimension(dimension);
if (scaledDimension != null && isValidScalingDimension(scaledDimension)) {
// overwrite image dimension of {@link Rendition} instance with scaled dimensions
dimension = scaledDimension;
// extension may have to be changed because scaling case produce different file format
if (!isVectorImage) {
processedFileName = ImageFileServlet.getImageFileName(processedFileName,
mediaArgs.getEnforceOutputFileExtension());
}
}
}
if (isValidScalingDimension(scaledDimension)) {
if (i > 0) {
// fallback (original) image dimension is used - clear ignored cropping parameters
this.cropDimension = null;
this.fallback = true;
}
break;
}
}
if (!isValidScalingDimension(scaledDimension) && mediaArgs.isAutoCrop() && !isVectorImage && dimension != null) {
// scaling is required, but not match with inline media - try auto cropping (if enabled)
InlineAutoCropping autoCropping = new InlineAutoCropping(dimension, mediaArgs);
List<CropDimension> autoCropDimensions = autoCropping.calculateAutoCropDimensions();
for (CropDimension autoCropDimension : autoCropDimensions) {
scaledDimension = getScaledDimension(autoCropDimension);
maxDimension = autoCropDimension;
if (scaledDimension == null) {
scaledDimension = autoCropDimension;
}
if (isValidScalingDimension(scaledDimension)) {
// overwrite image dimension of {@link Rendition} instance with scaled dimensions
dimension = scaledDimension;
this.cropDimension = autoCropDimension;
// extension may have to be changed because scaling case produce different file format
if (!isVectorImage) {
processedFileName = ImageFileServlet.getImageFileName(processedFileName,
mediaArgs.getEnforceOutputFileExtension());
}
break;
}
}
}
}
this.fileName = processedFileName;
this.fileExtension = FilenameUtils.getExtension(processedFileName);
this.imageDimension = dimension;
this.maxImageDimension = maxDimension;
// build media url (it is null if no rendition is available for the given media args)
this.url = buildMediaUrl(scaledDimension);
// set first media format as resolved format - because only the first is supported
MediaFormat firstMediaFormat = MediaArgsDimension.getFirstMediaFormat(mediaArgs);
if (url != null && firstMediaFormat != null) {
this.resolvedMediaFormat = firstMediaFormat;
}
}
private boolean isValidScalingDimension(@Nullable Dimension dimension) {
return dimension == null || !dimension.equals(SCALING_NOT_POSSIBLE_DIMENSION);
}
/**
* Gets a list of possible dimensions for media processing. If cropping parameters are given
* the list contains the cropping dimension and the original image dimension; if not only the latter.
* If the original image is not an image at all, an empty list is returned.
* @return Dimension
*/
private List<Dimension> getImageOrCroppedDimensions() {
List<Dimension> dimensions = new ArrayList<>();
Dimension originalDimension = getImageDimension();
if (originalDimension != null) {
if (this.cropDimension != null) {
dimensions.add(this.cropDimension);
}
dimensions.add(originalDimension);
}
return dimensions;
}
/**
* Gets the dimension of the uploaded image (if the binary is an image file at all).
* @return Dimension
*/
private Dimension getImageDimension() {
Dimension dimension = null;
// if binary is image try to calculate dimensions by loading it into a layer
Layer layer = this.resource.adaptTo(Layer.class);
if (layer != null) {
dimension = new Dimension(layer.getWidth(), layer.getHeight());
}
return dimension;
}
/**
* Checks if the current binary is an image and has to be scaled. In this case the destination dimension is returned.
* @return Scaled destination or null if no scaling is required. If a destination object with both
* width and height set to -1 is returned, a scaling is required but not possible with the given source
* object.
*/
private @Nullable Dimension getScaledDimension(@NotNull Dimension originalDimension) {
// check if image has to be rescaled
Dimension requestedDimension = MediaArgsDimension.getRequestedDimension(mediaArgs);
double requestedRatio = MediaArgsDimension.getRequestedRatio(mediaArgs);
double imageRatio = Ratio.get(originalDimension);
if (requestedRatio > 0 && !Ratio.matches(requestedRatio, imageRatio)) {
return SCALING_NOT_POSSIBLE_DIMENSION;
}
boolean scaleWidth = (requestedDimension.getWidth() > 0
&& requestedDimension.getWidth() != originalDimension.getWidth());
boolean scaleHeight = (requestedDimension.getHeight() > 0
&& requestedDimension.getHeight() != originalDimension.getHeight());
if (scaleWidth || scaleHeight) {
long requestedWidth = requestedDimension.getWidth();
long requestedHeight = requestedDimension.getHeight();
// calculate missing width/height from ratio if not specified
if (requestedWidth == 0 && requestedHeight > 0) {
requestedWidth = Math.round(requestedHeight * imageRatio);
}
else if (requestedWidth > 0 && requestedHeight == 0) {
requestedHeight = Math.round(requestedWidth / imageRatio);
}
// calculate requested ratio
requestedRatio = Ratio.get(requestedWidth, requestedHeight);
// if ratio does not match, or requested width/height is larger than the original image scaling is not possible
if (!Ratio.matches(imageRatio, requestedRatio)
|| (originalDimension.getWidth() < requestedWidth)
|| (originalDimension.getHeight() < requestedHeight)) {
return SCALING_NOT_POSSIBLE_DIMENSION;
}
else {
return new Dimension(requestedWidth, requestedHeight);
}
}
return null;
}
/**
* Build media URL for this rendition - either "native" URL to repository or virtual url to rescaled version.
* @return Media URL - null if no rendition is available
*/
private String buildMediaUrl(Dimension scaledDimension) {
// check for file extension filtering
if (!isMatchingFileExtension()) {
return null;
}
// check if image has to be rescaled
if (scaledDimension != null) {
// check if scaling is not possible
if (scaledDimension.equals(SCALING_NOT_POSSIBLE_DIMENSION)) {
return null;
}
// otherwise generate scaled image URL
return buildScaledMediaUrl(scaledDimension, this.cropDimension);
}
// if no scaling but cropping or rotation required build scaled image URL
if (this.cropDimension != null || this.rotation != null) {
return buildScaledMediaUrl(this.cropDimension != null ? this.cropDimension : this.imageDimension, this.cropDimension);
}
if (mediaArgs.isContentDispositionAttachment()) {
// if not scaling and no cropping required but special content disposition headers required build download url
return buildDownloadMediaUrl();
}
else if (MediaFileType.isBrowserImage(getFileExtension()) || !MediaFileType.isImage(getFileExtension())) {
if (enforceVirtualRendition()) {
// enforce virtual rendition instead of native media URL
return buildScaledMediaUrl(this.imageDimension, null);
}
else {
// if no scaling and no cropping required build native media URL
return buildNativeMediaUrl();
}
}
else {
// image rendition uses a file extension that cannot be displayed in browser directly - render via ImageFileServlet
return buildScaledMediaUrl(this.imageDimension, null);
}
}
private boolean enforceVirtualRendition() {
if (MediaFileType.isImage(getFileExtension()) && !MediaFileType.isVectorImage(getFileExtension())) {
if (mediaHandlerConfig.enforceVirtualRenditions()) {
return true;
}
if (mediaArgs.getEnforceOutputFileExtension() != null) {
return !StringUtils.equalsIgnoreCase(getFileExtension(), mediaArgs.getEnforceOutputFileExtension());
}
}
return false;
}
/**
* Builds "native" URL that returns the binary data directly from the repository.
* @return Media URL
*/
private String buildNativeMediaUrl() {
String path = null;
Resource parentResource = this.resource.getParent();
if (parentResource != null && JcrBinary.isNtFile(parentResource)) {
// if parent resource is nt:file and its node name equals the detected filename, directly use the nt:file node path
if (StringUtils.equals(parentResource.getName(), getFileName())) {
path = parentResource.getPath();
}
// otherwise use nt:file node path with custom filename
else {
path = parentResource.getPath() + "./" + getFileName();
}
}
else {
// nt:resource node does not have a nt:file parent, use its path directly
path = this.resource.getPath() + "./" + getFileName();
}
// build externalized URL
UrlHandler urlHandler = AdaptTo.notNull(this.adaptable, UrlHandler.class);
return urlHandler.get(path).urlMode(this.mediaArgs.getUrlMode()).buildExternalResourceUrl(this.resource);
}
/**
* Builds URL to rescaled version of the binary image.
* @return Media URL
*/
@SuppressFBWarnings("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE")
@SuppressWarnings("java:S1075") // not a file path
private String buildScaledMediaUrl(@NotNull Dimension dimension, @Nullable CropDimension mediaUrlCropDimension) {
if (isVectorImage()) {
// vector images are scaled in browser, so use native URL
return buildNativeMediaUrl();
}
String resourcePath = this.resource.getPath();
// if parent resource is a nt:file resource, use this one as path for scaled image
Resource parentResource = this.resource.getParent();
if (parentResource != null && JcrBinary.isNtFile(parentResource)) {
resourcePath = parentResource.getPath();
}
// URL to render scaled image via {@link InlineRenditionServlet}
String path = resourcePath
+ "." + ImageFileServletSelector.build(dimension.getWidth(), dimension.getHeight(),
mediaUrlCropDimension, this.rotation, this.mediaArgs.getImageQualityPercentage(),
this.mediaArgs.isContentDispositionAttachment())
+ "." + MediaFileServletConstants.EXTENSION + "/"
// replace extension based on the format supported by ImageFileServlet for rendering for this rendition
+ ImageFileServlet.getImageFileName(getFileName(),
mediaArgs.getEnforceOutputFileExtension());
// build externalized URL
UrlHandler urlHandler = AdaptTo.notNull(this.adaptable, UrlHandler.class);
return urlHandler.get(path).urlMode(this.mediaArgs.getUrlMode()).buildExternalResourceUrl(this.resource);
}
/**
* Builds URL to rescaled version of the binary image.
* @return Media URL
*/
@SuppressWarnings("java:S1075") // not a file path
private String buildDownloadMediaUrl() {
String resourcePath = this.resource.getPath();
// if parent resource is a nt:file resource, use this one as path for scaled image
Resource parentResource = this.resource.getParent();
if (parentResource != null && JcrBinary.isNtFile(parentResource)) {
resourcePath = parentResource.getPath();
}
// URL to render scaled image via {@link InlineRenditionServlet}
String path = resourcePath + "." + MediaFileServletConstants.SELECTOR
+ "." + MediaFileServletConstants.SELECTOR_DOWNLOAD
+ "." + MediaFileServletConstants.EXTENSION + "/" + getFileName();
// build externalized URL
UrlHandler urlHandler = AdaptTo.notNull(this.adaptable, UrlHandler.class);
return urlHandler.get(path).urlMode(this.mediaArgs.getUrlMode()).buildExternalResourceUrl(this.resource);
}
/**
* Checks if the file extension of the current binary matches with the requested extensions from the media args.
* @return true if file extension matches
*/
private boolean isMatchingFileExtension() {
String[] extensions = MediaFormatSupport.getRequestedFileExtensions(mediaArgs);
if (extensions == null) {
// constraints for filtering file extensions are not fulfilled - not matching possible
return false;
}
if (extensions.length == 0) {
return true;
}
for (String extension : extensions) {
if (StringUtils.equalsIgnoreCase(extension, this.originalFileExtension)) {
return true;
}
}
return false;
}
@Override
public String getUrl() {
return this.url;
}
@Override
public String getPath() {
return this.resource.getPath();
}
@Override
public String getFileName() {
if (this.url != null) {
return FilenameUtils.getName(this.url);
}
return this.fileName;
}
@Override
public String getFileExtension() {
if (this.url != null) {
return FilenameUtils.getExtension(this.url);
}
return StringUtils.defaultString(this.fileExtension, this.originalFileExtension);
}
@Override
@SuppressWarnings("java:S112") // allow runtime exception
public long getFileSize() {
Node node = this.resource.adaptTo(Node.class);
if (node != null) {
try {
Property data = node.getProperty(JcrConstants.JCR_DATA);
return data.getBinary().getSize();
}
catch (RepositoryException ex) {
throw new RuntimeException("Unable to detect binary file size for " + this.resource.getPath(), ex);
}
}
else {
// fallback to Sling API if JCR node is not present (inefficient - but this should happen only in unit tests)
try {
InputStream is = this.resource.getValueMap().get(JcrConstants.JCR_DATA, InputStream.class);
return IOUtils.toByteArray(is).length;
}
catch (IOException ex) {
throw new RuntimeException("Unable to detect binary file size for " + this.resource.getPath(), ex);
}
}
}
@Override
public String getMimeType() {
return JcrBinary.getMimeType(this.resource);
}
@Override
public Date getModificationDate() {
return ModificationDate.get(this.resource);
}
@Override
public MediaFormat getMediaFormat() {
return resolvedMediaFormat;
}
@Override
@SuppressWarnings("null")
public ValueMap getProperties() {
return this.resource.getValueMap();
}
@Override
public boolean isImage() {
return MediaFileType.isImage(getFileExtension());
}
@Override
public boolean isBrowserImage() {
return MediaFileType.isBrowserImage(getFileExtension());
}
@Override
public boolean isVectorImage() {
return MediaFileType.isVectorImage(getFileExtension());
}
@Override
public boolean isDownload() {
return !isImage();
}
@Override
public long getWidth() {
if (imageDimension != null) {
return ImageTransformation.rotateMapDimension(imageDimension, rotation).getWidth();
}
else {
return 0;
}
}
@Override
public long getHeight() {
if (imageDimension != null) {
return ImageTransformation.rotateMapDimension(imageDimension, rotation).getHeight();
}
else {
return 0;
}
}
@Override
public boolean isFallback() {
return fallback;
}
@Override
public @NotNull UriTemplate getUriTemplate(@NotNull UriTemplateType type) {
if (type == UriTemplateType.CROP_CENTER) {
throw new IllegalArgumentException("CROP_CENTER not supported for rendition URI templates.");
}
if (!isImage() || isVectorImage()) {
throw new UnsupportedOperationException("Unable to build URI template for " + resource.getPath());
}
if (this.maxImageDimension == null) {
throw new IllegalStateException("Unable to detect dimension for inline image: " + resource.getPath());
}
Dimension dimension = ImageTransformation.rotateMapDimension(maxImageDimension, rotation);
return new InlineUriTemplate(type, dimension, this.resource, fileName,
this.cropDimension, this.rotation, mediaArgs, adaptable);
}
@Override
@SuppressWarnings({ "unchecked", "null" })
public <AdapterType> AdapterType adaptTo(Class<AdapterType> type) {
if (type == Resource.class) {
return (AdapterType)this.resource;
}
else if (type == Layer.class && isImage()) {
return (AdapterType)getLayer();
}
else if (type == InputStream.class) {
return (AdapterType)this.resource.adaptTo(InputStream.class);
}
return super.adaptTo(type);
}
private Layer getLayer() {
Layer layer = this.resource.adaptTo(Layer.class);
if (layer != null) {
if (cropDimension != null) {
layer.crop(cropDimension.getRectangle());
}
if (rotation != null) {
layer.rotate(rotation);
}
long width = getWidth();
long height = getHeight();
if (width <= layer.getWidth() && height <= layer.getHeight()) {
layer.resize((int)width, (int)height);
}
}
return layer;
}
@Override
public String toString() {
return Objects.toString(url, "#invalid");
}
}