Raster drawing with GD#

GD is Perl’s binding to libgd, the small C library behind a great deal of the web’s generated images: thumbnails, charts, captchas, badges. It creates an image, draws on it, filters it, and saves it, and its drawing primitives are the ones from the algorithms chapter running at C speed. On pperl it is a native module: the C half is bound directly to the system’s libgd, and GD’s own Perl half (GD::Image, GD::Polygon, GD::Simple) runs as it is, so code written for GD on any Perl 5 runs here unchanged.

The API is positional and terse. Methods take coordinates and a colour in a fixed order, and most of them return nothing useful. The ones that can fail either return false or undef, and the idiom is to check those, or die with the reason, as the file loaders do.

use GD;

my $im = GD::Image->new(400, 300, 1);   # width, height, true-colour

Colours#

GD’s defining quirk is how it names a colour. You never pass red, green and blue to a drawing method. You ask the image for a colour first, with colorAllocate, and then draw with the integer it returns. What that integer means depends on the kind of image.

A palette image (created without the third argument) holds a table of at most 256 colours, and every pixel stores an index into it. This is the model of GIF and of 256-colour displays. colorAllocate adds an entry and returns its index, and the first colour you allocate becomes the background.

A true-colour image (third argument 1) stores the colour itself in every pixel. colorAllocate has no table to add to, so it simply packs the channels into one integer, and that integer is the colour. Prefer true-colour for all new work: it has no 256-colour ceiling, and filters, resampling and alpha blending all assume it.

use strict;
use warnings;
use GD;

# a palette image: colorAllocate hands out table indices
my $pal   = GD::Image->new(100, 100);
my $white = $pal->colorAllocate(255, 255, 255);   # first one is the background
my $red   = $pal->colorAllocate(255, 0, 0);
print "palette: white=$white red=$red, ",
      $pal->colorsTotal, " colours in the table\n";

# a truecolor image: the "index" is the colour itself, packed
my $tc   = GD::Image->new(100, 100, 1);
my $tw   = $tc->colorAllocate(255, 255, 255);
my $tr   = $tc->colorAllocate(255, 0, 0);
my $half = $tc->colorAllocateAlpha(255, 0, 0, 64);  # about 50% transparent
printf "truecolor: white=0x%08x red=0x%08x half=0x%08x\n", $tw, $tr, $half;
palette: white=0 red=1, 2 colours in the table
truecolor: white=0x00ffffff red=0x00ff0000 half=0x40ff0000

On a palette image the table does fill up. The 257th colorAllocate returns -1, and colorResolve is the safe alternative: it returns an exact match if there is one, allocates if there is room, and otherwise falls back to the closest colour already in the table.

use strict;
use warnings;
use GD;

my $pal = GD::Image->new(10, 10);
$pal->colorAllocate($_, 0, 0) for 0 .. 255;       # fill all 256 slots
print "table: ", $pal->colorsTotal, " colours\n";
print "allocate one more: ", $pal->colorAllocate(1, 2, 3), "\n";
print "resolve instead:   ", $pal->colorResolve(1, 2, 3), "\n";
table: 256 colours
allocate one more: -1
resolve instead:   1

colorExact and colorClosest are the two halves of colorResolve on their own; rgb($colour) turns an index or packed colour back into its three channels.

Alpha, the GD way#

colorAllocateAlpha takes a fourth value, and it runs the opposite way to the foundations chapter: in GD, alpha 0 is fully opaque and 127 is fully transparent, a 7-bit range with transparency at the top. So 64 is roughly half and half.

What happens when you draw with a translucent colour depends on alphaBlending. With blending on (the default for true-colour images) the colour is composited source-over onto what is already there. With it off, the pixel is simply replaced, alpha and all.

use strict;
use warnings;
use GD;

my $im    = GD::Image->new(100, 100, 1);
my $white = $im->colorAllocate(255, 255, 255);
my $half  = $im->colorAllocateAlpha(255, 0, 0, 64);

my @before = $im->rgb($im->getPixel(50, 50));
print "fresh canvas: @before\n";                 # truecolor starts black

$im->filledRectangle(0, 0, 99, 99, $white);
$im->filledRectangle(0, 0, 49, 99, $half);       # blending on: composite
$im->alphaBlending(0);
$im->filledRectangle(50, 0, 99, 99, $half);      # blending off: replace

for my $x (25, 75) {
    my $c = $im->getPixel($x, 50);
    printf "x=%d: rgb=%s alpha=%d\n", $x, join(' ', $im->rgb($c)), $im->alpha($c);
}
fresh canvas: 0 0 0
x=25: rgb=255 128 128 alpha=0
x=75: rgb=255 0 0 alpha=64

