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