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