The blended half is the pink 255 128 128 the foundations chapter computed by hand for red at half opacity over white. The other half keeps pure red with alpha 64 stored in the pixel, which is what you want when you are building an image with transparent regions to save (see transparency in files below).

The first line of output is a trap worth stating once: a new true-colour image starts black, not white. Always paint the background with a filledRectangle over the whole canvas before drawing anything else.

The primitives#

Every primitive takes its geometry first and the colour last, all positional. Rectangles use two corners, both inclusive, so a full 400 x 300 canvas is (0, 0, 399, 299). Ellipses and arcs use a centre plus the full width and height (not a radius), and arc angles are in degrees, starting at three o’clock and increasing clockwise (clockwise because y grows downward).

use strict;
use warnings;
use GD;

my $im = GD::Image->new(400, 300, 1);
my $white  = $im->colorAllocate(255, 255, 255);
my $black  = $im->colorAllocate(0, 0, 0);
my $blue   = $im->colorAllocate(0, 90, 200);
my $red    = $im->colorAllocate(220, 40, 40);
my $green  = $im->colorAllocate(60, 170, 80);
my $yellow = $im->colorAllocate(250, 200, 40);

$im->filledRectangle(0, 0, 399, 299, $white);      # background

# lines: plain, dashed, and thick
$im->line(10, 10, 130, 60, $black);
$im->dashedLine(10, 30, 130, 80, $black);
$im->setThickness(5);
$im->line(10, 60, 130, 110, $blue);
$im->setThickness(1);

# rectangles: corner to corner, both corners inclusive
$im->rectangle(150, 10, 250, 80, $black);
$im->filledRectangle(270, 10, 390, 80, $yellow);

# ellipses and arcs: centre, then full width and height
$im->ellipse(70, 170, 110, 60, $black);
$im->filledEllipse(70, 170, 50, 50, $blue);
$im->arc(200, 170, 100, 100, 0, 270, $black);          # three quarters
$im->filledArc(330, 170, 100, 100, 30, 330, $yellow);  # a pie slice
$im->filledArc(330, 170, 100, 100, 30, 330, $black, gdEdged | gdNoFill);

# polygons come from a GD::Polygon
my $tri = GD::Polygon->new;
$tri->addPt(20, 290);
$tri->addPt(120, 290);
$tri->addPt(70, 220);
$im->filledPolygon($tri, $green);
$im->polygon($tri, $black);

my $zig = GD::Polygon->new;
$zig->addPt($_->[0], $_->[1]) for [150, 280], [180, 230], [210, 280], [240, 230];
$im->unclosedPolygon($zig, $red);                  # open path, no closing edge

# flood fills
$im->rectangle(270, 230, 390, 290, $black);
$im->line(330, 230, 330, 290, $black);
$im->fill(300, 260, $red);                         # fills the left cell only
$im->fillToBorder(360, 260, $black, $blue);        # stops at black pixels

open my $fh, '>', 'primitives.png' or die "primitives.png: $!";
binmode $fh;
print $fh $im->png;
close $fh;

The result is a 3 x 3 sheet: three lines, an outlined and a filled rectangle; an ellipse around a disc, a three-quarter arc open at the top right, a pie with its mouth to the right; a filled triangle, a red zigzag, and a box whose two halves were flood-filled red and blue.

A few details the sheet shows:

  • filledArc takes an optional style. The default fills a pie slice; gdChord replaces the curved edge with a straight line between the two end points, gdNoFill draws only the outline, and gdEdged adds the two radii. They combine with |, which is how the pie above gets its black outline on a second call.

  • polygon (also spelled openPolygon) draws a closed outline; unclosedPolygon leaves off the edge from the last point back to the first, which is the polyline. filledPolygon fills using the scanline method.

  • fill floods the connected region of the colour under the seed point, so it stopped at the black divider. fillToBorder floods until it meets one named border colour, whatever it crosses on the way.

  • setThickness changes the pen for every later line, outline and arc, until you set it back.

Single pixels are setPixel($x, $y, $colour) and getPixel($x, $y); the latter returns the index or packed colour, which rgb and alpha take apart.

Anti-aliasing#

Everything above is aliased: each pixel is fully on or fully off, the staircase of the algorithms chapter. GD anti-aliases through a special colour. You name the real colour with setAntiAliased, then draw with the constant gdAntiAliased in place of a colour:

use strict;
use warnings;
use GD;

my $im    = GD::Image->new(220, 60, 1);
my $white = $im->colorAllocate(255, 255, 255);
my $black = $im->colorAllocate(0, 0, 0);
$im->filledRectangle(0, 0, 219, 59, $white);

$im->line(10, 10, 100, 37, $black);                # aliased

