DEV Community

Aaron Powell for Microsoft Azure

Posted on • Originally published at aaron-powell.com on

Generating Images with Azure Functions

Since I converted my blog to a static website whenever posts were shared on social media, which I automatically do via my RSS feed, you'd see something like this in your timeline.

Massive Aaron picture in timeline

It looked no better in Slack.

Massive Aaron picture in Slack

Now sure, I'm a handsome guy and that is a great photo if I do say so myself, but do you really need a massive picture of me to adorn a timeline? After all, it doesn't do much to relate back to the post in question does it?

So when I decided to embark on the rebuilt of my website recently this was one of the things I wanted to fix was that.

Open Graph Protocol

Before looking into how I do things better let's just look at what we're working with. When you share links on social sites such as Twitter, Facebook, LinkedIn, etc. they will look for some additional metadata on the page called the Open Graph Protocol. This protocol started its life in Facebook as a way to embed more information into the social graph that they generate around anything shared on the platform, but over time it's become a bit of a defacto standard in social graph markup so other sites have adopted it.

Specifically the image comes from the <meta property="og:image" content="..." /> meta property, and there are a number of other meta properties that are useful to markup the social graph.

So when a link is shared the service will request the HTML, look for this metadata and if it exists, embed it in a “card” to make your link more appealing (at least, in theory).

A Better Open Graph Image

In my original theme the og:image was hard coded so that every page used the same image file, which I'm sure was fine for how the theme author intended the theme to be used, but in my usage of it, it was sub-optimal.

I've been spending quite a bit of time on DEV this past year and one thing I like about it is that when you share a link there's a really nice og:image.

Example of DEV.to social links

You can also optionally upload an image to be the “cover image” which will become the og:image so it doesn't use the generated card but something you can control.

I decided that I wanted to try and replicate this, allow for me to control the og:image and if there isn't one, generate a title card like DEV does.

Dynamic Static Images

Everything I need for my website lives in the git repo, including all images. This does mean that the repo is getting larger over time as I blog more and add more rich media to the posts, so I wanted to be careful about how I added these new og:image images. I also didn't want to make the build pipeline more complex, I think my GitHub Actions process is really quite slick, so I decided that I'd make an API endpoint to generate the images for me, or return a previously generated one, so let's take a look at how to do that.

Generating Images in .NET

