HTML 5 Smart Image Component Custom Aspect Ratios

Goal


Create a html 5 smart image component that supports custom aspect ratios, image cropping. An author can create images of different aspect ratios using the same image component. Before you proceed, the process explained here is not Adobe suggested approach, it's just a thought; Package install, Source code and Video demonstration are available for download..





Prerequisites


If you are new to CQ pls visit this blog post; it explains the page component basics and setting up your IDE

Create the Component


1) In your CRXDE Lite http://localhost:4502/crx/de, create the below folder and save changes

                      /apps/imagecustomar

2) Copy the component /libs/foundation/components/logo and paste it in path /apps/imagecustomar

3) Rename /apps/imagecustomar/logo to /apps/imagecustomar/image

4) Rename /apps/imagecustomar/image/logo.jsp to /apps/imagecustomar/image/image.jsp

5) Change the package statement of /apps/imagecustomar/image/img.GET.java from libs.foundation.components.logo to apps.imagecustomar.image

6) Change the following properties of /apps/imagecustomar/image

                     componentGroup - My Components
                     jcr:title - Image with Custom Aspect Ratios

7) Rename /apps/imagecustomar/image/design_dialog to /apps/imagecustomar/image/dialog

8) Delete /apps/imagecustomar/image/dialog/items/basic

9) Replace the code in /apps/imagecustomar/image/image.jsp with the following...

<%@include file="/libs/foundation/global.jsp" %>
<%@ page import="com.day.cq.commons.Doctype,
                 com.day.cq.wcm.foundation.Image,
                 java.io.PrintWriter" %>
<%
    try {
        Resource res = null;

        if (currentNode.hasProperty("imageReference")) {
            res = resource;
        }
%>

<%
        if (res == null) {
%>
            Configure Image
<%
        } else {
            Image img = new Image(res);
            img.setItemName(Image.NN_FILE, "image");
            img.setItemName(Image.PN_REFERENCE, "imageReference");
            img.setSelector("img");
            img.setDoctype(Doctype.fromRequest(request));
            img.setAlt("Home");
            img.draw(out);
        }
    } catch (Exception e) {
        e.printStackTrace(new PrintWriter(out));
    }
%>


10) Add this Image component on a page and select an image. The image dialog with cropping features enabled looks like below. Here, Free crop has default aspect ratio (0,0)




10) We now have a basic image component with ootb features ( crop, rotate etc ). Let us modify this component to add custom aspect ratios; in the process we also create and register a new ExtJS xtype

Create and Register xtype


1) Create the node /apps/imagecustomar/image/clientlib of type cq:ClientLibraryFolder and add the following properties

             categories - String[] - mycomponent.imagear
             dependencies - String[] - cq.widgets

2) Create file (type nt:file) /apps/imagecustomar/image/clientlib/js.txt and add the following

              imagear.js

3) Create file /apps/imagecustomar/image/clientlib/imagear.js. For now, add the following code

              alert("hi");

4) Modify /apps/imagecustomar/image/image.jsp to include the clientlib created above

               <cq:includeClientLib categories="mycomponent.imagear"/>

5) Save changes and access the component dialog; an alert "hi" should popup, confirming the js file load.

6) Add the following JS code to imagear.js. This logic adds the custom aspect ratios UI changes to crop tool

var MyClientLib = MyClientLib || {};

