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