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