Image creation with PHP

First published at Tuesday, 11 December 2007

Warning: This blog post is more then 17 years old – read and use with care.

Image creation with PHP

There are several ways to create images or graphics with PHP. First there are some well known extensions, like ext/GD, or perhaps ext/ming everybody immediately remembers, when it comes to graphics generation. But there are several structural differences not in between the avaliable libraries, but also between the image formats you can create. This article series will give you some insight on the formats and libraries, and then shows how an abstraction layer could be build.

Introduction

General notes, relevant for this article.

Terms

Some terms I use in a special way.

Image
I use it as a generalization of pictures and graphics.
Picture
Images with natural contents, like photos or drawings. Usually there are no or only few clear borders in those images.
Graphic
Computer generated graphics with technical illustrations or charts. They often contain clear borders.

Agenda

The fifth part of the article covers the complex topic of text rendering with all the backends.

  • Formats

    Describes the advantages and drawbacks of the different extensions and the formats they can generate.

  • Simple shapes

    Describes the basic required data structure and the generation of a first simple shape.

  • Gradients

    Describes how you can add radial and linear gradients to your generated graphics with each of the backends.

  • Integrated bitmaps

    Integrating bitmaps with the backends.

  • Text rendering

    The basics of text rendering with all extensions.

  • Additional image tools

    With a small set of tools the automatic generation of images gets a lot simpler and allows you the construction of nice images using the existing APIs.

The code

I provide completely working code within this article, which will not be developed any further, because there are already existing packages, which try to provide such an abstraction layer, like Image_Canvas in PEAR. In the graph component from the eZ Components I personally develop very similar backends under the New BSD license, which will stay limited to the drawing methods we need for the graph component for now.

The complete source code can be downloaded here, or partially copied from the code examples. The source code is not provided under some OpenSource license, but stays under my personal copyright, like the article does. If you want to take the code and continue developing it, please send me a mail and we can discuss this.

The code is written for PHP 5.3, which can currently be compiled from the CVS and makes use of the namespaces features in 5.3.

To run the provided code, you need at least the following extensions installed:

  • GD (with PNG and FreeType 2 support)

  • cairo_wrapper 0.2.3-beta

  • Ming 0.3.0

  • The default extensions: DOM, SPL

Text rendering

Text rendering is a quite complex task in a graphic library, because normally the user passes a position, where the text should be rendered at, and some boundings defining the maximum space the text should use. While rendering you need to respect the requested size, which may be reduce to fit the text in the boundings, and a specified alignment, so that the text may be centered in the box.

This text fitting algorithms are common to all backends, so that this is the first time, the base class really proofs useful, besides defining the API, because we can implement the algorithms here, and reuse them in the backends. The most work in this chapter will actually be done in in the Base class.

We do not implement support for rotated texts, but this would only be one more parameter to the methods and each of the here mentioned backends supports this.

The base class

We create the base class in several steps, starting with the public API, then implementing the algorithm to fit strings into a box, and then, finally rendering the text with the specified text options.

Public API

As a first step, we define the public API we want to use to render texts.

/** * Draw a single string. * * Draw a single string, with the exact given parameters. Do not asjust * font size any more. * * @param string $string * @param Coordinate $position * @param int $size * @param Color $color * @return void */ abstract public function drawString( $string, Coordinate $position, $size, Color $color ); /** * Draws a string * * Render a string or text at the specified position, using a box of the maximum specified size. * * The text will be drawn using the specified font size, or rendered * smaller, if it does not fit into the box. The method will use at minimum * use the font size specified in the class property $minFontSize. If the * string still does not fit the box, the method will throw an exception. * * Using the last parameter you may specify the alignment of the text in * the box. This may be a bitmask of the alignment constants in the base * class. It defaults to a top left alignment. * * @param string $text * @param Coordinate $position * @param int $width * @param int $height * @param int $size * @param Color $color * @param int $align * @return void */ public function drawText( $text, Coordinate $position, $width, $height, $size, Color $color, $align = 18 ) { }

The first method just should render the given string at the given position, without performing any resizing or wrapping of texts. This method needs to be implemented by the backends, because it only does the necessary rendering.