MyClientLib.Html5SmartImage = CQ.Ext.extend(CQ.html5.form.SmartImage, {
    crops: {},

    constructor: function (config) {
        config = config || {};

        var aRatios = {
            "freeCrop": {
                "value": "0,0",
                "text": CQ.I18n.getMessage("Free crop")
            }
        };

        var tObj = this;

        $.each(config, function (key, value) {
            if (key.endsWith("AspectRatio")) {
                var text = config[key + "Text"];

                if (!text) {
                    text = key;
                }

                if (!value) {
                    value = "0,0";
                }

                aRatios[key] = {
                    "value": value,
                    "text": text
                };

                tObj.crops[key] = { text: text, cords : ''};
            }
        });

        var defaults = { "cropConfig": { "aspectRatios": aRatios } };
        config = CQ.Util.applyDefaults(config, defaults);

        MyClientLib.Html5SmartImage.superclass.constructor.call(this, config);
    },

    initComponent: function () {
        MyClientLib.Html5SmartImage.superclass.initComponent.call(this);

        var imgTools = this.imageToolDefs;
        var cropTool;

        if(imgTools){
            for(var x = 0; x < imgTools.length; x++){
                if(imgTools[x].toolId == 'smartimageCrop'){
                    cropTool = imgTools[x];
                    break;
                }
            }
        }

        if(!cropTool){
            return;
        }

        for(var x in this.crops){
            if(this.crops.hasOwnProperty(x)){
                var field = new CQ.Ext.form.Hidden({
                    id: x,
                    name: "./" + x
                });

                this.add(field);

                field = new CQ.Ext.form.Hidden({
                    name: "./" + x + "Text",
                    value: this.crops[x].text
                });

                this.add(field);
            }
        }

        var userInterface = cropTool.userInterface;

        this.on("loadimage", function(){
            var aRatios = userInterface.aspectRatioMenu.findByType("menucheckitem");

            if(!aRatios){
                return;
            }

            for(var x = 0; x < aRatios.length; x++){
                if(aRatios[x].text !== "Free crop"){
                    aRatios[x].on('click', function(radio){
                        var key = this.getCropKey(radio.text);

                        if(!key){
                            return;
                        }

                        if(this.crops[key].cords){
                            this.setCoords(cropTool, this.crops[key].cords);
                        }else{
                            var field = CQ.Ext.getCmp(key);
                            this.crops[key].cords = this.getRect(radio, userInterface);
                            field.setValue(this.crops[key].cords);
                        }
                    },this);
                }

                var key = this.getCropKey(aRatios[x].text);

                if(key && this.dataRecord && this.dataRecord.data[key]){
                    this.crops[key].cords = this.dataRecord.data[key];

                    var field = CQ.Ext.getCmp(key);
                    field.setValue(this.crops[key].cords);
                }
            }
        });

        cropTool.workingArea.on("contentchange", function(changeDef){
            var aRatios = userInterface.aspectRatioMenu.findByType("menucheckitem");
            var aRatioChecked;

            if(aRatios){
                for(var x = 0; x < aRatios.length; x++){
                    if(aRatios[x].checked === true){
                        aRatioChecked = aRatios[x];
                        break;
                    }
                }
            }

            if(!aRatioChecked){
                return;
            }

            var key = this.getCropKey(aRatioChecked.text);
            var field = CQ.Ext.getCmp(key);

            this.crops[key].cords = this.getRect(aRatioChecked, userInterface);
            field.setValue(this.crops[key].cords);
        }, this);
    },

    getCropKey: function(text){
        for(var x in this.crops){
            if(this.crops.hasOwnProperty(x)){
                if(this.crops[x].text == text){
                    return x;
                }
            }
        }

        return null;
    },

    getRect: function (radio, ui) {
        var ratioStr = "";
        var aspectRatio = radio.value;

        if ((aspectRatio != null) && (aspectRatio != "0,0")) {
            ratioStr = "/" + aspectRatio;
        }

        if (ui.cropRect == null) {
            return ratioStr;
        }

        return ui.cropRect.x + "," + ui.cropRect.y + "," + (ui.cropRect.x + ui.cropRect.width) + ","
            + (ui.cropRect.y + ui.cropRect.height) + ratioStr;
    },

    setCoords: function (cropTool, cords) {
        cropTool.initialValue = cords;
        cropTool.onActivation();
    }
});

CQ.Ext.reg("myhtml5smartimage", MyClientLib.Html5SmartImage);


7) To create the following image source paths...

/content/firstapp-demo-site/test/_jcr_content/par/image.img.png/2To1AspectRatio.jpg
/content/firstapp-demo-site/test/_jcr_content/par/image.img.png/9To1AspectRatio.jpg
/content/firstapp-demo-site/test/_jcr_content/par/image.img.png/5To1AspectRatio.jpg

Replace the code in /apps/imagecustomar/image/image.jsp with below code. This jsp renders the cropped custom aspect ratio images...

<%@include file="/libs/foundation/global.jsp" %>
<%@ page import="com.day.cq.wcm.foundation.Image,
                 java.io.PrintWriter" %>
<%@ page import="java.util.HashMap" %>
<%@ page import="java.util.Map" %>

<cq:includeClientLib js="mycomponent.imagear"/>

<%
    try {
        Resource res = null;

        if (currentNode.hasProperty("imageReference")) {
            res = resource;
        }

        if (res == null) {
%>
            Configure Image
<%
        } else {
            PropertyIterator itr = currentNode.getProperties();
            Property prop = null; String text = "";
            Map<String, String> aMap = new HashMap<String, String>();

            while(itr.hasNext()){
                prop = itr.nextProperty();

                if(prop.getName().endsWith("AspectRatio")){
                    text = prop.getName();

                    if(currentNode.hasProperty(prop.getName() + "Text")){
                        text = currentNode.getProperty(prop.getName() + "Text").getString();
                    }

                    aMap.put(prop.getName(), text);
                }
            }

            Image img = null; String src = null;

            if(aMap.isEmpty()){
%>
                Cropped Images with custom aspect ratios not available
<%
            }else{
                for(Map.Entry entry : aMap.entrySet()){
                    img = new Image(res);
                    img.setItemName(Image.PN_REFERENCE, "imageReference");
                    img.setSuffix(entry.getKey() + ".jpg");
                    img.setSelector("img");

                    src = img.getSrc();
%>
                    <br><br><b><%=entry.getValue()%></b><br><br>
                    <img src='<%=src%>'/>
<%
                }
            }
        }
    } catch (Exception e) {
        e.printStackTrace(new PrintWriter(out));
    }
