dab21a58c1bb49eb60255d5d1c870058a16a692d
[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         $region = array();
258         foreach (array('x', 'y', 'width', 'height') as $key) {
259             if (array_key_exists($key, $requestEnv)) {
260                 $region[$key] = $requestEnv[$key];
261             }
262         }
263         $regionDimensions = $this->getRegionDimensions($imageInfo, $region);    
264         
265         /*
266         print('imageInfo');
267         print_r($imageInfo);
268         print('region');
269         print_r($region);
270         print('regionDimensions');
271         print_r($regionDimensions);
272         print('asFloat');
273         print_r($this->getRegionDimensionsAsFloat($imageInfo, $region));
274         die(-1);
275         */
276         
277         // Output json if requested
278         if ('json' == $ext) {
279             // $$$ we should determine the output size first based on requested scale
280             $this->outputJSON($imageInfo, $callback); // $$$ move to BookReaderRequest
281             exit;
282         }
283         
284         // Unfortunately kakadu requires us to know a priori if the
285         // output file should be .ppm or .pgm.  By decompressing to
286         // .bmp kakadu will write a file we can consistently turn into
287         // .pnm.  Really kakadu should support .pnm as the file output
288         // extension and automatically write ppm or pgm format as
289         // appropriate.
290         $this->decompressToBmp = true; // $$$ shouldn't be necessary if we use file info to determine output format
291         if ($this->decompressToBmp) {
292           $stdoutLink = '/tmp/stdout.bmp';
293         } else {
294           $stdoutLink = '/tmp/stdout.ppm';
295         }
296         
297         $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
298         
299         // Rotate is currently only supported for jp2 since it does not add server load
300         $allowedRotations = array("0", "90", "180", "270");
301         $rotate = $requestEnv['rotate'];
302         if ( !in_array($rotate, $allowedRotations) ) {
303             $rotate = "0";
304         }
305         
306         // Image conversion options
307         $pngOptions = '';
308         $jpegOptions = '-quality 75';
309         
310         // The pbmreduce reduction factor produces an image with dimension 1/n
311         // The kakadu reduction factor produces an image with dimension 1/(2^n)
312         
313         // We interpret the requested size and scale, look at image format, and determine the
314         // actual scaling to be returned to the client.  We generally return the largest
315         // power of 2 reduction that is larger than the requested size in order to reduce
316         // image processing load on our cluster.  The client should then scale to their final
317         // needed size.
318         
319         // Set scale from height or width if set and no x or y specified
320         if ( isset($requestEnv['height']) && !isset($requestEnv['x']) && !isset($requestEnv['y']) ) {
321             $powReduce = $this->nearestPow2Reduce($requestEnv['height'], $imageInfo['height']);
322             $scale = pow(2, $powReduce);
323         } else if ( isset($requestEnv['width']) && !isset($requestEnv['x']) && !isset($requestEnv['y']) ) {
324             $powReduce = $this->nearestPow2Reduce($requestEnv['width'], $imageInfo['width']);
325             $scale = pow(2, $powReduce);
326
327         } else {
328             // Set scale from named size (e.g. 'large') if set
329             $size = $requestEnv['size'];
330             if ( $size && array_key_exists($size, self::$imageSizes)) {
331                 $srcRatio = floatval($imageInfo['width']) / floatval($imageInfo['height']);
332                 if ($srcRatio > 1) {
333                     // wide
334                     $dimension = 'width';
335                 } else {
336                     $dimension = 'height';
337                 }
338                 $powReduce = $this->nearestPow2Reduce(self::$imageSizes[$size], $imageInfo[$dimension]);
339                 $scale = pow(2, $powReduce);
340                 
341             } else {
342                 // No named size - use explicit scale, if given
343                 $scale = $requestEnv['scale'];
344                 if (!$scale) {
345                     $scale = 1;
346                 }
347                 $powReduce = $this->nearestPow2ForScale($scale);
348                 // ensure integer scale
349                 $scale = pow(2, $powReduce);
350             }            
351         }
352         
353         // Override depending on source image format
354         // $$$ consider doing a 302 here instead, to make better use of the browser cache
355         // Limit scaling for 1-bit images.  See https://bugs.edge.launchpad.net/bookreader/+bug/486011
356         if (1 == $imageInfo['bits']) {
357             
358             if ($scale > 1) {
359                 $scale /= 2;
360                 $powReduce -= 1;
361                 
362                 // Hard limit so there are some black pixels to use!
363                 if ($scale > 4) {
364                     $scale = 4;
365                     $powReduce = 2;
366                 }
367             }
368         }
369         
370         if (!file_exists($stdoutLink)) 
371         {  
372           system('ln -s /dev/stdout ' . $stdoutLink);  
373         }
374         
375         putenv('LD_LIBRARY_PATH=/petabox/sw/lib/kakadu');
376         
377         $unzipCmd  = $this->getUnarchiveCommand($zipPath, $file);
378         
379         $decompressCmd = $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink);
380                
381         // Non-integer scaling is currently disabled on the cluster
382         // if (isset($_REQUEST['height'])) {
383         //     $cmd .= " | pnmscale -height {$_REQUEST['height']} ";
384         // }
385         
386         switch ($ext) {
387             case 'png':
388                 $compressCmd = ' | pnmtopng ' . $pngOptions;
389                 break;
390                 
391             case 'jpeg':
392             case 'jpg':
393             default:
394                 $compressCmd = ' | pnmtojpeg ' . $jpegOptions;
395                 $ext = 'jpeg'; // for matching below
396                 break;
397         
398         }
399         
400         if (($ext == $fileExt) && ($scale == 1) && ($rotate === "0")) {
401             // Just pass through original data if same format and size
402             $cmd = $unzipCmd;
403         } else {
404             $cmd = $unzipCmd . $decompressCmd . $compressCmd;
405         }
406         
407         // print $cmd;
408         
409         $filenameForClient = $this->filenameForClient($file, $ext);
410         
411         $headers = array('Content-type: '. self::$MIMES[$ext],
412                          'Cache-Control: max-age=15552000',
413                          'Content-disposition: inline; filename=' . $filenameForClient);
414                           
415         
416         $errorMessage = '';
417         
418         if (! $this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
419             // $$$ automated reporting
420             trigger_error('BookReader Processing Error: ' . $cmd . ' -- ' . $errorMessage, E_USER_WARNING);
421             
422             // Try some content-specific recovery
423             $recovered = false;
424             if ($imageInfo['type'] == 'jp2') {
425                 $records = $this->getJp2Records($zipPath, $file);
426                 if (array_key_exists('Clevels', $records)) {
427                     $maxReduce = intval($records['Clevels']);
428                     trigger_error("BookReader using max reduce $maxReduce from jp2 records");
429                 } else {
430                     $maxReduce = 0;
431                 }
432                 
433                 $powReduce = min($powReduce, $maxReduce);
434                 $reduce = pow(2, $powReduce);
435                 
436                 $cmd = $unzipCmd . $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) . $compressCmd;
437                 trigger_error('BookReader rerunning with new cmd: ' . $cmd, E_USER_WARNING);
438                 if ($this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
439                     $recovered = true;
440                 } else {
441                     $this->cleanup();
442                     trigger_error('BookReader fallback image processing also failed: ' . $errorMessage, E_USER_WARNING);
443                 }
444             }
445             
446             if (! $recovered) {
447                 $this->BRfatal('Problem processing image - command failed');
448             }
449         }
450         
451         $this->cleanup();
452     }    
453     
454     function getUnarchiveCommand($archivePath, $file)
455     {
456         $lowerPath = strtolower($archivePath);
457         if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
458             $suffix = $matches[1];
459             
460             if ($suffix == 'zip') {
461                 return 'unzip -p '
462                     . escapeshellarg($archivePath)
463                     . ' ' . escapeshellarg($file);
464             } else if ($suffix == 'tar') {
465                 return ' ( 7z e -so '
466                     . escapeshellarg($archivePath)
467                     . ' ' . escapeshellarg($file) . ' 2>/dev/null ) ';
468             } else {
469                 $this->BRfatal('Incompatible archive format');
470             }
471     
472         } else {
473             $this->BRfatal('Bad image stack path');
474         }
475         
476         $this->BRfatal('Bad image stack path or archive format');
477         
478     }
479     
480     /*
481      * Returns the image type associated with the file extension.
482      */
483     function imageExtensionToType($extension)
484     {
485         
486         if (array_key_exists($extension, self::$EXTENSIONS)) {
487             return self::$EXTENSIONS[$extension];
488         } else {
489             $this->BRfatal('Unknown image extension');
490         }            
491     }
492     
493     /*
494      * Get the image information.  The returned associative array fields will
495      * vary depending on the image type.  The basic keys are width, height, type
496      * and bits.
497      */
498     function getImageInfo($zipPath, $file)
499     {
500         return $this->getImageInfoFromExif($zipPath, $file); // this is fast
501         
502         /*
503         $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
504         $type = imageExtensionToType($fileExt);
505         
506         switch ($type) {
507             case "jp2":
508                 return getImageInfoFromJp2($zipPath, $file);
509                 
510             default:
511                 return getImageInfoFromExif($zipPath, $file);
512         }
513         */
514     }
515     
516     // Get the records of of JP2 as returned by kdu_expand
517     function getJp2Records($zipPath, $file)
518     {
519         
520         $cmd = $this->getUnarchiveCommand($zipPath, $file)
521                  . ' | ' . $this->kduExpand
522                  . ' -no_seek -quiet -i /dev/stdin -record /dev/stdout';
523         exec($cmd, $output);
524         
525         $records = Array();
526         foreach ($output as $line) {
527             $elems = explode("=", $line, 2);
528             if (1 == count($elems)) {
529                 // delimiter not found
530                 continue;
531             }
532             $records[$elems[0]] = $elems[1];
533         }
534         
535         return $records;
536     }
537     
538     /*
539      * Get the image width, height and depth using the EXIF information.
540      */
541     function getImageInfoFromExif($zipPath, $file)
542     {
543         
544         // We look for all the possible tags of interest then act on the
545         // ones presumed present based on the file type
546         $tagsToGet = ' -ImageWidth -ImageHeight -FileType'        // all formats
547                      . ' -BitsPerComponent -ColorSpace'          // jp2
548                      . ' -BitDepth'                              // png
549                      . ' -BitsPerSample';                        // tiff
550                             
551         $cmd = $this->getUnarchiveCommand($zipPath, $file)
552             . ' | '. $this->exiftool . ' -S -fast' . $tagsToGet . ' -';
553         exec($cmd, $output);
554         
555         $tags = Array();
556         foreach ($output as $line) {
557             $keyValue = explode(": ", $line);
558             $tags[$keyValue[0]] = $keyValue[1];
559         }
560         
561         $width = intval($tags["ImageWidth"]);
562         $height = intval($tags["ImageHeight"]);
563         $type = strtolower($tags["FileType"]);
564         
565         switch ($type) {
566             case "jp2":
567                 $bits = intval($tags["BitsPerComponent"]);
568                 break;
569             case "tiff":
570                 $bits = intval($tags["BitsPerSample"]);
571                 break;
572             case "jpeg":
573                 $bits = 8;
574                 break;
575             case "png":
576                 $bits = intval($tags["BitDepth"]);
577                 break;
578             default:
579                 $this->BRfatal("Unsupported image type $type for file $file in $zipPath");
580                 break;
581         }
582        
583        
584         $retval = Array('width' => $width, 'height' => $height,
585             'bits' => $bits, 'type' => $type);
586         
587         return $retval;
588     }
589     
590     /*
591      * Output JSON given the imageInfo associative array
592      */
593     function outputJSON($imageInfo, $callback)
594     {
595         header('Content-type: text/plain');
596         $jsonOutput = json_encode($imageInfo);
597         if ($callback) {
598             $jsonOutput = $callback . '(' . $jsonOutput . ');';
599         }
600         echo $jsonOutput;
601     }
602     
603     function getDecompressCmd($srcInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) {
604         
605         switch ($srcInfo['type']) {
606             case 'jp2':
607                 $regionAsFloat = $this->getRegionDimensionsAsFloat($srcInfo, $region);
608                 $regionString = sprintf("{%f,%f},{%f,%f}", $regionAsFloat['y'], $regionAsFloat['x'], $regionAsFloat['h'], $regionAsFloat['w']);
609                 $decompressCmd = 
610                     " | " . $this->kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -region $regionString -i /dev/stdin -o " . $stdoutLink;
611                 if ($this->decompressToBmp) {
612                     // We suppress output since bmptopnm always outputs on stderr
613                     $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
614                 }
615                 break;
616         
617             case 'tiff':
618                 // We need to create a temporary file for tifftopnm since it cannot
619                 // work on a pipe (the file must be seekable).
620                 // We use the BookReaderTiff prefix to give a hint in case things don't
621                 // get cleaned up.
622                 $tempFile = tempnam("/tmp", "BookReaderTiff");
623                 array_push($this->tempFiles, $tempFile);
624             
625                 // $$$ look at bit depth when reducing
626                 $decompressCmd = 
627                     ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . $this->reduceCommand($scale);
628                 break;
629          
630             case 'jpeg':
631                 $decompressCmd = ' | ( jpegtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
632                 break;
633         
634             case 'png':
635                 $decompressCmd = ' | ( pngtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
636                 break;
637                 
638             default:
639                 $this->BRfatal('Unknown image type: ' . $imageType);
640                 break;
641         }
642         return $decompressCmd;
643     }
644     
645     
646     // If the command has its initial output on stdout the headers will be emitted followed
647     // by the stdout output.  If initial output is on stderr an error message will be
648     // returned.
649     // 
650     // Returns:
651     //   true - if command emits stdout and has zero exit code
652     //   false - command has initial output on stderr or non-zero exit code
653     //   &$errorMessage - error string if there was an error
654     //
655     // $$$ Tested with our command-line image processing.  May be deadlocks for
656     //     other cases.
657     function passthruIfSuccessful($headers, $cmd, &$errorMessage)
658     {
659         $retVal = false;
660         $errorMessage = '';
661         
662         $descriptorspec = array(
663            0 => array("pipe", "r"),  // stdin is a pipe that the child will read from
664            1 => array("pipe", "w"),  // stdout is a pipe that the child will write to
665            2 => array("pipe", "w"),   // stderr is a pipe to write to
666         );
667         
668         $cwd = NULL;
669         $env = NULL;
670         
671         $process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env);
672         
673         if (is_resource($process)) {
674             // $pipes now looks like this:
675             // 0 => writeable handle connected to child stdin
676             // 1 => readable handle connected to child stdout
677             // 2 => readable handle connected to child stderr
678         
679             $stdin = $pipes[0];        
680             $stdout = $pipes[1];
681             $stderr = $pipes[2];
682             
683             // check whether we get input first on stdout or stderr
684             $read = array($stdout, $stderr);
685             $write = NULL;
686             $except = NULL;
687             
688             $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
689             if (false === $numChanged) {
690                 // select failed
691                 $errorMessage = 'Select failed';
692                 $retVal = false;
693                 error_log('BookReader select failed!');
694             } else {            
695                 if (in_array($stderr, $read)) {
696                     // Either content in stderr, or stderr is closed (could read 0 bytes)
697                     $error = stream_get_contents($stderr);
698                     if ($error) {
699                     
700                         $errorMessage = $error;
701                         $retVal = false;
702                         
703                         fclose($stderr);
704                         fclose($stdout);
705                         fclose($stdin);
706                         
707                         // It is important that you close any pipes before calling
708                         // proc_close in order to avoid a deadlock
709                         proc_close($process);
710                         return $retVal;             
711  
712                     }
713                 }
714                 
715                 $output = fopen('php://output', 'w');
716                 foreach($headers as $header) {
717                     header($header);
718                 }
719                 stream_copy_to_stream($pipes[1], $output);
720                 fclose($output); // okay since tied to special php://output
721                 $retVal = true;
722             }
723     
724             fclose($stderr);
725             fclose($stdout);
726             fclose($stdin);
727     
728             
729             // It is important that you close any pipes before calling
730             // proc_close in order to avoid a deadlock
731             $cmdRet = proc_close($process);
732             if (0 != $cmdRet) {
733                 $retVal = false;
734                 $errorMessage .= "Command failed with result code " . $cmdRet;
735             }
736         }
737         return $retVal;
738     }
739     
740     function BRfatal($string) {
741         $this->cleanup();
742         throw new Exception("Image error: $string");
743     }
744     
745     // Returns true if using a power node
746     // XXX change to "on red box" - not working for new Xeon
747     function onPowerNode() {
748         exec("lspci | fgrep -c Realtek", $output, $return);
749         if ("0" != $output[0]) {
750             return true;
751         } else {
752             exec("egrep -q AMD /proc/cpuinfo", $output, $return);
753             if ($return == 0) {
754                 return true;
755             }
756         }
757         return false;
758     }
759     
760     function reduceCommand($scale) {
761         if (1 != $scale) {
762             if ($this->onPowerNode()) {
763                 return ' | pnmscale -reduce ' . $scale . ' 2>/dev/null ';
764             } else {
765                 return ' | pnmscale -nomix -reduce ' . $scale . ' 2>/dev/null ';
766             }
767         } else {
768             return '';
769         }
770     }
771     
772     function checkPrivs($filename) {
773         // $$$ we assume here that requests for the title, cover or preview
774         //     come in via BookReaderPreview.php which will be re-run with
775         //     privileges after we return the 403
776         if (!is_readable($filename)) {
777             header('HTTP/1.1 403 Forbidden');
778             exit(0);
779         }
780     }
781     
782     // Given file path (inside archive) and output file extension, return a filename
783     // suitable for Content-disposition header
784     function filenameForClient($filePath, $ext) {
785         $pathParts = pathinfo($filePath);
786         if ('jpeg' == $ext) {
787             $ext = 'jpg';
788         }
789         return $pathParts['filename'] . '.' . $ext;
790     }
791     
792     // Returns the nearest power of 2 reduction factor that results in a larger image
793     function nearestPow2Reduce($desiredDimension, $sourceDimension) {
794         $ratio = floatval($sourceDimension) / floatval($desiredDimension);
795         return $this->nearestPow2ForScale($ratio);
796     }
797     
798     // Returns nearest power of 2 reduction factor that results in a larger image
799     function nearestPow2ForScale($scale) {
800         $scale = intval($scale);
801         if ($scale <= 1) {
802             return 0;
803         }
804         $binStr = decbin($scale); // convert to binary string. e.g. 5 -> '101'
805         return strlen($binStr) - 1;
806     }
807     
808     /*
809      * Parses a page request like "page5_r2.jpg" or "cover_t.jpg" to corresponding
810      * page type, size, reduce, and format
811      */
812     function parsePageRequest($pageRequest, $bookPrefix) {
813     
814         // Will hold parsed results
815         $pageInfo = array();
816         
817         // Normalize
818         $pageRequest = strtolower($pageRequest);
819         
820         // Pull off extension
821         if (preg_match('#(.*)\.([^.]+)$#', $pageRequest, $matches) === 1) {
822             $pageRequest = $matches[1];
823             $extension = $matches[2];
824             if ($extension == 'jpeg') {
825                 $extension = 'jpg';
826             }
827         } else {
828             $extension = 'jpg';
829         }
830         $pageInfo['extension'] = $extension;
831         
832         // Split parts out
833         $parts = explode('_', $pageRequest);
834
835         // Remove book prefix if it was included (historical)
836         if ($parts[0] == $bookPrefix) {
837             array_shift($parts);
838         }
839         
840         if (count($parts) === 0) {
841             $this->BRfatal('No page type specified');
842         }
843         $page = array_shift($parts);
844         
845         $pageTypes = array(
846             'page' => 'str',
847             'n' => 'num',
848             'cover' => 'single',
849             'preview' => 'single',
850             'title' => 'single',
851             'leaf' => 'num'
852         );
853         
854         // Look for known page types
855         foreach ( $pageTypes as $pageName => $kind ) {
856             if ( preg_match('#^(' . $pageName . ')(.*)#', $page, $matches) === 1 ) {
857                 $pageInfo['type'] = $matches[1];
858                 switch ($kind) {
859                     case 'str':
860                         $pageInfo['value'] = $matches[2];
861                         break;
862                     case 'num':
863                         $pageInfo['value'] = intval($matches[2]);
864                         break;
865                     case 'single':
866                         break;
867                 }
868             }
869         }
870         
871         if ( !array_key_exists('type', $pageInfo) ) {
872             $this->BRfatal('Unrecognized page type');
873         }
874         
875         // Look for other known parts
876         foreach ($parts as $part) {
877             if ( array_key_exists($part, self::$imageSizes) ) {
878                 $pageInfo['size'] = $part;
879                 continue;
880             }
881         
882             // Key must be alpha, value must start with digit and contain digits, alpha, ',' or '.'
883             // Should prevent injection of strange values into the redirect to datanode
884             if ( preg_match('#^([a-z]+)(\d[a-z0-9,.]*)#', $part, $matches) === 0) {
885                 // Not recognized
886                 continue;
887             }
888             
889             $key = $matches[1];
890             $value = $matches[2];
891             
892             if ( array_key_exists($key, self::$imageUrlKeys) ) {
893                 $pageInfo[self::$imageUrlKeys[$key]] = $value;
894                 continue;
895             }
896             
897             // If we hit here, was unrecognized (no action)
898         }
899         
900         return $pageInfo;
901     }
902     
903     function getRegionDimensions($sourceDimensions, $regionDimensions) {
904         // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
905         // in terms of full resolution image.
906         // Note: this will clip the returned dimensions to fit within the source image
907
908         $sourceX = 0;
909         if (array_key_exists('x', $regionDimensions)) {
910             $sourceX = $this->intAmount($regionDimensions['x'], $sourceDimensions['width']);
911         }
912         $sourceX = $this->clamp(0, $sourceDimensions['width'] - 2, $sourceX); // Allow at least one pixel
913         
914         $sourceY = 0;
915         if (array_key_exists('y', $regionDimensions)) {
916             $sourceY = $this->intAmount($regionDimensions['y'], $sourceDimensions['height']);
917         }
918         $sourceY = $this->clamp(0, $sourceDimensions['height'] - 2, $sourceY); // Allow at least one pixel
919         
920         $sourceWidth = $sourceDimensions['width'] - $sourceX;
921         if (array_key_exists('width', $regionDimensions)) {
922             $sourceWidth = $this->intAmount($regionDimensions['width'], $sourceDimensions['width']);
923         }
924         $sourceWidth = $this->clamp(1, max(1, $sourceDimensions['width'] - $sourceX), $sourceWidth);
925         
926         $sourceHeight = $sourceDimensions['height'] - $sourceY;
927         if (array_key_exists('height', $regionDimensions)) {
928             $sourceHeight = $this->intAmount($regionDimensions['height'], $sourceDimensions['height']);
929         }
930         $sourceHeight = $this->clamp(1, max(1, $sourceDimensions['height'] - $sourceY), $sourceHeight);
931         
932         return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
933     }
934
935     function getRegionDimensionsAsFloat($sourceDimensions, $regionDimensions) {
936         // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
937         // in terms of full resolution image.
938         // Note: this will clip the returned dimensions to fit within the source image
939     
940         $sourceX = 0;
941         if (array_key_exists('x', $regionDimensions)) {
942             $sourceX = $this->floatAmount($regionDimensions['x'], $sourceDimensions['width']);
943         }
944         $sourceX = $this->clamp(0.0, 1.0, $sourceX);
945         
946         $sourceY = 0;
947         if (array_key_exists('y', $regionDimensions)) {
948             $sourceY = $this->floatAmount($regionDimensions['y'], $sourceDimensions['height']);
949         }
950         $sourceY = $this->clamp(0.0, 1.0, $sourceY);
951         
952         $sourceWidth = 1 - $sourceX;
953         if (array_key_exists('width', $regionDimensions)) {
954             $sourceWidth = $this->floatAmount($regionDimensions['width'], $sourceDimensions['width']);
955         }
956         $sourceWidth = $this->clamp(0.0, 1.0, $sourceWidth);
957         
958         $sourceHeight = 1 - $sourceY;
959         if (array_key_exists('height', $regionDimensions)) {
960             $sourceHeight = $this->floatAmount($regionDimensions['height'], $sourceDimensions['height']);
961         }
962         $sourceHeight = $this->clamp(0.0, 1.0, $sourceHeight);
963         
964         return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
965     }
966     
967     function intAmount($stringValue, $maximum) {
968         // Returns integer amount for string like "5" (5 units) or "0.5" (50%)
969         if (strpos($stringValue, '.') === false) {
970             // No decimal, assume int
971             return intval($stringValue);
972         }
973         
974         return floatval($stringValue) * $maximum + 0.5;
975     }
976     
977     function floatAmount($stringValue, $maximum) {
978         // Returns float amount (0.0 to 1.0) for string like "0.4" (40%) or "4" (40% if max is 10)
979         if (strpos($stringValue, ".") === false) {
980             // No decimal, assume int value out of maximum
981             return floatval($stringValue) / $maximum;
982         }
983         
984         // Given float - just pass through
985         return floatval($stringValue);
986     }
987     
988     function clamp($minValue, $maxValue, $observedValue) {
989         if ($observedValue < $minValue) {
990             return $minValue;
991         }
992         
993         if ($observedValue > $maxValue) {
994             return $maxValue;
995         }
996         
997         return $observedValue;
998     }
999     
1000     // Clean up temporary files and resources
1001     function cleanup() {
1002         foreach($this->tempFiles as $tempFile) {
1003             unlink($tempFile);
1004         }
1005         $this->tempFiles = array();
1006     }
1007     
1008 }
1009
1010 ?>