Text out of the box for converting into image in php
Asked Answered
E

2

11

I am trying to convert text into image. I already did it, but the are some cases when text is out of the image boxImage

The "e" of the word "The" is cut. I have tried decreasing the font size or increasing the width of the image, but in some cases this happen again with another text. This is the code:

    $new_line_position = 61;        
    $angle = 0;        
    $left = 20;
    $top = 45;
    $image_width = 1210;
    $image_line_height = 45;                

    $content_input = wordwrap($content_input,    $new_line_position, "\n", true);  

    $lineas = preg_split('/\\n/', $content_input);
    $lines_breaks = count($lineas); 
    $image_height = $image_line_height * $lines_breaks;
    $im = imagecreatetruecolor($image_width, $image_height);

    // Create some colors
    $white = imagecolorallocate($im, 255, 255, 255);        
    $black = imagecolorallocate($im, 0, 0, 0);
    imagefilledrectangle($im, 0, 0, $image_width, $image_height, $white);   

   $font_ttf =  public_path().'/fonts/'.$ttf_font;                     


    foreach($lineas as $linea){
        imagettftext($im, $font_size, $angle, $left, $top, $black, $font_ttf, $linea);        
        $top = $top + $image_line_height;
    }

    // Add the text                        
    imagepng($im);                
    imagedestroy($im);

Thank you.

Errata answered 6/10, 2016 at 16:49 Comment(0)
G
9

The problem is that every single character may have a slightly different width, for example W and i. Becuase of that you cannot split a string by letter count per line you need a more accurate method.

The main trick is to use

imagettfbbox

which gives the bounding box of a text using TrueType fonts and from this, you can get the real width that text will use.

Here is a function for pixel perfect split found at http://php.net/manual/en/function.wordwrap.php use it instead of wordwrap and pass extra values like image width, font-size and font path

<?php

/**
 * Wraps a string to a given number of pixels.
 * 
 * This function operates in a similar fashion as PHP's native wordwrap function; however,
 * it calculates wrapping based on font and point-size, rather than character count. This
 * can generate more even wrapping for sentences with a consider number of thin characters.
 * 
 * @static $mult;
 * @param string $text - Input string.
 * @param float $width - Width, in pixels, of the text's wrapping area.
 * @param float $size - Size of the font, expressed in pixels.
 * @param string $font - Path to the typeface to measure the text with.
 * @return string The original string with line-breaks manually inserted at detected wrapping points.
 */
function pixel_word_wrap($text, $width, $size, $font)
{

    #    Passed a blank value? Bail early.
    if (!$text)
        return $text;

    #    Check if imagettfbbox is expecting font-size to be declared in points or pixels.
    static $mult;
    $mult = $mult ?: version_compare(GD_VERSION, '2.0', '>=') ? .75 : 1;

    #    Text already fits the designated space without wrapping.
    $box = imagettfbbox($size * $mult, 0, $font, $text);
    if ($box[2] - $box[0] / $mult < $width)
        return $text;

    #    Start measuring each line of our input and inject line-breaks when overflow's detected.
    $output = '';
    $length = 0;

    $words      = preg_split('/\b(?=\S)|(?=\s)/', $text);
    $word_count = count($words);
    for ($i = 0; $i < $word_count; ++$i) {

        #    Newline
        if (PHP_EOL === $words[$i])
            $length = 0;

        #    Strip any leading tabs.
        if (!$length)
            $words[$i] = preg_replace('/^\t+/', '', $words[$i]);

        $box = imagettfbbox($size * $mult, 0, $font, $words[$i]);
        $m   = $box[2] - $box[0] / $mult;

        #    This is one honkin' long word, so try to hyphenate it.
        if (($diff = $width - $m) <= 0) {
            $diff = abs($diff);

            #    Figure out which end of the word to start measuring from. Saves a few extra cycles in an already heavy-duty function.
            if ($diff - $width <= 0)
                for ($s = strlen($words[$i]); $s; --$s) {
                    $box = imagettfbbox($size * $mult, 0, $font, substr($words[$i], 0, $s) . '-');
                    if ($width > ($box[2] - $box[0] / $mult) + $size) {
                        $breakpoint = $s;
                        break;
                    }
                }

            else {
                $word_length = strlen($words[$i]);
                for ($s = 0; $s < $word_length; ++$s) {
                    $box = imagettfbbox($size * $mult, 0, $font, substr($words[$i], 0, $s + 1) . '-');
                    if ($width < ($box[2] - $box[0] / $mult) + $size) {
                        $breakpoint = $s;
                        break;
                    }
                }
            }

            if ($breakpoint) {
                $w_l = substr($words[$i], 0, $s + 1) . '-';
                $w_r = substr($words[$i], $s + 1);

                $words[$i] = $w_l;
                array_splice($words, $i + 1, 0, $w_r);
                ++$word_count;
                $box = imagettfbbox($size * $mult, 0, $font, $w_l);
                $m   = $box[2] - $box[0] / $mult;
            }
        }

        #    If there's no more room on the current line to fit the next word, start a new line.
        if ($length > 0 && $length + $m >= $width) {
            $output .= PHP_EOL;
            $length = 0;

            #    If the current word is just a space, don't bother. Skip (saves a weird-looking gap in the text).
            if (' ' === $words[$i])
                continue;
        }

        #    Write another word and increase the total length of the current line.
        $output .= $words[$i];
        $length += $m;
    }

    return $output;
}
;