$im->setAntiAliased($black);
$im->line(120, 10, 210, 37, gdAntiAliased);        # anti-aliased

# the same column of each line, three pixels high
for my $x (55, 165) {
    my @grey = map { ($im->rgb($im->getPixel($x, $_)))[0] } 22 .. 24;
    print "x=$x: @grey\n";
}

open my $fh, '>', 'aa.png' or die "aa.png: $!";
binmode $fh;
print $fh $im->png;
close $fh;
x=55: 255 255 0
x=165: 255 127 128

At that column the ideal line passes exactly between two pixel rows. The aliased line picks one and paints it solid black; the anti-aliased line gives each row half coverage, two mid greys. That is the coverage idea from the algorithms chapter, computed for you.

gdAntiAliased works for lines and for polygons, outlined or filled. filledEllipse ignores it and keeps a hard edge. For curves that must be smooth, see when GD is the wrong tool.

GD has three more special colours in the same style: gdStyled draws with a repeating pattern of colours set by setStyle (a custom dash), gdBrushed stamps a small image set by setBrush along the line, and gdTiled fills with an image set by setTile.

Text#

GD has two text systems. The built-in bitmap fonts need nothing installed: gdTinyFont, gdSmallFont, gdMediumBoldFont, gdLargeFont and gdGiantFont, fixed-size and ASCII-oriented, drawn with string (and stringUp for text rotated 90 degrees). The TrueType route, stringFT, renders any font file through FreeType at any size and angle, anti-aliased.

use strict;
use warnings;
use GD;

my $im    = GD::Image->new(320, 150, 1);
my $white = $im->colorAllocate(255, 255, 255);
my $black = $im->colorAllocate(0, 0, 0);
my $blue  = $im->colorAllocate(0, 90, 200);
$im->filledRectangle(0, 0, 319, 149, $white);

# the built-in bitmap fonts: x, y is the top-left of the text
$im->string(gdSmallFont, 10, 10, 'gdSmallFont', $black);
$im->string(gdGiantFont, 10, 28, 'gdGiantFont', $black);
$im->stringUp(gdMediumBoldFont, 300, 140, 'stringUp', $blue);
printf "gdGiantFont cell: %dx%d\n", gdGiantFont->width, gdGiantFont->height;

# TrueType through FreeType: x, y is the baseline start
my $font = '/usr/share/fonts/TTF/DejaVuSans.ttf';
my @box = $im->stringFT($blue, $font, 20, 0, 10, 90, 'TrueType text')
    or die "stringFT: $@";
print "bounding box: @box\n";

# angle is in radians, counter-clockwise
$im->stringFT($black, $font, 11, 0.3, 180, 135, 'at an angle')
    or die "stringFT: $@";

# measure without drawing: call it on the class, not an image
my @size = GD::Image->stringFT($black, $font, 20, 0, 0, 0, 'TrueType text');
printf "width %d, height %d\n", $size[2] - $size[0], $size[1] - $size[5];

# with fontconfig on, a font name works instead of a path
$im->useFontConfig(1);
$im->stringFT($black, 'DejaVu Sans:bold', 11, 0, 10, 135, 'by name')
    or die "stringFT: $@";

open my $fh, '>', 'text.png' or die "text.png: $!";
binmode $fh;
print $fh $im->png;
close $fh;
gdGiantFont cell: 9x15
bounding box: 9 95 188 95 188 70 9 70
width 178, height 24

The font path is a system detail; on your machine fc-list shows the installed fonts and their files, or turn on useFontConfig and name the font as the last call does.

Three things differ between the two systems, and all three catch people. The position: string places the top-left corner of the text, stringFT places the start of the baseline, so the same y puts the two at different heights. The argument order: string takes the font first and the colour last, stringFT takes the colour first. And the angle: stringFT wants radians, counter-clockwise, while arc wants degrees, clockwise.

stringFT returns the four corners of the text’s bounding box as eight numbers (lower-left, lower-right, upper-right, upper-left, each as x and y), and an empty list on failure with the reason in $@, so with a wrong font path the example above dies with stringFT: Could not find/open font. Called on the class instead of an image it measures without drawing, which is how you centre or right-align a caption.

Saving and loading#

Output is one method per format, each returning the encoded bytes; you write them to a file opened in binary mode.

use strict;
use warnings;
use GD;

sub save {
    my ($file, $bytes) = @_;
    defined $bytes or die "$file: this libgd cannot write that format\n";
    open my $fh, '>', $file or die "$file: $!";
    binmode $fh;
    print $fh $bytes;
    close $fh;
}

