File Coverage

blib/lib/IIIF/ImageAPI.pm
Criterion Covered Total %
statement 44 113 38.9
branch 0 40 0.0
condition 0 20 0.0
subroutine 15 24 62.5
pod 2 9 22.2
total 61 206 29.6


line stmt bran cond sub pod time code
1             package IIIF::ImageAPI;
2 1     1   572 use 5.014001;
  1         3  
3              
4             our $VERSION = "0.06";
5              
6 1     1   6 use parent 'Plack::Component';
  1         2  
  1         6  
7              
8 1     1   3685 use IIIF::Magick qw(info convert);
  1         4  
  1         51  
9 1     1   5 use File::Spec;
  1         2  
  1         16  
10 1     1   438 use Try::Tiny;
  1         1726  
  1         392  
11 1     1   429 use Plack::Request;
  1         56326  
  1         41  
12 1     1   644 use IIIF::Request;
  1         3  
  1         28  
13 1     1   872 use JSON::PP;
  1         12282  
  1         66  
14 1     1   7 use File::Temp qw(tempdir);
  1         8  
  1         39  
15 1     1   5 use Digest::MD5 qw(md5_hex);
  1         2  
  1         37  
16 1     1   748 use HTTP::Date;
  1         3337  
  1         53  
17              
18 1     1   461 use Plack::MIME;
  1         1854  
  1         95  
19             Plack::MIME->add_type( '.jp2', 'image/jp2' );
20             Plack::MIME->add_type( '.webp', 'image/webp' );
21              
22 1     1   7 use Cwd;
  1         1  
  1         59  
23 1     1   6 use Plack::Util;
  1         1  
  1         22  
24              
25             use Plack::Util::Accessor
26 1     1   4 qw(images base cache formats rights service canonical magick_args);
  1         2  
  1         7  