I decided I'd create an Azure Function in F# to handle this problem and to generate the images I'd use the open source ImageSharp library (I've used the 1.0.0-beta0009 and 1.0.0-beta0007 packages which are the latest across a few of the dependencies I needed at the time of writing).

ImageSharp is a very low-level image manipulation library and can be used to work with existing images allowing you to edit, crop, transform, etc. but in this post we're going to create the image entirely via code as it gives us the flexibility to tweak it over time.

Building Our Function

Let's start by defining a HTTP Trigger Function and combine it with a Blob input binding that will give access to the previously generated image (if there is one). The HTTP binding will have an ID for the post we're generating the image for:

module BlogCardGenerator =
    [<FunctionName("BlogCardGenerator")>]
    let run ([<HttpTrigger(AuthorizationLevel.Function, "get", Route = "title-card/{id}")>] req: HttpRequest)
        ([<Blob("title-cards/{id}.png", FileAccess.ReadWrite, Connection = "ImageStorage")>] postImage: ICloudBlob)
        (id: string) (log: ILogger) =
        NotFoundResult()
Enter fullscreen mode Exit fullscreen mode

With the scaffolding in place, let's start building our image generator!

Limiting Abuse

We're creating an unsecured API endpoint that allows people to provide a bit of data to via a GET request which will generate an image that is stored in storage. This doesn't sound like the kind of thing that could be abused does it… Since the og:image URL needs to be unsecured we needed to think of a creative way to validate that what is being requested is actually for a post on my blog. But since it's all markdown files in GitHub how can we validate that?

It turns out that that isn't too hard thanks to the work done to create my search app. For this to work we generate a JSON file that is deployed as part of my website (although it doesn't need to be anymore, but I'm just too lazy to exclude it), so my website has a list of all posts in JSON (there also exists a list in XML which is the RSS output that could be used instead) and that's what we can use to validate against.

Also, the advantage of having this “database” available is that we don't need to pass anything more than an ID to the post in the API call, since all the metadata is in the JSON file anyway.

We'll use the JSON Type Provider to create a strongly typed way of accessing the blog posts. In a new file called RequestValidator.fs we'll create the module:

module RequestValidator

open FSharp.Data

[<Literal>]
let JsonExample = """
{
    "posts": [{
        "title": "Post Title",
        "date": "Tue, 17 Dec 2019 08:50:14 +1100",
        "tags": ["tag", "tag2"],
        "id": "Test"
    }]
}
"""

type Blog = JsonProvider<JsonExample>

let getBlogMetadata() = Blog.AsyncLoad "https://www.aaron-powell.com/index.json"

let tryFindPost (id: string) (blogs: Blog.Post array) = blogs |> Array.tryFind (fun blog -> blog.Id = id)
Enter fullscreen mode Exit fullscreen mode

A string represents the structure of the JSON (so the Type Provider knows what types to generate) and then two functions are exposed from the module, getBlogMetadata which downloads the JSON using the Type Provider to give us the object graph and tryFindPost which will return an Option when matching the blog by the provided ID.

Time to incorporate this into the Function:

[<FunctionName("BlogCardGenerator")>]
let run ([<HttpTrigger(AuthorizationLevel.Function, "get", Route = "title-card/{id}")>] req: HttpRequest)
    ([<Blob("title-cards/{id}.png", FileAccess.ReadWrite, Connection = "ImageStorage")>] postImage: ICloudBlob)
    (id: string) (log: ILogger) =
    async {
        log.LogInformation <| sprintf "ID: %s" id

        let! blogData = getBlogMetadata()
        match tryFindPost id blogData.Posts with
        | Some post -> return OkResult() :> IActionResult
        | None -> return NotFoundResult() :> IActionResult
    }
    |> Async.StartAsTask
Enter fullscreen mode Exit fullscreen mode

Since getBlogMetadata is an async function we have wrapped the function in an async computation expression and used let! to invoke the method. But the Functions host doesn't understand the F# async API so we have to convert it back to a Task, and that's done with Async.StartAsTask. Once the metadata is downloaded it's converted into the object structure we expect and our tryFindPost method is called with a match expression to either go does a happy path Some post or an unhappy path None, with the latter to be used when the API is being called by someone trying to be tricky. Lastly, we have to cast the result to a common base type, which IActionResult is ideal for, as F# doesn't do implicit casting, so we have to ensure that every return is returning the same type.

Checking Image Cache

We don't want to generate the images every time, it'd be much more preferable to only generate the images once and then future requests can use that image. This is where the Blob input binding comes in. To use this binding we need to provide it with:

  • The name of the Blob to bind to (title-cards/{id}.png)
    • This can use a binding expression that's shared with the Trigger action, so in this case we can use the ID that comes in on the HTTP trigger on the Blob binding
  • Access Mode, in our case we need it to be FileAccess.ReadWrite
  • The name of the connection string
    • This is optional, it'll use the storage account of the Function if not provided

This is then bound to a type of ICloudBlob which gives us an API to query information about the Blob as well as grab a copy or upload to it, think of it as a proxy container.

Since the Blob might not exist though we need to check that first, and if it does, send it in the response, otherwise we'll generate a new image.

let downloadImage (postImage: ICloudBlob) =
    async {
        let ms = new MemoryStream()
        do! postImage.DownloadToStreamAsync ms |> Async.AwaitTask
        ms.Position <- int64 0
        return ms
    }

[<FunctionName("BlogCardGenerator")>]
let run ([<HttpTrigger(AuthorizationLevel.Function, "get", Route = "title-card/{id}")>] req: HttpRequest)
    ([<Blob("title-cards/{id}.png", FileAccess.ReadWrite, Connection = "ImageStorage")>] postImage: ICloudBlob)
    (id: string) (log: ILogger) =
    async {
        log.LogInformation <| sprintf "ID: %s" id

        let! blogData = getBlogMetadata()
        match tryFindPost id blogData.Posts with
        | Some post ->
            let! exists = postImage.ExistsAsync() |> Async.AwaitTask

            if exists then
                log.LogInformation "Image existed"
                let! ms = downloadImage postImage
                return FileStreamResult(ms, "image/png") :> IActionResult
            else
                // todo: generate image
                return OkResult() :> IActionResult
        | None -> return NotFoundResult() :> IActionResult
    }
    |> Async.StartAsTask
Enter fullscreen mode Exit fullscreen mode

On line 19 we use ExistsAsync on the ICloudBlob to see if the blob we've requested from storage exists already and if it does the downloadImage function creates a new MemoryStream that the ICloudBlob will be streamed into using the DownloadToStringAsync function so that we can return the stream in the FileStreamResult on line 24 (on line 5 we reset the stream position to 0 so the response can read it). If it doesn't exist, we'll need to create the image using ImageSharp.

Creating Our Image

We've made a request for an image but it turns on that it doesn't exist yet, guess we'd better try and generate one, but how do we go about doing that? First, we need to think about what the image we want to generate looks like. Let's look back at the DEV image we want to replicate:

Example of DEV.to social links

It's a rectangle with rounded corners top left and right, a drop shadow, the title of the post with the author's name below in slightly smaller font and some other little images. I'm going to ditch the avatar of myself (useful on DEV since many people blog, but not on my site) and I'm going to ditch the icons since I'm lazy.

Drawing A Box With Rounded Corners

Seems like a pretty straight forward requirement, draw a box with two rounded corners, but of course it wont be that simple. To do this with ImageSharp we'll use the SixLabors.Shapes NuGet package (1.0.0-beta0009 version at the time of writing) as it gives us some primitives to work with such as RectangularPolygon and EllipsePolygon.

The reason that we'll need these two types is that a box with rounded corners isn't a primitive shape, those are rectangles (or squares if the height and width match), ellipse (or a circle if the height and width match), lines, triangles, things like that. This means we'll need to get tricky and layer a few shapes on top of each other.

The pieces of a box with rounded corners

This image shows what we're going to have to create and I've coloured each of the main components. In our solution we'll colour them the same, but this helps understand the problem.

Let's create a new file called ImageCreator.fs and start scaffolding out how we'll make the box (I'll omit the open statements for brevity):

module ImageCreator

let generateBox (colour: Color) (width: float32) (height: float32) (image: Image<Rgba32>) =
    image
Enter fullscreen mode Exit fullscreen mode

There's a lot of arguments to this function, some of them are self explanatory (width, height, colour) and then there's some like image which is the drawing canvas from ImageSharp.

Now we can draw what I call the “inner box”, which is the yellow one from the image above, and this is skinnier than the full box by the radius of the rounded corner.

module ImageCreator

let cornerRadius = 10.f
let gutter = 20.f

let generateBox (colour: Color) (width: float32) (height: float32) (image: Image<Rgba32>) =
    let xStart = gutter + cornerRadius
    let yStart = gutter + cornerRadius

    image.Mutate
        (fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             RectangularPolygon(xStart, yStart, width - xStart - gutter - cornerRadius, height - yStart - gutter)) |> ignore)


    image
Enter fullscreen mode Exit fullscreen mode

With ImageSharp we use a Mutate function on the Image object which we provide a function that takes a IImageProcessingContext (called ctx in the above snippet) that we can manipulate the image from. Because ImageSharp is designed for C# it's a bit clunky to work with in F# due to the chained API and that F# does implicit returns not explicit like C#, so we'll use a lot of |> ignore pipes.

From the ctx we will call Fill and provide GraphicsOptions (which we can control the antialiasing of the image) and the colour, then we can start to draw on the canvas with something, in this case, a RectangularPolygon. For the generated image let's set a 20px gutter around it, so we start the rectangle at the coordinates 30x 30y so that we can have a 10px rounded corner that sits above this box. This is because if the box started at the same x/y as the rounded corner you'd see the corner of the box poking through underneath, so it has to have a slight offset. If you wanted a more dynamic corner sizing then you might pass in the radius of the circle and calculate the x/y offset. Next we need the height and width of the rectangle; height is simple, it's the total canvas height (height) less our gutter (20px) less the y starting position (30px), so height - gutter - yStart; width is similar, except we also need to subtract the corner radius, so it is width - xStart - gutter - cornerRadius.

Congratulations, you've drawn a rectangle!

Now it's time to add our rounded corners, which we'll do by creating some circles.

module ImageCreator

let cornerRadius = 10.f
let gutter = 20.f

let generateBox (colour: Color) (width: float32) (height: float32) (image: Image<Rgba32>) =
    let xStart = gutter + cornerRadius
    let yStart = gutter + cornerRadius

    image.Mutate(fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             RectangularPolygon(xStart, yStart, width - xStart - gutter - cornerRadius, height - yStart - gutter))
        |> ignore)

    // rounded corner - left top
    image.Mutate(fun ctx ->
        ctx.Fill(GraphicsOptions(true), colour, EllipsePolygon(xStart, yStart, cornerRadius * 2.f, cornerRadius * 2.f))
        |> ignore)

    // rounded corner - right top
    image.Mutate(fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             EllipsePolygon(width - xStart, yStart, cornerRadius * 2.f, cornerRadius * 2.f)) |> ignore)

    image
