Update tests
[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         switch ($type) {
599             case "jp2":
600                 $bits = intval($tags["BitsPerComponent"]);
601                 break;
602             case "tiff":
603                 $bits = intval($tags["BitsPerSample"]);
604                 break;
605             case "jpeg":
606                 $bits = 8;
607                 break;
608             case "png":
609                 $bits = intval($tags["BitDepth"]);
610                 break;
611             default:
612                 $this->BRfatal("Unsupported image type $type for file $file in $zipPath");
613                 break;
614         }
615        
616        
617         $retval = Array('width' => $width, 'height' => $height,
618             'bits' => $bits, 'type' => $type);
619         
620         return $retval;
621     }
622     
623     /*
624      * Output JSON given the imageInfo associative array
625      */
626     function outputJSON($imageInfo, $callback)
627     {
628         header('Content-type: text/plain');
629         $jsonOutput = json_encode($imageInfo);
630         if ($callback) {
631             $jsonOutput = $callback . '(' . $jsonOutput . ');';
632         }
633         echo $jsonOutput;
634     }
635     
636     function getDecompressCmd($srcInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) {
637         
638         switch ($srcInfo['type']) {
639             case 'jp2':
640                 $regionAsFloat = $this->getRegionDimensionsAsFloat($srcInfo, $region);
641                 $regionString = sprintf("{%f,%f},{%f,%f}", $regionAsFloat['y'], $regionAsFloat['x'], $regionAsFloat['h'], $regionAsFloat['w']);
642                 $decompressCmd = 
643                     " | " . $this->kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -region $regionString -i /dev/stdin -o " . $stdoutLink;
644                 if ($this->decompressToBmp) {
645                     // We suppress output since bmptopnm always outputs on stderr
646                     $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
647                 }
648                 break;        
649 /*
650             case 'tiff':
651                 // We need to create a temporary file for tifftopnm since it cannot
652                 // work on a pipe (the file must be seekable).
653                 // We use the BookReaderTiff prefix to give a hint in case things don't
654                 // get cleaned up.
655                 $tempFile = tempnam("/tmp", "BookReaderTiff");
656                 array_push($this->tempFiles, $tempFile);
657             
658                 // $$$ look at bit depth when reducing
659                 $decompressCmd = 
660                     ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . $this->reduceCommand($scale);
661                 break;
662          
663             case 'jpeg':
664                 $decompressCmd = ' | ( jpegtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
665                 break;
666         
667             case 'png':
668                 $decompressCmd = ' | ( pngtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
669                 break;
670 */
671
672             // Formats handled by ImageMagick
673             case 'tiff':
674             case 'jpeg':
675             case 'png':
676                 $region = $this->getRegionDimensions($srcInfo, $region);
677                 $regionString = sprintf('[%dx%d+%d+%d]', $region['w'], $region['h'], $region['x'], $region['y']);
678
679                 // The argument to ImageMagick's scale command is a "geometry". We pass in the new width/height
680                 $scaleString = ' -scale ' . sprintf("%dx%d", $region['w'] / $scale, $region['h'] / $scale);
681                 
682                 $rotateString = '';
683                 if ($rotate && $rotate != '0') {
684                     $rotateString = ' -rotate ' . $rotate; // was previously checked to be a known value
685                 }
686                 
687                 $decompressCmd = ' | convert -' . $regionString . $scaleString . $rotateString . ' pnm:-';
688                 break;
689                 
690             default:
691                 $this->BRfatal('Unknown image type: ' . $imageType);
692                 break;
693         }
694         return $decompressCmd;
695     }
696     
697     
698     // If the command has its initial output on stdout the headers will be emitted followed
699     // by the stdout output.  If initial output is on stderr an error message will be
700     // returned.
701     // 
702     // Returns:
703     //   true - if command emits stdout and has zero exit code
704     //   false - command has initial output on stderr or non-zero exit code
705     //   &$errorMessage - error string if there was an error
706     //
707     // $$$ Tested with our command-line image processing.  May be deadlocks for
708     //     other cases.
709     function passthruIfSuccessful($headers, $cmd, &$errorMessage)
710     {
711         $retVal = false;
712         $errorMessage = '';
713         
714         $descriptorspec = array(
715            0 => array("pipe", "r"),  // stdin is a pipe that the child will read from
716            1 => array("pipe", "w"),  // stdout is a pipe that the child will write to
717            2 => array("pipe", "w"),   // stderr is a pipe to write to
718         );
719         
720         $cwd = NULL;
721         $env = NULL;
722         
723         $process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env);
724         
725         if (is_resource($process)) {
726             // $pipes now looks like this:
727             // 0 => writeable handle connected to child stdin
728             // 1 => readable handle connected to child stdout
729             // 2 => readable handle connected to child stderr
730         
731             $stdin = $pipes[0];        
732             $stdout = $pipes[1];
733             $stderr = $pipes[2];
734             
735             // check whether we get input first on stdout or stderr
736             $read = array($stdout, $stderr);
737             $write = NULL;
738             $except = NULL;
739             
740             $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
741             if (false === $numChanged) {
742                 // select failed
743                 $errorMessage = 'Select failed';
744                 $retVal = false;
745                 error_log('BookReader select failed!');
746             } else {            
747                 if (in_array($stderr, $read)) {
748                     // Either content in stderr, or stderr is closed (could read 0 bytes)
749                     $error = stream_get_contents($stderr);
750                     if ($error) {
751                     
752                         $errorMessage = $error;
753                         $retVal = false;
754                         
755                         fclose($stderr);
756                         fclose($stdout);
757                         fclose($stdin);
758                         
759                         // It is important that you close any pipes before calling
760                         // proc_close in order to avoid a deadlock
761                         proc_close($process);
762                         return $retVal;             
763  
764                     }
765                 }
766                 
767                 $output = fopen('php://output', 'w');
768                 foreach($headers as $header) {
769                     header($header);
770                 }
771                 stream_copy_to_stream($pipes[1], $output);
772                 fclose($output); // okay since tied to special php://output
773                 $retVal = true;
774             }
775     
776             fclose($stderr);
777             fclose($stdout);
778             fclose($stdin);
779     
780             
781             // It is important that you close any pipes before calling
782             // proc_close in order to avoid a deadlock
783             $cmdRet = proc_close($process);
784             if (0 != $cmdRet) {
785                 $retVal = false;
786                 $errorMessage .= "Command failed with result code " . $cmdRet;
787             }
788         }
789         return $retVal;
790     }
791     
792     function BRfatal($string) {
793         $this->cleanup();
794         throw new Exception("Image error: $string");
795     }
796     
797     // Returns true if using a power node
798     // XXX change to "on red box" - not working for new Xeon
799     function onPowerNode() {
800         exec("lspci | fgrep -c Realtek", $output, $return);
801         if ("0" != $output[0]) {
802             return true;
803         } else {
804             exec("egrep -q AMD /proc/cpuinfo", $output, $return);
805             if ($return == 0) {
806                 return true;
807             }
808         }
809         return false;
810     }
811     
812     function reduceCommand($scale) {
813         if (1 != $scale) {
814             if ($this->onPowerNode()) {
815                 return ' | pnmscale -reduce ' . $scale . ' 2>/dev/null ';
816             } else {
817                 return ' | pnmscale -nomix -reduce ' . $scale . ' 2>/dev/null ';
818             }
819         } else {
820             return '';
821         }
822     }
823     
824     function checkPrivs($filename) {
825         // $$$ we assume here that requests for the title, cover or preview
826         //     come in via BookReaderPreview.php which will be re-run with
827         //     privileges after we return the 403
828         if (!is_readable($filename)) {
829             header('HTTP/1.1 403 Forbidden');
830             exit(0);
831         }
832     }
833     
834     // Given file path (inside archive) and output file extension, return a filename
835     // suitable for Content-disposition header
836     function filenameForClient($filePath, $ext) {
837         $pathParts = pathinfo($filePath);
838         if ('jpeg' == $ext) {
839             $ext = 'jpg';
840         }
841         return $pathParts['filename'] . '.' . $ext;
842     }
843     
844     // Returns the nearest power of 2 reduction factor that results in a larger image
845     function nearestPow2Reduce($desiredDimension, $sourceDimension) {
846         $ratio = floatval($sourceDimension) / floatval($desiredDimension);
847         return $this->nearestPow2ForScale($ratio);
848     }
849     
850     // Returns nearest power of 2 reduction factor that results in a larger image
851     function nearestPow2ForScale($scale) {
852         $scale = intval($scale);
853         if ($scale <= 1) {
854             return 0;
855         }
856         $binStr = decbin($scale); // convert to binary string. e.g. 5 -> '101'
857         $largerPow2 = strlen($binStr) - 1;
858         
859         if ( strrpos($binStr, '1', 1) === false ) {
860             // Exact match for pow reduce, string is like '1000...'
861             return $largerPow2 - 1;
862         } else {
863             return $largerPow2;
864         }
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 ?>