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