Enter fullscreen mode Exit fullscreen mode

We're now using EllipsePolygon to generate a an ellipse with an equal width and height to make it a circle not an oval, and we'll do it twice, one for each corner. We provide an x & y position for the centre of the ellipse, rather than the top-left corner like a rectangle, so that width and height can be appropriately applied. For our left corner we'll use the same xStart and yStart to draw outwards from and then specify the height and width is double the radius, since these values represent the diameter. With the circles drawn our image will look like this:

Partially complete box

Awesome! Now all we need to do is fill in the gaps between the circles and the inner box then everything should be set!

module ImageCreator

let cornerRadius = 10.f
let gutter = 20.f

let generateBox (colour: Color) (width: float32) (height: float32) (image: Image<Rgba32>) =
    let xStart = gutter + cornerRadius
    let yStart = gutter + cornerRadius

    image.Mutate(fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             RectangularPolygon(xStart, yStart, width - xStart - gutter - cornerRadius, height - yStart - gutter))
        |> ignore)

    // rounded corner - left top
    image.Mutate(fun ctx ->
        ctx.Fill(GraphicsOptions(true), colour, EllipsePolygon(xStart, yStart, cornerRadius * 2.f, cornerRadius * 2.f))
        |> ignore)

    // rounded corner - right top
    image.Mutate(fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             EllipsePolygon(width - xStart, yStart, cornerRadius * 2.f, cornerRadius * 2.f)) |> ignore)

    // left gutter
    image.Mutate
        (fun ctx ->
        ctx.Fill(GraphicsOptions(true), colour, RectangularPolygon(gutter, yStart, cornerRadius, height - yStart - gutter))
        |> ignore)

    // right gutter
    image.Mutate
        (fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             RectangularPolygon(width - xStart, yStart, cornerRadius, height - yStart - gutter)) |> ignore)

    // top gutter
    image.Mutate
        (fun ctx ->
        ctx.Fill(GraphicsOptions(true), colour, RectangularPolygon(xStart, gutter, width - xStart - gutter - cornerRadius, cornerRadius))
        |> ignore)

    image
