Handling of non-jp2 image formats using ImageMagick's convert
[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 require_once("BookReaderMeta.inc.php");
28
29 class BookReaderImages
30 {
31     public static $MIMES = array('gif' => 'image/gif',
32                    'jp2' => 'image/jp2',
33                    'jpg' => 'image/jpeg',
34                    'jpeg' => 'image/jpeg',
35                    'png' => 'image/png',
36                    'tif' => 'image/tiff',
37                    'tiff' => 'image/tiff');
38                    
39     public static $EXTENSIONS = array('gif' => 'gif',
40                         'jp2' => 'jp2',
41                         'jpeg' => 'jpeg',
42                         'jpg' => 'jpeg',
43                         'png' => 'png',
44                         'tif' => 'tiff',
45                         'tiff' => 'tiff');
46     
47     // Width when generating thumbnails
48     public static $imageSizes = array(
49         'thumb' => 100,
50         'small' => 256,
51         'medium' => 512,
52         'large' => 2048,
53     );
54
55     // Keys in the image permalink urls, e.g. http://www.archive.org/download/itemid/page/cover_{keyval}_{keyval}.jpg
56     public static $imageUrlKeys = array(
57         //'r' => 'reduce', // pow of 2 reduction
58         's' => 'scale', // $$$ scale is downscaling factor in BookReaderImages but most people call this "reduce"
59         'region' => 'region',
60         'tile' => 'tile',
61         'w' => 'width',
62         'h' => 'height',
63         'x' => 'x',
64         'y' => 'y',
65         'rotate' => 'rotate'
66     );
67     
68     // Paths to command-line tools
69     var $exiftool = '/petabox/sw/books/exiftool/exiftool';
70     var $kduExpand = '/petabox/sw/bin/kdu_expand';
71     
72     // Name of temporary files, to be cleaned at exit
73     var $tempFiles = array();
74     
75     /*
76      * Serve an image request that requires looking up the book metadata
77      *
78      * Code path:
79      *   - Get book metadata
80      *   - Parse the requested page (e.g. cover_t.jpg, n5_r4.jpg) to determine which page type,
81      *       size and format (etc) is being requested
82      *   - Determine the leaf number corresponding to the page
83      *   - Determine scaling values
84      *   - Serve image request now that all information has been gathered
85      */
86
87     function serveLookupRequest($requestEnv) {
88         $brm = new BookReaderMeta();
89         try {
90             $metadata = $brm->buildMetadata($_REQUEST['id'], $_REQUEST['itemPath'], $_REQUEST['subPrefix'], $_REQUEST['server']);
91         } catch (Exception $e) {
92             $this->BRfatal($e->getMessage());
93         }
94         
95         $page = $_REQUEST['page'];
96
97         // Index of image to return
98         $imageIndex = null;
99
100         // deal with subPrefix
101         if ($_REQUEST['subPrefix']) {
102             $parts = explode('/', $_REQUEST['subPrefix']);
103             $bookId = $parts[count($parts) - 1 ];
104         } else {
105             $bookId = $_REQUEST['id'];
106         }
107         
108         $pageInfo = $this->parsePageRequest($page, $bookId);
109
110         $basePage = $pageInfo['type'];
111         
112         $leaf = null;
113         $region = null;
114         switch ($basePage) {
115         
116             case 'title':
117                 if (! array_key_exists('titleIndex', $metadata)) {
118                     $this->BRfatal("No title page asserted in book");
119                 }
120                 $imageIndex = $metadata['titleIndex'];
121                 break;
122             
123             /* Old 'cover' behaviour where it would show cover 0 if it exists or return 404.
124                Could be re-added as cover0, cover1, etc
125             case 'cover':
126                 if (! array_key_exists('coverIndices', $metadata)) {
127                     $this->BRfatal("No cover asserted in book");
128                 }
129                 $imageIndex = $metadata['coverIndices'][0]; // $$$ TODO add support for other covers
130                 break;
131             */
132             
133             case 'preview':
134             case 'cover': // Show our best guess if cover is requested
135                 // Preference is:
136                 //   Cover page if book was published >= 1950
137                 //   Title page
138                 //   Cover page
139                 //   Page 0
140                          
141                 if ( array_key_exists('date', $metadata) && array_key_exists('coverIndices', $metadata) ) {
142                     if ($brm->parseYear($metadata['date']) >= 1950) {
143                         $imageIndex = $metadata['coverIndices'][0];                
144                         break;
145                     }
146                 }
147                 if (array_key_exists('titleIndex', $metadata)) {
148                     $imageIndex = $metadata['titleIndex'];
149                     break;
150                 }
151                 if (array_key_exists('coverIndices', $metadata)) {
152                     $imageIndex = $metadata['coverIndices'][0];
153                     break;
154                 }
155                 
156                 // First page
157                 $imageIndex = 0;
158                 break;
159                 
160             case 'n':
161                 // Accessible index page
162                 $imageIndex = intval($pageInfo['value']);
163                 break;
164                 
165             case 'page':
166                 // Named page
167                 $index = array_search($pageInfo['value'], $metadata['pageNums']);
168                 if ($index === FALSE) {
169                     // Not found
170                     $this->BRfatal("Page not found");
171                     break;
172                 }
173                 
174                 $imageIndex = $index;
175                 break;
176                 
177             case 'leaf':
178                 // Leaf explicitly specified
179                 $leaf = $pageInfo['value'];
180                 break;
181                                 
182             default:
183                 // Shouldn't be possible
184                 $this->BRfatal("Unrecognized page type requested");
185                 break;
186                 
187         }
188         
189         if (is_null($leaf)) {
190             // Leaf was not explicitly set -- look it up
191             $leaf = $brm->leafForIndex($imageIndex, $metadata['leafNums']);
192         }
193         
194         $requestEnv = array(
195             'zip' => $metadata['zip'],
196             'file' => $brm->imageFilePath($leaf, $metadata['subPrefix'], $metadata['imageFormat']),
197             'ext' => 'jpg', // XXX should pass through ext
198         );
199         
200         // remove non-passthrough keys from pageInfo
201         unset($pageInfo['type']);
202         unset($pageInfo['value']);
203         
204         // add pageinfo to request
205         $requestEnv = array_merge($pageInfo, $requestEnv);
206
207         // Return image data - will check privs        
208         $this->serveRequest($requestEnv);
209     
210     }
211     
212     /*
213      * Returns a page image when all parameters such as the image stack location are
214      * passed in.
215      * 
216      * Approach:
217      * 
218      * Get info about requested image (input)
219      * Get info about requested output format
220      * Determine processing parameters
221      * Process image
222      * Return image data
223      * Clean up temporary files
224      */
225      function serveRequest($requestEnv) {
226      
227         // Process some of the request parameters
228         $zipPath  = $requestEnv['zip'];
229         $file     = $requestEnv['file'];
230         if (! $ext) {
231             $ext = $requestEnv['ext'];
232         } else {
233             // Default to jpg
234             $ext = 'jpeg';
235         }
236         if (isset($requestEnv['callback'])) {
237             // validate callback is valid JS identifier (only)
238             $callback = $requestEnv['callback'];
239             $identifierPatt = '/^[[:alpha:]$_]([[:alnum:]$_])*$/';
240             if (! preg_match($identifierPatt, $callback)) {
241                 $this->BRfatal('Invalid callback');
242             }
243         } else {
244             $callback = null;
245         }
246
247         if ( !file_exists($zipPath) ) {
248             $this->BRfatal('Image stack does not exist at ' . $zipPath);
249         }
250         // Make sure the image stack is readable - return 403 if not
251         $this->checkPrivs($zipPath);
252         
253         
254         // Get the image size and depth
255         $imageInfo = $this->getImageInfo($zipPath, $file);
256                 
257         // Output json if requested
258         if ('json' == $ext) {
259             // $$$ we should determine the output size first based on requested scale
260             $this->outputJSON($imageInfo, $callback); // $$$ move to BookReaderRequest
261             exit;
262         }
263         
264         // Unfortunately kakadu requires us to know a priori if the
265         // output file should be .ppm or .pgm.  By decompressing to
266         // .bmp kakadu will write a file we can consistently turn into
267         // .pnm.  Really kakadu should support .pnm as the file output
268         // extension and automatically write ppm or pgm format as
269         // appropriate.
270         $this->decompressToBmp = true; // $$$ shouldn't be necessary if we use file info to determine output format
271         if ($this->decompressToBmp) {
272           $stdoutLink = '/tmp/stdout.bmp';
273         } else {
274           $stdoutLink = '/tmp/stdout.ppm';
275         }
276         
277         $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
278         
279         // Rotate is currently only supported for jp2 since it does not add server load
280         $allowedRotations = array("0", "90", "180", "270");
281         $rotate = $requestEnv['rotate'];
282         if ( !in_array($rotate, $allowedRotations) ) {
283             $rotate = "0";
284         }
285         
286         // Image conversion options
287         $pngOptions = '';
288         $jpegOptions = '-quality 75';
289         
290         // The pbmreduce reduction factor produces an image with dimension 1/n
291         // The kakadu reduction factor produces an image with dimension 1/(2^n)
292         
293         // We interpret the requested size and scale, look at image format, and determine the
294         // actual scaling to be returned to the client.  We generally return the largest
295         // power of 2 reduction that is larger than the requested size in order to reduce
296         // image processing load on our cluster.  The client should then scale to their final
297         // needed size.
298         
299         // Set scale from height or width if set and no x or y specified
300         if ( isset($requestEnv['height']) && !isset($requestEnv['x']) && !isset($requestEnv['y']) ) {
301             // No x or y specified, use height for scaling
302             $powReduce = $this->nearestPow2Reduce($requestEnv['height'], $imageInfo['height']);
303             $scale = pow(2, $powReduce);
304         } else if ( isset($requestEnv['width']) && !isset($requestEnv['x']) && !isset($requestEnv['y']) ) {
305             // No x or y specified, use width for scaling
306             $powReduce = $this->nearestPow2Reduce($requestEnv['width'], $imageInfo['width']);
307             $scale = pow(2, $powReduce);
308
309         } else {
310             // Set scale from named size (e.g. 'large') if set
311             $size = $requestEnv['size'];
312             if ( $size && array_key_exists($size, self::$imageSizes)) {
313                 $srcRatio = floatval($imageInfo['width']) / floatval($imageInfo['height']);
314                 if ($srcRatio > 1) {
315                     // wide
316                     $dimension = 'width';
317                 } else {
318                     $dimension = 'height';
319                 }
320                 $powReduce = $this->nearestPow2Reduce(self::$imageSizes[$size], $imageInfo[$dimension]);
321                 $scale = pow(2, $powReduce);
322                 
323             } else {
324                 // No named size - use explicit scale, if given
325                 $scale = $requestEnv['scale'];
326                 if (!$scale) {
327                     $scale = 1;
328                 }
329                 $powReduce = $this->nearestPow2ForScale($scale);
330                 // ensure integer scale
331                 $scale = pow(2, $powReduce);
332             }            
333         }
334         
335         // Only extract a specific region if x or y were set
336         $region = array();
337         if (isset($reqeuestEnv['x']) || isset($requestEnv['y'])) {
338             foreach (array('x', 'y', 'width', 'height') as $key) {
339                 if (array_key_exists($key, $requestEnv)) {
340                     $region[$key] = $requestEnv[$key];
341                 }
342             }
343         }
344         $regionDimensions = $this->getRegionDimensions($imageInfo, $region);    
345         
346         /*
347         print('imageInfo');
348         print_r($imageInfo);
349         print('region');
350         print_r($region);
351         print('regionDimensions');
352         print_r($regionDimensions);
353         print('asFloat');
354         print_r($this->getRegionDimensionsAsFloat($imageInfo, $region));
355         die(-1);
356         */
357
358         
359         // Override depending on source image format
360         // $$$ consider doing a 302 here instead, to make better use of the browser cache
361         // Limit scaling for 1-bit images.  See https://bugs.edge.launchpad.net/bookreader/+bug/486011
362         if (1 == $imageInfo['bits']) {
363             
364             if ($scale > 1) {
365                 $scale /= 2;
366                 $powReduce -= 1;
367                 
368                 // Hard limit so there are some black pixels to use!
369                 if ($scale > 4) {
370                     $scale = 4;
371                     $powReduce = 2;
372                 }
373             }
374         }
375         
376         if (!file_exists($stdoutLink)) 
377         {  
378           system('ln -s /dev/stdout ' . $stdoutLink);  
379         }
380         
381         putenv('LD_LIBRARY_PATH=/petabox/sw/lib/kakadu');
382         
383         $unzipCmd  = $this->getUnarchiveCommand($zipPath, $file);
384         
385         $decompressCmd = $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink);
386                
387         // Non-integer scaling is currently disabled on the cluster
388         // if (isset($_REQUEST['height'])) {
389         //     $cmd .= " | pnmscale -height {$_REQUEST['height']} ";
390         // }
391         
392         switch ($ext) {
393             case 'png':
394                 $compressCmd = ' | pnmtopng ' . $pngOptions;
395                 break;
396                 
397             case 'jpeg':
398             case 'jpg':
399             default:
400                 $compressCmd = ' | pnmtojpeg ' . $jpegOptions;
401                 $ext = 'jpeg'; // for matching below
402                 break;
403         
404         }
405         
406         if (($ext == $fileExt) && ($scale == 1) && ($rotate === "0")) {
407             // Just pass through original data if same format and size
408             $cmd = $unzipCmd;
409         } else {
410             $cmd = $unzipCmd . $decompressCmd . $compressCmd;
411         }
412         
413         // print $cmd;
414         
415         $filenameForClient = $this->filenameForClient($file, $ext);
416         
417         $headers = array('Content-type: '. self::$MIMES[$ext],
418                          'Cache-Control: max-age=15552000',
419                          'Content-disposition: inline; filename=' . $filenameForClient);
420                           
421         
422         $errorMessage = '';
423         
424         if (! $this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
425             // $$$ automated reporting
426             trigger_error('BookReader Processing Error: ' . $cmd . ' -- ' . $errorMessage, E_USER_WARNING);
427             
428             // Try some content-specific recovery
429             $recovered = false;
430             if ($imageInfo['type'] == 'jp2') {
431                 $records = $this->getJp2Records($zipPath, $file);
432                 if (array_key_exists('Clevels', $records)) {
433                     $maxReduce = intval($records['Clevels']);
434                     trigger_error("BookReader using max reduce $maxReduce from jp2 records");
435                 } else {
436                     $maxReduce = 0;
437                 }
438                 
439                 $powReduce = min($powReduce, $maxReduce);
440                 $reduce = pow(2, $powReduce);
441                 
442                 $cmd = $unzipCmd . $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) . $compressCmd;
443                 trigger_error('BookReader rerunning with new cmd: ' . $cmd, E_USER_WARNING);
444                 if ($this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
445                     $recovered = true;
446                 } else {
447                     $this->cleanup();
448                     trigger_error('BookReader fallback image processing also failed: ' . $errorMessage, E_USER_WARNING);
449                 }
450             }
451             
452             if (! $recovered) {
453                 $this->BRfatal('Problem processing image - command failed');
454             }
455         }
456         
457         $this->cleanup();
458     }    
459     
460     function getUnarchiveCommand($archivePath, $file)
461     {
462         $lowerPath = strtolower($archivePath);
463         if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
464             $suffix = $matches[1];
465             
466             if ($suffix == 'zip') {
467                 return 'unzip -p '
468                     . escapeshellarg($archivePath)
469                     . ' ' . escapeshellarg($file);
470             } else if ($suffix == 'tar') {
471                 return ' ( 7z e -so '
472                     . escapeshellarg($archivePath)
473                     . ' ' . escapeshellarg($file) . ' 2>/dev/null ) ';
474             } else {
475                 $this->BRfatal('Incompatible archive format');
476             }
477     
478         } else {
479             $this->BRfatal('Bad image stack path');
480         }
481         
482         $this->BRfatal('Bad image stack path or archive format');
483         
484     }
485     
486     /*
487      * Returns the image type associated with the file extension.
488      */
489     function imageExtensionToType($extension)
490     {
491         
492         if (array_key_exists($extension, self::$EXTENSIONS)) {
493             return self::$EXTENSIONS[$extension];
494         } else {
495             $this->BRfatal('Unknown image extension');
496         }            
497     }
498     
499     /*
500      * Get the image information.  The returned associative array fields will
501      * vary depending on the image type.  The basic keys are width, height, type
502      * and bits.
503      */
504     function getImageInfo($zipPath, $file)
505     {
506         return $this->getImageInfoFromExif($zipPath, $file); // this is fast
507         
508         /*
509         $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
510         $type = imageExtensionToType($fileExt);
511         
512         switch ($type) {
513             case "jp2":
514                 return getImageInfoFromJp2($zipPath, $file);
515                 
516             default:
517                 return getImageInfoFromExif($zipPath, $file);
518         }
519         */
520     }
521     
522     // Get the records of of JP2 as returned by kdu_expand
523     function getJp2Records($zipPath, $file)
524     {
525         
526         $cmd = $this->getUnarchiveCommand($zipPath, $file)
527                  . ' | ' . $this->kduExpand
528                  . ' -no_seek -quiet -i /dev/stdin -record /dev/stdout';
529         exec($cmd, $output);
530         
531         $records = Array();
532         foreach ($output as $line) {
533             $elems = explode("=", $line, 2);
534             if (1 == count($elems)) {
535                 // delimiter not found
536                 continue;
537             }
538             $records[$elems[0]] = $elems[1];
539         }
540         
541         return $records;
542     }
543     
544     /*
545      * Get the image width, height and depth using the EXIF information.
546      */
547     function getImageInfoFromExif($zipPath, $file)
548     {
549         
550         // We look for all the possible tags of interest then act on the
551         // ones presumed present based on the file type
552         $tagsToGet = ' -ImageWidth -ImageHeight -FileType'        // all formats
553                      . ' -BitsPerComponent -ColorSpace'          // jp2
554                      . ' -BitDepth'                              // png
555                      . ' -BitsPerSample';                        // tiff
556                             
557         $cmd = $this->getUnarchiveCommand($zipPath, $file)
558             . ' | '. $this->exiftool . ' -S -fast' . $tagsToGet . ' -';
559         exec($cmd, $output);
560         
561         $tags = Array();
562         foreach ($output as $line) {
563             $keyValue = explode(": ", $line);
564             $tags[$keyValue[0]] = $keyValue[1];
565         }
566         
567         $width = intval($tags["ImageWidth"]);
568         $height = intval($tags["ImageHeight"]);
569         $type = strtolower($tags["FileType"]);
570         
571         switch ($type) {
572             case "jp2":
573                 $bits = intval($tags["BitsPerComponent"]);
574                 break;
575             case "tiff":
576                 $bits = intval($tags["BitsPerSample"]);
577                 break;
578             case "jpeg":
579                 $bits = 8;
580                 break;
581             case "png":
582                 $bits = intval($tags["BitDepth"]);
583                 break;
584             default:
585                 $this->BRfatal("Unsupported image type $type for file $file in $zipPath");
586                 break;
587         }
588        
589        
590         $retval = Array('width' => $width, 'height' => $height,
591             'bits' => $bits, 'type' => $type);
592         
593         return $retval;
594     }
595     
596     /*
597      * Output JSON given the imageInfo associative array
598      */
599     function outputJSON($imageInfo, $callback)
600     {
601         header('Content-type: text/plain');
602         $jsonOutput = json_encode($imageInfo);
603         if ($callback) {
604             $jsonOutput = $callback . '(' . $jsonOutput . ');';
605         }
606         echo $jsonOutput;
607     }
608     
609     function getDecompressCmd($srcInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) {
610         
611         switch ($srcInfo['type']) {
612             case 'jp2':
613                 $regionAsFloat = $this->getRegionDimensionsAsFloat($srcInfo, $region);
614                 $regionString = sprintf("{%f,%f},{%f,%f}", $regionAsFloat['y'], $regionAsFloat['x'], $regionAsFloat['h'], $regionAsFloat['w']);
615                 $decompressCmd = 
616                     " | " . $this->kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -region $regionString -i /dev/stdin -o " . $stdoutLink;
617                 if ($this->decompressToBmp) {
618                     // We suppress output since bmptopnm always outputs on stderr
619                     $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
620                 }
621                 break;        
622 /*
623             case 'tiff':
624                 // We need to create a temporary file for tifftopnm since it cannot
625                 // work on a pipe (the file must be seekable).
626                 // We use the BookReaderTiff prefix to give a hint in case things don't
627                 // get cleaned up.
628                 $tempFile = tempnam("/tmp", "BookReaderTiff");
629                 array_push($this->tempFiles, $tempFile);
630             
631                 // $$$ look at bit depth when reducing
632                 $decompressCmd = 
633                     ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . $this->reduceCommand($scale);
634                 break;
635          
636             case 'jpeg':
637                 $decompressCmd = ' | ( jpegtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
638                 break;
639         
640             case 'png':
641                 $decompressCmd = ' | ( pngtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
642                 break;
643 */
644
645             // Formats handled by ImageMagick
646             case 'tiff':
647             case 'jpeg':
648             case 'png':
649                 $region = $this->getRegionDimensions($srcInfo, $region);
650                 $regionString = sprintf('[%dx%d+%d+%d]', $region['w'], $region['h'], $region['x'], $region['y']);
651
652                 // The argument to ImageMagick's scale command is a "geometry". We pass in the new width/height
653                 $scaleString = sprintf("%dx%d", $region['w'] / $scale, $region['h'] / $scale);
654                 
655                 $decompressCmd = ' | convert -' . $regionString . ' -scale ' . $scaleString . ' pnm:-';
656                 break;
657                 
658             default:
659                 $this->BRfatal('Unknown image type: ' . $imageType);
660                 break;
661         }
662         return $decompressCmd;
663     }
664     
665     
666     // If the command has its initial output on stdout the headers will be emitted followed
667     // by the stdout output.  If initial output is on stderr an error message will be
668     // returned.
669     // 
670     // Returns:
671     //   true - if command emits stdout and has zero exit code
672     //   false - command has initial output on stderr or non-zero exit code
673     //   &$errorMessage - error string if there was an error
674     //
675     // $$$ Tested with our command-line image processing.  May be deadlocks for
676     //     other cases.
677     function passthruIfSuccessful($headers, $cmd, &$errorMessage)
678     {
679         $retVal = false;
680         $errorMessage = '';
681         
682         $descriptorspec = array(
683            0 => array("pipe", "r"),  // stdin is a pipe that the child will read from
684            1 => array("pipe", "w"),  // stdout is a pipe that the child will write to
685            2 => array("pipe", "w"),   // stderr is a pipe to write to
686         );
687         
688         $cwd = NULL;
689         $env = NULL;
690         
691         $process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env);
692         
693         if (is_resource($process)) {
694             // $pipes now looks like this:
695             // 0 => writeable handle connected to child stdin
696             // 1 => readable handle connected to child stdout
697             // 2 => readable handle connected to child stderr
698         
699             $stdin = $pipes[0];        
700             $stdout = $pipes[1];
701             $stderr = $pipes[2];
702             
703             // check whether we get input first on stdout or stderr
704             $read = array($stdout, $stderr);
705             $write = NULL;
706             $except = NULL;
707             
708             $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
709             if (false === $numChanged) {
710                 // select failed
711                 $errorMessage = 'Select failed';
712                 $retVal = false;
713                 error_log('BookReader select failed!');
714             } else {            
715                 if (in_array($stderr, $read)) {
716                     // Either content in stderr, or stderr is closed (could read 0 bytes)
717                     $error = stream_get_contents($stderr);
718                     if ($error) {
719                     
720                         $errorMessage = $error;
721                         $retVal = false;
722                         
723                         fclose($stderr);
724                         fclose($stdout);
725                         fclose($stdin);
726                         
727                         // It is important that you close any pipes before calling
728                         // proc_close in order to avoid a deadlock
729                         proc_close($process);
730                         return $retVal;             
731  
732                     }
733                 }
734                 
735                 $output = fopen('php://output', 'w');
736                 foreach($headers as $header) {
737                     header($header);
738                 }
739                 stream_copy_to_stream($pipes[1], $output);
740                 fclose($output); // okay since tied to special php://output
741                 $retVal = true;
742             }
743     
744             fclose($stderr);
745             fclose($stdout);
746             fclose($stdin);
747     
748             
749             // It is important that you close any pipes before calling
750             // proc_close in order to avoid a deadlock
751             $cmdRet = proc_close($process);
752             if (0 != $cmdRet) {
753                 $retVal = false;
754                 $errorMessage .= "Command failed with result code " . $cmdRet;
755             }
756         }
757         return $retVal;
758     }
759     
760     function BRfatal($string) {
761         $this->cleanup();
762         throw new Exception("Image error: $string");
763     }
764     
765     // Returns true if using a power node
766     // XXX change to "on red box" - not working for new Xeon
767     function onPowerNode() {
768         exec("lspci | fgrep -c Realtek", $output, $return);
769         if ("0" != $output[0]) {
770             return true;
771         } else {
772             exec("egrep -q AMD /proc/cpuinfo", $output, $return);
773             if ($return == 0) {
774                 return true;
775             }
776         }
777         return false;
778     }
779     
780     function reduceCommand($scale) {
781         if (1 != $scale) {
782             if ($this->onPowerNode()) {
783                 return ' | pnmscale -reduce ' . $scale . ' 2>/dev/null ';
784             } else {
785                 return ' | pnmscale -nomix -reduce ' . $scale . ' 2>/dev/null ';
786             }
787         } else {
788             return '';
789         }
790     }
791     
792     function checkPrivs($filename) {
793         // $$$ we assume here that requests for the title, cover or preview
794         //     come in via BookReaderPreview.php which will be re-run with
795         //     privileges after we return the 403
796         if (!is_readable($filename)) {
797             header('HTTP/1.1 403 Forbidden');
798             exit(0);
799         }
800     }
801     
802     // Given file path (inside archive) and output file extension, return a filename
803     // suitable for Content-disposition header
804     function filenameForClient($filePath, $ext) {
805         $pathParts = pathinfo($filePath);
806         if ('jpeg' == $ext) {
807             $ext = 'jpg';
808         }
809         return $pathParts['filename'] . '.' . $ext;
810     }
811     
812     // Returns the nearest power of 2 reduction factor that results in a larger image
813     function nearestPow2Reduce($desiredDimension, $sourceDimension) {
814         $ratio = floatval($sourceDimension) / floatval($desiredDimension);
815         return $this->nearestPow2ForScale($ratio);
816     }
817     
818     // Returns nearest power of 2 reduction factor that results in a larger image
819     function nearestPow2ForScale($scale) {
820         $scale = intval($scale);
821         if ($scale <= 1) {
822             return 0;
823         }
824         $binStr = decbin($scale); // convert to binary string. e.g. 5 -> '101'
825         return strlen($binStr) - 1;
826     }
827     
828     /*
829      * Parses a page request like "page5_r2.jpg" or "cover_t.jpg" to corresponding
830      * page type, size, reduce, and format
831      */
832     function parsePageRequest($pageRequest, $bookPrefix) {
833     
834         // Will hold parsed results
835         $pageInfo = array();
836         
837         // Normalize
838         $pageRequest = strtolower($pageRequest);
839         
840         // Pull off extension
841         if (preg_match('#(.*)\.([^.]+)$#', $pageRequest, $matches) === 1) {
842             $pageRequest = $matches[1];
843             $extension = $matches[2];
844             if ($extension == 'jpeg') {
845                 $extension = 'jpg';
846             }
847         } else {
848             $extension = 'jpg';
849         }
850         $pageInfo['extension'] = $extension;
851         
852         // Split parts out
853         $parts = explode('_', $pageRequest);
854
855         // Remove book prefix if it was included (historical)
856         if ($parts[0] == $bookPrefix) {
857             array_shift($parts);
858         }
859         
860         if (count($parts) === 0) {
861             $this->BRfatal('No page type specified');
862         }
863         $page = array_shift($parts);
864         
865         $pageTypes = array(
866             'page' => 'str',
867             'n' => 'num',
868             'cover' => 'single',
869             'preview' => 'single',
870             'title' => 'single',
871             'leaf' => 'num'
872         );
873         
874         // Look for known page types
875         foreach ( $pageTypes as $pageName => $kind ) {
876             if ( preg_match('#^(' . $pageName . ')(.*)#', $page, $matches) === 1 ) {
877                 $pageInfo['type'] = $matches[1];
878                 switch ($kind) {
879                     case 'str':
880                         $pageInfo['value'] = $matches[2];
881                         break;
882                     case 'num':
883                         $pageInfo['value'] = intval($matches[2]);
884                         break;
885                     case 'single':
886                         break;
887                 }
888             }
889         }
890         
891         if ( !array_key_exists('type', $pageInfo) ) {
892             $this->BRfatal('Unrecognized page type');
893         }
894         
895         // Look for other known parts
896         foreach ($parts as $part) {
897             if ( array_key_exists($part, self::$imageSizes) ) {
898                 $pageInfo['size'] = $part;
899                 continue;
900             }
901         
902             // Key must be alpha, value must start with digit and contain digits, alpha, ',' or '.'
903             // Should prevent injection of strange values into the redirect to datanode
904             if ( preg_match('#^([a-z]+)(\d[a-z0-9,.]*)#', $part, $matches) === 0) {
905                 // Not recognized
906                 continue;
907             }
908             
909             $key = $matches[1];
910             $value = $matches[2];
911             
912             if ( array_key_exists($key, self::$imageUrlKeys) ) {
913                 $pageInfo[self::$imageUrlKeys[$key]] = $value;
914                 continue;
915             }
916             
917             // If we hit here, was unrecognized (no action)
918         }
919         
920         return $pageInfo;
921     }
922     
923     function getRegionDimensions($sourceDimensions, $regionDimensions) {
924         // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
925         // in terms of full resolution image.
926         // Note: this will clip the returned dimensions to fit within the source image
927
928         $sourceX = 0;
929         if (array_key_exists('x', $regionDimensions)) {
930             $sourceX = $this->intAmount($regionDimensions['x'], $sourceDimensions['width']);
931         }
932         $sourceX = $this->clamp(0, $sourceDimensions['width'] - 2, $sourceX); // Allow at least one pixel
933         
934         $sourceY = 0;
935         if (array_key_exists('y', $regionDimensions)) {
936             $sourceY = $this->intAmount($regionDimensions['y'], $sourceDimensions['height']);
937         }
938         $sourceY = $this->clamp(0, $sourceDimensions['height'] - 2, $sourceY); // Allow at least one pixel
939         
940         $sourceWidth = $sourceDimensions['width'] - $sourceX;
941         if (array_key_exists('width', $regionDimensions)) {
942             $sourceWidth = $this->intAmount($regionDimensions['width'], $sourceDimensions['width']);
943         }
944         $sourceWidth = $this->clamp(1, max(1, $sourceDimensions['width'] - $sourceX), $sourceWidth);
945         
946         $sourceHeight = $sourceDimensions['height'] - $sourceY;
947         if (array_key_exists('height', $regionDimensions)) {
948             $sourceHeight = $this->intAmount($regionDimensions['height'], $sourceDimensions['height']);
949         }
950         $sourceHeight = $this->clamp(1, max(1, $sourceDimensions['height'] - $sourceY), $sourceHeight);
951         
952         return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
953     }
954
955     function getRegionDimensionsAsFloat($sourceDimensions, $regionDimensions) {
956         // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
957         // in terms of full resolution image.
958         // Note: this will clip the returned dimensions to fit within the source image
959     
960         $sourceX = 0;
961         if (array_key_exists('x', $regionDimensions)) {
962             $sourceX = $this->floatAmount($regionDimensions['x'], $sourceDimensions['width']);
963         }
964         $sourceX = $this->clamp(0.0, 1.0, $sourceX);
965         
966         $sourceY = 0;
967         if (array_key_exists('y', $regionDimensions)) {
968             $sourceY = $this->floatAmount($regionDimensions['y'], $sourceDimensions['height']);
969         }
970         $sourceY = $this->clamp(0.0, 1.0, $sourceY);
971         
972         $sourceWidth = 1 - $sourceX;
973         if (array_key_exists('width', $regionDimensions)) {
974             $sourceWidth = $this->floatAmount($regionDimensions['width'], $sourceDimensions['width']);
975         }
976         $sourceWidth = $this->clamp(0.0, 1.0, $sourceWidth);
977         
978         $sourceHeight = 1 - $sourceY;
979         if (array_key_exists('height', $regionDimensions)) {
980             $sourceHeight = $this->floatAmount($regionDimensions['height'], $sourceDimensions['height']);
981         }
982         $sourceHeight = $this->clamp(0.0, 1.0, $sourceHeight);
983         
984         return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
985     }
986     
987     function intAmount($stringValue, $maximum) {
988         // Returns integer amount for string like "5" (5 units) or "0.5" (50%)
989         if (strpos($stringValue, '.') === false) {
990             // No decimal, assume int
991             return intval($stringValue);
992         }
993         
994         return floatval($stringValue) * $maximum + 0.5;
995     }
996     
997     function floatAmount($stringValue, $maximum) {
998         // Returns float amount (0.0 to 1.0) for string like "0.4" (40%) or "4" (40% if max is 10)
999         if (strpos($stringValue, ".") === false) {
1000             // No decimal, assume int value out of maximum
1001             return floatval($stringValue) / $maximum;
1002         }
1003         
1004         // Given float - just pass through
1005         return floatval($stringValue);
1006     }
1007     
1008     function clamp($minValue, $maxValue, $observedValue) {
1009         if ($observedValue < $minValue) {
1010             return $minValue;
1011         }
1012         
1013         if ($observedValue > $maxValue) {
1014             return $maxValue;
1015         }
1016         
1017         return $observedValue;
1018     }
1019     
1020     // Clean up temporary files and resources
1021     function cleanup() {
1022         foreach($this->tempFiles as $tempFile) {
1023             unlink($tempFile);
1024         }
1025         $this->tempFiles = array();
1026     }
1027     
1028 }
1029
1030 ?>