my $im    = GD::Image->new(200, 100, 1);
my $white = $im->colorAllocate(255, 255, 255);
my $red   = $im->colorAllocate(200, 40, 40);
$im->filledRectangle(0, 0, 199, 99, $white);
$im->filledEllipse(100, 50, 160, 80, $red);

save('shape.png',  $im->png);          # optional 0..9 compression level
save('shape.jpg',  $im->jpeg(85));     # quality 0..100
save('shape.webp', $im->webp(80));     # quality 0..100
save('shape.gif',  $im->gif);          # palette format: colours are reduced
save('shape.bmp',  $im->bmp);

my @loaded = (
    [ 'shape.png',  GD::Image->newFromPng('shape.png', 1) ],   # 1: true-colour
    [ 'shape.jpg',  GD::Image->newFromJpeg('shape.jpg', 1) ],
    [ 'shape.webp', GD::Image->newFromWebp('shape.webp') ],
    [ 'shape.gif',  GD::Image->newFromGif('shape.gif') ],
    [ 'shape.bmp',  GD::Image->newFromBmp('shape.bmp') ],
);
for (@loaded) {
    my ($file, $back) = @$_;
    printf "%-10s %5d bytes  %dx%d  centre=%s\n", $file, -s $file,
        $back->getBounds, join(' ', $back->rgb($back->getPixel(100, 50)));
}
shape.png    765 bytes  200x100  centre=200 40 40
shape.jpg   3216 bytes  200x100  centre=200 40 40
shape.webp  1274 bytes  200x100  centre=199 40 39
shape.gif    413 bytes  200x100  centre=204 42 44
shape.bmp  60054 bytes  200x100  centre=200 40 40

The centre pixel tells the story of each format. PNG and BMP are lossless and give back exactly 200 40 40. JPEG and WEBP are lossy; on a flat area like this they come back exact or within a step. GIF is a palette format, so the true-colour image was reduced to at most 256 colours on the way out, and the red moved to the nearest colour in that palette.

Loading is one constructor per format, each taking a file name or an open filehandle: newFromPng, newFromJpeg, newFromGif, newFromWebp, newFromBmp. Each has a Data variant for bytes you already hold in a scalar, such as newFromPngData($bytes). A loader either returns an image or dies: a missing file with ... not found, a file it cannot decode with the name of the libgd call that refused it. That is why the loop above checks nothing.

The second argument of newFromPng and newFromJpeg (and their Data variants) decides whether a true-colour file stays true-colour, and leaving it out has a cost:

use strict;
use warnings;
use GD;

my $pal = GD::Image->newFromPng('shape.png');       # no flag: palette
my $tc  = GD::Image->newFromPng('shape.png', 1);    # 1: true-colour
for ([ 'no flag' => $pal ], [ 'flag 1' => $tc ]) {
    my ($how, $im) = @$_;
    printf "%-8s isTrueColor=%d  centre=%s\n", $how, $im->isTrueColor,
        join(' ', $im->rgb($im->getPixel(100, 50)));
}

my $was = GD::Image->trueColor(1);                  # the program-wide default
my $any = GD::Image->new('shape.png');
printf "trueColor(1), was %d: new(file) isTrueColor=%d\n", $was, $any->isTrueColor;

eval { GD::Image->newFromPng('shape.jpg') };
my ($why) = $@ =~ /^(.*?) at /;
print "wrong format: $why\n";
no flag  isTrueColor=0  centre=204 42 44
flag 1   isTrueColor=1  centre=200 40 40
trueColor(1), was 0: new(file) isTrueColor=1
wrong format: gdImageCreateFromPng error

Without the flag, GD converts the loaded file to a palette image, the same reduction the GIF went through, and the red moves. Pass 1 for anything you will filter, resample or blend, which is what the rest of this chapter assumes. GD::Image->trueColor(1) changes the default for the whole program and returns the previous one; it also makes GD::Image->new($width, $height) true-colour, and it is the only way to steer GD::Image->new($file), which recognises PNG, JPEG, GIF and WEBP from the file’s first bytes but takes no flag of its own. A palette file such as a GIF loads as a palette image either way, and paletteToTrueColor converts it in place.

Which formats work is decided by how the system’s libgd was built, not by GD. An encoder libgd was built without returns undef, which is why save above checks. GD::supportsFileType answers from libgd’s format table, which lists a format as present even when its codec is a stub, so the reliable test is the one save does: ask for the bytes and see whether you got any.

Transparency in files#

Two separate mechanisms make parts of a saved image transparent, one per image kind.

use strict;
use warnings;
use GD;