27              
28             our @FORMATS = qw(jpg png gif);
29              
30             sub new {
31 0     0 1   my $class = shift;
32 0           my $self = $class->SUPER::new(@_);
33              
34 0 0         $self->images('.') unless $self->images;
35 0 0         $self->formats( [qw{jpg png gif}] ) unless $self->formats;
36              
37 0           $self;
38             }
39              
40             sub call {
41 0     0 1   my ( $self, $env ) = @_;
42 0           my $req = Plack::Request->new($env);
43              
44 0 0         if ( $req->path_info =~ qr{^/([^/]+)/?(.*)$} ) {
45 0           my ( $identifier, $request ) = ( $1, $2 );
46 0 0         if ( my $file = $self->file($identifier) ) {
47 0   0       $file->{id} = ( $self->base // $req->base ) . $identifier;
48 0           return $self->response( $file, $request );
49             }
50             }
51              
52 0           return error_response( 404, "Not Found" );
53             }
54              
55             sub response {
56 0     0 0   my ( $self, $file, $local ) = @_;
57              
58             # Image Information Request
59 0 0         if ( $local eq '' ) {
    0          
60 0           return redirect( $file->{id} . "/info.json" );
61             }
62             elsif ( $local eq 'info.json' ) {
63 0           return $self->info_response($file);
64             }
65              
66             # allow abbreviated requests, redirect to full form
67 0           my $request = eval { IIIF::Request->new($local) };
  0            
68 0 0         if ($@) {
69 0           return error_response( 400, ( split( " at ", $@ ) )[0] );
70             }
71              
72 0   0       $request->{format} = $request->{format} // $file->{format};
73              
74             return error_response( 400, "unsupported format" )
75 0 0         unless grep { $_ eq $request->{format} } @{ $self->formats };
  0            
  0            
76              
77 0 0         if ( $self->canonical ) {
78 0           my $info = info( $file->{path} );
79             my $canonical = $request->canonical( $info->{width}, $info->{height} )
80 0 0         or return error_response();
81 0           $request = IIIF::Request->new($canonical);
82             }
83              
84 0 0         if ( "$request" ne $local ) {
85 0           return redirect( $file->{id} . "/$request" );
86             }
87              
88             # Image Request
89              
90             # directly serve unmodified image
91 0 0 0       if ( $request->is_default && $request->{format} eq $file->{format} ) {
92 0           return image_response( $file->{path} );
93             }
94              
95 0   0       my $cache = $self->cache // $self->cache( tempdir( CLEANUP => 1 ) );
96 0           my $cache_file = File::Spec->catfile( $cache,
97             md5_hex("$request") . ".$request->{format}" );
98              
99 0 0         if ( -r $cache_file ) {
100 0           return image_response($cache_file);
101             }
102             else {
103              
104             # TODO: only get image dimensions once and only if actually needed
105 0           my $info = info( $file->{path} );
106 0 0         if ( !$request->canonical( $info->{width}, $info->{height} ) ) {
107 0           return error_response();
108             }
109              
110 0           my @args = ( $request, $file->{path}, $cache_file );
111 0 0         push @args, @{ $self->{magick_args} || [] };
  0            
112 0 0         return image_response($cache_file) if convert(@args);
113             }
114              
115 0           error_response( 500, "Conversion failed" );
116             }
117              
118             sub info_response {
119 0     0 0   my ( $self, $file ) = @_;
120              
121             my $info = info(
122             $file->{path},
123             id => $file->{id},
124 0           profile => 'level2',
125              
126             # TODO: maxWidth or maxArea, maxHeight (required!)
127              
128             extraQualities => [qw(color gray bitonal default)],
129             extraFormats => $self->formats,
130             extraFeatures => [
131             qw(
132             baseUriRedirect cors jsonldMediaType mirroring
133             profileLinkHeader
134             regionByPct regionByPx regionSquare rotationArbitrary rotationBy90s
135             sizeByConfinedWh sizeByH sizeByPct sizeByW sizeByWh sizeUpscaling
136             )
137             ]
138             );
139              
140             # TODO: canonicalLinkHeader?
141              
142 0 0         $info->{rights} = $self->rights if $self->rights;
143 0 0         $info->{service} = $self->service if $self->service;
144              
145 0           return json_response( 200, $info,
146             'application/ld+json;profile="http://iiif.io/api/image/3/context.json"'
147             );
148             }
149              
150             sub file {
151 0     0 0   my ( $self, $identifier ) = @_;
152              
153 0           for my $format ( @{ $self->formats } ) {
  0            
154 0           my $path = File::Spec->catfile( $self->images, "$identifier.$format" );
155 0 0         if ( -r $path ) {
156             return {
157 0           path => $path,
158             format => $format
159             };
160             }
161             }
162             }
163              
164             sub redirect {
165 0     0 0   return [ 303, [ Location => $_[0] ], [] ];
166             }
167              
168             # adopted from Plack::App::File
169             sub image_response {
170 0     0 0   my ($file) = @_;
171              
172 0 0         open my $fh, "<:raw", $file
173             or return error_response( 403, "Forbidden" );
174              
175 0   0       my $type = Plack::MIME->mime_type($file) // 'image';
176 0           my @stat = stat $file;
177              
178 0           Plack::Util::set_io_path( $fh, Cwd::realpath($file) );
179              
180             return [
181 0           200,
182             [
183             'Content-Type' => $type,
184             'Content-Length' => $stat[7],
185             'Last-Modified' => HTTP::Date::time2str( $stat[9] ),
186             'Link' => ';rel="profile"'
187             ],
188             $fh,
189             ];
190             }
191              
192             sub json_response {
193 0     0 0   my ( $code, $body, $type ) = @_;
194              
195 0           state $JSON = JSON::PP->new->pretty->canonical(1);
196              
197             [
198 0   0       $code,
199             [
200             'Content-Type' => $type // 'application/json',
201             'Link' => ';rel="profile"'
202             ],
203             [ $JSON->encode($body) ]
204             ];
205             }
206              
207             sub error_response {
208 0   0 0 0   my $code = shift // 400;
209 0   0       my $message = shift
210             // "Invalid IIIF Image API Request: region or size out of bounds";
211 0           json_response( $code, { message => $message } );
212             }
213              
214             1;
215              
216             =head1 NAME
217              
218             IIIF::ImageAPI - IIIF Image API implementation as Plack application
219              
220             =head1 SYNOPSIS
221              
222             use Plack::Builder;
223             use IIIF::ImageAPI;
224              
225             builder {
226             enable 'CrossOrigin', origins => '*';
227             IIIF::ImageAPI->new(
228             images => 'path/to/images',
229             base => 'https://example.org/iiif/',
230             formats => [qw(jpg png gif tif pdf webp jp2)],
231             );
232             }
233              
234             =head1 CONFIGURATION
235              
236             =over
237              
238             =item images
239              
240             Image directory
241              
242             =item cache
243              
244             Cache directory. Set to a temporary per-process directory by default.
245              
246             =item base
247              
248             Base URI which the service is hosted at, including trailing slash. Likely
249             required if the service is put behind a web proxy.
250              
251             =item canonical
252              
253             Redirect requests to the L
254             and include (disabled by default).
255              
256             =item formats
257              
258             List of supported image formats. Set to C<['jpg', 'png', 'gif']> by default. On
259             configuration with other formats make sure ImageMagick supports them (see
260             L).
261              
262             =item rights
263              
264             Optional string that identifies a license or rights statement for all images,
265             to be included in image information responses.
266              
267             =item service
268              
269             Optional array with L
270             to be included in image information responses.
271              
272             =item magick_args
273              
274             Additional command line arguments always used when calling ImageMagick. For
275             instance C<[qw(-limit memory 1GB -limit disk 1GB)]> to limit resources.
276              
277             =back
278              
279             =cut