Scripting for Fun - Building a Word Cloud Generator

Sometimes I stumble across a particularly intriguing idea which just captures my attention until I finish making it work. This was suggested by @sifb in the PowerShell Slack & Discord group after I detailed the process of writing Export-Png here. This time around, his suggestion was to take the code from Export-Png and use it to create a word cloud generator.

The finished product!

Word cloud of Reddit user /u/Lee_Dailey's posts.

@sifb has also helped me out with one of my favourite pull requests to PowerShell Core, creating some of the most arcane and yet ruthlessly performant code I've seen in a while.

This is a more or less complete little tool at the present moment, so if you'd like to check out the finished code as you follow along, you're more than welcome to examine it in detail in my Github repo.

First Hurdles

This sounds like a relatively straightforward thing to do, right? I suppose, once you've got a good conceptual grasp of it, it's not too ridiculous to figure out. However, never having approached it before, I wasn't sure how to attack the challenge.

Position and Size of Words

Word cloud generators, typically, need some way to work out the position and covered area of each word. Thankfully, in Export-Png we have precisely that — sort of.

$Width = [int] $Graphics.MeasureString($ImageText, $ImageFont).Width
$Height = [int] $Graphics.MeasureString($ImageText, $ImageFont).Height

$Image = [Bitmap]::new($Image, [Size]::new($Width, $Height))
$Graphics = [Graphics]::FromImage($Image)

# ...

$Graphics.DrawString($ImageText, $ImageFont, [SolidBrush]::new($ForegroundColor), 0, 0)

So, from this we have:

  1. The size of the text we want to draw.
  2. The position we're drawing at.

That gives us a good start, but in order to make an effective word cloud we also need to be keeping track of these things. I'd typically use a List<T> for this sort of thing, but I need an object type that can store the position as well as the size. Thankfully, in this instance, a bit of searching turns up the Rectangle and RectangleF classes, which gives me exactly what I need.

In this instance, I selected RectangleF as it will handle floating-point position and size data, which just makes it easier to work with the MeasureString() method's output. I opted to store the objects in a [List[RectangleF]] collection, so that I can build it as we go. And yes, we're drawing text, but the simplest way to avoid collisions is to treat it as a solid rectangle and just avoid drawing over another word's "reserved space" completely.

Avoiding Collisions or Overwrites

One of the other important things about word clouds is that they should generally avoid letting the words overwrite each other. This isn't particularly easy to calculate just from storing location and size information; it's certainly doable, but it means a lot of manual calculation. I suspect there is a shortcut method, if you know more geometry than I do, for determining whether a rectangle intersects another, but I'm not familiar with it.

Instead, more looking through the RectangleF class shows that the .NET developers have already considered this problem, and wrote a [bool] IntersectsWith([RectangleF] $Other) method. I'll have to take a look at their code sometime, but for now it gives us exactly what we're looking for. We can simply check any new potential draw locations by creating a rectangle and checking if that intersects with any of our previously stored rectangles.

With those initial hurdles out of the way, we really need to think about our approach to building the word cloud.

How Do You Build a Word Cloud, Anyway

The common approach to building a word cloud seems to be more or less stick things in a vaguely circular or ovoid shape around the centrepoint of an image. I've been playing in the PowerShell tokenizer still relatively recently, and a small part of that was a fun little project to implement imaginary numbers; I'll cover that perhaps in my next PSPowerHour session!

As I was considering ways to scan for available positions to place words, I realised that complex numbers (a.k.a. imaginary numbers) might offer a very easy solution. For those of you who are like me, never having really looked at them until I found a lovely little Youtube series on them, imaginary numbers can always be represented in two forms: rectangular form (a + bi), and polar coordinate form (m ∠ θ).

Complex Numbers As Coordinates

Now, that's fun and all, but our use case here is that they offer a fairly neat way to very easily translate a magnitude and angle from a center point into real and imaginary portions. With a bit of handwaving, we can pretend they are instead X and Y coordinates, which is actually not that far from the truth: just a pair of coordinates, or a single point, in the complex plane.

That means we can quite easily create a loop that scans a full circle, and expand that to scan the entire image in a circular or even spiral fashion, should we want to! Just one more hurdle… you see, polar coordinate form in .NET's System.Numerics.Complex numbers requires us to input angles in radians. Just a little extra math!

using namespace System.Drawing
using namespace System.Numerics

foreach ($Angle in 0..360) {
    $Radians = ([math]::PI / 180) * $Angle
    $Distance = 10
    $Complex = [Complex]::FromPolarCoordinates($Distance, $Radians)

    $Point = [PointF]::new($Complex.Real, $Complex.Imaginary)
}

A Complete Algorithm

The full code is rather more complicated, having additional random jitter here and there for a bit more variety in output, and adding a bit of scaling for different image sizes, but that is essentially the idea with its scanning algorithm, plus a bit of checking to see if the space it's looking at is already occupied.

Working out font sizes was a particular sticking point for a short while, but in the end I came up with a way to calculate everything out relatively neatly. I also decided to alternate direction of radial scanning to avoid the tendency to place words on one side instead of the other, and added a small bit of backtracking to help maximise packing as much as possible, as it scans through words progressively.

There is one or two more interesting little bits of code that I could mention, but we covered ArgumentCompleters last week, and in truth the more interesting [ArgumentTransformationAttribute] class in System.Management.Automation deserves its own blog post, which I'll write for next week!

Once again, if you'd like to check out the word cloud code in detail, head on over to my Github repo where you can see the full working code.

You can also download it as a module from the PowerShell gallery with a simple command.

Import-Module PSWordCloud

It will accept piped or direct input of text to its New-WordCloud command, and you can freely explore different possibilities with the rather overdone set of parameters I gave it. If you make anything particularly interesting or pretty with it, do let me know.

Thanks for reading!