Enter fullscreen mode Exit fullscreen mode

Using the RectangularPolygon struct again we just manipulate some starting positions to fill in the three gaps with a rectangle the width (or height) of cornerRadius.

A box with rounded corners

🎉 We now have a box with rounded corners that looks like it is a single shape but is really made up of a bunch of smaller ones. A point to note is that you can simplify this a bit further by removing the left & right “fillers” and expanding the width of the inner box, but I found that doing so made it a lot harder to track how everything was drawn and positioned, so I'll take the few extra CPU cycles as the cost of maintainability.

Adding a Drop Shadow

It wouldn't be modern web design if we didn't have a drop shadow to make everything pop and the simplest way for us to do a drop shadow of the box is to draw a second box at a slight offset that represents the shadow effect. Now in reality the shadow is drawn first on the canvas since we're layering everything over the top of it. Let's expand our generateBox function to have two more arguments that represents the offset of the shadow relative to the main box. Again this won't be the most efficient solution as we're going to be drawing a shadow for parts that aren't visible, but again it's a small cost of CPU cycles to make it more maintainable.

let cornerRadius = 10.f
let gutter = 20.f

let generateBox (colour: Color) (width: float32) (height: float32) (xOffset: float32) (yOffset: float32)
    (image: Image<Rgba32>) =
    let xStart = gutter + cornerRadius
    let yStart = gutter + cornerRadius

    // main box
    image.Mutate(fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             RectangularPolygon
                 (xStart + xOffset, yStart + yOffset, width - xStart - gutter - cornerRadius, height - yStart - gutter))
        |> ignore)

    // left gutter
    image.Mutate(fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             RectangularPolygon(gutter + xOffset, yStart + yOffset, cornerRadius, height - yStart - gutter)) |> ignore)

    // right gutter
    image.Mutate(fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             RectangularPolygon(width - xStart + xOffset, yStart + yOffset, cornerRadius, height - yStart - gutter))
        |> ignore)

    // top gutter
    image.Mutate(fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             RectangularPolygon
                 (xStart + xOffset, gutter + yOffset, width - xStart - gutter - cornerRadius, cornerRadius)) |> ignore)

    // rounded corner - left top
    image.Mutate(fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             EllipsePolygon(xStart + xOffset, yStart + yOffset, cornerRadius * 2.f, cornerRadius * 2.f)) |> ignore)

    // rounded corner - right top
    image.Mutate(fun ctx ->
        ctx.Fill
            (GraphicsOptions(true), colour,
             EllipsePolygon(width - xStart + xOffset, yStart + yOffset, cornerRadius * 2.f, cornerRadius * 2.f))
        |> ignore)

    image
