File Coverage

blib/lib/Image/WordCloud.pm
Criterion Covered Total %
statement 34 36 94.4
branch n/a
condition n/a
subroutine 12 12 100.0
pod n/a
total 46 48 95.8


line stmt bran cond sub pod time code
1             package Image::WordCloud;
2              
3 1     1   27827 use 5.008;
  1         3  
  1         120  
4              
5 1     1   5 use strict;
  1         2  
  1         44  
6 1     1   4 use warnings;
  1         6  
  1         39  
7              
8 1     1   558 use Image::WordCloud::StopWords::EN qw(%STOP_WORDS);
  1         3  
  1         179  
9 1     1   8 use Carp qw(carp croak confess);
  1         1  
  1         81  
10 1     1   1314 use Params::Validate qw(:all);
  1         17394  
  1         710  
11 1     1   11 use List::Util qw(sum shuffle);
  1         1  
  1         125  
12 1     1   5 use File::Spec;
  1         2  
  1         27  
13 1     1   1198 use File::ShareDir qw(:ALL);
  1         8200  
  1         519  
14 1     1   1047 use File::Find::Rule;
  1         9557  
  1         11  
15 1     1   60277 use Encode;
  1         30954  
  1         107  
16 1     1   620 use GD;
  0            
  0            
17             use GD::Text::Align;
18             use Color::Scheme;
19             use Math::PlanePath::TheodorusSpiral;
20              
21             our $VERSION = '0.03';
22              
23             $ENV{IWC_DEBUG} = 0 if ! defined $ENV{IWC_DEBUG} || ! $ENV{IWC_DEBUG};
24              
25             =head1 NAME
26              
27             Image::WordCloud - Create word cloud images
28              
29             =head1 SYNOPSIS
30              
31             use Image::WordCloud;
32             use File::Slurp;
33            
34             my $wc = Image::WordCloud->new();
35            
36             # Add the Gettysburg Address
37             my $text = read_file('script/gettysburg.txt');
38             $wc->words($text);
39            
40             # Create the word cloud as a GD image
41             my $gd = $wc->cloud();
42            
43             open(my $fh, '>', 'gettysburg.png');
44             binmode $fh;
45             print $fh $gd->png();
46             close($fh);
47            
48             # See examples/gettysburg.png for how the created image looks.
49             # script/gettysburg.pl will create it
50            
51             # The calls can also be chained like so:
52             my $text = read_file('script/gettysburg.txt');
53             my $gd = Image::WordCloud->new()
54             ->words($text)
55             ->cloud();
56              
57             Create "word cloud" images from a set of specified words, similar to http://wordle.net.
58             Font size indicates the frequency with which a word is used.
59              
60             Colors are generated randomly using L. Fonts can be specified or chosen randomly.
61              
62             =head1 FUNCTIONS
63              
64             =head2 new( ... )
65              
66             Accepts a number of parameters to alter the image look.
67              
68             =over 4
69              
70             =item * image_size => [$x, $y]
71              
72             Sets the size of the image in pixels, accepts an arrayref. Defaults to [400, 400].
73              
74             NOTE: Non-square images currently can look a little squirrely due to how Math::TheodorusSpiral fills a rectangle.
75              
76             =item * word_count => $count
77              
78             Number of words to show on the image. Defaults to 70.
79              
80             =item * prune_boring => <1,0>
81              
82             Prune "boring", or "stop" words. This module currently only supports English stop words (like 'the', 'a', 'and', 'but').
83             The full list is in L
84              
85             Defaults to true.
86              
87             =item * font => $name
88              
89             Name of font to use. This is passed directly to L so it can either be a string like 'arial', or
90             a full path. However in order for non-path font names to work, L needs an environment variable like FONT_PATH
91             or FONT_TT_PATH to be set, or C can be used to set it manually.
92              
93             =item * font_path => $path_to_fonts
94              
95             Set where your font .ttf files are located. If this is not specified, the path of this module's distribution
96             directory will be used via L. Currently this module comes bundled with one set of fonts.
97              
98             =item * background => [$r, $g, $b]
99              
100             Takes an arrayref defining the background color to use. Defaults to [40, 40, 40]
101              
102             =item * border_padding => <$pixels | $percent>
103              
104             Padding to leave clear around the edges of the image, either in pixels or a percent with '%' sign. Defaults to '5%'
105              
106             my $wc = Image::WordCloud->new(border_padding => 20);
107             my $wc = Image::WordCloud->new(border_padding => '25%');
108              
109             Please note that this affects the speed with which this module can fit words into the image. In my tests on
110             the text of the Declaration of Independence, bumping the percentage by 5% increments progressed like so:
111              
112             0%: 15.25s
113             5%: 21.50s
114             10%: 30.00s
115             15%: 63.6s avg
116              
117             =back
118              
119             =cut
120              
121             #'
122              
123             sub new {
124             my $proto = shift;
125            
126             my %opts = validate(@_, {
127             image_size => { type => ARRAYREF | UNDEF, optional => 1, default => [400, 400] },
128             word_count => { type => SCALAR | UNDEF, optional => 1, default => 70 },
129             prune_boring => { type => SCALAR | UNDEF, optional => 1, default => 1 },
130             font => { type => SCALAR | UNDEF, optional => 1 },
131             font_file => { type => SCALAR | UNDEF, optional => 1 },
132             font_path => { type => SCALAR | UNDEF, optional => 1 },
133             background => { type => ARRAYREF, optional => 1, default => [40, 40, 40] },
134             border_padding => { type => SCALAR, optional => 1, regex => qr/^\d+\%?$/, default => '5%' },
135             });
136            
137             # ***TODO: Figure out how many words to use based on image size?
138            
139             # Make sure the font file exists if it is specified
140             if ($opts{'font_file'}) {
141             unless (-f $opts{'font_file'}) {
142             carp sprintf "Specified font file '%s' not found", $opts{'font_file'};
143             }
144             }
145            
146             # Make sure the font path exists if it is specified
147             if ($opts{'font_path'}) {
148             unless (-d $opts{'font_path'}) {
149             carp sprintf "Specified font path '%s' not found", $opts{'font_path'};
150             }
151             }
152            
153             # Otherwise, try using ./share/fonts (so testing can be done)
154             if (! $opts{'font_path'}) {
155             my $local_font_path = File::Spec->catdir(".", "share", "fonts");
156             unless (-d $local_font_path) {
157             #carp sprintf "Local font path '%s' not found", $local_font_path;
158             }
159            
160             $opts{'font_path'} = $local_font_path;
161             }
162            
163             # If we still haven't found a font path, find the font path with File::ShareDir
164             if (! $opts{'font_path'}) {
165             my $font_path;
166             eval {
167             $font_path = File::Spec->catdir(dist_dir('Image-WordCloud'), "fonts");
168             };
169             if ($@) {
170             #carp "Font path for dist 'Image-WordCloud' could not be found";
171             }
172             else {
173             $opts{'font_path'} = $font_path;
174             }
175             }
176            
177             my $class = ref( $proto ) || $proto;
178             my $self = { #Will need to allow for params passed to constructor
179             words => {},
180             image_size => $opts{'image_size'},
181             word_count => $opts{'word_count'},
182             prune_boring => $opts{'prune_boring'},
183             font => $opts{'font'} || "",
184             font_path => $opts{'font_path'} || "",
185             font_file => $opts{'font_file'} || "",
186             background => $opts{'background'},
187             border_padding => $opts{'border_padding'},
188             };
189             bless($self, $class);
190            
191             # Make sure we have a usable font file or font path
192             unless (-f $self->{'font_file'} || -d $self->{'font_path'}) {
193             carp sprintf "No usable font path or font file found, only fonts available will be from libgd, which suck";
194             }
195             # If a font_file is specified, use that as the only font
196             elsif (-f $self->{'font_file'}) {
197             $self->{fonts} = $self->{'font_file'};
198             }
199             # Otherwise if no font_file was specified and we have a font path, read in all the fonts from font_path
200             elsif (! -f $self->{'font_file'} && -d $self->{'font_path'}) {
201             my @fonts = File::Find::Rule->new()
202             ->extras({ untaint => 1})
203             ->file()
204             ->name('*.ttf')
205             ->in( $self->{'font_path'} );
206            
207             $self->{fonts} = \@fonts;
208             }
209            
210             # Set the font path for GD::Text::* objects, if we have one to use
211             if (-d $self->{'font_path'}) {
212             GD::Text->font_path( $self->{'font_path'} );
213             }
214              
215             return $self;
216             }
217              
218             =head2 words(\%words_to_use | \@words | @words_to_use | $words)
219              
220             Takes either a hashref, arrayref, array or string.
221              
222             If the argument is a hashref, keys are the words, values are their count. No further processing is done (we assume you've done it on your own).
223              
224             If the argument is an array, arrayref, or string, the words are parsed to remove non-word characters and turn them lower-case.
225              
226             =cut
227              
228             #'
229              
230             sub words {
231             my $self = shift;
232            
233             #my @opts = validate_pos(@_,
234             # { type => HASHREF | ARRAYREF, optional => 1 }, # \%words
235             #);
236            
237             # Return words if no arguments are specified
238             if (scalar(@_) == 0) { return $self->{words}; }
239            
240             my $arg1 = $_[0];
241            
242             my %words = ();
243            
244             # More than one argument, assume we're being passed a list of words
245             if (scalar(@_) > 1) {
246             my @words = @_;
247            
248             # Strip non-word characters, lc() each word and build the counts
249             foreach my $word (map { lc } @words) {
250             $word = Encode::decode('iso-8859-1', $word);
251             $word =~ s/\W//o;
252             $words{ $word }++;
253             }
254             }
255             else {
256             # Argument is a hashref, just push it straight into %words
257             if (ref($arg1) eq 'HASH') {
258             %words = %{ $arg1 };
259             }
260             # Argument is an arrayref
261             elsif (ref($arg1) eq 'ARRAY') {
262             my @words = @$arg1;
263            
264             # Strip non-word characters, lc() each word and build the counts
265             foreach my $word (map { lc } @words) {
266             $word = Encode::decode('iso-8859-1', $word);
267             $word =~ s/\W//o;
268             $words{ $word }++;
269             }
270             }
271             # Argument is a scalar, assume it's a string of words
272             else {
273             my $words = $arg1;
274             $words = Encode::decode('iso-8859-1', $words);
275            
276             while ($words =~ /(?)/g) { #' <-- so UltraEdit doesnt fubar syntax highliting
277             my $word = lc($1);
278             $word =~ s/\W//o;
279             $words{ $word }++;
280             }
281             }
282             }
283            
284             # Blank out the current word list;
285             $self->{words} = {};
286            
287             $self->_prune_stop_words(\%words) if $self->{prune_boring};
288            
289             # Sort the words by count and let N number of words through, based on $self->{word_count}
290             my $word_count = 1;
291             foreach my $word (map { lc } sort { $words{$b} <=> $words{$a} } keys %words) {
292             last if $word_count > $self->{word_count};
293            
294             my $count = $words{$word};
295            
296             if ($word_count == 1) {
297             $self->{max_count} = $count;
298             }
299            
300             # Add this word to our list of words
301             $self->{words}->{$word} = $count;
302            
303             push(@{ $self->{word_list} }, {
304             word => $word,
305             count => $count
306             });
307            
308             $word_count++;
309             }
310            
311             $self->{words_changed} = 1;
312            
313             return $self;
314             }
315              
316             =head2 cloud()
317              
318             Make the word cloud. Returns a L.
319              
320             my $gd = Image::WordCloud->new()->words(qw/some words etc/)->cloud();
321            
322             # Spit out the wordlcoud as a PNG
323             $gd->png;
324            
325             # ... or a jpeg
326             $gd->jpg;
327            
328             # Get the dimensions
329             $gd->width;
330             $gd->height;
331            
332             # Or anything else you can do with a GD::Image object
333              
334             =cut
335              
336             sub cloud {
337             my $self = shift;
338            
339             # Set the font path for GD::Text::* objects, if we have one to use
340             #if (-d $self->{'font_path'}) {
341             # GD::Text->font_path( $self->{'font_path'} );
342             #}
343            
344             # Create the image object
345             my $gd = GD::Image->new($self->width, $self->height, 1); # Adding the 3rd argument (for truecolor) borks the background, it defaults to black.
346            
347             # Center coordinates of this iamge
348             my $center_x = $gd->width / 2;
349             my $center_y = $gd->height / 2;
350            
351             my $background = $gd->colorAllocate( @{$self->{background}}[0,1,2] ); # Background color
352            
353             # Fill completely with background color
354             $gd->filledRectangle(0, 0, $gd->width, $gd->height, $background);
355            
356             my $white = $gd->colorAllocate(255, 255, 255);
357             my $black = $gd->colorAllocate(0, 0, 0);
358            
359             my @rand_colors = $self->_random_colors();
360              
361             my @palette = ();
362             foreach my $c (@rand_colors) {
363             my $newc = $gd->colorAllocate($c->[0], $c->[1], $c->[2]);
364             push @palette, $newc;
365             }
366            
367             # make the background interlaced (***TODO: why?)
368             $gd->interlaced('true');
369            
370             # Array of GD::Text::Align objects that we will move around and then draw
371             my @texts = ();
372            
373             # Get the bounds of the image
374             my ($left_bound, $top_bound, $right_bound, $bottom_bound) = $self->_image_bounds();
375            
376             # Max an min font sizes in points
377             my $max_points = $self->_max_font_size();
378             #my $min_points = $self->_pixels_to_points(($bottom_bound - $top_bound) * 0.0175); # 0.02625;
379             my $min_points = $self->_min_font_size();
380            
381             # Get the view scaling based on the area we can fill and what all the areas of
382             # the words at their scaled font sizes would produce
383             my $view_scaling = $self->_view_scaling();
384            
385             # Scaling modifier for font sizes
386             my $max_count = $self->{max_count};
387             my $scaling = $max_points / $max_count;
388            
389             # For each word we have
390             my @areas = ();
391             #my @drawn_texts = ();
392            
393             # List of the bounding boxes of each text object. Each element is an arrayref
394             # containing:
395             # 1. Upper left x coordinate
396             # 2. Upper left y coordinate
397             # 3. Bounding box width
398             # 4. Bounding box height
399             my @bboxes = ();
400            
401             my $loop = 1;
402            
403             # Get a list of words sorted by frequency
404             my @word_keys = sort { $self->{words}->{$b} <=> $self->{words}->{$a} } keys %{ $self->{words} };
405            
406             # Get the word scaling factors (higher frequency == bigger size
407             my $scalings = $self->_word_scalings();
408            
409             # And then create the font sizes based on the scaling * the maximum font size
410            
411             # Get the initial font sizes
412             my $word_sizes = $self->_word_font_sizes();
413            
414             # Scale the sizes by the view scaling
415             my %word_sizes = map { $_ => $word_sizes->{$_} * $view_scaling } keys %$word_sizes;
416            
417             # Get the font size for each word using the Fibonacci sequence
418             # my %word_sizes = ();
419             # my $sloop = 0;
420             # my $fib_counter = 1;
421             # my $cur_size;
422             # foreach my $word (@word_keys) {
423             # if ($sloop == 0) {
424             # my $term = Math::Fibonacci::term($fib_counter);
425             #
426             # $cur_size = (1 / $fib_counter * $max_points);
427             #
428             # $sloop = $term;
429             #
430             # $fib_counter++;
431             # }
432             #
433             # $word_sizes{ $word } = $cur_size;
434             #
435             # $sloop--;
436             # }
437            
438             foreach my $word ( shift @word_keys, shuffle @word_keys ) {
439             my $count = $self->{words}->{$word};
440            
441             my $text = GD::Text::Align->new($gd);
442            
443             # Use a random color
444             my $color = $palette[ rand @palette ];
445             $text->set(color => $color);
446            
447             # Either use the specified font file...
448             my $font = "";
449             if ($self->{'font_file'}) {
450             $font = $self->{'font_file'};
451             }
452             # Or the specified font
453             elsif ($self->{'font'} && -d $self->{'font_path'}) {
454             $font = $self->{'font'};
455             }
456             # ...or use a random font
457             elsif (scalar @{$self->{'fonts'}} > 0) {
458             $font = $self->{'fonts'}->[ rand @{$self->{'fonts'}} ];
459             unless (-f $font) { carp "Font file '$font' not found"; }
460             }
461            
462             my $size = $word_sizes{ $word };
463            
464             #my $size = $count * $scaling;
465             #my $size = (1.75 / $loop) * $max_points;
466            
467             $size = $max_points if $size > $max_points;
468             $size = $min_points if $size < $min_points;
469            
470             $text->set_font($font, $size);
471            
472             # Set the text to this word
473             $text->set_text($word);
474            
475             push(@texts, $text);
476            
477             my ($w, $h) = $text->get('width', 'height');
478            
479             push(@areas, $w * $h);
480            
481             # Position to place the word in
482             my ($x, $y);
483            
484             # Place the first word in the center of the screen
485             if ($loop == 1) {
486             $x = $center_x - ($w / 2);
487             $y = $center_y + ($h / 4); # I haven't done the math see why dividing the height by 4 works, but it does
488            
489             # Move the image center around a little
490             #$x += $self->_random_int_between($gd->width * .1 * -1, $gd->width * .1 );
491             #$y += $self->_random_int_between($gd->height * .1 * -1, $gd->height * .1);
492            
493             # Move the first word around a little, but not TOO much!
494             ($x, $y) = $self->_init_coordinates($gd, $text, $x, $y);
495             }
496             else {
497             # Get a random place to draw the text
498             # 1. The text is drawn starting at its lower left corner
499             # 2. So we need to push the y value by the height of the text, but keep it less than the image height
500             # 3. Keep a padding of 5px around the edges of the image
501             #$y = $self->_random_int_between($h, $gd->height - 5);
502             #$x = $self->_random_int_between(5, $gd->width - $w - 5);
503            
504             # While this text collides with any of the other placed texts,
505             # move it in an enlarging spiral around the image
506            
507             # Make a spiral
508             my $path = Math::PlanePath::TheodorusSpiral->new;
509            
510             # Get the boundary width and height for random initial placement (which is bounds of the first (biggest) string)
511             my ($rand_bound_w, $rand_bound_h) = @{$bboxes[0]}[2,3];
512            
513             # Get the initial starting point
514             my ($this_x, $this_y) = $self->_new_coordinates($gd, $path, 1, $rand_bound_w, $rand_bound_h);
515            
516             my $collision = 1;
517             my $col_iter = 1; # Iterator to pass to M::P::TheodorusSpiral get new X,Y coords
518            
519             # Within an area of 250k pixels, it seems to work okay.
520             #my $col_iter_increment = int($self->width * $self->height * 0.00002); # Increment to increase $col_iter by on each loop
521             my $col_iter_increment = 1;
522             $col_iter_increment = 1 if $col_iter_increment < 1; # Move it at least ONE iteration
523            
524             while ($collision) {
525             # New text's coords and width/height
526             # (x1,y1) lower left corner
527             # (x2,y2) lower right corner
528             # (x3,y3) upper right corner
529             # (x4,y4) upper left corner
530             my ($b_x, $b_y, $b_x2, $b_y2) = ( $text->bounding_box($this_x, $this_y) )[6,7,2,3];
531             my ($b_w, $b_h) = ($b_x2 - $b_x, $b_y2 - $b_y);
532            
533             foreach my $b (@bboxes) {
534             my ($a_x, $a_y, $a_w, $a_h) = @$b;
535            
536             # Upper left to lower right
537             if ($self->_detect_collision(
538             $a_x, $a_y, $a_w, $a_h,
539             $b_x, $b_y, $b_w, $b_h)) {
540            
541             $collision = 1;
542             last;
543             }
544             else {
545             $collision = 0;
546             }
547             }
548             last if $collision == 0;
549            
550             # TESTING:
551             if ($col_iter % 1 == 0 && $ENV{IWC_DEBUG} >= 2) {
552             my $hue = $col_iter;
553            
554             my ($r,$g,$b) = $self->_hex2rgb( (Color::Scheme->new->from_hue($hue)->colors())[0] ); # hues can be over 360, they just wrap around the wheel
555             my $c = $gd->colorAllocate($r,$g,$b);
556            
557             #$gd->filledRectangle($this_x, $this_y, $this_x + 1, $this_y + 1, $c);
558             #$gd->string(gdGiantFont, $this_x, $this_y, $col_iter, $c);
559            
560             #$gd->setPixel($this_x, $this_y, $c);
561            
562             #my @bo = $text->bounding_box($this_x, $this_y, 0);
563             #$self->_stroke_bbox($gd, $c, @bo);
564            
565             $gd->colorDeallocate($c);
566             }
567            
568             $col_iter += $col_iter_increment;
569            
570             # Move text
571             my $new_loc = 0;
572             while (! $new_loc) {
573             ($this_x, $this_y) = $self->_new_coordinates($gd, $path, $col_iter, $rand_bound_w, $rand_bound_h);
574            
575             my ($newx, $newy, $newx2, $newy2) = ( $text->bounding_box($this_x, $this_y) )[6,7,2,3];
576            
577             if ($newx < $left_bound || $newx2 > $right_bound ||
578             $newy < $top_bound || $newy2 > $bottom_bound) {
579            
580             #carp sprintf "New coordinates outside of image: (%s, %s), (%s, %s)", $newx, $newy, $newx2, $newy2;
581             $col_iter += $col_iter_increment;
582             if ($col_iter > 10_000) {
583             carp sprintf "New coordinates for '%s' outside of image: (%s, %s)", $text->get('text'), $newx, $newy if $ENV{IWC_DEBUG};
584             last;
585             }
586             }
587             else {
588             $new_loc = 1;
589             }
590             }
591            
592             # Center the image
593             #$this_x -= $text->get('width') / 2;
594             #$this_y -= $text->get('height') / 2;
595            
596             # Center the spiral
597             #if (! $centered) {
598             # $this_x += $center_x;
599             # $this_y += $center_y;
600             #}
601             }
602            
603             # test draw
604             #my @bounding = $text->bounding_box($this_x, $this_y, 0);
605             #$self->_stroke_bbox($gd, $white, @bounding);
606            
607             # Backtrack the coordinates towards the center
608             ($this_x, $this_y) = $self->_backtrack_coordinates($text, \@bboxes, $this_x, $this_y, $gd);
609            
610             $x = $this_x;
611             $y = $this_y;
612             }
613            
614             my @bounding = $text->draw($x, $y, 0);
615             #$self->_stroke_bbox($gd, undef, @bounding);
616            
617             my @rect = ($bounding[6], $bounding[7], $bounding[2] - $bounding[6], $bounding[3] - $bounding[7]);
618             push(@bboxes, \@rect);
619            
620             $loop++;
621             }
622            
623             my $total_area = sum @areas;
624            
625             $self->{words_changed} = 0; # reset the words changed flag
626            
627             # Return the image as PNG content
628             return $gd;
629             }
630              
631             # Return the bounds of the image
632             sub _image_bounds {
633             my $self = shift;
634            
635             my ($left_bound, $top_bound, $right_bound, $bottom_bound);
636            
637             # Make the boundaries for the words
638             my $pad = $self->{'border_padding'};
639            
640             # Handle zero-padding
641             if ($pad =~ /^0\%?$/) {
642             return (0, 0, $self->width, $self->height);
643             }
644            
645             # Pad width a percentage of the image size
646             if ($pad =~ /^\d+\%$/) {
647             my ($percentage) = $pad =~ /(\d+)/;
648             $percentage = $percentage / 100;
649            
650             $left_bound = 0 + $self->width * $percentage;
651             $top_bound = 0 + $self->height * $percentage;
652             $right_bound = $self->width - $self->width * $percentage;
653             $bottom_bound = $self->height - $self->height * $percentage;
654             }
655             else {
656             $left_bound = 0 + $self->{'border_padding'};
657             $top_bound = 0 + $self->{'border_padding'};
658             $right_bound = $self->width - $self->{'border_padding'};
659             $bottom_bound = $self->height - $self->{'border_padding'};
660             }
661            
662             return ($left_bound, $top_bound, $right_bound, $bottom_bound);
663             }
664              
665             # Return the width and height of the image bounds
666             sub _image_bounds_width_height() {
667             my $self = shift;
668            
669             my ($left_bound, $top_bound, $right_bound, $bottom_bound) = $self->_image_bounds();
670            
671             my $w = $right_bound - $left_bound;
672             my $h = $bottom_bound - $top_bound;
673            
674             return ($w, $h);
675             }
676              
677             # Given an initial starting point, move
678             sub _init_coordinates {
679             my $self = shift;
680             my ($gd, $text, $x, $y) = @_;
681            
682             croak "No X coordinate specified" if ! defined $x;
683             croak "No Y coordinate specified" if ! defined $y;
684            
685             # Make the boundaries for the words
686             my ($left_bound, $top_bound, $right_bound, $bottom_bound) = $self->_image_bounds();
687            
688             my $fits = 0;
689             my $c = 0;
690             while (! $fits) {
691             # Re-initialize the coords
692             my $try_x = $x;
693             my $try_y = $y;
694            
695             # Move the x,y coords around a little (width 10% of the image's dimensions so we stay mostly centered)
696             $try_x += $self->_random_int_between($gd->width * .1 * -1, $gd->width * .1 );
697             $try_y += $self->_random_int_between($gd->height * .1 * -1, $gd->height * .1);
698            
699             # Make sure the new coordinates aren't outside the bounds of the image!
700             my ($newx, $newy, $newx2, $newy2) = ( $text->bounding_box($try_x, $try_y) )[6,7,2,3];
701            
702             if ($newx < $left_bound || $newx2 > $right_bound ||
703             $newy < $top_bound || $newy2 > $bottom_bound) {
704            
705             $fits = 0;
706             }
707             else {
708             $x = $try_x;
709             $y = $try_y;
710            
711             $fits = 1;
712             }
713            
714             # Only try 50 times
715             $c++;
716            
717             if ($c > 50) {
718             #carp "Tried over 50 times to fit a word";
719             last;
720             }
721             }
722            
723             return ($x, $y);
724             }
725              
726             # Return new coordinates ($x, $y) that are no more than $bound_x or $bound_y digits away from the center of GD image $gd
727             sub _new_coordinates {
728             my $self = shift;
729            
730             #my @opts = validate_pos(@_,
731             # { isa => 'GD::Image' },
732             # { isa => 'Math::PlanePath::TheodorusSpiral' },
733             # { type => SCALAR, regex => qr/^[-+]?\d+$/, },
734             # { type => SCALAR, regex => qr/^\d+|\d+\.\d+$/, },
735             # { type => SCALAR, regex => qr/^\d+|\d+\.\d+$/, },
736             #);
737            
738             my @opts = @_;
739            
740             my ($gd, $path, $iteration, $bound_x, $bound_y) = @opts;
741            
742             my ($x, $y) = map { int } $path->n_to_xy($iteration * 100); # use 'int' because it returns fractional coordinates
743            
744             # Move the center of this word within 50% of the area of the first word's bounding box
745             $x += $self->_random_int_between($bound_x * -1 * .25, $bound_x * .25);
746             $y += $self->_random_int_between($bound_y * -1 * .25, $bound_y * .25);
747            
748             $x += $gd->width / 2;
749             $y += $gd->height / 2;
750            
751             return ($x, $y);
752             }
753              
754             # Given a text box's position and dimensions, try to backtrack it towards the center
755             # of the image until it collides with something. This should keep our words nicely
756             # nestled against each other
757             sub _backtrack_coordinates {
758             my $self = shift;
759            
760             my $text = shift;
761            
762             # Arrayref of bounding boxes to check for collision against
763             my $colliders = shift;
764            
765             # X,Y coords to start with
766             my ($x, $y) = (shift, shift);
767            
768             my ($center_x, $center_y) = ($self->width / 2, $self->height / 2);
769             $center_x = $center_x - ($text->get('width') / 2);
770             $center_y = $center_y + ($text->get('height') / 4);
771            
772             my $collision = 0;
773             my $iter = 0;
774             while (! $collision) {
775             # Stop processing if we're within 1 pixel of the center of the iamge
776             if (abs($center_x - $x) <= 1 &&
777             abs($center_y - $y) <= 1) {
778            
779             #printf "Coords (%s,%s) too near center (%s, %s), stopping on word '%s'\n",
780             # $x, $y,
781             # $center_x, $center_y, $text->get('text') if $ENV{IWC_DEBUG} >=2;
782            
783             last;
784             }
785            
786             # Position and dimensions of the text string
787             my ($a_x, $a_y, $a_x2, $a_y2) = ( $text->bounding_box($x, $y) )[6,7,2,3];
788             my ($a_w, $a_h) = ($a_x2 - $a_x, $a_y2 - $a_y);
789            
790             my $collision_with = [];
791             foreach my $b (@$colliders) {
792             my ($b_x, $b_y, $b_w, $b_h) = @$b;
793            
794             # Upper left to lower right
795             if ($self->_detect_collision(
796             $a_x, $a_y, $a_w, $a_h,
797             $b_x, $b_y, $b_w, $b_h)) {
798            
799             # Add this rectangle on to the ones we've had collisions with
800             $collision_with = [$b_x, $b_y, $b_w, $b_h];
801            
802             $collision = 1;
803             last;
804             }
805             else {
806             $collision = 0;
807             }
808             }
809            
810             # If there was collision...
811             if ($collision == 1) {
812             # Get the sides that we collided with the other rectangle on
813             my @collision_sides = $self->_collision_sides($a_x, $a_y, $a_w, $a_h, @$collision_with);
814            
815             # If we only collided with one side, we should be able to move further along the other side,
816             # i.e. if we collided only on the X axis we can still move closer on the Y axis
817             if (scalar @collision_sides == 1) {
818             # We collided on a Y-axis side, so we can move on the X-axis
819             if ($collision_sides[0] eq 'top' || $collision_sides[0] eq 'bottom') {
820             $x = ($x < $center_x) ? $x+1 : $x-1;
821             }
822             # We collided on a X-axis side, so we can move on the Y-axis
823             elsif ($collision_sides[0] eq 'left' || $collision_sides[0] eq 'right') {
824             $y = ($y < $center_y) ? $y+1 : $y-1;
825             }
826             }
827             # Total collision, stop moving!
828             elsif (scalar @collision_sides >= 2) {
829             last;
830             }
831             }
832             # No collision!
833             else {
834             $x = ($x < $center_x) ? $x+1 : $x-1;
835             $y = ($y < $center_y) ? $y+1 : $y-1;
836             }
837            
838             #my @bbox = $text->bounding_box($x, $y, 0);
839             #$self->_stroke_bbox($gd, $gd->colorClosest(255, 255, 255), @bbox) if $iter % 10 == 0;
840            
841             $iter++;
842             }
843            
844             #printf "New xy: $x, $y\n";
845            
846             return $x, $y;
847             }
848              
849             # Return the minimum area we need to have to fit all the words based on the _max_font_size
850             sub _playing_field_area {
851             my $self = shift;
852            
853             my ($max_font_size, $lastfont) = $self->_max_font_size();
854             my $word_scalings = $self->_word_scalings();
855            
856             my $words = $self->words();
857            
858             my $area = 0;
859            
860             # Test GD object
861             my $text_gd = GD::Image->new();
862            
863             # Get the area
864             foreach my $word (keys %$words) {
865             my $text = GD::Text::Align->new($text_gd);
866             $text->set_text($word);
867            
868             my $fontsize = $word_scalings->{ $word } * $max_font_size;
869             $fontsize = $max_font_size if $fontsize > $max_font_size;
870             $text->set_font($lastfont, $fontsize);
871            
872             my $word_area = $text->get('width') * $text->get('height');
873            
874             $area += $word_area;
875             }
876            
877             return $area;
878             }
879              
880             # Overall scaling we have to use to get all the words to fit in the playing field
881             sub _view_scaling {
882             my $self = shift;
883            
884             # Get the total area we have to use
885             my $pf_area = $self->_playing_field_area();
886            
887             # Get the ratio of width to height
888             my ($w, $h) = $self->_image_bounds_width_height();
889             #my $wh_ratio = $w / $h;
890            
891             #my $area_sq = sqrt($pf_area);
892             #my $area_w = $area_sq * $wh_ratio;
893             #my $area_h = $area_sq / $wh_ratio;
894            
895             my $area = $w * $h;
896            
897             my $scaling = $area / $pf_area;
898             }
899              
900             # Return the maximum font-size this image can use
901             # optionally also return the font that caused us the most issues
902             # (i.e. has the largest size)
903             sub _max_font_size {
904             my $self = shift;
905            
906             # If we already have a max font size and the words we are using haven't changed,
907             # return the saved max font size
908             return $self->{max_font_size} if $self->{max_font_size} && ! $self->{words_changed};
909            
910             # Font size we'll return (start with 25% of the image height);
911             my $init_fontsize = $self->_init_max_font_size();
912             my $fontsize = $init_fontsize;
913            
914             # Image width and heigth
915             #my ($w, $h) = ($self->width, $self->height);
916            
917             # Get the image bounds
918             my ($left_bound, $top_bound, $right_bound, $bottom_bound) = $self->_image_bounds();
919            
920             # Get the word scaling factors
921             my $scalings = $self->_word_scalings();
922            
923             # Get the longest word (length * scaling is being used to determine it, but there may be a better way)
924             my $max_word = "";
925             foreach my $word (keys %{ $self->words() }) {
926             if (! $max_word) { $max_word = $word; next; } # init $max_word
927            
928             if (length($word) * $scalings->{ $word } > length($max_word) * $scalings->{ $max_word }) {
929             $max_word = $word;
930             }
931             }
932            
933             #printf "Using max word %s\n", $max_word;
934            
935             # Create the text object
936             my $t = new GD::Text::Align( GD::Image->new() );
937             $t->set_text($max_word);
938            
939             # Get every possible font we can use
940             my @fonts = $self->_get_all_fonts();
941            
942             # The last font that caused us size problems
943             my $lastfont = "";
944            
945             while ($fontsize > 0) {
946             my $toobig = 0;
947            
948             # The font size we try must include the scaling
949             my $tryfontsize = $fontsize * $scalings->{ $max_word };
950            
951             # If the size exceeds our "max", set it back to the max. This is a hacky way
952             # of making the sizes scale right but not excessively at the top end.
953             if ($tryfontsize > $init_fontsize) {
954             $tryfontsize = $init_fontsize;
955             }
956            
957             # Go through every font
958             foreach my $font (@fonts) {
959             $lastfont = $font if ! $lastfont;
960            
961             # Set the font on this text object
962             $t->set_font($font, $tryfontsize);
963            
964             #printf "Width is %s (max $w) at size %s in font %s\n", $t->get('width'), $tryfontsize, $font;
965            
966             # The text box is wider than the image bounds in this font, don't check the other fonts
967             if ($t->get('width') > $right_bound - $left_bound) {
968             $toobig = 1;
969             $lastfont = $font;
970             last;
971             }
972             }
973            
974             # If the text box wasn't too big, we've found our font size
975             last if ! $toobig;
976            
977             # Decrease the font size for next iteration
978             $fontsize--;
979             }
980            
981             # Return the font size INCLUDING the scaling, because it will be scaled down
982             # in cloud()
983             my $fontsize_with_scaling = $fontsize * $scalings->{ $max_word };
984            
985             #if ($fontsize_with_scaling > $init_fontsize) {
986             # carp sprintf "Fontsize %s bigger than init fontsize %s, reverting", $fontsize_with_scaling, $init_fontsize if $ENV{IWC_DEBUG};
987             # $fontsize_with_scaling = $init_fontsize;
988             #}
989            
990             # Save the max font size so we can reuse it whenever cloud() is called,
991             # without running this method again
992             $self->{max_font_size} = $fontsize_with_scaling;
993            
994             return wantarray ? ($fontsize_with_scaling, $lastfont) : $fontsize_with_scaling;
995             }
996              
997             # Initial maximum font size is the 1/4 the heigth of the image
998             sub _init_max_font_size {
999             my $self = shift;
1000            
1001             return $self->_pixels_to_points($self->width * .25);
1002             }
1003              
1004             # The minimum font size to use
1005             sub _min_font_size {
1006             my $self = shift;
1007            
1008             # Get the image bound dimensions
1009             my ($w, $h) = $self->_image_bounds_width_height();
1010            
1011             # The minimum font size is 0.8% of the image bounds height, seems to work nicely
1012             return $self->_pixels_to_points($h * 0.00875);
1013             }
1014              
1015             # Return a hashref of words with their associated scaling
1016             sub _word_scalings {
1017             my $self = shift;
1018            
1019             # Get the words sorted by their count
1020             my @word_keys = sort { $self->{words}->{$b} <=> $self->{words}->{$a} } keys %{ $self->words() };
1021            
1022             my $sloop = 0;
1023             my %word_scalings = map { $sloop++; $_ => (1.75 / $sloop) } @word_keys;
1024            
1025             return \%word_scalings;
1026             }
1027              
1028             # Return a hashref of words with their scaled font sizes
1029             sub _word_font_sizes {
1030             my $self = shift;
1031            
1032             my $max_font_size = $self->_max_font_size();
1033            
1034             my $word_scalings = $self->_word_scalings();
1035            
1036             my %word_sizes = map { $_ => $word_scalings->{$_} * $max_font_size } keys %{ $self->words() };
1037            
1038             return \%word_sizes;
1039             }
1040              
1041             # Return a single font
1042             sub _get_font {
1043             my $self = shift;
1044            
1045             my $font = "";
1046            
1047             # From a font file
1048             if ($self->{'font_file'}) {
1049             $font = $self->{'font_file'};
1050             }
1051             # Or the specified font
1052             elsif ($self->{'font'} && -d $self->{'font_path'}) {
1053             $font = $self->{'font'};
1054             }
1055             # ...or use a random font
1056             elsif (scalar @{$self->{'fonts'}} > 0) {
1057             $font = $self->{'fonts'}->[ rand @{$self->{'fonts'}} ];
1058             unless (-f $font) { carp "Font file '$font' not found"; }
1059             }
1060            
1061             return $font;
1062             }
1063              
1064             # Get all fonts we can possibly use
1065             sub _get_all_fonts {
1066             my $self = shift;
1067            
1068             my @fonts = ();
1069             if ($self->{'font_file'}) {
1070             @fonts = ($self->{'font_file'});
1071             }
1072             # Or the specified font
1073             elsif ($self->{'font'} && -d $self->{'font_path'}) {
1074             @fonts = ($self->{'font'});
1075             }
1076             # ...or all the fonts
1077             elsif (scalar @{$self->{'fonts'}} > 0) {
1078             @fonts = @{$self->{'fonts'}};
1079             }
1080            
1081             return @fonts;
1082             }
1083              
1084             # Given a number of pixels return the value in points (font size)
1085             sub _pixels_to_points {
1086             my $self = shift;
1087             my $pixels = shift;
1088            
1089             return $pixels * 72 / 96;
1090             }
1091              
1092             # Given a number of points return the value in pixels
1093             sub _points_to_pixels {
1094             my $self = shift;
1095             my $points = shift;
1096            
1097             return $points * 96 / 72;
1098             }
1099              
1100             # Return a list of random colors as an array of RGB arrayrefs
1101             # ( [25,30,60], [2,204,300] ), etc.
1102             sub _random_colors {
1103             my $self = shift;
1104            
1105             my %opts = validate(@_, {
1106             hue => { type => SCALAR, optional => 1, default => int(rand(359)) },
1107             scheme => { type => SCALAR, optional => 1, default => 'analogic' },
1108             variation => { type => SCALAR, optional => 1, default => 'default' },
1109             });
1110            
1111             carp sprintf "Color scheme hue: %s", $opts{'hue'} if $ENV{IWC_DEBUG};
1112            
1113             my @rand_colors = map { [$self->_hex2rgb($_)] } Color::Scheme->new
1114             ->from_hue( $opts{'hue'} )
1115             ->scheme( $opts{'scheme'} )
1116             ->variation( $opts{'variation'} )
1117             ->colors();
1118            
1119             return @rand_colors;
1120             }
1121              
1122             # Convert a hexadecimal color to a list of rgb values
1123             sub _hex2rgb {
1124             my $self = shift;
1125             my $hex = shift;
1126              
1127             my @rgb = map {hex($_) } unpack 'a2a2a2', $hex;
1128             return @rgb;
1129             }
1130              
1131             sub _prune_stop_words {
1132             my $self = shift;
1133            
1134             my @opts = validate_pos(@_, { type => HASHREF, optional => 1 });
1135            
1136             # Either use the words supplied to the subroutine or use what we have in the object
1137             my $words = {};
1138             if ($opts[0]) {
1139             $words = $opts[0];
1140             }
1141             else {
1142             $words = $self->{words};
1143             }
1144            
1145             # Read in the stop word file if we haven't already
1146             #if (! $self->{read_stop_file}) { $self->_read_stop_file(); }
1147            
1148             foreach my $word (keys %$words) {
1149             delete $words->{$word} if exists $STOP_WORDS{ $word };
1150             }
1151            
1152             return 1;
1153             }
1154              
1155             =head2 add_stop_words(@words)
1156              
1157             Add new stop words onto the list. Automatically puts words in lowercase.
1158              
1159             =cut
1160              
1161             sub add_stop_words {
1162             my $self = shift;
1163             my @words = @_;
1164            
1165             foreach my $word (@words) {
1166             $STOP_WORDS{ lc($word) } = 1;
1167             }
1168            
1169             return $self;
1170             }
1171              
1172             # Detect a collision between two rectangles
1173             # Arguments are:
1174             # 1: First rectangle's upper left X coord
1175             # 2: First rectangle's upper left Y coord
1176             # 3: First rectangle's width
1177             # 4: First rectangle's height
1178             #
1179             # 1: Second rectangle's upper left X coord
1180             # 2: Second rectangle's upper left Y coord
1181             # 3: Second rectangle's width
1182             # 4: Second rectangle's height
1183             sub _detect_collision {
1184             my $self = shift;
1185            
1186             #my ($a_x, $a_y, $a_w, $a_h,
1187             # $b_x, $b_y, $b_w, $b_h) = @_;
1188            
1189             #if (
1190             # !( ($b_x > $a_x + $a_w) || ($b_x + $b_w < $a_x) ||
1191             # ($b_y > $a_y + $a_h) || ($b_y + $b_h < $a_y) )) {
1192             #
1193             # return 1;
1194             #}
1195            
1196             # If the two rectangle collide on the both planes then they intersect
1197             if ($self->_detect_x_collision(@_) && $self->_detect_y_collision(@_)) {
1198             return 1;
1199             }
1200             else {
1201             return 0;
1202             }
1203             }
1204              
1205             # Detect a collision on the X plane
1206             sub _detect_x_collision {
1207             my $self = shift;
1208            
1209             my ($a_x, $a_y, $a_w, $a_h,
1210             $b_x, $b_y, $b_w, $b_h) = @_;
1211            
1212             if (! (($b_x > $a_x + $a_w) || ($b_x + $b_w < $a_x)) ) {
1213             return 1;
1214             }
1215             else {
1216             return 0;
1217             }
1218             }
1219              
1220             # Detect a collision on the Y plane
1221             sub _detect_y_collision {
1222             my $self = shift;
1223            
1224             my ($a_x, $a_y, $a_w, $a_h,
1225             $b_x, $b_y, $b_w, $b_h) = @_;
1226            
1227             if (! (($b_y > $a_y + $a_h) || ($b_y + $b_h < $a_y)) ) {
1228             return 1;
1229             }
1230             else {
1231             return 0;
1232             }
1233             }
1234              
1235             # Return which side of object A collides with object B
1236             sub _collision_sides {
1237             my $self = shift;
1238            
1239             my @sides = ();
1240            
1241             return @sides if ! $self->_detect_collision(@_);
1242            
1243             my ($a_x, $a_y, $a_w, $a_h,
1244             $b_x, $b_y, $b_w, $b_h) = @_;
1245            
1246             if (! ($b_x + $b_w > $a_x + $a_w)) {
1247             push(@sides, 'right');
1248             }
1249            
1250             if (! ($b_x + $b_w < $a_x + $a_w)) {
1251             push(@sides, 'left');
1252             }
1253            
1254             if (! ($b_y + $b_h > $a_y + $a_h)) {
1255             push(@sides, 'bottom');
1256             }
1257            
1258             if (! ($b_y + $b_h < $a_y + $a_h)) {
1259             push(@sides, 'top');
1260             }
1261            
1262             return @sides;
1263             }
1264              
1265             # Stroke the outline of a bounding box
1266             sub _stroke_bbox {
1267             my $self = shift;
1268             my $gd = shift;
1269             my $color = shift;
1270            
1271             my ($x1, $y1, $x2, $y2, $x3, $y3, $x4, $y4) = @_;
1272            
1273             $color ||= $gd->colorClosest(255,0,0);
1274            
1275             $gd->line($x1, $y1, $x2, $y2, $color);
1276             $gd->line($x2, $y2, $x3, $y3, $color);
1277             $gd->line($x3, $y3, $x4, $y4, $color);
1278             $gd->line($x4, $y4, $x1, $y1, $color);
1279             }
1280              
1281             # Return a random ingeger between two numbers
1282             sub _random_int_between {
1283             my $self = shift;
1284             my($min, $max) = @_;
1285            
1286             # Assumes that the two arguments are integers themselves!
1287             return $min if $min == $max;
1288             ($min, $max) = ($max, $min) if $min > $max;
1289             return $min + int rand(1 + $max - $min);
1290             }
1291              
1292             =head2 width()
1293              
1294             Return wordcloud image width
1295              
1296             =cut
1297             sub width {
1298             return shift->{image_size}->[0];
1299             }
1300              
1301             =head2 height()
1302              
1303             Return wordcloud image height
1304              
1305             =cut
1306             sub height {
1307             return shift->{image_size}->[1];
1308             }
1309              
1310             =head1 AUTHOR
1311              
1312             Brian Hann, C<< >>
1313              
1314             =head1 BUGS
1315              
1316             Please report any bugs or feature requests here L.
1317             I will be notified, and then you'll automatically be notified of progress on your bug as I make changes.
1318              
1319             =head1 SUPPORT
1320              
1321             You can find documentation for this module with the perldoc command.
1322              
1323             perldoc Image::WordCloud
1324              
1325              
1326             You can also look for information at:
1327              
1328             =over 4
1329              
1330             =item * Github Issues Tracker (report bugs here)
1331              
1332             L
1333              
1334             =item * AnnoCPAN: Annotated CPAN documentation
1335              
1336             L
1337              
1338             =item * CPAN Ratings
1339              
1340             L
1341              
1342             =item * Search CPAN
1343              
1344             L
1345              
1346             =item * MetaCPAN
1347              
1348             L
1349              
1350             =back
1351              
1352              
1353             =head1 LICENSE AND COPYRIGHT
1354              
1355             Copyright 2012 Brian Hann.
1356              
1357             This program is free software; you can redistribute it and/or modify it
1358             under the terms of either: the GNU General Public License as published
1359             by the Free Software Foundation; or the Artistic License.
1360              
1361             See http://dev.perl.org/licenses/ for more information.
1362              
1363              
1364             =cut
1365              
1366             1; # End of Image::WordCloud