5

I've seen a lot of questions around this, but not exactly this question. Within apex code (not using VF so JavaScript isn't an option), how can one find the dimensions of an image given its URL?

Some use cases are:

How do other people deal with this? I've written my own code (see below), but I'm also curious to see what other answers you all have.

7

This is my approach. The most glaring inefficiency is calling convertToHex on the image blob. It only knows about PNG, JPEG, and GIF. This is code I've pieced together by reading file format specs, so it's quite possible that I've over-simplified things a bit and it may fail for certain images (it works on the few I tested). JPEG in particular is much harder to parse than PNG or GIF... it's likely that there are some JPEG encoders that will write an image that my code can't parse.

I left in imgtest() to show examples on how it may be used.

global class ImageSizer {
    class ImageInfo {
        public boolean success;
        public string format;
        public integer width;
        public integer height;
    }
    
    public static void imgtest() {
        Case caserec = [select id, description_rt__c from case where id = '5000j000004NowzAAC'];
        string descrt = caserec.description_rt__c;
        pattern imgpatt = pattern.compile('<img .*?src="(.+?)"');
        matcher imgmatch = imgpatt.matcher(descrt);
        while(imgmatch.find()) {
            string url = imgmatch.group(1);
            url = url.unescapeHtml4();
            system.debug('foung img src: ' + url);            
            ImageInfo imginfo = getImageSize(url);
            if(imginfo.success) {
                system.debug('format: ' + imginfo.format);
                system.debug('size: ' + imginfo.width + 'x' + imginfo.height);
            } else {
                system.debug('failed to get image metadata');
            }
        }
        ImageInfo imginfo = getImageSize('https://upload.wikimedia.org/wikipedia/en/thumb/c/c1/The_Matrix_Poster.jpg/220px-The_Matrix_Poster.jpg');
        system.debug('Jpeg test1: ' + imginfo);
        imginfo = getImageSize('https://upload.wikimedia.org/wikipedia/commons/thumb/a/a4/Anatomy_of_a_Sunset-2.jpg/1280px-Anatomy_of_a_Sunset-2.jpg');
        system.debug('Jpeg test2: ' + imginfo);
        imginfo = getImageSize('https://methode--amigo--c.documentforce.com/sfc/servlet.shepherd/version/renditionDownload?rendition=ORIGINAL_Jpg&versionId=0680j000000yGrO&operationContext=CHATTER&contentId=05T0j000002oIQF');
        system.debug('Jpeg test3: ' + imginfo);
        imginfo = getImageSize('https://upload.wikimedia.org/wikipedia/commons/b/bb/Quilt_design_as_46x46_uncompressed_GIF.gif');
        system.debug('GIF test: ' + imginfo);
        imginfo = getImageSize('https://upload.wikimedia.org/wikipedia/commons/1/11/B%C3%B6dele_Bregenzerwald_Panorama.jpg');
        system.debug('Pano test (2.5MB): ' + imginfo);
        imginfo = getImageSize('https://www.gstatic.com/webp/gallery/1.webp');
        system.debug('WebP lossy: ' + imginfo);
        imginfo = getImageSize('https://www.gstatic.com/webp/gallery3/2_webp_ll.webp');
        system.debug('WebP lossless: ' + imginfo);
        imginfo = getImageSize('https://methode--amigo--c.visualforce.com/resource/1595887321000/webptest_vp8x');
        system.debug('WebP with VP8X: ' + imginfo);
        imginfo = getImageSize('http://res.cloudinary.com/demo/image/upload/fl_awebp/bored_animation.webp');
        system.debug('WebP animation: ' + imginfo);
        
        
        // 29.5MB image give heap overflow error
        //imginfo = getImageSize('https://upload.wikimedia.org/wikipedia/commons/e/ea/Panorama_from_poonhill-2019-BJ.jpg');
        //system.debug('Big jpeg test: ' + imginfo);
    }
    
    public static ImageInfo getImageSize(string url) {
        pagereference page = new pagereference(url);
        blob imgdata = page.getContent();
        // Can't substring a blob, have to do the ugly and double the size as a hex string
        // When parsing the hex string, all offsets must be doubled, requiring *2 below
        string imgstring = EncodingUtil.convertToHex(imgdata);
        ImageInfo imginfo = new ImageInfo();
        imginfo.success = false;
        imginfo.format = 'UNKNOWN "' + imgstring.substring(0,16) + '"';
        imginfo.width = 0;
        imginfo.height = 0;
        
        if (imgstring.substring(0,8) == '89504e47') {
            // PNG
            imginfo.success = true;
            imginfo.format = 'PNG';
            imginfo.width = hexToInt(imgstring.substring(16*2,20*2));
            imginfo.height = hexToInt(imgstring.substring(20*2,24*2));
        } else if (imgstring.substring(0,4) == 'ffd8') {
            // JPEG
            // Might need a lot of the imgstring, but don't want to pass it by value
            // due to limited heap space.  Instead, just parse it all here.
            imginfo.format = 'JPEG';
            imginfo.width = 0;
            imginfo.height = 0;
            imginfo.success = false;
            integer offset = 2 *2; // Skip past the SOI marker that we already found
            while (offset < imgstring.length()) {
                if (imgstring.substring(offset, offset+3) == 'ffc') { // SOFn
                    imginfo.success = true;
                    imginfo.height = hexToInt(imgstring.substring(offset+5*2, offset+7*2));
                    imginfo.width = hexToInt(imgstring.substring(offset+7*2, offset+9*2));
                    break;
                } else if (imgstring.substring(offset, offset+2*2) == 'ffd8' // SOI (may appear after thumbnail)
                        || imgstring.substring(offset, offset+2*2) == 'ffd9' // EOF (maybe end of thumbnail, keep going)
                    ) {
                    offset += 2 *2;
                } else if (imgstring.substring(offset, offset+2) == 'ff') {
                    // Most blocks are variable length, find the length and skip it
                    integer blocklen = hexToInt(imgstring.substring(offset+4, offset+8));
                    offset += (blocklen+2) * 2;
                } else { // Not a header, either bad data or bad parsing, stop reading.
                    break;
                }
            }
        } else if (imgstring.substring(0, 8) == '47494638') {
            // GIF
            imginfo.success = true;
            imginfo.format = 'GIF';
            imginfo.width  = hexToInt(imgstring.substring(7*2,  8*2) + imgstring.substring(6*2, 7*2));
            imginfo.height = hexToInt(imgstring.substring(9*2, 10*2) + imgstring.substring(8*2, 9*2));
        } else if (imgstring.substring(0, 8) == '52494646' && imgstring.substring(16,24) == '57454250') {
            // WebP
            integer offset = 12*2; // Skip past the RIFF header and WEBP identifier (already matched above)
            imginfo.format = 'WebP';
            while (offset < imgstring.length()) {
                string chunk = imgstring.substring(offset, offset + 4*2);
                offset += 4*2;
                if (chunk == '56503820') {
                    // "VP8 " - lossy
                    imginfo.success = true;
                    // 16-bit unsigned, little endian, with 2 bits reserved.
                    imginfo.width  = hexToInt(imgstring.substring(offset+11*2, offset+12*2) + imgstring.substring(offset+10*2, offset+11*2)) & 16383;
                    imginfo.height = hexToInt(imgstring.substring(offset+13*2, offset+14*2) + imgstring.substring(offset+12*2, offset+13*2)) & 16383;
                    break;
                } else if (chunk == '5650384c') {
                    // "VP8L" - lossless
                    imginfo.success = true;
                    // Horrible: https://developers.google.com/speed/webp/docs/webp_lossless_bitstream_specification
                    imginfo.width  = hexToInt(imgstring.substring(offset+5*2, offset+6*2)) + ((hexToInt(imgstring.substring(offset+6*2, offset+7*2)) & 63) << 8) + 1;
                    imginfo.height = (hexToInt(imgstring.substring(offset+6*2, offset+7*2)) >> 6) + (hexToInt(imgstring.substring(offset+7*2, offset+8*2)) << 2) + ((hexToInt(imgstring.substring(offset+8*2, offset+9*2)) & 15) << 10) + 1;
                    break;
                } else if (chunk == '414e4d46') {
                    // "ANMF" - don't skip it, find the size of the 1st frame inside
                    offset += 20*2;
                } else {
                    // Skip all other chunks
                    integer chunklen = hexToInt(imgstring.substring(offset+3*2,offset+4*2) + imgstring.substring(offset+2*2,offset+3*2) + imgstring.substring(offset+1*2,offset+2*2) + imgstring.substring(offset,offset+1*2));
                    offset += (chunklen+4)*2;
                }
            }
        }
        return imginfo;
    }
    
    public static final List<String> hex = '0123456789abcdef'.split('');
    public static Integer hexToInt(String str) {
        str = str.toLowerCase();
        String[] hexValue = str.split('');
        Integer retVal = 0;
        for(Integer i = 0; i < hexValue.size(); i++) {
            retVal = (retVal << 4) + hex.indexOf(hexValue[i]);
        }
        return retVal;
    }
}

Credit for hexToInt() goes to Henk3000 for his answer to this question.

Edit: now supports WebP images too (ugh, lots of bit twiddling).

1
  • +1 I really love seeing solutions where we make Apex do things it's not really supposed to be able to do.
    – sfdcfox
    Jul 25 '20 at 18:53
3

Depending on the amount of processing to do monthly, I would outsource it. Set up a NodeJS project on Heroku (the $5/month package would be ideal to avoid sleep/wakeup cycles, but the free tier is enough for development), or AWS EB/lambda/etc.

Write an Express server (npm install express --save-prod) with image-parser (npm install image-parser --save-prod). Have it accept a POST and return the metadata. You could skip downloading the file directly and just have that server do the callout for you (or include it in the response) to minimize the callout size and fit easier within governor limits.

The downside is that you can't do this in triggers, but the upside is that it works everywhere else and should always work, as opposed to rolling your own processor that may fail on edge cases.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.