Skip to content

Commit

Permalink
Allow to set image quality per media request (#33)
Browse files Browse the repository at this point in the history
and add proper quality handling for web-optimized image delivery
  • Loading branch information
stefanseifert authored Jan 17, 2024
1 parent dd6e844 commit d834824
Show file tree
Hide file tree
Showing 30 changed files with 573 additions and 174 deletions.
3 changes: 3 additions & 0 deletions changes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
Add support for Web-Optimized Image Delivery (part of Next Generation Dynamic Media) - rendering asset renditions from AEM Sites instance on the edge.<br/>
<b>This feature is active by default on AEMaaCS cloud instances, can be disabled via OSGi configuration</b>.
]]></action>
<action type="add" dev="sseifert" issue="33">
Allow to set image quality per media request.
</action>
<action type="remove" dev="sseifert" issue="27">
Remove deprecated functionality.
</action>
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/io/wcm/handler/media/MediaArgs.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public final class MediaArgs implements Cloneable {
private Boolean includeAssetWebRenditions;
private ImageSizes imageSizes;
private PictureSource[] pictureSourceSets;
private Double imageQualityPercentage;
private DragDropSupport dragDropSupport = DragDropSupport.AUTO;
private IPERatioCustomize ipeRatioCustomize = IPERatioCustomize.AUTO;
private boolean dynamicMediaDisabled;
Expand Down Expand Up @@ -672,6 +673,22 @@ public boolean isWebOptimizedImageDeliveryDisabled() {
return this;
}

/**
* @return Image quality in percent (0..1) for images with lossy compression (e.g. JPEG).
*/
public @Nullable Double getImageQualityPercentage() {
return this.imageQualityPercentage;
}

/**
* @param value Image quality in percent (0..1) for images with lossy compression (e.g. JPEG).
* @return this
*/
public @NotNull MediaArgs imageQualityPercentage(@Nullable Double value) {
this.imageQualityPercentage = value;
return this;
}

/**
* Drag&amp;Drop support for media builder.
* @return Drag&amp;Drop support
Expand Down Expand Up @@ -809,6 +826,9 @@ public String toString() {
if (pictureSourceSets != null && pictureSourceSets.length > 0) {
sb.append("pictureSourceSets", "[" + StringUtils.join(pictureSourceSets, ",") + "]");
}
if (imageQualityPercentage != null) {
sb.append("imageQualityPercentage ", imageQualityPercentage);
}
if (dragDropSupport != DragDropSupport.AUTO) {
sb.append("dragDropSupport ", dragDropSupport);
}
Expand Down Expand Up @@ -856,6 +876,7 @@ public MediaArgs clone() { //NOPMD
clone.includeAssetWebRenditions = this.includeAssetWebRenditions;
clone.imageSizes = this.imageSizes;
clone.pictureSourceSets = ArrayUtils.clone(this.pictureSourceSets);
clone.imageQualityPercentage = this.imageQualityPercentage;
clone.dragDropSupport = this.dragDropSupport;
clone.ipeRatioCustomize = this.ipeRatioCustomize;
clone.dynamicMediaDisabled = this.dynamicMediaDisabled;
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/io/wcm/handler/media/MediaBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,13 @@ public interface MediaBuilder {
@NotNull
MediaBuilder dummyImageUrl(@NotNull String value);

/**
* @param value Image quality in percent (0..1) for images with lossy compression (e.g. JPEG).
* @return this
*/
@NotNull
MediaBuilder imageQualityPercentage(@NotNull Double value);

/**
* @param value Defines which types of AEM-generated renditions (with <code>cq5dam.</code> prefix) are taken into
* account when trying to resolve the media request.
Expand Down
58 changes: 52 additions & 6 deletions src/main/java/io/wcm/handler/media/MediaFileType.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.EnumSet;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
Expand All @@ -40,36 +41,40 @@ public enum MediaFileType {
/**
* JPEG
*/
JPEG(new String[] { ContentType.JPEG }, new String[] { FileExtension.JPEG, "jpeg" }),
JPEG(new String[] { ContentType.JPEG }, new String[] { FileExtension.JPEG, "jpeg" }, true),

/**
* PNG
*/
PNG(new String[] { ContentType.PNG }, new String[] { FileExtension.PNG }),
PNG(new String[] { ContentType.PNG }, new String[] { FileExtension.PNG }, false),

/**
* GIF
*/
GIF(new String[] { ContentType.GIF }, new String[] { FileExtension.GIF }),
GIF(new String[] { ContentType.GIF }, new String[] { FileExtension.GIF }, false),

/**
* TIFF
*/
TIFF(new String[] { ContentType.TIFF }, new String[] { FileExtension.TIFF, "tiff" }),
TIFF(new String[] { ContentType.TIFF }, new String[] { FileExtension.TIFF, "tiff" }, false),

/**
* SVG
*/
SVG(new String[] { ContentType.SVG }, new String[] { FileExtension.SVG });
SVG(new String[] { ContentType.SVG }, new String[] { FileExtension.SVG }, false);


private final Set<String> contentTypes;
private final Set<String> extensions;
private final boolean imageQualityPercentage;

@SuppressWarnings("null")
MediaFileType(@NotNull String @NotNull [] contentTypes, @NotNull String @NotNull [] extensions) {
MediaFileType(@NotNull String @NotNull [] contentTypes,
@NotNull String @NotNull [] extensions,
boolean imageQualityPercentage) {
this.contentTypes = Set.of(contentTypes);
this.extensions = Set.of(extensions);
this.imageQualityPercentage = imageQualityPercentage;
}

/**
Expand All @@ -86,6 +91,13 @@ public Set<String> getExtensions() {
return extensions;
}

/**
* @return true if this image type has lossy compression and image quality is specified in percentage
*/
public boolean isImageQualityPercentage() {
return imageQualityPercentage;
}

/**
* All file types that are supported by the Media Handler for rendering as image.
*/
Expand Down Expand Up @@ -200,4 +212,38 @@ private static Set<String> getFileExtensions(@NotNull EnumSet<MediaFileType> fil
.collect(Collectors.toSet());
}

/**
* Get Media file type by content type.
* @param contentType Content type
* @return Media file type or null if not found
*/
@SuppressWarnings("null")
public static @Nullable MediaFileType getByContentType(@Nullable String contentType) {
if (contentType == null) {
return null;
}
String contentTypeLowerCase = StringUtils.toRootLowerCase(contentType);
return Stream.of(MediaFileType.values())
.filter(type -> type.getContentTypes().contains(contentTypeLowerCase))
.findFirst()
.orElse(null);
}

/**
* Get Media file type by file extension.
* @param extension File extension
* @return Media file type or null if not found
*/
@SuppressWarnings("null")
public static @Nullable MediaFileType getByFileExtensions(@Nullable String extension) {
if (extension == null) {
return null;
}
String extensionLowerCase = StringUtils.toRootLowerCase(extension);
return Stream.of(MediaFileType.values())
.filter(type -> type.getExtensions().contains(extensionLowerCase))
.findFirst()
.orElse(null);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.wcm.sling.commons.request.RequestPath;
Expand Down Expand Up @@ -89,7 +90,7 @@ protected void doGet(@NotNull SlingHttpServletRequest request, @NotNull SlingHtt
* @param request Request
* @return Resource pointing to nt:file or nt:resource node
*/
protected Resource getBinaryDataResource(SlingHttpServletRequest request) {
protected @Nullable Resource getBinaryDataResource(SlingHttpServletRequest request) {
return request.getResource();
}

Expand All @@ -100,8 +101,8 @@ protected Resource getBinaryDataResource(SlingHttpServletRequest request) {
* @param response Response
* @return true if the resource is not modified and should not be delivered anew
*/
protected boolean isNotModified(Resource resource, SlingHttpServletRequest request,
SlingHttpServletResponse response) {
protected boolean isNotModified(@NotNull Resource resource, @NotNull SlingHttpServletRequest request,
@NotNull SlingHttpServletResponse response) {
// check resource's modification date against the If-Modified-Since header and send 304 if resource wasn't modified
// never send expires header on author or publish instance (performance optimization - if medialib items changes
// users have to refresh browsers cache)
Expand All @@ -113,7 +114,8 @@ protected boolean isNotModified(Resource resource, SlingHttpServletRequest reque
* @param resource Resource
* @return Binary data or null if not binary data found
*/
protected byte[] getBinaryData(Resource resource, @SuppressWarnings("unused") SlingHttpServletRequest request) throws IOException {
protected byte @Nullable [] getBinaryData(@NotNull Resource resource,
@SuppressWarnings({ "unused", "java:S1172" }) @NotNull SlingHttpServletRequest request) throws IOException {
InputStream is = resource.adaptTo(InputStream.class);
if (is == null) {
return null;
Expand All @@ -131,7 +133,8 @@ protected byte[] getBinaryData(Resource resource, @SuppressWarnings("unused") Sl
* @param resource Resource
* @return Content type (never null)
*/
protected String getContentType(Resource resource, @SuppressWarnings("unused") SlingHttpServletRequest request) {
protected @NotNull String getContentType(@NotNull Resource resource,
@SuppressWarnings({ "unused", "java:S1172" }) @NotNull SlingHttpServletRequest request) {
String mimeType = JcrBinary.getMimeType(resource);
if (StringUtils.isEmpty(mimeType)) {
mimeType = ContentType.OCTET_STREAM;
Expand All @@ -146,8 +149,8 @@ protected String getContentType(Resource resource, @SuppressWarnings("unused") S
* @param request Request
* @param response Response
*/
protected void sendBinaryData(byte[] binaryData, String contentType,
SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
protected void sendBinaryData(byte @NotNull [] binaryData, @NotNull String contentType,
@NotNull SlingHttpServletRequest request, @NotNull SlingHttpServletResponse response) throws IOException {

// set content type and length
response.setContentType(contentType);
Expand All @@ -174,7 +177,7 @@ else if (StringUtils.equals(contentType, ContentType.SVG)) {
out.flush();
}

private void setContentDispositionAttachmentHeader(SlingHttpServletRequest request, SlingHttpServletResponse response) {
private void setContentDispositionAttachmentHeader(@NotNull SlingHttpServletRequest request, @NotNull SlingHttpServletResponse response) {
// Construct disposition header
StringBuilder dispositionHeader = new StringBuilder("attachment;");
String suffix = request.getRequestPathInfo().getSuffix();
Expand Down
94 changes: 22 additions & 72 deletions src/main/java/io/wcm/handler/media/impl/ImageFileServlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,12 @@
*/
package io.wcm.handler.media.impl;

import static io.wcm.handler.media.impl.ImageTransformation.isValidRotation;

import java.io.ByteArrayOutputStream;
import java.io.IOException;

import javax.servlet.Servlet;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.servlets.HttpConstants;
Expand Down Expand Up @@ -70,46 +67,23 @@ public final class ImageFileServlet extends AbstractMediaFileServlet {
private AssetStore assetStore;

@Override
protected byte[] getBinaryData(Resource resource, SlingHttpServletRequest request) throws IOException {
protected byte @Nullable [] getBinaryData(@NotNull Resource resource, @NotNull SlingHttpServletRequest request) throws IOException {
// get media app config
MediaHandlerConfig config = AdaptTo.notNull(request, MediaHandlerConfig.class);

// check for image scaling parameters
int width = 0;
int height = 0;
String[] selectors = request.getRequestPathInfo().getSelectors();
if (selectors.length >= 3) {
width = NumberUtils.toInt(selectors[1]);
height = NumberUtils.toInt(selectors[2]);
}
// parse selectors
ImageFileServletSelector params = new ImageFileServletSelector(request.getRequestPathInfo().getSelectors());
int width = params.getWidth();
int height = params.getHeight();
CropDimension cropDimension = params.getCropDimension();
int rotation = params.getRotation();
int quality = params.getQuality();

// ensure valid image size
if (width < 0 || height < 0 || (width == 0 && height == 0)) {
return null;
}

// check for cropping parameter
CropDimension cropDimension = null;
if (selectors.length >= 4) {
String cropString = selectors[3];
if (!StringUtils.equals(cropString, "-")) {
try {
cropDimension = CropDimension.fromCropString(cropString);
}
catch (IllegalArgumentException ex) {
// ignore
}
}
}

// check for rotation parameter
int rotation = 0;
if (selectors.length >= 5) {
String rotationString = selectors[4];
rotation = NumberUtils.toInt(rotationString);
if (!isValidRotation(rotation)) {
rotation = 0;
}
}

Layer layer = ResourceLayerUtil.toLayer(resource, assetStore);
if (layer == null) {
return null;
Expand Down Expand Up @@ -153,16 +127,25 @@ else if (height == 0) {
layer.resize(width, height);
}

// determine layer quality with fallback to default image quality if not set
String contentType = getContentType(resource, request);
double layerQuality;
if (quality > 0) {
layerQuality = quality / 100d;
}
else {
layerQuality = config.getDefaultImageQuality(contentType);
}

// stream to byte array
ByteArrayOutputStream bos = new ByteArrayOutputStream();
String contentType = getContentType(resource, request);
layer.write(contentType, config.getDefaultImageQuality(contentType), bos);
layer.write(contentType, layerQuality, bos);
bos.flush();
return bos.toByteArray();
}

@Override
protected String getContentType(Resource resource, SlingHttpServletRequest request) {
protected @NotNull String getContentType(@NotNull Resource resource, @NotNull SlingHttpServletRequest request) {

// get filename from suffix to get extension
String fileName = request.getRequestPathInfo().getSuffix();
Expand Down Expand Up @@ -213,37 +196,4 @@ public static String getImageFileName(@NotNull String originalFilename,
return namePart + "." + extensionPart;
}

/**
* Build selector string for this servlet.
* @param width Width
* @param height Height
* @param cropDimension Crop dimension
* @param rotation Rotation
* @param contentDispositionAttachment Content disposition attachment
* @return Selector string
*/
public static @NotNull String buildSelectorString(long width, long height,
@Nullable CropDimension cropDimension, @Nullable Integer rotation,
boolean contentDispositionAttachment) {
StringBuilder result = new StringBuilder()
.append(SELECTOR)
.append(".").append(Long.toString(width))
.append(".").append(Long.toString(height));

if (cropDimension != null) {
result.append(".").append(cropDimension.getCropString());
}
else if (rotation != null) {
result.append(".-");
}
if (rotation != null) {
result.append(".").append(rotation.toString());
}
if (contentDispositionAttachment) {
result.append(".").append(AbstractMediaFileServlet.SELECTOR_DOWNLOAD);
}

return result.toString();
}

}
Loading

0 comments on commit d834824

Please sign in to comment.