Enter fullscreen mode Exit fullscreen mode

Here we have a liberal sprinkling of the offset values to shift the box right and down as that's the direction we're going with the shadow (assuming a positive offset is passed in, negative will go up and left).

A box with rounded corners and drop shadow

Generating Text

The final thing we need to do with our image is write the text that we want on it. We'll be putting the title as the main heading and then a subheading that is the author name, published date and the tags. To generate the text we'll use the SixLabors.Shapes.Text (version 1.0.0-beta0009 at the time of writing) and SixLabors.ImageSharp.Drawing (version 1.0.0-beta0007 at the time of writing) NuGet packages.

Disclaimer: I struggled a lot with doing text properly and while I have a working solution, I'm not sure if it's the most efficient. I primarily modeled it off this sample so if there's a better way to do it, please let me know.

Let's add another function to ImageCreator.fs:

let addText (text: string) (fontSize: float32) (xEnd: float32) (y: float32) (image: Image<Rgba32>) =
    image
Enter fullscreen mode Exit fullscreen mode

The first thing we need to do to render some text is to choose a font to render it using. For portability I'm going to pick a font off the host machine rather than trying to create one and we can do that with the SystemFont.Find method that the library provides. One thing to be aware of if you work across multiple OSes your available fonts will vary, I got caught out with this because I dev on Linux (via WSL2) but deployed to a Windows-hosted Azure Function, so the fonts were different!

let getFontName =
    match Environment.OSVersion.Platform with
    | PlatformID.Unix -> "DejaVu Sans Mono"
    | _ -> "Consolas"