%>

8) Add the following code to /apps/imagecustomar/image/img.GET.java. This java logic reads crop co-ordinates from CRX and outputs the image bytes to browser

package apps.imagecustomar.image;

import java.awt.*;
import java.io.IOException;
import java.io.InputStream;

import javax.jcr.RepositoryException;
import javax.jcr.Property;
import javax.servlet.http.HttpServletResponse;

import com.day.cq.commons.ImageHelper;
import com.day.cq.wcm.foundation.Image;
import com.day.cq.wcm.commons.AbstractImageServlet;
import com.day.image.Layer;
import org.apache.commons.io.IOUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;

public class img_GET extends AbstractImageServlet {
    protected Layer createLayer(ImageContext c) throws RepositoryException, IOException {
        return null;
    }

    protected void writeLayer(SlingHttpServletRequest req, SlingHttpServletResponse resp, ImageContext c, Layer layer)
                                throws IOException, RepositoryException {
        Image image = new Image(c.resource);
        image.setItemName(Image.NN_FILE, "image");
        image.setItemName(Image.PN_REFERENCE, "imageReference");

        if (!image.hasContent()) {
            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        layer = image.getLayer(false, false,false);

        String rUri = req.getRequestURI();
        String ratio = rUri.substring(rUri.lastIndexOf("/") + 1, rUri.lastIndexOf(".jpg"));
        String cords = c.properties.get(ratio, "");

        boolean modified = false;

        if(!"".equals(cords)){
            Rectangle rect = ImageHelper.getCropRect(cords, c.resource.getPath());
            layer.crop(rect);

            modified = true;
        }else{
            modified = image.crop(layer) != null;
        }

        modified |= image.resize(layer) != null;
        modified |= image.rotate(layer) != null;

        if (modified) {
            resp.setContentType(c.requestImageType);
            layer.write(c.requestImageType, 1.0, resp.getOutputStream());
        } else {
            Property data = image.getData();
            InputStream in = data.getStream();
            resp.setContentLength((int) data.getLength());
            String contentType = image.getMimeType();

            if (contentType.equals("application/octet-stream")) {
                contentType=c.requestImageType;
            }

            resp.setContentType(contentType);
            IOUtils.copy(in, resp.getOutputStream());
            in.close();
        }

        resp.flushBuffer();
    }
}

9) Finally, add the following properties in your CRX node /apps/imagecustomar/image/dialog/items/img





7 comments:

  1. Very interesting. I need to add functionality where when you upload a new image will be saved maps that were imposed on the old image. Is this possible and if so, where can I get information? I would be grateful for any informations.

    ReplyDelete
  2. Andrii, do you mean preserving the existing maps when new image is uploaded? http://experience-aem.blogspot.com/2014/03/aem-cq-56-html5smarimage-save-existing-image-maps.html

    ReplyDelete
    Replies
    1. Thank you so much! This is exactly what I was looking for!

      Delete
  3. Hi sreekanth this is a very interesting component and is just what I need, but I'm having troubles to make it work. basically the "Image crop tools" menu only shows the "Free crop" option, and because the "getCropKey" function return null for "Free crop" everything starts to fail. Do you know what could be happening that the crop menu is not being populated with the other crop options?

    ReplyDelete
    Replies
    1. hello Sebastian, have you specified the custom aspect ratios in /apps/imagecustomar/image/dialog/items/img ( check the last screenshot in blog post above)

      Delete
    2. yes! I have it just like the screenshot, I'm using CQ 5.6.1 could that be a problem? I'm debugging the JS and everything looks fine, at least in the constructor the config has all the crop options. MyClientLib.Html5SmartImage.superclass.constructor.call(this, config);

      In the 'loadimage' and 'contentchange' functions, this line var aRatios = userInterface.aspectRatioMenu.findByType("menucheckitem"); always returns an array with only the free crop option.

      Delete
  4. hi in the touch ui, we have pre define list of aspect ratio but how do we add custom aspect ratios to it

    ReplyDelete