# a PNG with a real alpha channel
my $im = GD::Image->new(100, 100, 1);
$im->alphaBlending(0);                              # write alpha, do not blend
$im->saveAlpha(1);                                  # keep it in the PNG
my $clear = $im->colorAllocateAlpha(0, 0, 0, 127);  # fully transparent
my $glass = $im->colorAllocateAlpha(0, 90, 200, 64);
$im->filledRectangle(0, 0, 99, 99, $clear);
$im->filledEllipse(50, 50, 80, 80, $glass);

open my $fh, '>', 'glass.png' or die "glass.png: $!";
binmode $fh;
print $fh $im->png;
close $fh;

# a palette GIF with one colour marked transparent
my $gif = GD::Image->new(100, 100);
my $bg  = $gif->colorAllocate(255, 255, 255);
my $ink = $gif->colorAllocate(0, 128, 0);
$gif->transparent($bg);
$gif->filledRectangle(25, 25, 74, 74, $ink);

open $fh, '>', 'stamp.gif' or die "stamp.gif: $!";
binmode $fh;
print $fh $gif->gif;
close $fh;

my $back = GD::Image->newFromPng('glass.png', 1);
printf "glass.png: corner alpha %d, centre alpha %d\n",
    $back->alpha($back->getPixel(0, 0)), $back->alpha($back->getPixel(50, 50));
my $g = GD::Image->newFromGif('stamp.gif');
printf "stamp.gif: transparent index %d\n", $g->transparent;
glass.png: corner alpha 127, centre alpha 64
stamp.gif: transparent index 0

For a true-colour PNG, per-pixel alpha survives the round trip only when both switches are set: alphaBlending(0) so the alpha is written into the pixels rather than blended away, and saveAlpha(1) so the PNG encoder keeps the channel. Forget the second and the file comes out opaque. For a palette image, transparent($index) marks one colour of the table as “see-through”, all or nothing, which is the only kind of transparency GIF has.

Scaling and copying#

GD scales by copying from one image into another of a different size. You create the destination first, then copy a rectangle of the source into a rectangle of the destination. There are two copies, and the difference is the one between the aliased and anti-aliased lines. copyResized picks the nearest source pixel for each destination pixel. copyResampled averages the source pixels that land on each destination pixel, which is the supersampling downscale from the algorithms chapter. The example shrinks the primitives.png sheet saved earlier both ways:

use strict;
use warnings;
use GD;

my $src = GD::Image->newFromPng('primitives.png', 1);
my ($sw, $sh) = $src->getBounds;

# fit inside 160x160, keeping the aspect ratio
my $scale = 160 / ($sw > $sh ? $sw : $sh);
my ($dw, $dh) = (int($sw * $scale + 0.5), int($sh * $scale + 0.5));

my $fast   = GD::Image->new($dw, $dh, 1);
my $smooth = GD::Image->new($dw, $dh, 1);
#                   src   dstX dstY srcX srcY dstW dstH srcW srcH
$fast->copyResized($src,     0,   0,   0,   0, $dw, $dh, $sw, $sh);
$smooth->copyResampled($src, 0,   0,   0,   0, $dw, $dh, $sw, $sh);

for ([nearest => $fast], [resampled => $smooth]) {
    my ($name, $im) = @$_;
    my %colours;
    for my $y (0 .. $dh - 1) {
        $colours{ $im->getPixel($_, $y) }++ for 0 .. $dw - 1;
    }
    printf "%-9s %dx%d, %d distinct colours\n", $name, $dw, $dh, scalar keys %colours;
    open my $fh, '>', "thumb-$name.png" or die "thumb-$name.png: $!";
    binmode $fh;
    print $fh $im->png;
    close $fh;
}
nearest   160x120, 6 distinct colours
resampled 160x120, 133 distinct colours

The nearest-neighbour thumbnail keeps only the six colours it was drawn with, and pays for it: one-pixel lines fall between the sampled pixels and vanish, so the rectangle outline and the arc from the primitives sheet are simply gone. The resampled thumbnail blends them into faint but present lines, which is where its 133 in-between colours come from. Use copyResampled for anything a person will look at, and copyResized for pixel art you want to stay blocky when enlarged.

The same copy-a-rectangle shape does compositing:

use strict;
use warnings;
use GD;

my $canvas = GD::Image->new(100, 100, 1);
my $blue   = $canvas->colorAllocate(0, 0, 255);
$canvas->filledRectangle(0, 0, 99, 99, $blue);

my $patch = GD::Image->new(40, 40, 1);
my $red   = $patch->colorAllocate(200, 40, 40);
$patch->filledRectangle(0, 0, 39, 39, $red);

#                  src     dstX dstY srcX srcY  w   h
$canvas->copy($patch,        10,  10,   0,   0, 40, 40);       # stamp it
$canvas->copyMerge($patch,   50,  50,   0,   0, 40, 40, 50);   # 50% blend