let addText (text: string) (fontSize: float32) (xEnd: float32) (y: float32) (image: Image<Rgba32>) =
    let fam = getFontName |> SystemFonts.Find
    let font = Font(fam, fontSize)
    image
Enter fullscreen mode Exit fullscreen mode

To handle this OS-variation I use a function that checks the OS I'm on and gives me a different font name so I can find the right font family and from that create a Font object with the right fontSize.

Now we need to generate a path which the text will be rendered along, this took a bit for me to understand but once I realised the point I realised how powerful it is. With ImageSharp each character in the string of text is a separate glyph that can be rendered and you can control where that goes relative to what's preceded it. This makes it easy to have text follow a non-linear path (such as circular text) making complex text generation really easy. To do this we use a PathBuilder from SixLabors.Shapes and add points for shapes to appear on.

But we just need a straight path for the text to follow so we only need a single step in our path that goes through to nearly the edge of the image:

let getFontName =
    match Environment.OSVersion.Platform with
    | PlatformID.Unix -> "DejaVu Sans Mono"
    | _ -> "Consolas"

let addText (text: string) (fontSize: float32) (xEnd: float32) (y: float32) (image: Image<Rgba32>) =
    let fam = getFontName |> SystemFonts.Find
    let font = Font(fam, fontSize)

    let pb = PathBuilder()
    pb.SetOrigin(PointF(gutter * 2.f, 0.f)) |> ignore
    pb.AddLine(0.f, y, xEnd, y) |> ignore
    let path = pb.Build()

    image
Enter fullscreen mode Exit fullscreen mode

On line 11 we set where the first glyph will appear to be double the gutter width, so we end up with a consistent gap from the edges. Next we'll add a line to be followed starting from the x position of the origin and a provided y position that will then run through to the right as far as we let it (the passed in xEnd value) with a consistent y. This results in a nice straight horizontal line for the text to follow. Finally we generate a path from the PathBuilder.

let addText (text: string) (fontSize: float32) (xEnd: float32) (y: float32) (image: Image<Rgba32>) =
    let fam = getFontName |> SystemFonts.Find
    let font = Font(fam, fontSize)

    let pb = PathBuilder()
    pb.SetOrigin(PointF(gutter * 2.f, 0.f)) |> ignore
    pb.AddLine(0.f, y, xEnd, y) |> ignore
    let path = pb.Build()

    let mutable opts = TextGraphicsOptions true
    opts.WrapTextWidth <- path.Length

    let mutable ro = RendererOptions(font, 72.f)
    ro.HorizontalAlignment <- opts.HorizontalAlignment
    ro.TabWidth <- opts.TabWidth
    ro.VerticalAlignment <- opts.VerticalAlignment
    ro.WrappingWidth <- opts.WrapTextWidth
    ro.ApplyKerning <- opts.ApplyKerning

    image
Enter fullscreen mode Exit fullscreen mode

