File Coverage

blib/lib/Plack/Middleware/Image/Scale.pm
Criterion Covered Total %
statement 5 7 71.4
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 8 10 80.0


line stmt bran cond sub pod time code
1 2     2   23912 use strict;
  2         5  
  2         136  
2             package Plack::Middleware::Image::Scale;
3             BEGIN {
4 2     2   63 $Plack::Middleware::Image::Scale::AUTHORITY = 'cpan:PNU';
5             }
6             {
7             $Plack::Middleware::Image::Scale::VERSION = '0.009';
8             }
9             # ABSTRACT: Resize jpeg and png images on the fly
10              
11 2     2   1374 use Moose;
  0            
  0            
12             use Class::MOP;
13             use Plack::Util;
14             use Plack::MIME;
15             use Try::Tiny;
16             use Image::Scale;
17             use List::Util qw( min max );
18             use Carp;
19              
20             extends 'Plack::Middleware';
21              
22              
23             has path => (
24             is => 'rw', lazy => 1, isa => 'RegexpRef|CodeRef|Str|Undef',
25             default => undef
26             );
27              
28              
29             has match => (
30             is => 'rw', lazy => 1, isa => 'RegexpRef|CodeRef',
31             default => sub { qr{^(.+?)(?:_([^_]+?))?(?:\.(jpe?g|png|image))$} }
32             );
33              
34              
35             has size => (
36             is => 'rw', lazy => 1, isa => 'RegexpRef|CodeRef|HashRef|Undef',
37             default => sub { qr{^(\d+)?x(\d+)?(?:-(.+))?$} }
38             );
39              
40              
41             has any_ext => (
42             is => 'rw', lazy => 1, isa => 'Str|Undef',
43             default => 'image'
44             );
45              
46              
47             has orig_ext => (
48             is => 'rw', lazy => 1, isa => 'ArrayRef',
49             default => sub { [qw( jpg png gif )] }
50             );
51              
52              
53             has memory_limit => (
54             is => 'rw', lazy => 1, isa => 'Int|Undef',
55             default => 10_000_000 # bytes
56             );
57              
58              
59             has jpeg_quality => (
60             is => 'rw', lazy => 1, isa => 'Int|Undef',
61             default => undef
62             );
63              
64              
65             has width => (
66             is => 'rw', lazy => 1, isa => 'Int|Undef',
67             default => undef
68             );
69              
70              
71             has height => (
72             is => 'rw', lazy => 1, isa => 'Int|Undef',
73             default => undef
74             );
75              
76              
77             has flags => (
78             is => 'rw', lazy => 1, isa => 'HashRef|Undef',
79             default => undef
80             );
81              
82             sub call {
83             my ($self,$env) = @_;
84             my $path = $env->{PATH_INFO};
85             my @param;
86              
87             if ( defined $self->path ) {
88             my ($m) = _match($path,$self->path);
89             return $self->app->($env) unless $m;
90             }
91              
92             my @m = _match($path,$self->match);
93             return $self->app->($env) unless @m;
94             ($path, my $size, my $ext) = @m;
95             return $self->app->($env) unless $path and $ext;
96              
97             if ( defined $size ) {
98             @param = _unroll(_match($size,$self->size));
99             return $self->app->($env) unless @param;
100             }
101              
102             my $res = $self->fetch_orig($env,$path);
103             return $self->app->($env) unless $res;
104              
105             ## Post-process the response with a body filter
106             $self->response_cb( $res, sub {
107             my $res = shift;
108             my $orig_ct = Plack::Util::header_get( $res->[1], 'Content-Type' );
109             my $ct;
110             if ( defined $self->any_ext and $ext eq $self->any_ext ) {
111             $ct = Plack::Util::header_get( $res->[1], 'Content-Type' );
112             } else {
113             $ct = Plack::MIME->mime_type(".$ext");
114             Plack::Util::header_set( $res->[1], 'Content-Type', $ct );
115             }
116             return $self->body_scaler( $ct, @param, $orig_ct );
117             });
118             }
119              
120             ## Helper for matching a Scalar value against CodeRef, HashRef,
121             ## RegexpRef or Str. The first argument may be modified during match.
122             sub _match {
123             my @match;
124             for ( $_[0] ) {
125             my $match = $_[1];
126             @match =
127             'CODE' eq ref $match ? $match->($_) :
128             'HASH' eq ref $match ? $match->{$_} :
129             'Regexp' eq ref $match ? $_ =~ $match :
130             defined $match ? (substr($_,0,length $match) eq $match ? ($match) : ()) :
131             undef;
132             }
133             return @match;
134             }
135              
136             ## Helper for extracting (width,height,flags) from
137             ## HashRef or ArrayRef.
138             sub _unroll {
139             return unless @_;
140             for ( $_[0] ) {
141             ## Config::General style hash of hashrefs.
142             if ( ref eq 'HASH' ) {
143             my %e = %{$_};
144             return (delete @e{'width','height'}, \%e);
145             ## Manual config friendly hash of arraysrefs.
146             } elsif ( ref eq 'ARRAY' ) {
147             return @{$_};
148             }
149             }
150             return @_;
151             }
152              
153              
154             sub fetch_orig {
155             my ($self,$env,$basename) = @_;
156              
157             for my $ext ( @{$self->orig_ext} ) {
158             local $env->{PATH_INFO} = "$basename.$ext";
159             my $r = $self->app->($env);
160             return $r unless ref $r eq 'ARRAY' and $r->[0] == 404;
161             }
162             return;
163             }
164              
165              
166             sub body_scaler {
167             my $self = shift;
168             my @args = @_;
169              
170             my $buffer = q{};
171             my $filter_cb = sub {
172             my $chunk = shift;
173              
174             ## Buffer until we get EOF
175             if ( defined $chunk ) {
176             $buffer .= $chunk;
177             return q{}; #empty
178             }
179              
180             ## Return EOF when done
181             return if not defined $buffer;
182              
183             ## Process the buffer
184             my $img = $buffer ? $self->image_scale(\$buffer,@args) : '';
185             undef $buffer;
186             return $img;
187             };
188              
189             return $filter_cb;
190             }
191              
192              
193             sub image_scale {
194             my ($self, $bufref, $ct, $width, $height, $flags, $orig_ct) = @_;
195              
196             ## $flags can be a HashRef, or it's parsed as a string
197             my %flag = 'HASH' eq ref $flags ? %{ $flags } :
198             map { (split /(?<=\w)(?=\d)/, $_, 2)[0,1]; } split '-', $flags || '';
199              
200             $width = $self->width if defined $self->width;
201             $height = $self->height if defined $self->height;
202             %flag = %{ $self->flags } if defined $self->flags;
203              
204             my $owidth = $width;
205             my $oheight = $height;
206              
207             if ( defined $flag{z} and $flag{z} > 0 ) {
208             $width *= 1 + $flag{z} / 100 if $width;
209             $height *= 1 + $flag{z} / 100 if $height;
210             }
211              
212             my $output;
213             if ($orig_ct eq 'application/pdf') {
214             try {
215             Class::MOP::load_class('Image::Magick::Thumbnail::PDF');
216             Class::MOP::load_class('File::Temp');
217             my $in = File::Temp->new( SUFFIX => '.pdf' );
218             my $out = File::Temp->new( SUFFIX => '.png' );
219             $in->write( $$bufref ); $in->close;
220             Image::Magick::Thumbnail::PDF::create_thumbnail(
221             $in->filename, $out->filename, $flag{p}||1, {
222             frame => 0, normalize => 0,
223             restriction => max($width, $height),
224             }
225             );
226             my $pdfdata;
227             $out->seek( 0, 0 );
228             $out->read( $pdfdata, 9999999 );
229             $bufref = \$pdfdata;
230             } catch {
231             carp $_;
232             $output = $$bufref;
233             };
234             }
235             try {
236             my $img = Image::Scale->new($bufref)
237             or die 'Invalid data / image format not recognized';
238              
239             if ( exists $flag{crop} and defined $width and defined $height ) {
240             my $ratio = $img->width / $img->height;
241             $width = max $width , $height * $ratio;
242             $height = max $height, $width / $ratio;
243             } elsif ( exists $flag{fit} and defined $width and defined $height ) {
244             my $ratio = $img->width / $img->height;
245             $width = min $width , $height * $ratio;
246             $height = min $height, $width / $ratio;
247             }
248              
249             unless ( defined $width or defined $height ) {
250             ## We want to keep the size, but Image::Scale
251             ## doesn't return data unless we call resize.
252             $width = $img->width; $height = $img->height;
253             }
254             $img->resize({
255             defined $width ? (width => $width) : (),
256             defined $height ? (height => $height) : (),
257             exists $flag{fill} ? (keep_aspect => 1) : (),
258             defined $flag{fill} ? (bgcolor => hex $flag{fill}) : (),
259             defined $self->memory_limit ?
260             (memory_limit => $self->memory_limit) : (),
261             });
262              
263             $output = $ct eq 'image/jpeg' ? $img->as_jpeg($self->jpeg_quality || ()) :
264             $ct eq 'image/png' ? $img->as_png :
265             die "Conversion to '$ct' is not implemented";
266             } catch {
267             carp $_;
268             $output = $$bufref;
269             };
270              
271             if ( defined $owidth and $width > $owidth or
272             defined $oheight and $height > $oheight ) {
273             try {
274             Class::MOP::load_class('Imager');
275             my $img = Imager->new;
276             $img->read( data => $output ) || die;
277             my $crop = $img->crop(
278             defined $owidth ? (width => $owidth) : (),
279             defined $oheight ? (height => $oheight) : (),
280             );
281             $crop->write( data => \$output, type => (split '/', $ct)[1] );
282             } catch {
283             carp $_;
284             };
285             }
286              
287             return $output;
288             }
289              
290             1;
291              
292             __END__
293              
294             =pod
295              
296             =head1 NAME
297              
298             Plack::Middleware::Image::Scale - Resize jpeg and png images on the fly
299              
300             =head1 VERSION
301              
302             version 0.009
303              
304             =head1 SYNOPSIS
305              
306             ## example1.psgi
307            
308             builder {
309             enable 'ConditionalGET';
310             enable 'Image::Scale';
311             enable 'Static', path => qr{^/images/};
312             $app;
313             };
314              
315             A request to /images/foo_40x40.png will use images/foo.(png|jpg|gif) as
316             original, scale it to 40x40 px size and convert to PNG format.
317              
318             ## example2.psgi
319              
320             my $thumber = builder {
321             enable 'ConditionalGET';
322             enable 'Image::Scale',
323             width => 200, height => 100,
324             flags => { fill => 'ff00ff' };
325             Plack::App::File->new( root => 'images' );
326             };
327              
328             builder {
329             mount '/thumbs' => $thumber;
330             mount '/' => $app;
331             };
332              
333             A request to /thumbs/foo.png will use images/foo.(png|jpg|gif) as original,
334             scale it small enough to fit 200x100 px size, fill extra borders (top/down or
335             left/right, depending on the original image aspect ratio) with cyan
336             background, and convert to PNG format. Also clipping is available, see
337             L</CONFIGURATION>.
338              
339             ## see example4.psgi
340              
341             my %imagesize = Config::General->new('imagesize.conf')->getall;
342            
343             # ...
344            
345             enable 'Image::Scale', size => \%imagesize;
346              
347             A request to /images/foo_medium.png will use images/foo.(png|jpg|gif) as
348             original. The size and flags are taken from the configuration file as
349             parsed by Config::General.
350              
351             ## imagesize.conf
352              
353             <medium>
354             width 200
355             height 100
356             crop
357             </medium>
358             <big>
359             width 300
360             height 100
361             crop
362             </big>
363             <thumbred>
364             width 50
365             height 100
366             fill ff0000
367             </thumbred>
368              
369             For more examples, browse into directory
370             L<eg|http://cpansearch.perl.org/src/PNU/> inside the distribution
371             directory for this version.
372              
373             =head1 DESCRIPTION
374              
375             Scale and convert images to the requested format on the fly. By default the
376             size and other scaling parameters are extracted from the request URI. Scaling
377             is done with L<Image::Scale>.
378              
379             The original image is not modified or even accessed directly by this module.
380             The converted image is not cached, but the request can be validated
381             (If-Modified-Since) against original image without doing the image processing.
382             This middleware should be used together a cache proxy, that caches the
383             converted images for all clients, and implements content validation.
384              
385             The response headers (like Last-Modified or ETag) are from the original image,
386             but body is replaced with a PSGI L<content
387             filter|Plack::Middleware/RESPONSE_CALLBACK> to do the image processing. The
388             original image is fetched from next middleware layer or application with a
389             normal PSGI request. You can use L<Plack::Middleware::Static>, or
390             L<Catalyst::Plugin::Static::Simple> for example.
391              
392             See L</CONFIGURATION> for various size/format specifications that can be used
393             in the request URI, and L</ATTRIBUTES> for common configuration options
394             that you can use when constructing the middleware.
395              
396             =head1 ATTRIBUTES
397              
398             =head2 path
399              
400             Must be a L<RegexpRef|Moose::Util::TypeConstraints/Default_Type_Constraints>,
401             L<CodeRef|Moose::Util::TypeConstraints/Default_Type_Constraints>,
402             L<Str|Moose::Util::TypeConstraints/Default_Type_Constraints> or
403             L<Undef|Moose::Util::TypeConstraints/Default_Type_Constraints>.
404              
405             The L<PATH_INFO|PSGI/The_Environment> is compared against this value to
406             evaluate if the request should be processed. Undef (the default) will match
407             always. C<PATH_INFO> is topicalized by settings it to C<$_>, and it may be
408             rewritten during C<CodeRef> matching. Rewriting can be used to relocate image
409             paths, much like C<path> parameter for L<Plack::Middleware::Static>.
410              
411             If path matches, next it will be compared against L</name>. If path doesn't
412             match, the request will be delegated to the next middleware layer or
413             application.
414              
415             =head2 match
416              
417             Must be a L<RegexpRef|Moose::Util::TypeConstraints/Default_Type_Constraints>,
418             or L<CodeRef|Moose::Util::TypeConstraints/Default_Type_Constraints>.
419              
420             The L<PATH_INFO|PSGI/The_Environment>, possibly rewritten during L</path>
421             matching, is compared against this value to extract C<name>, C<size>
422             and C<ext>. The default value is:
423              
424             qr{^(.+)(?:_(.+?))?(?:\.(jpe?g|png|image))$}
425              
426             The expression is evaluated in array context and may return three elements:
427             C<name>, C<size> and C<ext>. Returning an empty array means no match.
428             Non-matching requests are delegated to the next middleware layer or
429             application.
430              
431             If the path matches, the original image is fetched from C<name>.L</orig_ext>,
432             scaled with parameters extracted from C<size> and converted to the content type
433             defined by C<ext>. See also L</any_ext>.
434              
435             =head2 size
436              
437             Must be a L<RegexpRef|Moose::Util::TypeConstraints/Default_Type_Constraints>,
438             L<CodeRef|Moose::Util::TypeConstraints/Default_Type_Constraints>,
439             L<HashRef|Moose::Util::TypeConstraints/Default_Type_Constraints>,
440             L<Undef|Moose::Util::TypeConstraints/Default_Type_Constraints>.
441              
442             The C<size> extracted by L</match> is compared against this value to evaluate
443             if the request should be processed, and to map it into width, height and flags
444             for image processing. Undef will match always and use default width, height
445             and flags as defined by the L</ATTRIBUTES>. The default value is:
446              
447             qr{^(\d+)?x(\d+)?(?:-(.+))?$}
448              
449             The expression is evaluated in array context and may return three elements;
450             C<width>, C<height> and C<flags>. Returning an empty array means no match.
451             Non-matching requests are delegated to the next middleware layer or
452             application.
453              
454             Optionally a hash reference can be returned. Keys C<width>, C<height>, and any
455             remaining keys as an hash reference, will be unrolled from the hash reference.
456              
457             =head2 any_ext
458              
459             If defined and request C<ext> is equal to this, the content type of the original
460             image is used in the output. This means that the image format of the original
461             image is preserved. Default is C<image>.
462              
463             =head2 orig_ext
464              
465             L<ArrayRef|Moose::Util::TypeConstraints/Default_Type_Constraints>
466             of possible original image formats. See L</fetch_orig>.
467              
468             =head2 memory_limit
469              
470             Memory limit for the image scaling in bytes, as defined in
471             L<Image::Scale|Image::Scale/resize(_\%OPTIONS_)>.
472              
473             =head2 jpeg_quality
474              
475             JPEG quality, as defined in
476             L<Image::Scale|Image::Scale/as_jpeg(_[_$QUALITY_]_)>.
477              
478             =head2 width
479              
480             Use this to set and override image width.
481              
482             =head2 height
483              
484             Use this to set and override image height.
485              
486             =head2 flags
487              
488             Use this to set and override image processing flags.
489              
490             =head1 METHODS
491              
492             =head2 fetch_orig
493              
494             Call parameters: PSGI request HashRef $env, Str $basename.
495             Return value: PSGI response ArrayRef $res.
496              
497             The original image is fetched from the next layer or application. All
498             possible extensions defined in L</orig_ext> are tried in order, to search for
499             the original image. All other responses except a straight 404 (as returned by
500             L<Plack::Middleware::Static> for example) are considered matches.
501              
502             =head2 body_scaler
503              
504             Call parameters: @args. Return value: PSGI content filter CodeRef $cb.
505              
506             Create the content filter callback and return a CodeRef to it. The filter will
507             buffer the data and call L</image_scale> with parameters C<@args> when EOF is
508             received, and finally return the converted data.
509              
510             =head2 image_scale
511              
512             Call parameters: ScalarRef $buffer, String $ct, Int $width, Int $height, HashRef|Str $flags.
513             Return value: $imagedata
514              
515             Read image from $buffer, scale it to $width x $height and
516             return as content-type $ct. Optional $flags to specify image processing
517             options like background fills or cropping.
518              
519             =head1 CONFIGURATION
520              
521             The default match pattern for URI is
522             "I<...>_I<width>xI<height>-I<flags>.I<ext>".
523              
524             If URI doesn't match, the request is passed through. Any number of flags can
525             be specified, separated with C<->. Flags can be boolean (exists or doesn't
526             exist), or have a numerical value. Flag name and value are separated with a
527             zero-width word to number boundary. For example C<z20> specifies flag C<z>
528             with value C<20>.
529              
530             =head2 width
531              
532             Width of the output image. If not defined, it can be anything
533             (to preserve the image aspect ratio).
534              
535             =head2 height
536              
537             Height of the output image. If not defined, it can be anything
538             (to preserve the image aspect ratio).
539              
540             =head2 flags: fill
541              
542             Image aspect ratio is preserved by scaling the image to fit within the
543             specified size. This means scaling to the smaller or the two possible sizes
544             that preserve aspect ratio. Extra borders of background color are added to
545             fill the requested image size exactly.
546              
547             /images/foo_400x200-fill.png
548              
549             If fill has a value, it specifies the background color to use. Undefined color
550             with png output means transparent background.
551              
552             =head2 flags: crop
553              
554             Image aspect ratio is preserved by scaling and cropping from middle of the
555             image. This means scaling to the bigger of the two possible sizes that
556             preserve the aspect ratio, and then cropping to the exact size.
557              
558             =head2 flags: fit
559              
560             Image aspect ratio is preserved by scaling the image to the smaller of the two
561             possible sizes. This means that the resulting picture may have one dimension
562             smaller than specified, but cropping or filling is avoided.
563              
564             See documentation in distribution directory C<doc> for a visual explanation.
565              
566             =head2 flags: z
567              
568             Zoom the original image N percent bigger. For example C<z20> to zoom 20%.
569             Zooming applies only to explicitly defined width and/or height, and it does
570             not change the crop size.
571              
572             /images/foo_40x-z20.png
573              
574             =head1 CAVEATS
575              
576             The cropping requires L<Imager>. This is a run-time dependency, and
577             fallback is not to crop the image to the expected size.
578              
579             =head1 SEE ALSO
580              
581             L<Image::Scale>
582              
583             L<Imager>
584              
585             L<Plack::App::ImageMagick>
586              
587             =head1 AUTHOR
588              
589             Panu Ervamaa <pnu@cpan.org>
590              
591             =head1 COPYRIGHT AND LICENSE
592              
593             This software is copyright (c) 2011 by Panu Ervamaa.
594              
595             This is free software; you can redistribute it and/or modify it under
596             the same terms as the Perl 5 programming language system itself.
597              
598             =cut