Return image at next larger power of 2 reduction compared to what was requested.
[bookreader.git] / BookReaderIA / datanode / BookReaderImages.php
1 <?php
2
3 /*
4 Copyright(c) 2008-2010 Internet Archive. Software license AGPL version 3.
5
6 This file is part of BookReader.  The full source code can be found at GitHub:
7 http://github.com/openlibrary/bookreader
8
9 The canonical short name of an image type is the same as in the MIME type.
10 For example both .jpeg and .jpg are considered to have type "jpeg" since
11 the MIME type is "image/jpeg".
12
13     BookReader is free software: you can redistribute it and/or modify
14     it under the terms of the GNU Affero General Public License as published by
15     the Free Software Foundation, either version 3 of the License, or
16     (at your option) any later version.
17
18     BookReader is distributed in the hope that it will be useful,
19     but WITHOUT ANY WARRANTY; without even the implied warranty of
20     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21     GNU Affero General Public License for more details.
22
23     You should have received a copy of the GNU Affero General Public License
24     along with BookReader.  If not, see <http://www.gnu.org/licenses/>.
25 */
26
27 $MIMES = array('gif' => 'image/gif',
28                'jp2' => 'image/jp2',
29                'jpg' => 'image/jpeg',
30                'jpeg' => 'image/jpeg',
31                'png' => 'image/png',
32                'tif' => 'image/tiff',
33                'tiff' => 'image/tiff');
34                
35 $EXTENSIONS = array('gif' => 'gif',
36                     'jp2' => 'jp2',
37                     'jpeg' => 'jpeg',
38                     'jpg' => 'jpeg',
39                     'png' => 'png',
40                     'tif' => 'tiff',
41                     'tiff' => 'tiff');
42                
43 $exiftool = '/petabox/sw/books/exiftool/exiftool';
44
45 // Process some of the request parameters
46 $zipPath  = $_REQUEST['zip'];
47 $file     = $_REQUEST['file'];
48 if (isset($_REQUEST['ext'])) {
49     $ext = $_REQUEST['ext'];
50 } else {
51     // Default to jpg
52     $ext = 'jpeg';
53 }
54 if (isset($_REQUEST['callback'])) {
55     // validate callback is valid JS identifier (only)
56     $callback = $_REQUEST['callback'];
57     $identifierPatt = '/^[[:alpha:]$_]([[:alnum:]$_])*$/';
58     if (! preg_match($identifierPatt, $callback)) {
59         BRfatal('Invalid callback');
60     }
61 } else {
62     $callback = null;
63 }
64
65 /*
66  * Approach:
67  * 
68  * Get info about requested image (input)
69  * Get info about requested output format
70  * Determine processing parameters
71  * Process image
72  * Return image data
73  * Clean up temporary files
74  */
75  
76 function getUnarchiveCommand($archivePath, $file)
77 {
78     $lowerPath = strtolower($archivePath);
79     if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
80         $suffix = $matches[1];
81         
82         if ($suffix == 'zip') {
83             return 'unzip -p '
84                 . escapeshellarg($archivePath)
85                 . ' ' . escapeshellarg($file);
86         } else if ($suffix == 'tar') {
87             return '7z e -so '
88                 . escapeshellarg($archivePath)
89                 . ' ' . escapeshellarg($file);
90         } else {
91             BRfatal('Incompatible archive format');
92         }
93
94     } else {
95         BRfatal('Bad image stack path');
96     }
97     
98     BRfatal('Bad image stack path or archive format');
99     
100 }
101
102 /*
103  * Returns the image type associated with the file extension.
104  */
105 function imageExtensionToType($extension)
106 {
107     global $EXTENSIONS;
108     
109     if (array_key_exists($extension, $EXTENSIONS)) {
110         return $EXTENSIONS[$extension];
111     } else {
112         BRfatal('Unknown image extension');
113     }            
114 }
115
116 /*
117  * Get the image width, height and depth from a jp2 file in zip.
118  */
119 function getImageInfo($zipPath, $file)
120 {
121     global $exiftool;
122     
123     $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
124     $type = imageExtensionToType($fileExt);
125      
126     // We look for all the possible tags of interest then act on the
127     // ones presumed present based on the file type
128     $tagsToGet = ' -ImageWidth -ImageHeight -FileType'        // all formats
129                  . ' -BitsPerComponent -ColorSpace'          // jp2
130                  . ' -BitDepth'                              // png
131                  . ' -BitsPerSample';                        // tiff
132                         
133     $cmd = getUnarchiveCommand($zipPath, $file)
134         . ' | '. $exiftool . ' -S -fast' . $tagsToGet . ' -';
135     exec($cmd, $output);
136     
137     $tags = Array();
138     foreach ($output as $line) {
139         $keyValue = explode(": ", $line);
140         $tags[$keyValue[0]] = $keyValue[1];
141     }
142     
143     $width = intval($tags["ImageWidth"]);
144     $height = intval($tags["ImageHeight"]);
145     $type = strtolower($tags["FileType"]);
146     
147     switch ($type) {
148         case "jp2":
149             $bits = intval($tags["BitsPerComponent"]);
150             break;
151         case "tiff":
152             $bits = intval($tags["BitsPerSample"]);
153             break;
154         case "jpeg":
155             $bits = 8;
156             break;
157         case "png":
158             $bits = intval($tags["BitDepth"]);
159             break;
160         default:
161             BRfatal("Unsupported image type");
162             break;
163     }
164    
165    
166     $retval = Array('width' => $width, 'height' => $height,
167         'bits' => $bits, 'type' => $type);
168     
169     return $retval;
170 }
171
172 /*
173  * Output JSON given the imageInfo associative array
174  */
175 function outputJSON($imageInfo, $callback)
176 {
177     header('Content-type: text/plain');
178     $jsonOutput = json_encode($imageInfo);
179     if ($callback) {
180         $jsonOutput = $callback . '(' . $jsonOutput . ');';
181     }
182     echo $jsonOutput;
183 }
184
185 // Get the image size and depth
186 $imageInfo = getImageInfo($zipPath, $file);
187
188 // Output json if requested
189 if ('json' == $ext) {
190     // $$$ we should determine the output size first based on requested scale
191     outputJSON($imageInfo, $callback);
192     exit;
193 }
194
195 // Unfortunately kakadu requires us to know a priori if the
196 // output file should be .ppm or .pgm.  By decompressing to
197 // .bmp kakadu will write a file we can consistently turn into
198 // .pnm.  Really kakadu should support .pnm as the file output
199 // extension and automatically write ppm or pgm format as
200 // appropriate.
201 $decompressToBmp = true;
202 if ($decompressToBmp) {
203   $stdoutLink = '/tmp/stdout.bmp';
204 } else {
205   $stdoutLink = '/tmp/stdout.ppm';
206 }
207
208 $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
209
210 // Rotate is currently only supported for jp2 since it does not add server load
211 $allowedRotations = array("0", "90", "180", "270");
212 $rotate = $_REQUEST['rotate'];
213 if ( !in_array($rotate, $allowedRotations) ) {
214     $rotate = "0";
215 }
216
217 // Image conversion options
218 $pngOptions = '';
219 $jpegOptions = '-quality 75';
220
221 // The pbmreduce reduction factor produces an image with dimension 1/n
222 // The kakadu reduction factor produceds an image with dimension 1/(2^n)
223 // $$$ handle continuous values for scale
224 if (isset($_REQUEST['height'])) {
225     $ratio = floatval($_REQUEST['origHeight']) / floatval($_REQUEST['height']);
226     if ($ratio <= 2) {
227         $scale = 2;
228         $powReduce = 1;    
229     } else if ($ratio <= 4) {
230         $scale = 4;
231         $powReduce = 2;
232     } else {
233         //$powReduce = 3; //too blurry!
234         $scale = 2;
235         $powReduce = 1;
236     }
237
238 } else {
239     // $$$ could be cleaner
240     $scale = intval($_REQUEST['scale']);
241     if (1 >= $scale) {
242         $scale = 1;
243         $powReduce = 0;
244     } else if (2 > $scale) {
245         $powReduce = 0;
246         $scale = 1;
247     } else if (4 > $scale) {
248         $powReduce = 1;
249         $scale = 2;
250     } else if (8 > $scale) {
251         $powReduce = 2;
252         $scale = 4;
253     } else if (16 > $scale) {
254         $powReduce = 3;
255         $scale = 8;
256     } else if (32 > $scale) {
257         $powReduce = 4;
258         $scale = 16;
259     } else if (64 > $scale) {
260         $powReduce = 5;
261         $scale = 32;
262     } else {
263         // $$$ Leaving this in as default though I'm not sure why it is...
264         $scale = 8;
265         $powReduce = 3;
266     }
267 }
268
269 // Override depending on source image format
270 // $$$ consider doing a 302 here instead, to make better use of the browser cache
271 // Limit scaling for 1-bit images.  See https://bugs.edge.launchpad.net/bookreader/+bug/486011
272 if (1 == $imageInfo['bits']) {
273     if ($scale > 1) {
274         $scale /= 2;
275         $powReduce -= 1;
276         
277         // Hard limit so there are some black pixels to use!
278         if ($scale > 4) {
279             $scale = 4;
280             $powReduce = 2;
281         }
282     }
283 }
284
285 if (!file_exists($stdoutLink)) 
286 {  
287   system('ln -s /dev/stdout ' . $stdoutLink);  
288 }
289
290
291 putenv('LD_LIBRARY_PATH=/petabox/sw/lib/kakadu');
292
293 $unzipCmd  = getUnarchiveCommand($zipPath, $file);
294
295 switch ($imageInfo['type']) {
296     case 'jp2':
297         $decompressCmd = 
298             " | /petabox/sw/bin/kdu_expand -no_seek -quiet -reduce $powReduce -rotate $rotate -i /dev/stdin -o " . $stdoutLink;
299         if ($decompressToBmp) {
300             $decompressCmd .= ' | bmptopnm ';
301         }
302         break;
303
304     case 'tiff':
305         // We need to create a temporary file for tifftopnm since it cannot
306         // work on a pipe (the file must be seekable).
307         // We use the BookReaderTiff prefix to give a hint in case things don't
308         // get cleaned up.
309         $tempFile = tempnam("/tmp", "BookReaderTiff");
310     
311         // $$$ look at bit depth when reducing
312         $decompressCmd = 
313             ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . reduceCommand($scale);
314         break;
315  
316     case 'jpeg':
317         $decompressCmd = ' | jpegtopnm ' . reduceCommand($scale);
318         break;
319
320     case 'png':
321         $decompressCmd = ' | pngtopnm ' . reduceCommand($scale);
322         break;
323         
324     default:
325         BRfatal('Unknown source file extension: ' . $fileExt);
326         break;
327 }
328        
329 // Non-integer scaling is currently disabled on the cluster
330 // if (isset($_REQUEST['height'])) {
331 //     $cmd .= " | pnmscale -height {$_REQUEST['height']} ";
332 // }
333
334 switch ($ext) {
335     case 'png':
336         $compressCmd = ' | pnmtopng ' . $pngOptions;
337         break;
338         
339     case 'jpeg':
340     case 'jpg':
341     default:
342         $compressCmd = ' | pnmtojpeg ' . $jpegOptions;
343         $ext = 'jpeg'; // for matching below
344         break;
345
346 }
347
348 if (($ext == $fileExt) && ($scale == 1) && ($rotate === "0")) {
349     // Just pass through original data if same format and size
350     $cmd = $unzipCmd;
351 } else {
352     $cmd = $unzipCmd . $decompressCmd . $compressCmd;
353 }
354
355 # print $cmd;
356
357
358 // $$$ investigate how to flush cache when this file is changed
359 header('Content-type: ' . $MIMES[$ext]);
360 header('Cache-Control: max-age=15552000');
361 passthru ($cmd); # cmd returns image data
362
363 if (isset($tempFile)) {
364     unlink($tempFile);
365 }
366
367 function BRFatal($string) {
368     echo "alert('$string');\n";
369     die(-1);
370 }
371
372 // Returns true if using a power node
373 function onPowerNode() {
374     exec("lspci | fgrep -c Realtek", $output, $return);
375     if ("0" != $output[0]) {
376         return true;
377     } else {
378         exec("egrep -q AMD /proc/cpuinfo", $output, $return);
379         if ($return == 0) {
380             return true;
381         }
382     }
383     return false;
384 }
385
386 function reduceCommand($scale) {
387     if (1 != $scale) {
388         if (onPowerNode()) {
389             return ' | pnmscale -reduce ' . $scale;
390         } else {
391             return ' | pnmscale -nomix -reduce ' . $scale;
392         }
393     } else {
394         return '';
395     }
396 }
397
398
399 ?>
400