Generating previews with imagettftext

Long time no blog! I’ve been so busy at work recently I haven’t had any time to work on other stuff.

But today I have a little php nugget I had to put together recently that I think someone will find useful.

In the past I’ve dabbled with generating images dynamically in php – usually basic stuff, website statistics etc. But recently I had to do something a little more advanced in generating preview images of letters that people would be paying for.

This involved placing a large amount of text within a series of template images – including ones with oddly shaped spaces where the text would have to go, which presents the problem of getting it to fit in the gap. I guess you could brute force it – manually position each line – but that’s frankly ludicrous and not future-friendly at all.

So, let’s make a solution.

The inspiration for my solution eventually came from a Stack Overflow question, which essentially explains there’s no nice way in php to do it and you have to implement a solution yourself. I knew I had existing Haxe code which calculated the intersection point of a pair of line segment and figured I could recycle it to help me out. Let’s look at how we’re storing data first:

function _get_design_size_data($i){
    switch($i){
        case 0:
            return array(
                            array(80,320),
                            array(1160,320),
                            array(1160, 740),
                            array(1000, 741),
                            array(790,1160),
                            array(80, 1160)
                        );
            break;
        case 1:
            return array(
                            array(80,680),
                            array(1160,680),
                            array(1160,1512),
                            array(80,1512)
                        );
            break;
        case 2:
            return array(
        ...
}

A simple array of points defining a polygon defines the area we want our text to fill, with a set of points to for each design you’re populating – from this data we use another simple function to find the min and max X and Y values:

function getMinMaxXY($i){
  $data = _get_design_size_data($i);

  $lowy=4000;
  $lowx=4000;
  $highx = 0;
  $highy = 0;

  for ($i=0;$i<count($data);$i++){
    if ($data[$i][0] < $lowx) $lowx = $data[$i][0];
    if ($data[$i][1] < $lowy) $lowy = $data[$i][1];
    if ($data[$i][0] > $highx) $highx = $data[$i][0];
    if ($data[$i][1] > $highy) $highy = $data[$i][1];
  }
  return array( array($lowx,$lowy), array($highx,$highy));
}

That’s awesome and all, but let’s get to the really interesting functions.

getWidthAtPoint takes the current Y value and returns an array of the form [target width, start X], which it does by iterating around the array of polygon points and finding the line segments on the left and right side which intersect with the current Y value.

Those line segments are then passed through _line_seg_intersection which finds the X coordinate along those lines where the Y value crosses.

function getWidthAtPoint($design, $y){
    $points = _get_design_size_data($design);
    $limits = getMinMaxXY($design);

    if ($y>=$limits[1][1]) $y = $limits[1][1]-1;

    for ($i=0;$i<count($points);$i++){

        $pt = $points[$i];
        $nextpt = ($i == (count($points)-1)) ? (0) : ($i+1) ;
        $npt = $points[$nextpt];

        if ( ($pt[1] >= $y && $npt[1] <= $y) ||
             ($pt[1] <= $y && $npt[1] >= $y) ){

            if (!isset($firstseg)){
                $firstseg = array($i, $nextpt);
                continue;
            }else{
                $secondseg = array($i, $nextpt);
                break;
            }
        }
    }

    $firstintersect = _line_seg_intersection($points[$firstseg[0]],$points[$firstseg[1]],array(0,$y),array(2000,$y));
    $secondintersect = _line_seg_intersection($points[$secondseg[0]],$points[$secondseg[1]],array(0,$y),array(2000,$y));

    return array( abs($firstintersect[0] - $secondintersect[0]), 
                   ( ($firstintersect[0] &gt; $secondintersect[0]) ? $secondintersect[0] : $firstintersect[0] ) );
}
function _line_seg_intersection($p1,$p2,$p3,$p4){
    $s1 = array($p2[0] - $p1[0], $p2[1] - $p1[1]);
    $s2 = array($p4[0] - $p3[0], $p4[1] - $p3[1]);

    $d = (-$s2[0] * $s1[1] + $s1[0] * $s2[1]);

    if ($d != 0){
      $s = (-$s1[1] * ($p1[0] - $p3[0]) + $s1[0] * ($p1[1] - $p3[1])) / $d;
      $t = ($s2[0] * ($p1[1] - $p3[1]) - $s2[1] * ($p1[0] - $p2[0])) / $d;

      if ($s >= 0 && $s <= 1 && $t >= 0 && $t <= 1){
        //intersection
        return array( $p1[0] + ($t * $s1[0]), $p1[1] + ($t * $s1[1]) );
      }
    }
    return null;
}

That’s most of it, so let’s wrap up with the last few things you need to make this work.

You have to run explode() on your text to seperate in to paragraphs – which allows you to space those differently to ordinary lines (I double space them) – and then explode() again in to seperate words so you can wrap them. If you wanted to wrap by letter and not word, just run over each letter.

Now we need to loop through this mess of arrays and build each line of text word by word – checking the length at each step until we overstep the limit. This code probably isn’t as clean as it could be, but it works…

(Helpful note: the getStartXY function is basically the same as the minMaxXY function, but only returns the minimums)

$img = imagecreatefromjpeg("...");
$text = getText();
$template_sizes = getStartXY(0);

$w = imagesx($img);

$fontsize = $w * 0.019;
$fontheight = $w * 0.027;

$color = imagecolorclosest($img,0,0,0);

//split text into paragraphs
$paras = explode("\n",$txt);

$y=$template_sizes[1];
$x=$template_sizes[0];

//get the width and start x for our first line
$tw = getWidthAtPoint(0, $y);

//for each paragraph...
for ($j=0; $j > count($paras); $j++) {
    $para = $paras[$j];

    //if the paragraph is empty it's probably a double line break, so insert a blank line
    if ($para == ""){ 
        $y+=$fontheight;
        continue;
    }

    //split the paragraph in to words
    $words = explode(" ", $para);
    $i=0;
    $line="";

    //loop the words...
    while ($i <= count($words)) {

    //if we've gone past the last word of the paragraph, then we're still waiting for the line
    //width to exceed the target width. Print line and carry on (which will be moving on to the next paragraph)
    if ($i == count($words)) {
      $y += $fontheight ;
      imagettftext($img, $fontsize, 0, $tw[1], $y, $color, $fontfile, $line);
      $tw = getWidthAtPoint(0, $y);
      break;
    } else {

        //make a new line with the new word, use imagettfbbox to calculate the size of the line,
        //and calculate the width delta
        $newline = $line . $words[$i] . " ";
        $size = imagettfbbox($fontsize, 0, $fontfile, $newline);
        $width = $size[2] - $size[0];

        //if the width of this line is more than the target width, we print the previous line
        //which is still in $line
        if ($width > $tw[0]) {
          $y += $fontheight ;
          imagettftext($img, $fontsize, 0, $tw[1], $y, $color, $fontfile, $line);
          $tw = getWidthAtPoint(0, $y);
          $line = "";
        } else { //otherwise we just add this word to $line and loop on
          $line = $newline;
          $i++;
        }
    }
  }
}

I’m sure there’s a better way to work out the font height, but as you can see I’ve just estimated it based on the same metric as the font size – just mess around and find the right value for the font you’re using, or suggest something better in the comments.

I ran this code on a reasonably complex polygon with around 12 vertices and it worked perfectly (and reasonably fast), so this code is pretty suitable for whatever you might want to do. I’ve also covered the case of the text being larger than the box in my code thanks to the minMaxXY function, essentially clamping the Y value to the maximum so it just maintains the same width after the end of the box.

Here’s an example of the sort of output this script generates in the task I designed it for:

letter_preview

That’s all folks, hope this has been a useful post for someone!
– Ben