my $backup = $canvas->clone;                       # an independent copy

printf "copy:      %s\n", join ' ', $canvas->rgb($canvas->getPixel(30, 30));
printf "copyMerge: %s\n", join ' ', $canvas->rgb($canvas->getPixel(70, 70));
printf "clone:     %s\n", join ' ', $backup->rgb($backup->getPixel(70, 70));
copy:      200 40 40
copyMerge: 100 20 147
clone:     100 20 147

copy stamps the source unchanged. copyMerge takes a last argument from 0 to 100 and mixes that percentage of the source into the destination: half of 200 40 40 plus half of 0 0 255 is 100 20 147. clone returns a new image with the same pixels, for when you want to keep the original before filtering it. Storable’s dclone is no substitute: GD’s Storable hooks hand it an empty object with no image behind it, and the first method called on that object dies.

Rotating, flipping and cropping#

The quarter turns and mirror images are exact pixel moves, with no resampling and nothing lost. The copy forms return a new image and leave the source alone; flipHorizontal and flipVertical also come in place. crop returns a new image holding one rectangle of the source, given as an array reference of x, y, width and height. The example marks two corners of a card so the output can say where they went:

use strict;
use warnings;
use GD;

# a card that shows its orientation: red block top-left, blue block top-right
my $card  = GD::Image->new(60, 40, 1);
my $white = $card->colorAllocate(255, 255, 255);
my $red   = $card->colorAllocate(200, 40, 40);
my $blue  = $card->colorAllocate(40, 90, 220);
$card->filledRectangle(0, 0, 59, 39, $white);
$card->filledRectangle(0, 0, 14, 14, $red);
$card->filledRectangle(50, 0, 59, 9, $blue);

# which corner pixel holds which block
sub corners {
    my ($im) = @_;
    my ($w, $h) = $im->getBounds;
    my %at;
    for ([ 'top-left', 0, 0 ], [ 'top-right', $w - 1, 0 ],
         [ 'bottom-left', 0, $h - 1 ], [ 'bottom-right', $w - 1, $h - 1 ]) {
        my ($name, $x, $y) = @$_;
        my $c = $im->getPixel($x, $y);
        $at{red}  = $name if $c == $red;
        $at{blue} = $name if $c == $blue;
    }
    return sprintf "%dx%d  red %-12s blue %s", $w, $h,
        $at{red} // '-', $at{blue} // '-';
}

print "original            ", corners($card), "\n";
for my $method (qw(copyRotate90 copyRotate180 copyRotate270
                   copyFlipHorizontal copyFlipVertical)) {
    my $turned = $card->$method;                   # a new image
    printf "%-19s %s\n", $method, corners($turned);
}

my $mirror = $card->clone;
$mirror->flipHorizontal;                           # in place
print "flipHorizontal      ", corners($mirror), "\n";

my $piece = $card->crop([0, 0, 30, 20]);           # [x, y, width, height]
print "crop                ", corners($piece), "\n";

# trim a border: every edge row and column within 0.5% of white goes
my $trimmed = $card->cropThreshold($white, 0.5);
print "cropThreshold       ", corners($trimmed), "\n";

# a contact sheet of the card and its three rotations
my $sheet = GD::Image->new(250, 70, 1);
$sheet->filledRectangle(0, 0, 249, 69, $sheet->colorAllocate(200, 200, 200));
my $x = 5;
for my $im ($card, $card->copyRotate90, $card->copyRotate180, $card->copyRotate270) {
    my ($w, $h) = $im->getBounds;
    $sheet->copy($im, $x, 5, 0, 0, $w, $h);
    $x += $w + 5;
}
open my $fh, '>', 'rotations.png' or die "rotations.png: $!";
binmode $fh;
print $fh $sheet->png;
close $fh;
original            60x40  red top-left     blue top-right
copyRotate90        40x60  red top-right    blue bottom-right
copyRotate180       60x40  red bottom-right blue bottom-left
copyRotate270       40x60  red bottom-left  blue top-left
copyFlipHorizontal  60x40  red top-right    blue top-left
copyFlipVertical    60x40  red bottom-left  blue bottom-right
flipHorizontal      60x40  red top-right    blue top-left
crop                30x20  red top-left     blue -
cropThreshold       60x15  red bottom-left  blue top-right

copyRotate90 turns the image a quarter clockwise as it appears on screen, the red corner moving from top-left to top-right, and swaps width and height; copyRotate270 is the quarter turn the other way. The contact sheet rotations.png shows the card and its three turns side by side. A flip is a mirror, not a turn: after copyFlipHorizontal the blue block is on the left, but both blocks are still at the top, which no rotation achieves.