We need to create some options to control how the text is drawn onto the canvas (we'll provide it to the Fill method eventually). First is the TextGraphicsOptions which is similar to the GraphicsOptions we used for shapes, but it has some text-specific properties, namely when to wrap. You might notice the mutable keyword on the let binding. This is because F# objects are immutable by default but we have to set some properties after construction (the same goes for RenderOptions).

With the TextGraphicsOptions created we can create the RendererOptions and copy some of the properties across. RendererOptions is used to control how the glyphs are created from the string of text so we provide it with the font we want to use and the DPI for the font.

let addText (text: string) (fontSize: float32) (xEnd: float32) (y: float32) (image: Image<Rgba32>) =
    let fam = getFontName |> SystemFonts.Find
    let font = Font(fam, fontSize)

    let pb = PathBuilder()
    pb.SetOrigin(PointF(gutter * 2.f, 0.f)) |> ignore
    pb.AddLine(0.f, y, xEnd, y) |> ignore
    let path = pb.Build()

    let mutable opts = TextGraphicsOptions true
    opts.WrapTextWidth <- path.Length

    let mutable ro = RendererOptions(font, 72.f)
    ro.HorizontalAlignment <- opts.HorizontalAlignment
    ro.TabWidth <- opts.TabWidth
    ro.VerticalAlignment <- opts.VerticalAlignment
    ro.WrappingWidth <- opts.WrapTextWidth
    ro.ApplyKerning <- opts.ApplyKerning

    let glyphs = TextBuilder.GenerateGlyphs(text, path, ro)

    image.Mutate(fun ctx -> ctx.Fill(opts, Color.Black, glyphs) |> ignore)

    image
Enter fullscreen mode Exit fullscreen mode

With the options ready we can generate the glyphs along the path using the RendererOptions and then mutate the image to render out the text. But we've got a compilation error. It turns out that TextGraphicsOptions doesn't inherit from GraphicsOptions, instead it relies on a custom explicit operator to convert. Well that's annoying as F# doesn't have a built-in way to handle the explicit casting like C#, instead you can call the op_Explicit function from the declaring type, or use a little snippet:

#nowarn "77"
let inline (!>) (x:^a) : ^b = ((^a or ^b) : (static member op_Explicit : ^a -> ^b) x) 
#warn "77"

let addText (text: string) (fontSize: float32) (xEnd: float32) (y: float32) (image: Image<Rgba32>) =
    let fam = getFontName |> SystemFonts.Find
    let font = Font(fam, fontSize)

    let pb = PathBuilder()
    pb.SetOrigin(PointF(gutter * 2.f, 0.f)) |> ignore
    pb.AddLine(0.f, y, xEnd, y) |> ignore
    let path = pb.Build()

    let mutable opts = TextGraphicsOptions true
    opts.WrapTextWidth <- path.Length

    let mutable ro = RendererOptions(font, 72.f)
    ro.HorizontalAlignment <- opts.HorizontalAlignment
    ro.TabWidth <- opts.TabWidth
    ro.VerticalAlignment <- opts.VerticalAlignment
    ro.WrappingWidth <- opts.WrapTextWidth
    ro.ApplyKerning <- opts.ApplyKerning

    let glyphs = TextBuilder.GenerateGlyphs(text, path, ro)

    image.Mutate(fun ctx -> ctx.Fill(!> opts, Color.Black, glyphs) |> ignore)

    image
Enter fullscreen mode Exit fullscreen mode

Here we're

Here we're defining a custom operator, !>, that will handle the explicit cast for us (I don't claim credit for it, credit to StackOverflow). We also disable the complier warning #77 which tells us to be careful about using op_Explicit as the compiler can do some optimisations with it, but it'll be fine!

Bringing It All Together

With our two core functions created it's time to wire everything up and plug it into the Azure Function we started with. Since we'll have to call each function a few times let's create a helper makeImage function to get our Image<Rgba32> instance:

let makeImage width height title author (date: DateTimeOffset) tags =
    let image = new Image<Rgba32>(int width, int height)
    image
Enter fullscreen mode Exit fullscreen mode

This function will take all the information it requires so it's not tied to our parsing of the blog data. When creating the Image<Rgba32> we provide it a width and height, but they are defined as a float32 (since that's mostly what we need them as) so they are boxed as int for the constructor (this is so ImageSharp can work with sub-pixel graphics but the final image is to a whole pixel).

Up until now the background has been transparent, but let's give it a colour to help it pop:

let makeImage width height title author (date: DateTimeOffset) tags =
    let image = new Image<Rgba32>(int width, int height)
    image.Mutate(fun ctx -> ctx.Fill(Color.FromHex "02bdd5") |> ignore)

    image
Enter fullscreen mode Exit fullscreen mode

And finally we'll call generateBox and addText