?>

Below is working code example: I modified this function pixel_word_wrap a little bit. Also, modified some calculation in your code. Right now is giving me the perfect image with correctly calculated margins. I am not super happy with the code noticed that there is a $adjustment variable, that should be bigger when you use bigger font-size. I think It's down to imperfection in imagettfbbox function. But It's a practical approach that works pretty well with most font-sizes.

<?php

$angle = 0;
$left_margin = 20;
$top_margin = 20;
$image_width = 1210;
$image_line_height = 42;
$font_size = 32;
$top = $font_size + $top_margin;

$font_ttf = './OpenSans-Regular.ttf';

$text = 'After reading Mr. Gatti`s interview I finally know what bothers me so much about his #elenaFerrante`s unamsking. The whole thing is about him, not the author, not the books, just himself and his delusion of dealing with some sort of unnamed corruption';$adjustment=  $font_size *2; //

$adjustment=  $font_size *2; // I think because imagettfbbox is buggy adding extra adjustment value for text width calculations,

function pixel_word_wrap($text, $width, $size, $font) {

  #    Passed a blank value? Bail early.
  if (!$text) {
    return $text;
  }


  $mult = 1;
  #    Text already fits the designated space without wrapping.
  $box = imagettfbbox($size * $mult, 0, $font, $text);

  $g = $box[2] - $box[0] / $mult < $width;

  if ($g) {
    return $text;
  }

  #    Start measuring each line of our input and inject line-breaks when overflow's detected.
  $output = '';
  $length = 0;

  $words = preg_split('/\b(?=\S)|(?=\s)/', $text);
  $word_count = count($words);
  for ($i = 0; $i < $word_count; ++$i) {

    #    Newline
    if (PHP_EOL === $words[$i]) {
      $length = 0;
    }

    #    Strip any leading tabs.
    if (!$length) {
      $words[$i] = preg_replace('/^\t+/', '', $words[$i]);
    }

    $box = imagettfbbox($size * $mult, 0, $font, $words[$i]);
    $m = $box[2] - $box[0] / $mult;

    #    This is one honkin' long word, so try to hyphenate it.
    if (($diff = $width - $m) <= 0) {
      $diff = abs($diff);

      #    Figure out which end of the word to start measuring from. Saves a few extra cycles in an already heavy-duty function.
      if ($diff - $width <= 0) {
        for ($s = strlen($words[$i]); $s; --$s) {
          $box = imagettfbbox($size * $mult, 0, $font,
            substr($words[$i], 0, $s) . '-');
          if ($width > ($box[2] - $box[0] / $mult) + $size) {
            $breakpoint = $s;
            break;
          }
        }
      }

      else {
        $word_length = strlen($words[$i]);
        for ($s = 0; $s < $word_length; ++$s) {
          $box = imagettfbbox($size * $mult, 0, $font,
            substr($words[$i], 0, $s + 1) . '-');
          if ($width < ($box[2] - $box[0] / $mult) + $size) {
            $breakpoint = $s;
            break;
          }
        }
      }

      if ($breakpoint) {
        $w_l = substr($words[$i], 0, $s + 1) . '-';
        $w_r = substr($words[$i], $s + 1);

        $words[$i] = $w_l;
        array_splice($words, $i + 1, 0, $w_r);
        ++$word_count;
        $box = imagettfbbox($size * $mult, 0, $font, $w_l);
        $m = $box[2] - $box[0] / $mult;
      }
    }

    #    If there's no more room on the current line to fit the next word, start a new line.
    if ($length > 0 && $length + $m >= $width) {
      $output .= PHP_EOL;
      $length = 0;

      #    If the current word is just a space, don't bother. Skip (saves a weird-looking gap in the text).
      if (' ' === $words[$i]) {
        continue;
      }
    }

    #    Write another word and increase the total length of the current line.
    $output .= $words[$i];
    $length += $m;
  }

  return $output;
}


