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