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