Merge branch 'master' into jp2levels
[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 // Paths to command-line tools
44 $exiftool = '/petabox/sw/books/exiftool/exiftool';
45 $kduExpand = '/petabox/sw/bin/kdu_expand';
46
47 // Process some of the request parameters
48 $zipPath  = $_REQUEST['zip'];
49 $file     = $_REQUEST['file'];
50 if (isset($_REQUEST['ext'])) {
51     $ext = $_REQUEST['ext'];
52 } else {
53     // Default to jpg
54     $ext = 'jpeg';
55 }
56 if (isset($_REQUEST['callback'])) {
57     // validate callback is valid JS identifier (only)
58     $callback = $_REQUEST['callback'];
59     $identifierPatt = '/^[[:alpha:]$_]([[:alnum:]$_])*$/';
60     if (! preg_match($identifierPatt, $callback)) {
61         BRfatal('Invalid callback');
62     }
63 } else {
64     $callback = null;
65 }
66
67 /*
68  * Approach:
69  * 
70  * Get info about requested image (input)
71  * Get info about requested output format
72  * Determine processing parameters
73  * Process image
74  * Return image data
75  * Clean up temporary files
76  */
77  
78 function getUnarchiveCommand($archivePath, $file)
79 {
80     $lowerPath = strtolower($archivePath);
81     if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
82         $suffix = $matches[1];
83         
84         if ($suffix == 'zip') {
85             return 'unzip -p '
86                 . escapeshellarg($archivePath)
87                 . ' ' . escapeshellarg($file);
88         } else if ($suffix == 'tar') {
89             return '7z e -so '
90                 . escapeshellarg($archivePath)
91                 . ' ' . escapeshellarg($file);
92         } else {
93             BRfatal('Incompatible archive format');
94         }
95
96     } else {
97         BRfatal('Bad image stack path');
98     }
99     
100     BRfatal('Bad image stack path or archive format');
101     
102 }
103
104 /*
105  * Returns the image type associated with the file extension.
106  */
107 function imageExtensionToType($extension)
108 {
109     global $EXTENSIONS;
110     
111     if (array_key_exists($extension, $EXTENSIONS)) {
112         return $EXTENSIONS[$extension];
113     } else {
114         BRfatal('Unknown image extension');
115     }            
116 }
117
118 /*
119  * Get the image information.  The returned associative array fields will
120  * vary depending on the image type.  The basic keys are width, height, type
121  * and bits.
122  */
123 function getImageInfo($zipPath, $file)
124 {
125     return getImageInfoFromExif($zipPath, $file); // this is fast
126     
127     /*
128     $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
129     $type = imageExtensionToType($fileExt);
130     
131     switch ($type) {
132         case "jp2":
133             return getImageInfoFromJp2($zipPath, $file);
134             
135         default:
136             return getImageInfoFromExif($zipPath, $file);
137     }
138     */
139 }
140
141 // Get the records of of JP2 as returned by kdu_expand
142 function getJp2Records($zipPath, $file)
143 {
144     global $kduExpand;
145     
146     $cmd = getUnarchiveCommand($zipPath, $file)
147              . ' | ' . $kduExpand
148              . ' -no_seek -quiet -i /dev/stdin -record /dev/stdout';
149     exec($cmd, $output);
150     
151     $records = Array();
152     foreach ($output as $line) {
153         $elems = explode("=", $line, 2);
154         if (1 == count($elems)) {
155             // delimiter not found
156             continue;
157         }
158         $records[$elems[0]] = $elems[1];
159     }
160     
161     return $records;
162 }
163
164 /*
165  * Get the image width, height and depth using the EXIF information.
166  */
167 function getImageInfoFromExif($zipPath, $file)
168 {
169     global $exiftool;
170     
171     // We look for all the possible tags of interest then act on the
172     // ones presumed present based on the file type
173     $tagsToGet = ' -ImageWidth -ImageHeight -FileType'        // all formats
174                  . ' -BitsPerComponent -ColorSpace'          // jp2
175                  . ' -BitDepth'                              // png
176                  . ' -BitsPerSample';                        // tiff
177                         
178     $cmd = getUnarchiveCommand($zipPath, $file)
179         . ' | '. $exiftool . ' -S -fast' . $tagsToGet . ' -';
180     exec($cmd, $output);
181     
182     $tags = Array();
183     foreach ($output as $line) {
184         $keyValue = explode(": ", $line);
185         $tags[$keyValue[0]] = $keyValue[1];
186     }
187     
188     $width = intval($tags["ImageWidth"]);
189     $height = intval($tags["ImageHeight"]);
190     $type = strtolower($tags["FileType"]);
191     
192     switch ($type) {
193         case "jp2":
194             $bits = intval($tags["BitsPerComponent"]);
195             break;
196         case "tiff":
197             $bits = intval($tags["BitsPerSample"]);
198             break;
199         case "jpeg":
200             $bits = 8;
201             break;
202         case "png":
203             $bits = intval($tags["BitDepth"]);
204             break;
205         default:
206             BRfatal("Unsupported image type");
207             break;
208     }
209    
210    
211     $retval = Array('width' => $width, 'height' => $height,
212         'bits' => $bits, 'type' => $type);
213     
214     return $retval;
215 }
216
217 /*
218  * Output JSON given the imageInfo associative array
219  */
220 function outputJSON($imageInfo, $callback)
221 {
222     header('Content-type: text/plain');
223     $jsonOutput = json_encode($imageInfo);
224     if ($callback) {
225         $jsonOutput = $callback . '(' . $jsonOutput . ');';
226     }
227     echo $jsonOutput;
228 }
229
230 // Get the image size and depth
231 $imageInfo = getImageInfo($zipPath, $file);
232
233 // Output json if requested
234 if ('json' == $ext) {
235     // $$$ we should determine the output size first based on requested scale
236     outputJSON($imageInfo, $callback);
237     exit;
238 }
239
240 // Unfortunately kakadu requires us to know a priori if the
241 // output file should be .ppm or .pgm.  By decompressing to
242 // .bmp kakadu will write a file we can consistently turn into
243 // .pnm.  Really kakadu should support .pnm as the file output
244 // extension and automatically write ppm or pgm format as
245 // appropriate.
246 $decompressToBmp = true;
247 if ($decompressToBmp) {
248   $stdoutLink = '/tmp/stdout.bmp';
249 } else {
250   $stdoutLink = '/tmp/stdout.ppm';
251 }
252
253 $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
254
255 // Rotate is currently only supported for jp2 since it does not add server load
256 $allowedRotations = array("0", "90", "180", "270");
257 $rotate = $_REQUEST['rotate'];
258 if ( !in_array($rotate, $allowedRotations) ) {
259     $rotate = "0";
260 }
261
262 // Image conversion options
263 $pngOptions = '';
264 $jpegOptions = '-quality 75';
265
266 // The pbmreduce reduction factor produces an image with dimension 1/n
267 // The kakadu reduction factor produceds an image with dimension 1/(2^n)
268 // $$$ handle continuous values for scale
269 if (isset($_REQUEST['height'])) {
270     $ratio = floatval($_REQUEST['origHeight']) / floatval($_REQUEST['height']);
271     if ($ratio <= 2) {
272         $scale = 2;
273         $powReduce = 1;    
274     } else if ($ratio <= 4) {
275         $scale = 4;
276         $powReduce = 2;
277     } else {
278         //$powReduce = 3; //too blurry!
279         $scale = 2;
280         $powReduce = 1;
281     }
282
283 } else {
284     // $$$ could be cleaner
285     $scale = intval($_REQUEST['scale']);
286     if (1 >= $scale) {
287         $scale = 1;
288         $powReduce = 0;
289     } else if (2 > $scale) {
290         $powReduce = 0;
291         $scale = 1;
292     } else if (4 > $scale) {
293         $powReduce = 1;
294         $scale = 2;
295     } else if (8 > $scale) {
296         $powReduce = 2;
297         $scale = 4;
298     } else if (16 > $scale) {
299         $powReduce = 3;
300         $scale = 8;
301     } else if (32 > $scale) {
302         $powReduce = 4;
303         $scale = 16;
304     } else if (64 > $scale) {
305         $powReduce = 5;
306         $scale = 32;
307     } else {
308         // $$$ Leaving this in as default though I'm not sure why it is...
309         $scale = 8;
310         $powReduce = 3;
311     }
312 }
313
314 // Override depending on source image format
315 // $$$ consider doing a 302 here instead, to make better use of the browser cache
316 // Limit scaling for 1-bit images.  See https://bugs.edge.launchpad.net/bookreader/+bug/486011
317 if (1 == $imageInfo['bits']) {
318     if ($scale > 1) {
319         $scale /= 2;
320         $powReduce -= 1;
321         
322         // Hard limit so there are some black pixels to use!
323         if ($scale > 4) {
324             $scale = 4;
325             $powReduce = 2;
326         }
327     }
328 }
329
330 if (!file_exists($stdoutLink)) 
331 {  
332   system('ln -s /dev/stdout ' . $stdoutLink);  
333 }
334
335
336 putenv('LD_LIBRARY_PATH=/petabox/sw/lib/kakadu');
337
338 $unzipCmd  = getUnarchiveCommand($zipPath, $file);
339
340 function getDecompressCmd($imageType) {
341     global $kduExpand;
342     global $powReduce, $rotate, $scale; // $$$ clean up
343     global $decompressToBmp; // $$$ TODO remove now that we have bit depth info
344     global $stdoutLink;
345     
346     switch ($imageType) {
347         case 'jp2':
348             $decompressCmd = 
349                 " | " . $kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -i /dev/stdin -o " . $stdoutLink;
350             if ($decompressToBmp) {
351                 // We suppress output since bmptopnm always outputs on stderr
352                 $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
353             }
354             break;
355     
356         case 'tiff':
357             // We need to create a temporary file for tifftopnm since it cannot
358             // work on a pipe (the file must be seekable).
359             // We use the BookReaderTiff prefix to give a hint in case things don't
360             // get cleaned up.
361             $tempFile = tempnam("/tmp", "BookReaderTiff");
362         
363             // $$$ look at bit depth when reducing
364             $decompressCmd = 
365                 ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . reduceCommand($scale);
366             break;
367      
368         case 'jpeg':
369             $decompressCmd = ' | jpegtopnm ' . reduceCommand($scale);
370             break;
371     
372         case 'png':
373             $decompressCmd = ' | pngtopnm ' . reduceCommand($scale);
374             break;
375             
376         default:
377             BRfatal('Unknown image type: ' . $imageType);
378             break;
379     }
380     return $decompressCmd;
381 }
382
383 $decompressCmd = getDecompressCmd($imageInfo['type']);
384        
385 // Non-integer scaling is currently disabled on the cluster
386 // if (isset($_REQUEST['height'])) {
387 //     $cmd .= " | pnmscale -height {$_REQUEST['height']} ";
388 // }
389
390 switch ($ext) {
391     case 'png':
392         $compressCmd = ' | pnmtopng ' . $pngOptions;
393         break;
394         
395     case 'jpeg':
396     case 'jpg':
397     default:
398         $compressCmd = ' | pnmtojpeg ' . $jpegOptions;
399         $ext = 'jpeg'; // for matching below
400         break;
401
402 }
403
404 if (($ext == $fileExt) && ($scale == 1) && ($rotate === "0")) {
405     // Just pass through original data if same format and size
406     $cmd = $unzipCmd;
407 } else {
408     $cmd = $unzipCmd . $decompressCmd . $compressCmd;
409 }
410
411 // print $cmd;
412
413 // If the command has its initial output on stdout the headers will be emitted followed
414 // by the stdout output.  If initial output is on stderr an error message will be
415 // returned.
416 // 
417 // Returns:
418 //   true - if command emits stdout and has zero exit code
419 //   false - command has initial output on stderr or non-zero exit code
420 //   &$errorMessage - error string if there was an error
421 //
422 // $$$ Tested with our command-line image processing.  May be deadlocks for
423 //     other cases.
424 function passthruIfSuccessful($headers, $cmd, &$errorMessage)
425 {
426     $retVal = false;
427     $errorMessage = '';
428     
429     $descriptorspec = array(
430        0 => array("pipe", "r"),  // stdin is a pipe that the child will read from
431        1 => array("pipe", "w"),  // stdout is a pipe that the child will write to
432        2 => array("pipe", "w"),   // stderr is a pipe to write to
433     );
434     
435     $cwd = NULL;
436     $env = NULL;
437     
438     $process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env);
439     
440     if (is_resource($process)) {
441         // $pipes now looks like this:
442         // 0 => writeable handle connected to child stdin
443         // 1 => readable handle connected to child stdout
444         // 2 => readable handle connected to child stderr
445     
446         $stdin = $pipes[0];        
447         $stdout = $pipes[1];
448         $stderr = $pipes[2];
449         
450         // check whether we get input first on stdout or stderr
451         $read = array($stdout, $stderr);
452         $write = NULL;
453         $except = NULL;
454         $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
455         if (false === $numChanged) {
456             // select failed
457             $errorMessage = 'Select failed';
458             $retVal = false;
459         }
460         if ($read[0] == $stdout && (1 == $numChanged)) {
461             // Got output first on stdout (only)
462             // $$$ make sure we get all stdout
463             $output = fopen('php://output', 'w');
464             foreach($headers as $header) {
465                 header($header);
466             }
467             stream_copy_to_stream($pipes[1], $output);
468             fclose($output); // okay since tied to special php://output
469             $retVal = true;
470         } else {
471             // Got output on stderr
472             // $$$ make sure we get all stderr
473             $errorMessage = stream_get_contents($stderr);
474             $retVal = false;
475         }
476
477         fclose($stderr);
478         fclose($stdout);
479         fclose($stdin);
480
481         
482         // It is important that you close any pipes before calling
483         // proc_close in order to avoid a deadlock
484         $cmdRet = proc_close($process);
485         if (0 != $cmdRet) {
486             $retVal = false;
487             $errorMessage .= "Command failed with result code " . $cmdRet;
488         }
489     }
490     return $retVal;
491 }
492
493 $headers = array('Content-type: '. $MIMES[$ext],
494                   'Cache-Control: max-age=15552000');
495
496 $errorMessage = '';
497 if (! passthruIfSuccessful($headers, $cmd, $errorMessage)) {
498     // $$$ automated reporting
499     trigger_error('BookReader Processing Error: ' . $cmd . ' -- ' . $errorMessage, E_USER_WARNING);
500     
501     // Try some content-specific recovery
502     $recovered = false;    
503     if ($imageInfo['type'] == 'jp2') {
504         $records = getJp2Records($zipPath, $file);
505         if ($powReduce > intval($records['Clevels'])) {
506             $powReduce = $records['Clevels'];
507             $reduce = pow(2, $powReduce);
508         } else {
509             $reduce = 1;
510             $powReduce = 0;
511         }
512          
513         $cmd = $unzipCmd . getDecompressCmd($imageInfo['type']) . $compressCmd;
514         if (passthruIfSuccessful($headers, $cmd, $errorMessage)) {
515             $recovered = true;
516         } else {
517             trigger_error('BookReader fallback image processing also failed: ' . $errorMessage, E_USER_WARNING);
518         }
519     }
520     
521     if (! $recovered) {
522         BRfatal('Problem processing image - command failed');
523     }
524 }
525
526 // passthru ($cmd); # cmd returns image data
527
528 if (isset($tempFile)) {
529     unlink($tempFile);
530 }
531
532 function BRFatal($string) {
533     echo "alert('$string');\n";
534     die(-1);
535 }
536
537 // Returns true if using a power node
538 function onPowerNode() {
539     exec("lspci | fgrep -c Realtek", $output, $return);
540     if ("0" != $output[0]) {
541         return true;
542     } else {
543         exec("egrep -q AMD /proc/cpuinfo", $output, $return);
544         if ($return == 0) {
545             return true;
546         }
547     }
548     return false;
549 }
550
551 function reduceCommand($scale) {
552     if (1 != $scale) {
553         if (onPowerNode()) {
554             return ' | pnmscale -reduce ' . $scale;
555         } else {
556             return ' | pnmscale -nomix -reduce ' . $scale;
557         }
558     } else {
559         return '';
560     }
561 }
562
563
564 ?>
565