cropThreshold strips a uniform border instead of taking a rectangle you name. It removes rows and columns from each edge for as long as every pixel in them lies within the threshold of the colour you pass, the threshold being a percentage of the largest possible colour distance, measured squared. On the card only the bottom goes: the top row, the left column and the right column each touch a coloured block, so the white below the red block’s 15 rows is all it can take. A threshold of 0 matches nothing, so a small positive value is the usual choice even for an exact colour.

Filters#

libgd ships a set of whole-image filters. Each works in place and returns true on success. Most of them are fixed 3 x 3 convolutions, the operation the algorithms chapter wrote by hand, which the example below makes visible by reading one pixel on the edge of a red square and one inside it:

use strict;
use warnings;
use GD;

# a test card: a red square on white, so edges show what a filter does
sub card {
    my $im = GD::Image->new(40, 40, 1);
    my $white = $im->colorAllocate(255, 255, 255);
    my $red   = $im->colorAllocate(200, 40, 40);
    $im->filledRectangle(0, 0, 39, 39, $white);
    $im->filledRectangle(10, 10, 29, 29, $red);
    return $im;
}

my @filters = (
    [ gaussianBlur    => [] ],
    [ meanRemoval     => [] ],     # sharpen
    [ emboss          => [] ],
    [ edgeDetectQuick => [] ],
    [ negate          => [] ],
    [ grayscale       => [] ],
    [ brightness      => [40] ],   # -255 .. 255
    [ contrast        => [50] ],   # positive lowers contrast, negative raises it
    [ smooth          => [1] ],    # weight of the centre pixel
    [ pixelate        => [8, 0] ], # block size, mode
);

for my $f (@filters) {
    my ($method, $args) = @$f;
    my $im = card();
    $im->$method(@$args) or die "$method failed\n";
    printf "%-16s edge=%-12s inside=%s\n", $method,
        join('/', $im->rgb($im->getPixel(10, 20))),
        join('/', $im->rgb($im->getPixel(20, 20)));
}
gaussianBlur     edge=213/93/93    inside=200/40/40
meanRemoval      edge=35/0/0       inside=200/40/40
emboss           edge=209/255/255  inside=127/127/127
edgeDetectQuick  edge=17/0/0       inside=127/127/127
negate           edge=55/215/215   inside=55/215/215
grayscale        edge=87/87/87     inside=87/87/87
brightness       edge=240/80/80    inside=240/80/80
contrast         edge=145/105/105  inside=145/105/105
smooth           edge=218/111/111  inside=200/40/40
pixelate         edge=255/255/255  inside=200/40/40

Reading the table:

  • gaussianBlur changes only the edge. Its kernel is the 3 x 3 1 2 1 / 2 4 2 / 1 2 1, divided by 16; at the edge pixel the left column (weight 4 of 16) is white, so red becomes (4 x 255 + 12 x 200) / 16 = 213. One pass is a small blur; call it several times for more.

  • meanRemoval is the sharpen kernel: nine times the centre minus the eight neighbours. Flat areas are untouched and the edge is pushed away from its neighbours, down to 35/0/0.

  • emboss and edgeDetectQuick respond only to change, and add a mid-grey offset of 127 so that “no change” is grey rather than black. That is why the flat inside reads 127/127/127.

  • negate, grayscale and brightness are per-pixel, not convolutions, so edge and inside change alike. grayscale uses the luma weights (0.299 R + 0.587 G + 0.114 B), giving 87 for this red.

  • contrast is per-pixel too, and runs the way its name does not suggest: a positive value lowers the contrast. Every channel’s distance from mid-grey (127.5) is scaled by ((100 - c) / 100) squared, so 50 keeps a quarter of it and red’s 200 becomes 127.5 + 72.5 / 4 = 145. A negative value pushes channels apart instead, and -50 already drives this red to pure 255/0/0.

  • smooth is a convolution with a centre weight you choose and weight 1 on the eight neighbours. With 1 all nine are equal, the plain box blur: three white pixels of nine give (3 x 255 + 6 x 200) / 9 = 218. A larger weight keeps more of the original pixel, so the blur gets weaker. Both smooth and contrast take fractional values, such as smooth(0.5) or contrast(12.5).

  • pixelate replaces each block with one colour; mode 0 takes the block’s top-left pixel, mode 1 its average. The edge pixel sits in a block whose top-left corner is white.

Also available is selectiveBlur, which smooths regions while leaving strong edges alone (on the test card it leaves the edge exactly as it was).

GD::Simple#

GD::Simple ships with GD and wraps an image in a friendlier API: named colours (190 of them), a pen with a current position, and separate fill (bgcolor) and outline (fgcolor) colours, so one call draws a filled shape with a border.

