Reading an image file’s dimensions into JSON from a worker

I’m trying to use fetch() to retrieve information on an image file. I see there’s a fetch option “format:json” that’s supposed to return a JSON object with image sizing information. This option is documented at Resize with Cloudflare Workers · Cloudflare Image Optimization docs

I have requested “format: ‘json’” in the fetch() options, but the function is still returning image data. The response.json() method then fails due to a JSON formatting issue. When I try response.text() instead, I see a console error and content that makes it clear the output is JPEG.

Here’s my code:

const options_json = { cf: { image: {} } };
options_json.cf.image.format = 'json';
let response = await fetch('https://domain.com/image/test.jpg', options_json);
if (response.ok || response.status == 304) {
	console.log('Fetch JSON response: '+await response_json.text());
}

Here is the error message and the first several bytes of the output, which shows it’s outputting a JPEG instead of the JSON that I requested:

Called .text() on an HTTP body which does not appear to be text. The body's Content-Type is "image/jpeg". The result will probably be corrupted. Consider checking the Content-Type header before interpreting entities as text.

Fetch JSON response: ����ExifII...

It actually doesn’t seem to matter what I set the “format” parameter to, I get the same results with options_json.cf.image.format = ‘webp’ or options_json.cf.image.format = ‘avif’

When doing image resizing from the CDN URL (without a worker), I can include the “format=json” parameter, and it does return JSON as expected:

https://domain.com/cdn-cgi/image/width=268,height=268,format=json/image/test.jpg

Is it possible the “format: json” option for fetch() has not actually been implemented? Maybe I’m just missing something completely obvious. I can’t find any specific examples of a worker image resizing call with the JSON output option, or this might be easier.

Thanks in advance!

I’m able to replicate this with my testing too.

In the docs here I do see a note on format that reads:

At the moment, this setting only works directly with Image Resizing.

And that links to the URL format version of Image Resizing, so maybe this is expected behaviour?

Let me poke a few people on the team and see if I can get any more info about this.

By golly, I think you’re right! I never took a second look at that “Image Resizing” note before, because that term “Image Resizing” is so vague. When I read it I thought “This whole page is about image resizing, so why are they making this statement?”

The link’s text should really be “URL Image Resizing”:

Good that you looked at precisely where that link was going!

Maybe my worker could pull the URL-based approach to get the same info. What I’m trying to do is get the original image’s size and aspect ratio first, in order to make better choices about the subsequent resizing operation. However, fetching the original image twice like this seems like a lot of waste.

Thanks!

Did anything come of that poking?

I’ve since discovered that if I know the origin image’s dimensions, I can force some padding at the bottom of the resized image. This would allow me to an overlay a caption image in the padding area, and I effectively have a burned-in caption. I don’t think there’s any direct way to change the image’s canvas area with the worker image resizer.

Nothing yet. I did do some more testing and wasn’t actually able to replicate this once I moved to a real zone. Here’s the basic script I used (not production hardended at all):

export default {
  async fetch(request) {
	const getUrl = new URL(request.url)
	if(!getUrl.searchParams.has('url')) {
		return new Response('Missing URL', { status: 400 })
	}

	const options = { cf: { image: {} } }
	if (getUrl.searchParams.has("format")) options.cf.image.format = getUrl.searchParams.get("format")

	const imageURL = new URL(getUrl.searchParams.get('url'))
	const imageRequest = new Request(imageURL, {
		headers: request.headers
	})
	return fetch(imageRequest, options)
  },
};

And then URLs formatted like: http://localhost:8787/?url=https://placedog.net/200/262&format=json

  • If I run with wrangler dev it works perfectly, and I see something like {"width":200,"height":262,"original":{"file_size":14823,"width":200,"height":262,"format":"image/jpeg"}} for the JSON output, and an image if I do jpge, etc.
  • And if I hit the prod URL at https://images.example.com/?url=https://placedog.net/200/262&format=json that also works fine.

Can you share a full worker to replicate this?

Thank you so much for trying, and I’m impressed you got it to work.

It’s quite large, but maybe the devil is in the details of what I did not show before.