The second method will be used to render longer texts, which should be wrapped, so that they fit into the given boundings, and the size of those texts may be reduced, until it fits into these boundings. Because we have boundings, we can also specify an alignment here, which consists of a bitmask of some class constants.

/** * Alignment: Horizontally centered */ const ALIGN_CENTER = 1; /** * Alignment: Horizontally left */ const ALIGN_LEFT = 2; /** * Alignment: Horizontally right */ const ALIGN_RIGHT = 4; /** * Alignment: Vertically centered */ const ALIGN_MIDDLE = 8; /** * Alignment: Vertically top */ const ALIGN_TOP = 16; /** * Alignment: Vertically bottom */ const ALIGN_BOTTOM = 32;

Using this specification you may put your text at all 9 possible positions in the given box. The alignment defaults to Base::ALIGN_LEFT | Base::ALIGN_TOP, which equals 18. As a last publicly visible change we add two public properties to the Base class.

  • $minFontSize

    This option limits the automatic reducing of the font size to some extend. The value of this property defaults to 6, which still should be somehow readable.

  • $lineSpacing

    This options defines how much of the current font size should be used for the space between lines. You may also provide negative values here, and it defaults to .1.

Fit string into box

Obviuosly the first call in the drawText() method will go to a method, which will split up the text into lines, and return a font size, which will work with the given boundings. This method will be called getLineArrayFromString() and is also implemented in the Base class.

