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