39b6673578426c91c439ce7275f6281296e19c12
[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                 $tempFile = $this->getTempFilename($ext);
478                 array_push($this->tempFiles, $tempFile);
479                 $imageCreated = $this->createOutputImage($cmd, $tempFile, $errorMessage);
480                 if ($imageCreated) {
481                     $recovered = true;
482                 } else {
483                     $this->cleanup();
484                     trigger_error('BookReader fallback image processing also failed: ' . $errorMessage, E_USER_WARNING);
485                 }
486             }
487             
488             if (! $recovered) {
489                 $this->BRfatal("Problem processing image - command failed:\n " . $cmd);
490             }
491         }
492         
493         if ($imageCreated) {
494             // Send the image
495                     
496             $headers = array('Content-type: '. self::$MIMES[$ext],
497                              'Cache-Control: max-age=15552000',
498                              'Content-disposition: inline; filename=' . $filenameForClient,
499                              'Content-Length: ' . filesize($tempFile));
500                              
501             foreach($headers as $header) {
502                 header($header);
503             }
504             ob_clean();
505             flush(); // attempt to send header to client
506             readfile($tempFile);
507         }
508         
509         $this->cleanup();
510     }    
511     
512     function getUnarchiveCommand($archivePath, $file)
513     {
514         $lowerPath = strtolower($archivePath);
515         if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
516             $suffix = $matches[1];
517             
518             if ($suffix == 'zip') {
519                 return 'unzip -p '
520                     . escapeshellarg($archivePath)
521                     . ' ' . escapeshellarg($file);
522             } else if ($suffix == 'tar') {
523                 return ' ( 7z e -so '
524                     . escapeshellarg($archivePath)
525                     . ' ' . escapeshellarg($file) . ' 2>/dev/null ) ';
526             } else {
527                 $this->BRfatal('Incompatible archive format');
528             }
529     
530         } else {
531             $this->BRfatal('Bad image stack path');
532         }
533         
534         $this->BRfatal('Bad image stack path or archive format');
535         
536     }
537     
538     /*
539      * Returns the image type associated with the file extension.
540      */
541     function imageExtensionToType($extension)
542     {
543         
544         if (array_key_exists($extension, self::$EXTENSIONS)) {
545             return self::$EXTENSIONS[$extension];
546         } else {
547             $this->BRfatal('Unknown image extension');
548         }            
549     }
550     
551     /*
552      * Get the image information.  The returned associative array fields will
553      * vary depending on the image type.  The basic keys are width, height, type
554      * and bits.
555      */
556     function getImageInfo($zipPath, $file)
557     {
558         return $this->getImageInfoFromExif($zipPath, $file); // this is fast
559         
560         /*
561         $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
562         $type = imageExtensionToType($fileExt);
563         
564         switch ($type) {
565             case "jp2":
566                 return getImageInfoFromJp2($zipPath, $file);
567                 
568             default:
569                 return getImageInfoFromExif($zipPath, $file);
570         }
571         */
572     }
573     
574     // Get the records of of JP2 as returned by kdu_expand
575     function getJp2Records($zipPath, $file)
576     {
577         
578         $cmd = $this->getUnarchiveCommand($zipPath, $file)
579                  . ' | ' . $this->kduExpand
580                  . ' -no_seek -quiet -i /dev/stdin -record /dev/stdout';
581         exec($cmd, $output);
582         
583         $records = Array();
584         foreach ($output as $line) {
585             $elems = explode("=", $line, 2);
586             if (1 == count($elems)) {
587                 // delimiter not found
588                 continue;
589             }
590             $records[$elems[0]] = $elems[1];
591         }
592         
593         return $records;
594     }
595     
596     /*
597      * Get the image width, height and depth using the EXIF information.
598      */
599     function getImageInfoFromExif($zipPath, $file)
600     {
601         
602         // We look for all the possible tags of interest then act on the
603         // ones presumed present based on the file type
604         $tagsToGet = ' -ImageWidth -ImageHeight -FileType'        // all formats
605                      . ' -BitsPerComponent -ColorSpace'          // jp2
606                      . ' -BitDepth'                              // png
607                      . ' -BitsPerSample';                        // tiff
608                             
609         $cmd = $this->getUnarchiveCommand($zipPath, $file)
610             . ' | '. $this->exiftool . ' -S -fast' . $tagsToGet . ' -';
611         exec($cmd, $output);
612         
613         $tags = Array();
614         foreach ($output as $line) {
615             $keyValue = explode(": ", $line);
616             $tags[$keyValue[0]] = $keyValue[1];
617         }
618         
619         $width = intval($tags["ImageWidth"]);
620         $height = intval($tags["ImageHeight"]);
621         $type = strtolower($tags["FileType"]);
622
623         // Treat jpx as jp2
624         if (strcmp($type,'jpx') == 0) {
625             $type = 'jp2';
626         }
627         
628         switch ($type) {
629             case "jp2":
630                 $bits = intval($tags["BitsPerComponent"]);
631                 break;
632             case "tiff":
633                 $bits = intval($tags["BitsPerSample"]);
634                 break;
635             case "jpeg":
636                 $bits = 8;
637                 break;
638             case "png":
639                 $bits = intval($tags["BitDepth"]);
640                 break;
641             default:
642                 $this->BRfatal("Unsupported image type $type for file $file in $zipPath");
643                 break;
644         }
645        
646        
647         $retval = Array('width' => $width, 'height' => $height,
648             'bits' => $bits, 'type' => $type);
649         
650         return $retval;
651     }
652     
653     /*
654      * Output JSON given the imageInfo associative array
655      */
656     function outputJSON($imageInfo, $callback)
657     {
658         header('Content-type: text/plain');
659         $jsonOutput = json_encode($imageInfo);
660         if ($callback) {
661             $jsonOutput = $callback . '(' . $jsonOutput . ');';
662         }
663         echo $jsonOutput;
664     }
665     
666     function getDecompressCmd($srcInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) {
667         
668         switch ($srcInfo['type']) {
669             case 'jp2':
670                 $regionAsFloat = $this->getRegionDimensionsAsFloat($srcInfo, $region);
671                 $regionString = sprintf("{%f,%f},{%f,%f}", $regionAsFloat['y'], $regionAsFloat['x'], $regionAsFloat['h'], $regionAsFloat['w']);
672                 $decompressCmd = 
673                     " | " . $this->kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -region $regionString -i /dev/stdin -o " . $stdoutLink;
674                 if ($this->decompressToBmp) {
675                     // We suppress output since bmptopnm always outputs on stderr
676                     $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
677                 }
678                 break;        
679 /*
680             case 'tiff':
681                 // We need to create a temporary file for tifftopnm since it cannot
682                 // work on a pipe (the file must be seekable).
683                 // We use the BookReaderTiff prefix to give a hint in case things don't
684                 // get cleaned up.
685                 $tempFile = tempnam("/tmp", "BookReaderTiff");
686                 array_push($this->tempFiles, $tempFile);
687             
688                 // $$$ look at bit depth when reducing
689                 $decompressCmd = 
690                     ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . $this->reduceCommand($scale);
691                 break;
692          
693             case 'jpeg':
694                 $decompressCmd = ' | ( jpegtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
695                 break;
696         
697             case 'png':
698                 $decompressCmd = ' | ( pngtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
699                 break;
700 */
701
702             // Formats handled by ImageMagick
703             case 'tiff':
704             case 'jpeg':
705             case 'png':
706                 $region = $this->getRegionDimensions($srcInfo, $region);
707                 $regionString = sprintf('[%dx%d+%d+%d]', $region['w'], $region['h'], $region['x'], $region['y']);
708
709                 // The argument to ImageMagick's scale command is a "geometry". We pass in the new width/height
710                 $scaleString = ' -scale ' . sprintf("%dx%d", $region['w'] / $scale, $region['h'] / $scale);
711                 
712                 $rotateString = '';
713                 if ($rotate && $rotate != '0') {
714                     $rotateString = ' -rotate ' . $rotate; // was previously checked to be a known value
715                 }
716                 
717                 $decompressCmd = ' | convert -quiet -' . $regionString . $scaleString . $rotateString . ' pnm:-';
718                 break;
719                 
720             default:
721                 $this->BRfatal('Unknown image type: ' . $imageType);
722                 break;
723         }
724         
725         return $decompressCmd;
726     }
727     
728     
729     // If the command has its initial output on stdout the headers will be emitted followed
730     // by the stdout output.  If initial output is on stderr an error message will be
731     // returned.
732     // 
733     // Returns:
734     //   true - if command emits stdout and has zero exit code
735     //   false - command has initial output on stderr or non-zero exit code
736     //   &$errorMessage - error string if there was an error
737     //
738     // $$$ Tested with our command-line image processing.  May be deadlocks for
739     //     other cases, e.g. if there are warnings on stderr
740     function passthruIfSuccessful($headers, $cmd, &$errorMessage)
741     {
742         
743         $retVal = false;
744         $errorMessage = '';
745         
746         $descriptorspec = array(
747            0 => array("pipe", "r"),  // stdin is a pipe that the child will read from
748            1 => array("pipe", "w"),  // stdout is a pipe that the child will write to
749            2 => array("pipe", "w"),   // stderr is a pipe to write to
750         );
751         
752         $cwd = NULL;
753         $env = NULL;
754         
755         $process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env);
756         
757         if (is_resource($process)) {
758             // $pipes now looks like this:
759             // 0 => writeable handle connected to child stdin
760             // 1 => readable handle connected to child stdout
761             // 2 => readable handle connected to child stderr
762         
763             $stdin = $pipes[0];        
764             $stdout = $pipes[1];
765             $stderr = $pipes[2];
766             
767             // check whether we get input first on stdout or stderr
768             $read = array($stdout, $stderr);
769             $write = NULL;
770             $except = NULL;
771             
772             $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
773             if (false === $numChanged) {
774                 // select failed
775                 $errorMessage = 'Select failed';
776                 $retVal = false;
777                 error_log('BookReader select failed!');
778             } else {            
779                 if (in_array($stderr, $read)) {
780                     // Either content in stderr, or stderr is closed (could read 0 bytes)
781                     $error = stream_get_contents($stderr);
782                     if ($error) {
783                     
784                         $errorMessage = $error;
785                         $retVal = false;
786                         
787                         fclose($stderr);
788                         fclose($stdout);
789                         fclose($stdin);
790                         
791                         // It is important that you close any pipes before calling
792                         // proc_close in order to avoid a deadlock
793                         proc_close($process);
794                         return $retVal;             
795  
796                     }
797                 }
798                 
799                 $output = fopen('php://output', 'w');
800                 foreach($headers as $header) {
801                     header($header);
802                 }
803                 stream_copy_to_stream($pipes[1], $output);
804                 fclose($output); // okay since tied to special php://output
805                 $retVal = true;
806             }
807     
808             fclose($stderr);
809             fclose($stdout);
810             fclose($stdin);
811     
812             
813             // It is important that you close any pipes before calling
814             // proc_close in order to avoid a deadlock
815             $cmdRet = proc_close($process);
816             if (0 != $cmdRet) {
817                 $retVal = false;
818                 $errorMessage .= "Command failed with result code " . $cmdRet;
819             }
820         }
821         return $retVal;
822     }
823     
824     function createOutputImage($cmd, $tempFile, &$errorMessage) {
825         $fullCmd = $cmd . " > " . $tempFile;
826         system($fullCmd); // $$$ better error handling
827         return file_exists($tempFile) && filesize($tempFile) > 0;
828     }
829     
830     function BRfatal($string) {
831         $this->cleanup();
832         throw new Exception("Image error: $string");
833     }
834     
835     // Returns true if using a power node
836     // XXX change to "on red box" - not working for new Xeon
837     function onPowerNode() {
838         exec("lspci | fgrep -c Realtek", $output, $return);
839         if ("0" != $output[0]) {
840             return true;
841         } else {
842             exec("egrep -q AMD /proc/cpuinfo", $output, $return);
843             if ($return == 0) {
844                 return true;
845             }
846         }
847         return false;
848     }
849     
850     function reduceCommand($scale) {
851         if (1 != $scale) {
852             if ($this->onPowerNode()) {
853                 return ' | pnmscale -reduce ' . $scale . ' 2>/dev/null ';
854             } else {
855                 return ' | pnmscale -nomix -reduce ' . $scale . ' 2>/dev/null ';
856             }
857         } else {
858             return '';
859         }
860     }
861     
862     function checkPrivs($filename) {
863         // $$$ we assume here that requests for the title, cover or preview
864         //     come in via BookReaderPreview.php which will be re-run with
865         //     privileges after we return the 403
866         if (!is_readable($filename)) {
867             header('HTTP/1.1 403 Forbidden');
868             exit(0);
869         }
870     }
871     
872     // Given file path (inside archive) and output file extension, return a filename
873     // suitable for Content-disposition header
874     function filenameForClient($filePath, $ext) {
875         $pathParts = pathinfo($filePath);
876         if ('jpeg' == $ext) {
877             $ext = 'jpg';
878         }
879         return $pathParts['filename'] . '.' . $ext;
880     }
881     
882     // Returns the nearest power of 2 reduction factor that results in a larger image
883     function nearestPow2Reduce($desiredDimension, $sourceDimension) {
884         $ratio = floatval($sourceDimension) / floatval($desiredDimension);
885         return $this->nearestPow2ForScale($ratio);
886     }
887     
888     // Returns nearest power of 2 reduction factor that results in a larger image
889     function nearestPow2ForScale($scale) {
890         $scale = intval($scale);
891         if ($scale <= 1) {
892             return 0;
893         }
894         $binStr = decbin($scale); // convert to binary string. e.g. 5 -> '101'
895         $largerPow2 = strlen($binStr) - 1;
896         
897         return $largerPow2;
898     }
899     
900     /*
901      * Parses a page request like "page5_r2.jpg" or "cover_t.jpg" to corresponding
902      * page type, size, reduce, and format
903      */
904     function parsePageRequest($pageRequest, $bookPrefix) {
905     
906         // Will hold parsed results
907         $pageInfo = array();
908         
909         // Normalize
910         $pageRequest = strtolower($pageRequest);
911         
912         // Pull off extension
913         if (preg_match('#(.*)\.([^.]+)$#', $pageRequest, $matches) === 1) {
914             $pageRequest = $matches[1];
915             $extension = $matches[2];
916             if ($extension == 'jpeg') {
917                 $extension = 'jpg';
918             }
919         } else {
920             $extension = 'jpg';
921         }
922         $pageInfo['extension'] = $extension;
923         
924         // Split parts out
925         $parts = explode('_', $pageRequest);
926
927         // Remove book prefix if it was included (historical)
928         if ($parts[0] == $bookPrefix) {
929             array_shift($parts);
930         }
931         
932         if (count($parts) === 0) {
933             $this->BRfatal('No page type specified');
934         }
935         $page = array_shift($parts);
936         
937         $pageTypes = array(
938             'page' => 'str',
939             'n' => 'num',
940             'cover' => 'single',
941             'preview' => 'single',
942             'title' => 'single',
943             'leaf' => 'num'
944         );
945         
946         // Look for known page types
947         foreach ( $pageTypes as $pageName => $kind ) {
948             if ( preg_match('#^(' . $pageName . ')(.*)#', $page, $matches) === 1 ) {
949                 $pageInfo['type'] = $matches[1];
950                 switch ($kind) {
951                     case 'str':
952                         $pageInfo['value'] = $matches[2];
953                         break;
954                     case 'num':
955                         $pageInfo['value'] = intval($matches[2]);
956                         break;
957                     case 'single':
958                         break;
959                 }
960             }
961         }
962         
963         if ( !array_key_exists('type', $pageInfo) ) {
964             $this->BRfatal('Unrecognized page type');
965         }
966         
967         // Look for other known parts
968         foreach ($parts as $part) {
969             if ( array_key_exists($part, self::$imageSizes) ) {
970                 $pageInfo['size'] = $part;
971                 continue;
972             }
973         
974             // Key must be alpha, value must start with digit and contain digits, alpha, ',' or '.'
975             // Should prevent injection of strange values into the redirect to datanode
976             if ( preg_match('#^([a-z]+)(\d[a-z0-9,.]*)#', $part, $matches) === 0) {
977                 // Not recognized
978                 continue;
979             }
980             
981             $key = $matches[1];
982             $value = $matches[2];
983             
984             if ( array_key_exists($key, self::$imageUrlKeys) ) {
985                 $pageInfo[self::$imageUrlKeys[$key]] = $value;
986                 continue;
987             }
988             
989             // If we hit here, was unrecognized (no action)
990         }
991         
992         return $pageInfo;
993     }
994     
995     function getRegionDimensions($sourceDimensions, $regionDimensions) {
996         // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
997         // in terms of full resolution image.
998         // Note: this will clip the returned dimensions to fit within the source image
999
1000         $sourceX = 0;
1001         if (array_key_exists('x', $regionDimensions)) {
1002             $sourceX = $this->intAmount($regionDimensions['x'], $sourceDimensions['width']);
1003         }
1004         $sourceX = $this->clamp(0, $sourceDimensions['width'] - 2, $sourceX); // Allow at least one pixel
1005         
1006         $sourceY = 0;
1007         if (array_key_exists('y', $regionDimensions)) {
1008             $sourceY = $this->intAmount($regionDimensions['y'], $sourceDimensions['height']);
1009         }
1010         $sourceY = $this->clamp(0, $sourceDimensions['height'] - 2, $sourceY); // Allow at least one pixel
1011         
1012         $sourceWidth = $sourceDimensions['width'] - $sourceX;
1013         if (array_key_exists('width', $regionDimensions)) {
1014             $sourceWidth = $this->intAmount($regionDimensions['width'], $sourceDimensions['width']);
1015         }
1016         $sourceWidth = $this->clamp(1, max(1, $sourceDimensions['width'] - $sourceX), $sourceWidth);
1017         
1018         $sourceHeight = $sourceDimensions['height'] - $sourceY;
1019         if (array_key_exists('height', $regionDimensions)) {
1020             $sourceHeight = $this->intAmount($regionDimensions['height'], $sourceDimensions['height']);
1021         }
1022         $sourceHeight = $this->clamp(1, max(1, $sourceDimensions['height'] - $sourceY), $sourceHeight);
1023         
1024         return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
1025     }
1026
1027     function getRegionDimensionsAsFloat($sourceDimensions, $regionDimensions) {
1028         // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
1029         // in terms of full resolution image.
1030         // Note: this will clip the returned dimensions to fit within the source image
1031     
1032         $sourceX = 0;
1033         if (array_key_exists('x', $regionDimensions)) {
1034             $sourceX = $this->floatAmount($regionDimensions['x'], $sourceDimensions['width']);
1035         }
1036         $sourceX = $this->clamp(0.0, 1.0, $sourceX);
1037         
1038         $sourceY = 0;
1039         if (array_key_exists('y', $regionDimensions)) {
1040             $sourceY = $this->floatAmount($regionDimensions['y'], $sourceDimensions['height']);
1041         }
1042         $sourceY = $this->clamp(0.0, 1.0, $sourceY);
1043         
1044         $sourceWidth = 1 - $sourceX;
1045         if (array_key_exists('width', $regionDimensions)) {
1046             $sourceWidth = $this->floatAmount($regionDimensions['width'], $sourceDimensions['width']);
1047         }
1048         $sourceWidth = $this->clamp(0.0, 1.0, $sourceWidth);
1049         
1050         $sourceHeight = 1 - $sourceY;
1051         if (array_key_exists('height', $regionDimensions)) {
1052             $sourceHeight = $this->floatAmount($regionDimensions['height'], $sourceDimensions['height']);
1053         }
1054         $sourceHeight = $this->clamp(0.0, 1.0, $sourceHeight);
1055         
1056         return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
1057     }
1058     
1059     function intAmount($stringValue, $maximum) {
1060         // Returns integer amount for string like "5" (5 units) or "0.5" (50%)
1061         if (strpos($stringValue, '.') === false) {
1062             // No decimal, assume int
1063             return intval($stringValue);
1064         }
1065         
1066         return floatval($stringValue) * $maximum + 0.5;
1067     }
1068     
1069     function floatAmount($stringValue, $maximum) {
1070         // Returns float amount (0.0 to 1.0) for string like "0.4" (40%) or "4" (40% if max is 10)
1071         if (strpos($stringValue, ".") === false) {
1072             // No decimal, assume int value out of maximum
1073             return floatval($stringValue) / $maximum;
1074         }
1075         
1076         // Given float - just pass through
1077         return floatval($stringValue);
1078     }
1079     
1080     function clamp($minValue, $maxValue, $observedValue) {
1081         if ($observedValue < $minValue) {
1082             return $minValue;
1083         }
1084         
1085         if ($observedValue > $maxValue) {
1086             return $maxValue;
1087         }
1088         
1089         return $observedValue;
1090     }
1091     
1092     // Get the directory for temporary files. Use the fast in-RAM tmp if available.
1093     function getTempDir() {
1094         $fastbr = '/var/tmp/fast/bookreader';
1095         
1096         if (is_writeable($fastbr)) {
1097             // Our directory in fast tmp already exists
1098             return $fastbr;    
1099         } else {
1100             // Check for fast tmp and make our directory
1101             $fast = '/var/tmp/fast';
1102             if (is_writeable($fast)) {
1103                 if (mkdir($fastbr)) {
1104                     return $fastbr;
1105                 } else {
1106                     return $fast;
1107                 }
1108             }
1109         }
1110         
1111         // All else failed -- system tmp that should get cleaned on reboot
1112         return '/tmp';
1113     }
1114     
1115     function getTempFilename($ext) {
1116         return tempnam($this->getTempDir(), "BookReaderImages");
1117     }
1118     
1119     // Clean up temporary files and resources
1120     function cleanup() {
1121         foreach($this->tempFiles as $tempFile) {
1122             unlink($tempFile);
1123         }
1124         $this->tempFiles = array();
1125     }
1126
1127     /*    
1128     function cleanTmp() {
1129         system('find /var/tmp/fast -name "BookReaderImages*" -cmin +10 -exec rm {} \;');
1130         system('find /var/tmp/fast/bookreader -name "BookReaderImages*" -cmin +10 -exec rm {} \;');
1131     }
1132     */
1133     
1134 }
1135
1136 ?>