I’ve include the whole worker below. I’m basically rolling my own URL-based parameter system, with some built-in presets. Much of my worker’s early code is doing all that parsing and handling. For now, the attempted JSON output is only sent to the console, but it would eventually be used in the overlay logic.

/**
 * https://my-worker.image.workers.dev
 * Fetch and log a request
 * @param {Request} request
 * From https://github.com/fransallen/image-resizing/blob/master/index.js
 */
 
addEventListener("fetch", (event) => {
	event.respondWith(
		handleRequest(event.request).catch(
			(err) => new Response(err.stack, { status: 500 })
		)
	);
});

async function handleRequest(request) {

	// Get URL of the original (full size) image to resize.
	const ORIGIN = 'https://www.domain.com/'; // origin of the images, with trailing slash

	// Parse request URL to get access to query string
	const url = new URL(request.url);

	const path = url.pathname;  // get path and file part of URL
	console.log('path: '+path);

    if (path == '/favicon.ico')
    {   // if request is for the favicon, silently redirect to main site's favicon
    	let response = await fetch(ORIGIN+'favicon.ico');
    	response = new Response(response.body, response);
		response.headers.set(
			'Cache-Control',
			'public, max-age=0, immutable',
		);
		response.headers.set('Vary', 'Accept');
		return response;

        // a 301 redirect is a waste of client work
        // return Response.redirect(ORIGIN+'favicon.ico', 301);
    }

	// Cloudflare-specific options are in the cf object.
	const options = { cf: { image: {} } };

    // Set defaults
	options.cf.image.width = 600;   // default to something not too monstrous
	options.cf.image.fit = 'scale-down';
	options.cf.image.sharpen = 0.6;
	options.cf.image.quality = 95;
	options.cf.image.background = '#FFFFFF';
    
	// console.log(url);

	// Copy parameters from query string to request options.
	// You can implement various different parameters here.
	if (url.searchParams.has('w'))
		options.cf.image.width = url.searchParams.get('w');
	if (url.searchParams.has('h'))
		options.cf.image.height = url.searchParams.get('h');
	if (url.searchParams.has('f'))
		options.cf.image.format = url.searchParams.get('f');
	if (url.searchParams.has('q'))
		options.cf.image.quality = url.searchParams.get('q');
	if (url.searchParams.has('bg'))
		options.cf.image.background = url.searchParams.get('bg');
	if (url.searchParams.has('dpr'))
		options.cf.image.dpr = url.searchParams.get('dpr');
	if (url.searchParams.has('fit'))
		options.cf.image.fit = url.searchParams.get('fit');
	if (url.searchParams.has('rotate'))
		options.cf.image.rotate = url.searchParams.get('rotate');
	if (url.searchParams.has('sharpen'))
		options.cf.image.sharpen = url.searchParams.get('sharpen');
	if (url.searchParams.has('gravity'))
		options.cf.image.gravity = url.searchParams.get('gravity');
	if (url.searchParams.has('metadata'))
		options.cf.image.metadata = url.searchParams.get('metadata');

    const re = /^\/?(.*)\/(([0-9]+)(-[0-9])?\.jpg)$/;
    const pathparts = re.exec(url.pathname);    // get slashy path and ending JPG filename
	// console.log('pathparts: '+pathparts);

    let pathslashes = pathparts[1];   // get slashy parts of path
    const pathfile = pathparts[2];      // get ending filename
	// console.log('pathfile: '+pathfile);

    pathslashes = pathslashes.split('/');   // get array of slashy parameters in form: /param/
     console.log('pathslashes: '+pathslashes);

    // first look for presets   
    if (/^[A-Z]+[^-]/i.test(pathslashes[0])) // if start with a letter but no hyphen
    {
        const preset = pathslashes.shift(); // extract preset
        console.log('Found preset: '+preset);
        switch(preset)                      // dispatch on preset
        {
        case 'thumb122':                    // smallest listing thumbs
            options.cf.image.width = undefined;
            options.cf.image.height = 122;
            break;
        case 'thumb140':                    // small thumbs on item page
            options.cf.image.width = undefined;
            options.cf.image.height = 140;
            break;
        case 'medium':                      // common medium image
            options.cf.image.width = 268;
            break;
        case 'square268':                   // square cropped for PCGS mailing
            options.cf.image.width = 268;
            options.cf.image.height = 268;
            options.cf.image.fit = 'crop';
            options.cf.image.gravity = 'bottom';
            break;
        default:
        } 
    }

    // look for width and height
    if (/^([0-9])+$/.test(pathslashes[0]))      // if first match is a number, it's a width
    {
        options.cf.image.width = pathslashes.shift();      // extract width
        console.log('Found width: '+options.cf.image.width);
        if (options.cf.image.width == 0) options.cf.image.width = undefined;
    }
    if (/^([0-9])+$/.test(pathslashes[0]))      // if second match is a number, it's a height
    {
        options.cf.image.height = pathslashes.shift();     // extract height
        console.log('Found height: '+options.cf.image.height);
        if (options.cf.image.height == 0) options.cf.image.height = undefined;
    }

    // look in rest of URL for "p-" type parameters
    let param;
    for (let pathslash of pathslashes) {
        console.log(pathslash);

        if ((param = /^([A-Z]+)-(.*)$/i.exec(pathslash)))
        {   // if start with letters and hyphen parameter delim
            console.log('Found param: '+param);
            let name = param[1].toLowerCase();  // get parameter name in lower case
            let value = param[2];               // get paramter value
            switch(name)                        // dispatch on param name
            {
            case 'w':
                options.cf.image.width = value;
                break;
            case 'h':
                options.cf.image.height = value;
                break;
            case 'f':
                options.cf.image.fit = value;
                break;
            case 'g':
                options.cf.image.gravity = value;
                break;
            case 't':
                options.cf.image.trim = value;
                break;
            case 'q':
                options.cf.image.quality = value;
                break;
            case 'm':
                options.cf.image.metadata = value;
                break;
            case 'b':
                options.cf.image.background = value;
                break;
            case 'r':
                options.cf.image.rotate = value;
                break;
            case 's':
                options.cf.image.sharpen = value;
                break;
            default:
            } 
        }
    }

	const imageURL = ORIGIN + 'getrawimage.php?id=' + pathfile;	// get origin server's full path to JPG image
	 console.log('imageURL: '+imageURL);
	 console.log('Size: '+options.cf.image.width+' x '+options.cf.image.height);

	// Build a request that passes through request headers,
	// so that automatic format negotiation can work.
	const imageRequest = new Request(imageURL, {
		headers: request.headers,
	});

    // pre-fetch to find image info... doesn't work
    const options_json = { cf: { image: {} } };
    options_json.cf.image.format = 'json';
    let response_json = await fetch(imageRequest, options_json);
    console.log('Fetch JSON response ok: '+response_json.ok+', status: '+response_json.status);

    if (response_json.ok || response_json.status == 304) {
        console.log('Fetch JSON function response: '+JSON.stringify(response_json));
        // let json = await response_json.json();
        console.log('Fetch JSON body response: '+await response_json.text()); // JSON.stringify()
    }

	// Returning fetch() with resizing options will pass through response with the resized image.
	let response = await fetch(imageRequest, options);

    // console.log('Fetch JSON body response: '+await response.text()); // JSON.stringify()
	// Reconstruct the Response object to make its headers mutable.
    response = new Response(response.body, response);

	if (response.ok || response.status == 304) {
		// Set cache for 1 year
			'Cache-Control',
		// 'public, max-age=31536000, immutable',
	    // console.log('Fetch/resize image response: '+JSON.stringify(response));
		response.headers.set(
			'Cache-Control',
			'public, max-age=0, immutable',
		);

		// Set Vary header
		response.headers.set('Vary', 'Accept');

		return response;
	} else {
		return new Response(
			`Could not fetch the image — the server returned HTTP error:
status: ${response.status}
statusText: ${response.statusText}
headers: ${JSON.stringify(response.headers)}
redirected: ${response.redirected}
url: ${response.url}
webSocket: ${response.webSocket}

... in response to a request to ${imageURL}`,
			{
				status: 400,
				headers: {
					'Cache-Control': 'no-cache',
				},
			},
		);
	}
}