/** * Adjust font size, until we find a way to fit string in the box * * The method will try fit the string in the box with the given font size, * and reduce it further, if it does not fit, until the string fits. * * If not fitting font size could be found, the method will throw an * exception. * * @param string $string * @param Coordinate $position * @param float $width * @param float $height * @param int $size * @return array */ protected function getLineArrayFromString( $string, Coordinate $position, $width, $height, $size ) { // Text cannot be rendered larger then the text box $maxSize = min( $height, $size ); $result = false; // Try to fit the stuff in the box, and reduce the font size until we // are smaller then the minimal fornt size, or we found a way to fit // the string in the box. for ( $size = $maxSize; $size >= $this->minFontSize; ) { $result = $this->testFitStringInTextBox( $string, $position, $width, $height, $size ); if ( is_array( $result ) ) { // We found a match return array( $result, $size, ); } // Reduce the font size otherwise $size = ( ( $newsize = $size * ( $result ) ) >= $size ? $size - 1 : floor( $newsize ) ); } // We could not find a working font size, so that we throw an exception throw new Exception( "Could not fit string '$string' in box $width * $height with minimum font size {$this->minFontSize}." ); }

First, we reduce the maximum font size to the height of the given boundings - obviously a string with a larger font size would never fit. This is one of the first optimizations to save us some iterations.

After this we start a for loop, which starts with the maximum size, and reduces this size, until we found a fitting text size, or reached the minimum font size. To test, if a string of some size fits into the box, we call the helper method testFitStringInTextBox(), which will either return an array, which contains the text split up into lines, or some numeric factor. If we get an array, we found our matching font size and can just return it.

If we received a numeric return value it is either a float value lower then 1, which gives us a factor to reduce the font size with, or an integer. The float values are used, when the testFitStringInTextBox() detected, that the font size needs a drastically reduction, so that we save some iterations again. The other possible value is the int value 1, which means, that we reduce the font size by only 1.

If we did not find a matching font size larger or equal the minimum font size, we just throw an exception at the very end of the method, so that the user knows, that he is required to change something here.

Interested in, what the testFitStringInTextBox() method does? Here it is:

/** * Test if string fits in a box with given font size * * This method splits the text up into tokens and tries to wrap the text * in an optimal way to fit in the Box defined by width and height. * * If the text fits into the box an array with lines is returned, which * can be used to render the text later: * array( * // Lines * array( 'word', 'word', .. ), * ) * Otherwise the function will return a factor to reduce the font size. * * @param string $string * @param Coordinate $position * @param float $width * @param float $height * @param int $size * @return mixed */ protected function testFitStringInTextBox( $string, Coordinate $position, $width, $height, $size ) { // Tokenize String $tokens = preg_split( '/\s+/', $string ); $initialHeight = $height; $lines = array( array() ); $line = 0; foreach ( $tokens as $nr => $token ) { // Add token to tested line $selectedLine = $lines[$line]; $selectedLine[] = $token; $boundings = $this->getTextBoundings( $size, implode( ' ', $selectedLine ) ); $boundingsWidth = $boundings[0]; // Check if line is too long if ( $boundingsWidth > $width ) { if ( count( $selectedLine ) == 1 ) { // Return false if one single word does not fit into one line // Scale down font size to fit this word in one line return $width / $boundingsWidth; } else { // Put word in next line instead and reduce available height by used space $lines[++$line][] = $token; $height -= $size * ( 1 + $this->lineSpacing ); } } else { // Everything is ok - put token in this line $lines[$line][] = $token; } // Return false if text exceeds vertical limit if ( $size > $height ) { return 1; } } // Check width of last line $boundings = $this->getTextBoundings( $size, implode( ' ', $lines[$line] ) ); $boundingsWidth = $boundings[0]; if ( $boundingsWidth > $width ) { return 1; } // It seems to fit - return line array return $lines; }

This method basically splits up a string in so called tokens, which are just the words in the given string, separated by whitespace, and tries to put as many words, as possible, in one line, continuing with the next line, until the vertical limit of the box is reached. If the limit is not reached at the end, the string obviously fits the box.

During this process the array with the lines, and for each line an array with the words fitting in this line, is stored, so that we can return this line array at the end - to store the word to line mapping and reuse it during the rendering process.

To know how much space a single line, with some amount of words, currently consumes the backends need to implement the here called method getTextBoundings(). This method returns an array with the width and height this string would consume, when rendered. This is, of course, highly dependant on the backend and its string rendering methods.

Render the text

Now we just need to implement the drawText() method, which of course makes use of the just introduced methods.

public function drawText( $text, Coordinate $position, $width, $height, $size, Color $color, $align = 18 ) { // Get text split into lines, and a fitting text size list( $lines, $size ) = $this->getLineArrayFromString( $text, $position, $width, $height, $size ); // Calculate height of text lines $completeHeight = count( $lines ) * $size + ( count( $lines ) - 1 ) * $this->lineSpacing; // We will modify the position during the text rendering - to not // affect outer reuse of the position, we clone it first $position = clone $position; // Calculate vertical offset depending on text alignment switch ( true ) { case $align & Base::ALIGN_MIDDLE: $position->y += ( $height - $completeHeight ) / 2; break; case $align & Base::ALIGN_BOTTOM: $position->y += $height - $completeHeight; break; } foreach ( $lines as $lineTokens ) { // Merge line tokens again $line = implode( ' ', $lineTokens ); // Get length of current line, to be able to align it correctly if ( $align & ( Base::ALIGN_CENTER | Base::ALIGN_RIGHT ) ) { $lineWidth = $this->getTextBoundings( $size, $line ); $lineWidth = $lineWidth[0]; } // Calculate string root position depending on the horizontal text alignment switch ( true ) { case $align & Base::ALIGN_CENTER: $linePosition = new Coordinate( $position->x + ( $width - $lineWidth ) / 2, $position->y ); break; case $align & Base::ALIGN_RIGHT: $linePosition = new Coordinate( $position->x + $width - $lineWidth, $position->y ); break; default: $linePosition = $position; break; } // Draw string at the calculated position with the calculated size $this->drawString( $line, $linePosition, $size, $color ); // Move current position to next line $position->y += $size + $size * $this->lineSpacing; } }

This method is easier, than it may look like at the first glance. Of course, we use the prior defined method getLineArrayFromString() to get a working font size and the separation of the text into lines. Once we know the number of lines required to render the text, we can calculate the height the text box will actually consume, and store it in the variable $completeHeight.

With the value in $completeHeight we can calculate the offset required for the vertical alignment and modify the text drawing position accordingly. The first switch statement in this method checks for the vertical spare value and modifies the y root position of the text depending on the alignment.

After this we iterate over the lines to actually draw them. For this we first combine the word tokens back to a string. The horizontal alignment, of course, needs to be recalculated for each line, depending on the length of the current line. To calculate the space such a line will consume, we again use the backend specific method getTextBoundings() and then modify the $linePosition depending on the horizontal alignment and the width of the current line.

Now we got the real position where the line string should be rendered at, so that we can call the method drawString(), which will be implemented by the backends. Finally, in the iteration, we need to move the rendering position to the next line, taking the $lineSpacing into account.

With the implementation in the base class we can now start with the backends, which now only need to implement two methods, drawString() and getTextBoundings().

DOM & SVG

I already mentioned in the overview of SVG, that we will get some rendering problems here, so let's again take a look at the implementation first.

/** * Return boundings for the given string * * Return the boundings of the given string, depending on the selected * font, the given string and the given text size. * * The boundings are returned as an array like: * array( * 0 => (float) width, * 1 => (float) height, * ) * * @param int $size * @param string $string * @return array */ protected function getTextBoundings( $size, $string ) { return array( // We guess, that a common char width is roughly .55 * $size $size * strlen( $string ) * .55, $size ); } /** * Draw a single string. * * Draw a single string, with the exact given parameters. Do not asjust * font size any more. * * @param string $string * @param Coordinate $position * @param int $size * @param Color $color * @return void */ public function drawString( $string, Coordinate $position, $size, Color $color ) { // Finally add text nodes to SVG document $textNode = $this->dom->createElement( 'text', $string ); $textNode->setAttribute( 'x', sprintf( '%.4F', $position->x ) ); $textNode->setAttribute( 'y', sprintf( '%.4F', $position->y + $size * .85 ) ); $textNode->setAttribute( 'style', sprintf( 'font-size: %dpx; font-family: %s; fill: #%02x%02x%02x; fill-opacity: %.2F; stroke: none;', $size, $this->font, $color->red, $color->green, $color->blue, 1 - ( $color->alpha / 255 ) ) ); if ( $this->textGroup !== null ) { // This method has been called out of the context of text // rendering, so that we add the string to the text group. $this->textGroup->appendChild( $textNode ); } else { // This method has been called out of the public API, the string is // added directly in the element node. $this->elements->appendChild( $textNode ); } } /** * Draws a string * * Render a string or text at the specified position, using a box of the maximum specified size. * * The text will be drawn using the specified font size, or rendered * smaller, if it does not fit into the box. The method will use at minimum * use the font size specified in the class property $minFontSize. If the * string still does not fit the box, the method will throw an exception. * * Using the last parameter you may specify the alignment of the text in * the box. This may be a bitmask of the alignment constants in the base * class. It defaults to a top left alignment. * * @param string $text * @param Coordinate $position * @param int $width * @param int $height * @param int $size * @param Color $color * @param int $align * @return void */ public function drawText( $text, Coordinate $position, $width, $height, $size, Color $color, $align = 18 ) { // Create group for the text lines $this->textGroup = $this->dom->createElement( 'g' ); $this->elements->appendChild( $this->textGroup ); // Let the method in the base class do the actual rendering parent::drawText( $text, $position, $width, $height, $size, $color, $align ); // Reset text group $this->textGroup = null; }

I am sure you already notice the code flaw, by taking a first look. The getTextBoundings() implementation looks plain wrong. The problem here is, that by default the rendering client actually selects the used font and performs the text rendering. Some clients like Firefox may additionally limit, or increase, the text size beyond the specified values, so that can't be sure, how large the text will be rendered. So that we just guess here, that - with a common font - an average character has the ratio .55 : 1 for width to height.

There are tools available, which can create SVG paths out of a available font and some string. This would allow you to calculate the real size of some string, and specify exactly how this text will be rendered, because the rendering client does not know any more, that this is text, but only sees some path.

This can be considered as a benefit, but this also means, that somebody who wants to modify the SVG at a later stage again, cannot easily modify the text, to fix some typos, or similar.

Besides this, the implementation is quite simple - let's take a first look at the overwritten drawText() method. If we draw multiple lines of text, we want them included in one SVG group element, so that, if one further modifies the document in some editor, the elements are grouped, and can be modified together.

To make this possible, we add some implicit state to the SVG object, by creating a new group before the parent method from the Base class renders the strings. After this we reset the group to null. This group, if existing, can now be used in the drawString method, when called by the Base implementation of drawText().

The drawString() method itself is really simple, we just create a new text node, assign style and position, containing the string, which should be drawn.

You may want to escape XML special chars here, because they may brake the XML document otherwise. Since we do not handle the input charset anywhere in this code, and do not have any knowledge about this here, I skip this in this example implementation.

At the end of the method the created text node is added either directly to the element node, if the drawString() has been called directly, or to the created group, when it is called out of the context of the drawText() method. To use this new created feature, we add one more call to the backend to the used example.

$graphic = new Svg( 150, 150 ); $graphic->addBitmap( new Coordinate( 50, 129 ), 'bitmap.png' ); $graphic->drawPolygon( // ... ); $graphic->drawText( 'SVG rocks!', new Coordinate( 25, 60 ), 70, 68, 32, new Color( '#2e343655' ), Base::ALIGN_CENTER | Base::ALIGN_MIDDLE ); $graphic->save( 'images/example_svg_06.svg' );

As you can see from the example, we want to render the string "SVG rocks!" in the bounding matching the cubes front side. We use a gray half transparent color for this, and define, that the text should horizontally and vertically centered in these boundings. The result may not really look like you expected, though.

Your browser needs to support SVG to display this image. SVG with text

The vertical alignment worked perfectly, but the horizontal alignment failed because of the inaccurate estimation in the getTextBoundings() method. Even you may enhance the guessing by providing better values for some character groups, this will never work really well, without converting the texts to paths, as mentioned above.

GD & PNG

This is the first time, the GD library works really well. We only use the ttf-functions which base on the FreeType2 library. These functions have a nice anti aliasing and you can calculate the text boundings exactly.

/** * Return boundings for the given string * * Return the boundings of the given string, depending on the selected * font, the given string and the given text size. * * The boundings are returned as an array like: * array( * 0 => (float) width, * 1 => (float) height, * ) * * @param int $size * @param string $string * @return array */ protected function getTextBoundings( $size, $string ) { // We only use the imagettf* functions here... $boundings = imagettfbbox( $size, 0, $this->font, $string ); return array( // For the returned boundings array by imagettfbbox check the // documentation $boundings[4] - $boundings[0], $boundings[5] - $boundings[1], ); } /** * Draw a single string. * * Draw a single string, with the exact given parameters. Do not asjust * font size any more. * * @param string $string * @param Coordinate $position * @param int $size * @param Color $color * @return void */ public function drawString( $string, Coordinate $position, $size, Color $color ) { // Merge images $this->mergeCanvas(); // Finally we draw the text imagettftext( $this->destination, $size, 0, $position->x, $position->y + $size, $this->allocate( $color ), $this->font, $string ); }

The implementation of getTextBoundings() using the GD function imagettfbbox() returns exact results, even the return values may look strange at the first glance, but we can easily convert it to the array structure we expect.

GD with textGD with text

As the imagettf* functions already do nice anti aliasing, so we do not want supersampling for the texts, or the texts would look quite blurry again. Because of this, we merge the active canvas again, like described in the previous section on bitmaps, and render the text directly on the destination image. The result now looks really nice, except it is still missing the gradients.

The GD libarary also supports some other font libraries, so that you may want to extend both methods with some more magic. Depending on the features of the current GD installation and the provided font you should be able to switch between the shown FreeType 2 library, the native TTF rendering and the rendering of PostScript Type 1 fonts, which do not support anti aliasing.

Cairo & PNG

As always there is no real problem implementing this with cairo, so just let's take a look at the implementation of the two required methods.

/** * Return boundings for the given string * * Return the boundings of the given string, depending on the selected * font, the given string and the given text size. * * The boundings are returned as an array like: * array( * 0 => (float) width, * 1 => (float) height, * ) * * @param int $size * @param string $string * @return array */ protected function getTextBoundings( $size, $string ) { ::cairo_select_font_face( $this->context, $this->font, CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL ); ::cairo_set_font_size( $this->context, $size ); $extents = ::cairo_text_extents( $this->context, $string ); return array( $extents['width'], $extents['height'], ); } /** * Draw a single string. * * Draw a single string, with the exact given parameters. Do not asjust * font size any more. * * @param string $string * @param Coordinate $position * @param int $size * @param Color $color * @return void */ public function drawString( $string, Coordinate $position, $size, Color $color ) { ::cairo_select_font_face( $this->context, $this->font, CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL ); ::cairo_set_font_size( $this->context, $size ); $this->setStyle( $color, true ); ::cairo_move_to( $this->context, $position->x, $position->y + $size * .85 ); ::cairo_show_text( $this->context, $string ); }

In the implementation of getTextBoundings() the cairo function cairo_text_extents() returns the space consumed by some string together with some other information about the drawn text. This information can easily be used to return the required information about width and height of the string.

Cairo may also load common fonts, like FreeType fonts and Win32 fonts. In this example we use the integrated fonts, like "Sans" in this example. You may of course handle those other fonts, too - but this is not required for this example implementation.

Cairo with textCairo with text

To actually draw the string in the method drawString(), we again select the same font, set the style reusing the known custom method, move our cursor position, to where the text should be drawn and finally render the text. The Cairo API again is quite intuitive, and renders a perfect result.

Ming & Flash

Flash is also a vector graphic format, so that we basically should have the same problems here, we also had in SVG, but there is a solution for flash. You can embed the shapes of the used characters in the delivered file, so that the client knowns how to render those characters, and the file is displayed the same everywhere. The other "benefit" is, that there is only one real flash client out there, so you can easily test how the graphic will look like.

/** * Return boundings for the given string * * Return the boundings of the given string, depending on the selected * font, the given string and the given text size. * * The boundings are returned as an array like: * array( * 0 => (float) width, * 1 => (float) height, * ) * * @param int $size * @param string $string * @return array */ protected function getTextBoundings( $size, $string ) { $text = new SWFText(); $text->setFont( new SWFFont( $this->font ) ); $text->setHeight( $size ); return array( $text->getWidth( $string ), $size ); } /** * Draw a single string. * * Draw a single string, with the exact given parameters. Do not asjust * font size any more. * * @param string $string * @param Coordinate $position * @param int $size * @param Color $color * @return void */ public function drawString( $string, Coordinate $position, $size, Color $color ) { // Finally add textbox to SWF movie $textBox = new SWFTextField( SWFTEXTFIELD_NOEDIT ); $textBox->setFont( new SWFFont( $this->font ) ); $textBox->setHeight( $this->modifyCoordinate( $size ) ); $textBox->setColor( $color->red, $color->green, $color->blue, 255 - $color->alpha ); $textBox->addString( $string ); $textBox->addChars( $this->usedChars ); $object = $this->movie->add( $textBox ); $object->moveTo( $this->modifyCoordinate( $position->x ), $this->modifyCoordinate( $position->y - .15 * $size ) ); }

There are two ways to add text to a flash image, on the one hand there is the SWFText class, and on the other the SWFTextField class. As you can see, I am using both here, so where are the differences?

  • SWFText

    This class has the method getWidth(), so you are able to receive the space consumed by the string. Both classes allow to set the font using a SWFFont object, constructed of the the special font type - the fdb-fonts. But the SWFText class does not allow the above mentioned embedding of the character shapes, so that the actual rendering depends on the fonts available at the viewers environment.

  • SWFTextField

    This class does not allow the calculation of consumed space by some string, but has other features we require for the actual text rendering, like the embedding of the character shapes. It also has some other features, like the optional possibility for the viewer to change the displayed texts. Of course we disable this for out graphics, as you can see in the constructor. Sadly the SWFTextField currently does not support UTF-8 characters, but this may change some time in the future.

After the explanations of the two used classes, there is one more thing to mention. The characters, which are actually embedded into the flash file do not depend on the used characters, but on the characters you set on the first call to addChars() on the SWFTextField object.

To work with this limitation, we have two choices:

  1. Wait with actual text rendering until the file is saved.

    This is the way I go in ezcGraph, because the texts should always be on top, so I can just wait with the text rendering, collect all chars, and render the texts into the movie just before the movie is saved.

  2. Add all chars, which will be used on the first call.

    In a more general purpose library we may want text in the graphic below some other shapes, so that I added a class property $usedChars, which contains several characters, which are added on the first call. If you intend to use some strange characters, you should modify its contents before the first class to drawString(), or drawText().

If you miss some characters, they will just not appear in the final image.

Your browser needs to support Flash contents to display this image. Flash with text

The reuslt now looks the same, as in the other libraries.


Comments

buJaNG at Saturday, 9.5. 2009

Thank's for your share info.

Subscribe to updates

There are multiple ways to stay updated with new posts on my blog: