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