use strict;
use warnings;
use GD::Simple;

my $img = GD::Simple->new(220, 120);     # cleared to white

$img->bgcolor('yellow');                 # fill colour
$img->fgcolor('blue');                   # outline colour
$img->rectangle(10, 10, 90, 60);

$img->bgcolor('orange');
$img->fgcolor('red');
$img->moveTo(150, 40);                   # shapes draw at the pen position
$img->ellipse(80, 50);

$img->fgcolor('black');
$img->penSize(3, 3);
$img->moveTo(10, 100);
$img->angle(0);                          # heading: 0 points right
$img->line(100);                         # turtle-style: 100 px ahead

$img->moveTo(130, 105);
$img->font(gdMediumBoldFont);
$img->string('GD::Simple');

open my $fh, '>', 'simple.png' or die "simple.png: $!";
binmode $fh;
print $fh $img->png;
close $fh;

printf "%d colour names\n", scalar(my @names = $img->color_names);
190 colour names

The result is a yellow box with a blue border, an orange ellipse with a red border, a thick black line and a caption. Any method GD::Simple does not define is passed through to the GD::Image inside it, so the filters, copyResampled and the rest of this chapter all work on a GD::Simple object, and $img->gd returns the image itself. It is a good fit for charts, diagrams and quick sketches; for anything that needs exact control of colours and alpha, the plain GD::Image API is more direct.

A complete program#

Putting it together: a poster with a gradient background, two translucent discs whose overlap blends, a blur pass, an anti-aliased line and a TrueType caption drawn crisp on top, saved as a PNG plus a resampled JPEG thumbnail.

use strict;
use warnings;
use GD;

my ($W, $H) = (400, 300);
my $img = GD::Image->new($W, $H, 1);

# background: a vertical ramp from dark blue to near black
for my $y (0 .. $H - 1) {
    my $t = $y / ($H - 1);
    my $c = $img->colorAllocate(int(30 * (1 - $t)), int(40 * (1 - $t)), int(90 - 70 * $t));
    $img->line(0, $y, $W - 1, $y, $c);
}

# two translucent discs; alpha blending composites the overlap
my $sun = $img->colorAllocateAlpha(255, 200, 60, 40);
my $sea = $img->colorAllocateAlpha(60, 200, 255, 40);
$img->filledEllipse(150, 140, 160, 160, $sun);
$img->filledEllipse(250, 140, 160, 160, $sea);

# soften: each gaussianBlur pass is one 3x3 kernel, so repeat for more
$img->gaussianBlur for 1 .. 3;

# a crisp anti-aliased horizon and a caption, drawn after the blur
$img->setAntiAliased($img->colorAllocate(255, 255, 255));
$img->line(20, 250, 380, 244, gdAntiAliased);

my $font = '/usr/share/fonts/TTF/DejaVuSans.ttf';
my $ink  = $img->colorAllocate(240, 240, 240);
$img->stringFT($ink, $font, 16, 0, 20, 285, 'Perl, drawn with GD')
    or die "stringFT: $@";

sub save {
    my ($file, $bytes) = @_;
    open my $fh, '>', $file or die "$file: $!";
    binmode $fh;
    print $fh $bytes;
    close $fh;
}
save('poster.png', $img->png);

# a half-size thumbnail
my $thumb = GD::Image->new($W / 2, $H / 2, 1);
$thumb->copyResampled($img, 0, 0, 0, 0, $W / 2, $H / 2, $W, $H);
save('poster-thumb.jpg', $thumb->jpeg(85));

Note that the background allocates 300 colours in a loop. On a true-colour image that costs nothing, because colorAllocate only packs the channels; on a palette image the table would be full after 256 lines. Order matters too: the blur runs before the line and the caption, so the discs are soft and the text is sharp.

When GD is the wrong tool#

GD is raster, so it has the raster limitation: it works at a fixed resolution, and scaling past it degrades. Its anti-aliasing covers lines and polygons only, and its coordinates are integers. For smooth curves, sub-pixel positioning, gradients and output that must stay crisp at any size (a logo, a diagram destined for print or the web), use a vector engine: Cairo and SVG covers both routes, and pperl’s native Cairo binding is shown in the Cairo showcase.

When the job is a per-pixel algorithm no filter covers (a custom kernel, a colour transform, a generated texture), write it over the pixels yourself. For small images the plain-Perl loops of the algorithms chapter are enough, and setPixel and getPixel move the result in and out of GD; for large images, PDL does the same arithmetic on whole arrays at once.

When the drawing needs to appear in a live window rather than a file, that is the GUI toolkits chapter. For everything that is “make or modify a bitmap on disk”, GD is a small, fast and complete answer.