let makeImage width height title author (date: DateTimeOffset) tags =
    let image = new Image<Rgba32>(int width, int height)
    image.Mutate(fun ctx -> ctx.Fill(Color.FromHex "02bdd5") |> ignore)

    let textX = width - (gutter + cornerRadius) * 2.f
    let textY = height / 2.f

    generateBox (Color.FromHex "333") width height 5.f 5.f image
    |> generateBox Color.White width height 0.f 0.f
    |> addText title 30.f textX textY
    |> addText (sprintf "%s | %s" author (date.ToString "MMMM dd, yyyy")) 20.f textX (textY + 40.f)
    |> addText
        (tags
         |> Array.map (fun t -> sprintf "#%s" t)
         |> Array.toSeq
         |> String.concat " ") 15.f textX (textY + 70.f)
Enter fullscreen mode Exit fullscreen mode

Since all these methods return the Image<Rgba32> we can leverage F#‘s pipeline operator to pass everything through in a nice pipeline. You'll also see that when we call addText we start with the y position being half the height of the image (title is vertically aligned in the middle) and then increase the offset from that for the other lines of text so it doesn't overlap.

The resulting output is an image like so:

Our generated image

All that is left now is to return that from the Azure Function.

Connecting With Our Function

[<FunctionName("BlogCardGenerator")>]
let run ([<HttpTrigger(AuthorizationLevel.Function, "get", Route = "title-card/{id}")>] req: HttpRequest)
    ([<Blob("title-cards/{id}.png", FileAccess.ReadWrite, Connection = "ImageStorage")>] postImage: ICloudBlob)
    (id: string) (log: ILogger) =
    async {
        log.LogInformation <| sprintf "ID: %s" id

        let! blogData = getBlogMetadata()
        match tryFindPost id blogData.Posts with
        | Some post ->
            let! exists = postImage.ExistsAsync() |> Async.AwaitTask

            if exists then
                log.LogInformation "Image existed"
                let! ms = downloadImage postImage
                return FileStreamResult(ms, "image/png") :> IActionResult
            else
                let title = post.Title
                let author = "Aaron Powell"
                let date = post.Date

                use image = makeImage width height title author date post.Tags
                let ms = imageToStream image
                do! postImage.UploadFromStreamAsync ms |> Async.AwaitTask
                ms.Position <- int64 0

                return FileStreamResult(ms, "image/png") :> IActionResult
        | None -> return NotFoundResult() :> IActionResult
    }
    |> Async.StartAsTask
Enter fullscreen mode Exit fullscreen mode

We unpack the metadata from our post (title and tags) then call our makeImage function that returns us the Image<Rgba32> object. Since we know that it doesn't already exist in storage we'll write it back by converting it to a stream:

let imageToStream (image: Image<Rgba32>) =
    let ms = new MemoryStream()
    image.SaveAsPng ms
    ms.Position <- int64 0
    ms
Enter fullscreen mode Exit fullscreen mode

And using the ICloudBlob UploadFromStreamAsync function to write it. Once we reset the stream position it can be handed over to FileStreamResult and the client will get the image.

Conclusion

I hope you've enjoyed this look at how we can generate and store images using Azure Functions and ImageSharp. If you're looking at how to manipulate an existing image check out this tutorial which also uses ImageSharp.

But we were looking at the primitives, how to create the base shapes, overlap them appropriately and then draw some text across them.

We also saw how to combine the HTTP trigger with a Blob input binding so that we get a connection to our blobs setup without us needing to do a lot of the wire-up ourselves. If you want to see the complete solution you'll find it on my GitHub.

We didn't cover how to deploy to Azure, but there's plenty of docs already on that. I'm using GitHub Actions for this project, you'll find them here.

And if you have any advice on how to improve the image generation let me know!

Top comments (2)

Collapse
 
shaijut profile image
Shaiju T

Why there is option as Azure Portal Development Environment to create Function App? , In real world we will write more code like fetching
from database etc. I think in that case Azure Portal option in not suitable.

Collapse
 
aaronpowell profile image
Aaron Powell

Every project and solution is different so will require a different approach to building it. Some projects may be suited to being created through the portal while others are suited to development through other tools.

Just like there's the az cli and Azure PowerShell commands, or GitHub Actions/Azure Pipelines/Jenkins/Bamboo/etc., it's all about giving people options to build solutions in a way that works for their needs.