$out = pixel_word_wrap($text, $image_width -$left_margin-$adjustment,
  $font_size, $font_ttf);


$lineas = preg_split('/\\n/', $out);
$lines_breaks = count($lineas);
$image_height = $image_line_height * $lines_breaks;
$im = imagecreatetruecolor($image_width, $image_height + $top);

// Create some colors
$white = imagecolorallocate($im, 255, 255, 255);
$black = imagecolorallocate($im, 0, 0, 0);
imagefilledrectangle($im, 0, 0, $image_width, $image_height + $top, $white);


foreach ($lineas as $linea) {
  imagettftext($im, $font_size, $angle, $left_margin, $top, $black, $font_ttf,
    $linea);
  $top = $top + $image_line_height;
}


header('Content-Type: image/png');
imagepng($im);

Here is an example

enter image description here enter image description here

You can also use a monospace font. A monospace is a font whose letters and characters each occupy the same amount of horizontal space.

Graft answered 9/10, 2016 at 23:31 Comment(5)
I just try this and gave me unexpected results. [link]s12.postimg.org/i7iobio7x/image_7708.pngErrata
@Errata Could you please tell me what font you are using. I take a look into this.Graft
@Errata Please check the second code example it gives me the perfect image on my machine. ( tested with various settings ). You need to setup at the top of the script, $left_margin, $top_margin, $text, $font_size and $image_line_heightGraft
thank you dude, now it is working. There are still some things to improve like justify the text, but I will try on my own first.Errata
Hello, I just found out a bug, sometimes words are cut, look: s15.postimg.org/6yrjadpi3/image_6414.png . I think is due to this kind of words: ó, ñ, í. So I replaced the substr and strlen for example: mb_strlen($words[$i], 'UTF-8') or mb_substr($words[$i], 0, $s) . '-', NULL, 'UTF-8'); , but it is still not workingErrata
C
4

The problem is that your font is a variable width per letter, but you are truncating based on the number of letters and not the width of the font.

Take the following example, ten "I"s vs ten "W", the second will be over twice as long.

iiiiiiiiii

WWWWWWWWWW

The "simple" option is to use a monospaced font, such as Courier, which is used in the block below:

iiiiiiiiii
WWWWWWWWWW

But that's a boring font!. So what you need is to use the ìmagettfbbox (Image True Type Font Bounding Box" function http://php.net/manual/en/function.imagettfbbox.php) on each line to get the width. You need to run this function one line at a time, in decreasing sizes until you get the size you need.

A pseduo bit of code (please note: written off-hand and not tested, you will need to juggle it to make it perfect):

$targetPixelWidth = 300;
$maximumChactersPerLine = 200;  // Make this larger then you expect, but too large will slow it down!
$textToDisplay = "Your long bit of text goes here"
$aLinesToDisplay = array();
while (strlen(textToDisplay) > 0) {
  $hasTextToShow = false;
  $charactersToTest = $maximumChactersPerLine;
  while (!$hasTextToShow && $maximumChactersPerLine>0) {
    $wrappedText = wordwrap($textToDisplay, $maximumChactersPerLine);
    $aSplitWrappedText = explode("\n", $wrappedText);
    $firstLine = trim($aSplitWrappedText[0]);
    if (strlen($firstLine) == 0) {
      // Fallback to "default"
      $charactersToTest = 0;
    } else {
      $aBoundingBox = imagettfbbox($fontSize, 0, $firstLine, $yourTTFFontFile);
      $width = abs($aBoundingBox[2] - $aBoundingBox[0]);
      if ($width <= $targetPixelWidth) {
        $hasTextToShow = true;
        $aLinesToDisplay[] = $firstLine;
        $textToDisplay = trim(substr($textToDisplay, strlen($firstLine));
      } else {
        --$charactersToTest;
      }
    }
  }
  if (!$hasTextToShow) {
    // You need to handle this by getting SOME text (e.g. first word) and decreasing the length of $textToDisplay, otherwise you'll stay in the loop forever!
    $firstLine = ???; // Suggest split at first "space" character (Use preg_split on \s?) 
    $aLinesToDisplay[] = $firstLine;
    $textToDisplay = trim(substr($textToDisplay, strlen($firstLine));
  }      
}
// Now run the "For Each" to print the lines.

Caveat: The TTF Bounding box function is not perfect either - so allow a bit of "lee way", but you'll still end up with far, far better results that you are doing above (i.e. +-10 pixels). It also depends on the font-file kerning (gaps between letters) information. A bit of Goggling and reading the comments in the manual will help you get more accurate results if you need it.

You should also optimize the function above (start with 10 characters and increase, taking the last string that fits may get you an faster answer over decreasing until something fits, and reduce the number of strlen calls for example).


Addendum in response to comment "Can you expand on "the TTF Bounding box function is not perfect either"?" (reply is too long for a comment)

The function relies on the "kerning" information in the font. For example, you want V to sit closer to A (VA - see how they "overlap" slightly) than you would V and W (VW - see how the W starts after the V). There are lots of rules embedded in the fonts regarding that spacing. Some of those rules also say "I know the 'box' starts at 0, but for this letter you need to start drawing at -3 pixels".

PHP does it's best to read the rules, but gets some wrong sometimes and therefore gives you the wrong dimensions. It's the reason as why you might tell PHP to write from "0,0" but it actually starts at "-3,0" and appears to cut off the font. The easiest solution is to allow a few pixels grace.

Yes, it's a well noted "issue" (https://www.google.com/webhp?q=php%20bounding%20box%20incorrect)

Crenelation answered 9/10, 2016 at 23:38 Comment(5)
Can you expand on "the TTF Bounding box function is not perfect either"? What is wrong with it? Is that a known issue? (The reason being that this may be a contributing factor to OP's problem.)Diner
@RadLexus - Replied in the answer (It's too long for a comment)Crenelation
On the imperfect boxes, I did a bit of further investigating. The root of that problem is that all (!) php font functions seem to return the pixel area of a text, and not the string bounding box. That means that in general the left x is a few pixels 'off' to the right (unless a character sticks out the left margin), and so that's where you have to start drawing the text. But for a single character 1 they all still return only the exact bounding box, not with the usual 'sidebearings' – the extra white left and right of a character definition to prevent them sticking together.Diner
.. I think this can be traced back to gdImageStringFTEx inside php's gd.c. The bounding box that is returned gets updated using values from FT_Glyph_Get_CBox. However(!), the advance position, which moves the 'cursor', gets used from image->advance (which got updated through FT_Glyph_To_Bitmap). For proper text bounding boxes, you'd need the accumulated advances, not the pixel boundaries. (Although there are other uses for those.)Diner
If you do require accuracy, there are some "fixes" for this around the web, including in the PHP Manual's comments. I've never tested their accuracy/effectiveness and I've never needed to worry about pixel perfect placement. But it's been like that since I can remember so must be some reason why it's not sorted - although I've never researched as deep as you've just done (again, never needed).Crenelation

© 2022 - 2024 — McMap. All rights reserved.