Update unit tests to autodetect testing host
[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         'rotate' => 'rotate'
64     );
65     
66     // Paths to command-line tools
67     var $exiftool = '/petabox/sw/books/exiftool/exiftool';
68     var $kduExpand = '/petabox/sw/bin/kdu_expand';
69     
70     // Name of temporary files, to be cleaned at exit
71     var $tempFiles = array();
72     
73     /*
74      * Serve an image request that requires looking up the book metadata
75      *
76      * Code path:
77      *   - Get book metadata
78      *   - Parse the requested page (e.g. cover_t.jpg, n5_r4.jpg) to determine which page type,
79      *       size and format (etc) is being requested
80      *   - Determine the leaf number corresponding to the page
81      *   - Determine scaling values
82      *   - Serve image request now that all information has been gathered
83      */
84
85     function serveLookupRequest($requestEnv) {
86         $brm = new BookReaderMeta();
87         try {
88             $metadata = $brm->buildMetadata($_REQUEST['id'], $_REQUEST['itemPath'], $_REQUEST['subPrefix'], $_REQUEST['server']);
89         } catch (Exception $e) {
90             $this->BRfatal($e->getMessage);
91         }
92         
93         $page = $_REQUEST['page'];
94
95         // Index of image to return
96         $imageIndex = null;
97
98         // deal with subPrefix
99         if ($_REQUEST['subPrefix']) {
100             $parts = split('/', $_REQUEST['subPrefix']);
101             $bookId = $parts[count($parts) - 1 ];
102         } else {
103             $bookId = $_REQUEST['id'];
104         }
105         
106         $pageInfo = $this->parsePageRequest($page, $bookId);
107
108         $basePage = $pageInfo['type'];
109         
110         $leaf = null;
111         switch ($basePage) {
112         
113             case 'title':
114                 if (! array_key_exists('titleIndex', $metadata)) {
115                     $this->BRfatal("No title page asserted in book");
116                 }
117                 $imageIndex = $metadata['titleIndex'];
118                 break;
119             
120             /* Old 'cover' behaviour where it would show cover 0 if it exists or return 404.
121                Could be re-added as cover0, cover1, etc
122             case 'cover':
123                 if (! array_key_exists('coverIndices', $metadata)) {
124                     $this->BRfatal("No cover asserted in book");
125                 }
126                 $imageIndex = $metadata['coverIndices'][0]; // $$$ TODO add support for other covers
127                 break;
128             */
129             
130             case 'preview':
131             case 'cover': // Show our best guess if cover is requested
132                 // Preference is:
133                 //   Cover page if book was published >= 1950
134                 //   Title page
135                 //   Cover page
136                 //   Page 0
137                          
138                 if ( array_key_exists('date', $metadata) && array_key_exists('coverIndices', $metadata) ) {
139                     if ($brm->parseYear($metadata['date']) >= 1950) {
140                         $imageIndex = $metadata['coverIndices'][0];                
141                         break;
142                     }
143                 }
144                 if (array_key_exists('titleIndex', $metadata)) {
145                     $imageIndex = $metadata['titleIndex'];
146                     break;
147                 }
148                 if (array_key_exists('coverIndices', $metadata)) {
149                     $imageIndex = $metadata['coverIndices'][0];
150                     break;
151                 }
152                 
153                 // First page
154                 $imageIndex = 0;
155                 break;
156                 
157             case 'n':
158                 // Accessible index page
159                 $imageIndex = intval($pageInfo['value']);
160                 break;
161                 
162             case 'page':
163                 // Named page
164                 $index = array_search($pageInfo['value'], $metadata['pageNums']);
165                 if ($index === FALSE) {
166                     // Not found
167                     $this->BRfatal("Page not found");
168                     break;
169                 }
170                 
171                 $imageIndex = $index;
172                 break;
173                 
174             case 'leaf':
175                 // Leaf explicitly specified
176                 $leaf = $pageInfo['value'];
177                 break;
178                 
179             default:
180                 // Shouldn't be possible
181                 $this->BRfatal("Unrecognized page type requested");
182                 break;
183                 
184         }
185         
186         if (is_null($leaf)) {
187             // Leaf was not explicitly set -- look it up
188             $leaf = $brm->leafForIndex($imageIndex, $metadata['leafNums']);
189         }
190         
191         $requestEnv = array(
192             'zip' => $metadata['zip'],
193             'file' => $brm->imageFilePath($leaf, $metadata['subPrefix'], $metadata['imageFormat']),
194             'ext' => 'jpg',
195         );
196         
197         // remove non-passthrough keys from pageInfo
198         unset($pageInfo['type']);
199         unset($pageInfo['value']);
200         
201         // add pageinfo to request
202         $requestEnv = array_merge($pageInfo, $requestEnv);
203
204         // Return image data - will check privs        
205         $this->serveRequest($requestEnv);
206     
207     }
208     
209     /*
210      * Returns a page image when all parameters such as the image stack location are
211      * passed in.
212      * 
213      * Approach:
214      * 
215      * Get info about requested image (input)
216      * Get info about requested output format
217      * Determine processing parameters
218      * Process image
219      * Return image data
220      * Clean up temporary files
221      */
222      function serveRequest($requestEnv) {
223         // Process some of the request parameters
224         $zipPath  = $requestEnv['zip'];
225         $file     = $requestEnv['file'];
226         if (! $ext) {
227             $ext = $requestEnv['ext'];
228         } else {
229             // Default to jpg
230             $ext = 'jpeg';
231         }
232         if (isset($requestEnv['callback'])) {
233             // validate callback is valid JS identifier (only)
234             $callback = $requestEnv['callback'];
235             $identifierPatt = '/^[[:alpha:]$_]([[:alnum:]$_])*$/';
236             if (! preg_match($identifierPatt, $callback)) {
237                 $this->BRfatal('Invalid callback');
238             }
239         } else {
240             $callback = null;
241         }
242
243         if ( !file_exists($zipPath) ) {
244             $this->BRfatal('Image stack does not exist at ' . $zipPath);
245         }
246         // Make sure the image stack is readable - return 403 if not
247         $this->checkPrivs($zipPath);
248         
249         
250         // Get the image size and depth
251         $imageInfo = $this->getImageInfo($zipPath, $file);
252         
253         // Output json if requested
254         if ('json' == $ext) {
255             // $$$ we should determine the output size first based on requested scale
256             $this->outputJSON($imageInfo, $callback); // $$$ move to BookReaderRequest
257             exit;
258         }
259         
260         // Unfortunately kakadu requires us to know a priori if the
261         // output file should be .ppm or .pgm.  By decompressing to
262         // .bmp kakadu will write a file we can consistently turn into
263         // .pnm.  Really kakadu should support .pnm as the file output
264         // extension and automatically write ppm or pgm format as
265         // appropriate.
266         $this->decompressToBmp = true; // $$$ shouldn't be necessary if we use file info to determine output format
267         if ($this->decompressToBmp) {
268           $stdoutLink = '/tmp/stdout.bmp';
269         } else {
270           $stdoutLink = '/tmp/stdout.ppm';
271         }
272         
273         $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
274         
275         // Rotate is currently only supported for jp2 since it does not add server load
276         $allowedRotations = array("0", "90", "180", "270");
277         $rotate = $requestEnv['rotate'];
278         if ( !in_array($rotate, $allowedRotations) ) {
279             $rotate = "0";
280         }
281         
282         // Image conversion options
283         $pngOptions = '';
284         $jpegOptions = '-quality 75';
285         
286         // The pbmreduce reduction factor produces an image with dimension 1/n
287         // The kakadu reduction factor produces an image with dimension 1/(2^n)
288         
289         // We interpret the requested size and scale, look at image format, and determine the
290         // actual scaling to be returned to the client.  We generally return the largest
291         // power of 2 reduction that is larger than the requested size in order to reduce
292         // image processing load on our cluster.  The client should then scale to their final
293         // needed size.
294         
295         // Set scale from height or width if set
296         if (isset($requestEnv['height'])) {
297             $powReduce = $this->nearestPow2Reduce($requestEnv['height'], $imageInfo['height']);
298             $scale = pow(2, $powReduce);
299         } else if (isset($requestEnv['width'])) {
300             $powReduce = $this->nearestPow2Reduce($requestEnv['width'], $imageInfo['width']);
301             $scale = pow(2, $powReduce);
302
303         } else {
304             // Set scale from named size (e.g. 'large') if set
305             $size = $requestEnv['size'];
306             if ( $size && array_key_exists($size, self::$imageSizes)) {
307                 $srcRatio = floatval($imageInfo['width']) / floatval($imageInfo['height']);
308                 if ($srcRatio > 1) {
309                     // wide
310                     $dimension = 'width';
311                 } else {
312                     $dimension = 'height';
313                 }
314                 $powReduce = $this->nearestPow2Reduce(self::$imageSizes[$size], $imageInfo[$dimension]);
315                 $scale = pow(2, $powReduce);
316                 
317             } else {
318                 // No named size - use explicit scale, if given
319                 $scale = $requestEnv['scale'];
320                 if (!$scale) {
321                     $scale = 1;
322                 }
323                 $powReduce = $this->nearestPow2ForScale($scale);
324                 // ensure integer scale
325                 $scale = pow(2, $powReduce);
326             }            
327         }
328         
329         // Override depending on source image format
330         // $$$ consider doing a 302 here instead, to make better use of the browser cache
331         // Limit scaling for 1-bit images.  See https://bugs.edge.launchpad.net/bookreader/+bug/486011
332         if (1 == $imageInfo['bits']) {
333             if ($scale > 1) {
334                 $scale /= 2;
335                 $powReduce -= 1;
336                 
337                 // Hard limit so there are some black pixels to use!
338                 if ($scale > 4) {
339                     $scale = 4;
340                     $powReduce = 2;
341                 }
342             }
343         }
344         
345         if (!file_exists($stdoutLink)) 
346         {  
347           system('ln -s /dev/stdout ' . $stdoutLink);  
348         }
349         
350         putenv('LD_LIBRARY_PATH=/petabox/sw/lib/kakadu');
351         
352         $unzipCmd  = $this->getUnarchiveCommand($zipPath, $file);
353         
354         $decompressCmd = $this->getDecompressCmd($imageInfo['type'], $powReduce, $rotate, $scale, $stdoutLink);
355                
356         // Non-integer scaling is currently disabled on the cluster
357         // if (isset($_REQUEST['height'])) {
358         //     $cmd .= " | pnmscale -height {$_REQUEST['height']} ";
359         // }
360         
361         switch ($ext) {
362             case 'png':
363                 $compressCmd = ' | pnmtopng ' . $pngOptions;
364                 break;
365                 
366             case 'jpeg':
367             case 'jpg':
368             default:
369                 $compressCmd = ' | pnmtojpeg ' . $jpegOptions;
370                 $ext = 'jpeg'; // for matching below
371                 break;
372         
373         }
374         
375         if (($ext == $fileExt) && ($scale == 1) && ($rotate === "0")) {
376             // Just pass through original data if same format and size
377             $cmd = $unzipCmd;
378         } else {
379             $cmd = $unzipCmd . $decompressCmd . $compressCmd;
380         }
381         
382         // print $cmd;
383         
384         $filenameForClient = $this->filenameForClient($file, $ext);
385         
386         $headers = array('Content-type: '. self::$MIMES[$ext],
387                          'Cache-Control: max-age=15552000',
388                          'Content-disposition: inline; filename=' . $filenameForClient);
389                           
390         
391         $errorMessage = '';
392         if (! $this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
393             // $$$ automated reporting
394             trigger_error('BookReader Processing Error: ' . $cmd . ' -- ' . $errorMessage, E_USER_WARNING);
395             
396             // Try some content-specific recovery
397             $recovered = false;    
398             if ($imageInfo['type'] == 'jp2') {
399                 $records = $this->getJp2Records($zipPath, $file);
400                 if ($powReduce > intval($records['Clevels'])) {
401                     $powReduce = $records['Clevels'];
402                     $reduce = pow(2, $powReduce);
403                 } else {
404                     $reduce = 1;
405                     $powReduce = 0;
406                 }
407                  
408                 $cmd = $unzipCmd . $this->getDecompressCmd($imageInfo['type'], $powReduce, $rotate, $scale, $stdoutLink) . $compressCmd;
409                 if ($this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
410                     $recovered = true;
411                 } else {
412                     $this->cleanup();
413                     trigger_error('BookReader fallback image processing also failed: ' . $errorMessage, E_USER_WARNING);
414                 }
415             }
416             
417             if (! $recovered) {
418                 $this->BRfatal('Problem processing image - command failed');
419             }
420         }
421         
422         $this->cleanup();
423     }    
424     
425     function getUnarchiveCommand($archivePath, $file)
426     {
427         $lowerPath = strtolower($archivePath);
428         if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
429             $suffix = $matches[1];
430             
431             if ($suffix == 'zip') {
432                 return 'unzip -p '
433                     . escapeshellarg($archivePath)
434                     . ' ' . escapeshellarg($file);
435             } else if ($suffix == 'tar') {
436                 return ' ( 7z e -so '
437                     . escapeshellarg($archivePath)
438                     . ' ' . escapeshellarg($file) . ' 2>/dev/null ) ';
439             } else {
440                 $this->BRfatal('Incompatible archive format');
441             }
442     
443         } else {
444             $this->BRfatal('Bad image stack path');
445         }
446         
447         $this->BRfatal('Bad image stack path or archive format');
448         
449     }
450     
451     /*
452      * Returns the image type associated with the file extension.
453      */
454     function imageExtensionToType($extension)
455     {
456         
457         if (array_key_exists($extension, self::$EXTENSIONS)) {
458             return self::$EXTENSIONS[$extension];
459         } else {
460             $this->BRfatal('Unknown image extension');
461         }            
462     }
463     
464     /*
465      * Get the image information.  The returned associative array fields will
466      * vary depending on the image type.  The basic keys are width, height, type
467      * and bits.
468      */
469     function getImageInfo($zipPath, $file)
470     {
471         return $this->getImageInfoFromExif($zipPath, $file); // this is fast
472         
473         /*
474         $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
475         $type = imageExtensionToType($fileExt);
476         
477         switch ($type) {
478             case "jp2":
479                 return getImageInfoFromJp2($zipPath, $file);
480                 
481             default:
482                 return getImageInfoFromExif($zipPath, $file);
483         }
484         */
485     }
486     
487     // Get the records of of JP2 as returned by kdu_expand
488     function getJp2Records($zipPath, $file)
489     {
490         
491         $cmd = $this->getUnarchiveCommand($zipPath, $file)
492                  . ' | ' . $this->kduExpand
493                  . ' -no_seek -quiet -i /dev/stdin -record /dev/stdout';
494         exec($cmd, $output);
495         
496         $records = Array();
497         foreach ($output as $line) {
498             $elems = explode("=", $line, 2);
499             if (1 == count($elems)) {
500                 // delimiter not found
501                 continue;
502             }
503             $records[$elems[0]] = $elems[1];
504         }
505         
506         return $records;
507     }
508     
509     /*
510      * Get the image width, height and depth using the EXIF information.
511      */
512     function getImageInfoFromExif($zipPath, $file)
513     {
514         
515         // We look for all the possible tags of interest then act on the
516         // ones presumed present based on the file type
517         $tagsToGet = ' -ImageWidth -ImageHeight -FileType'        // all formats
518                      . ' -BitsPerComponent -ColorSpace'          // jp2
519                      . ' -BitDepth'                              // png
520                      . ' -BitsPerSample';                        // tiff
521                             
522         $cmd = $this->getUnarchiveCommand($zipPath, $file)
523             . ' | '. $this->exiftool . ' -S -fast' . $tagsToGet . ' -';
524         exec($cmd, $output);
525         
526         $tags = Array();
527         foreach ($output as $line) {
528             $keyValue = explode(": ", $line);
529             $tags[$keyValue[0]] = $keyValue[1];
530         }
531         
532         $width = intval($tags["ImageWidth"]);
533         $height = intval($tags["ImageHeight"]);
534         $type = strtolower($tags["FileType"]);
535         
536         switch ($type) {
537             case "jp2":
538                 $bits = intval($tags["BitsPerComponent"]);
539                 break;
540             case "tiff":
541                 $bits = intval($tags["BitsPerSample"]);
542                 break;
543             case "jpeg":
544                 $bits = 8;
545                 break;
546             case "png":
547                 $bits = intval($tags["BitDepth"]);
548                 break;
549             default:
550                 $this->BRfatal("Unsupported image type $type for file $file in $zipPath");
551                 break;
552         }
553        
554        
555         $retval = Array('width' => $width, 'height' => $height,
556             'bits' => $bits, 'type' => $type);
557         
558         return $retval;
559     }
560     
561     /*
562      * Output JSON given the imageInfo associative array
563      */
564     function outputJSON($imageInfo, $callback)
565     {
566         header('Content-type: text/plain');
567         $jsonOutput = json_encode($imageInfo);
568         if ($callback) {
569             $jsonOutput = $callback . '(' . $jsonOutput . ');';
570         }
571         echo $jsonOutput;
572     }
573     
574     function getDecompressCmd($imageType, $powReduce, $rotate, $scale, $stdoutLink) {
575         
576         switch ($imageType) {
577             case 'jp2':
578                 $decompressCmd = 
579                     " | " . $this->kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -i /dev/stdin -o " . $stdoutLink;
580                 if ($this->decompressToBmp) {
581                     // We suppress output since bmptopnm always outputs on stderr
582                     $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
583                 }
584                 break;
585         
586             case 'tiff':
587                 // We need to create a temporary file for tifftopnm since it cannot
588                 // work on a pipe (the file must be seekable).
589                 // We use the BookReaderTiff prefix to give a hint in case things don't
590                 // get cleaned up.
591                 $tempFile = tempnam("/tmp", "BookReaderTiff");
592                 array_push($this->tempFiles, $tempFile);
593             
594                 // $$$ look at bit depth when reducing
595                 $decompressCmd = 
596                     ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . $this->reduceCommand($scale);
597                 break;
598          
599             case 'jpeg':
600                 $decompressCmd = ' | ( jpegtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
601                 break;
602         
603             case 'png':
604                 $decompressCmd = ' | ( pngtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
605                 break;
606                 
607             default:
608                 $this->BRfatal('Unknown image type: ' . $imageType);
609                 break;
610         }
611         return $decompressCmd;
612     }
613     
614     // If the command has its initial output on stdout the headers will be emitted followed
615     // by the stdout output.  If initial output is on stderr an error message will be
616     // returned.
617     // 
618     // Returns:
619     //   true - if command emits stdout and has zero exit code
620     //   false - command has initial output on stderr or non-zero exit code
621     //   &$errorMessage - error string if there was an error
622     //
623     // $$$ Tested with our command-line image processing.  May be deadlocks for
624     //     other cases.
625     function passthruIfSuccessful($headers, $cmd, &$errorMessage)
626     {
627         $retVal = false;
628         $errorMessage = '';
629         
630         $descriptorspec = array(
631            0 => array("pipe", "r"),  // stdin is a pipe that the child will read from
632            1 => array("pipe", "w"),  // stdout is a pipe that the child will write to
633            2 => array("pipe", "w"),   // stderr is a pipe to write to
634         );
635         
636         $cwd = NULL;
637         $env = NULL;
638         
639         $process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env);
640         
641         if (is_resource($process)) {
642             // $pipes now looks like this:
643             // 0 => writeable handle connected to child stdin
644             // 1 => readable handle connected to child stdout
645             // 2 => readable handle connected to child stderr
646         
647             $stdin = $pipes[0];        
648             $stdout = $pipes[1];
649             $stderr = $pipes[2];
650             
651             // check whether we get input first on stdout or stderr
652             $read = array($stdout, $stderr);
653             $write = NULL;
654             $except = NULL;
655             $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
656             if (false === $numChanged) {
657                 // select failed
658                 $errorMessage = 'Select failed';
659                 $retVal = false;
660             }
661             if ($read[0] == $stdout && (1 == $numChanged)) {
662                 // Got output first on stdout (only)
663                 // $$$ make sure we get all stdout
664                 $output = fopen('php://output', 'w');
665                 foreach($headers as $header) {
666                     header($header);
667                 }
668                 stream_copy_to_stream($pipes[1], $output);
669                 fclose($output); // okay since tied to special php://output
670                 $retVal = true;
671             } else {
672                 // Got output on stderr
673                 // $$$ make sure we get all stderr
674                 $errorMessage = stream_get_contents($stderr);
675                 $retVal = false;
676             }
677     
678             fclose($stderr);
679             fclose($stdout);
680             fclose($stdin);
681     
682             
683             // It is important that you close any pipes before calling
684             // proc_close in order to avoid a deadlock
685             $cmdRet = proc_close($process);
686             if (0 != $cmdRet) {
687                 $retVal = false;
688                 $errorMessage .= "Command failed with result code " . $cmdRet;
689             }
690         }
691         return $retVal;
692     }
693     
694     function BRfatal($string) {
695         $this->cleanup();
696         throw new Exception("Image error: $string");
697     }
698     
699     // Returns true if using a power node
700     function onPowerNode() {
701         exec("lspci | fgrep -c Realtek", $output, $return);
702         if ("0" != $output[0]) {
703             return true;
704         } else {
705             exec("egrep -q AMD /proc/cpuinfo", $output, $return);
706             if ($return == 0) {
707                 return true;
708             }
709         }
710         return false;
711     }
712     
713     function reduceCommand($scale) {
714         if (1 != $scale) {
715             if ($this->onPowerNode()) {
716                 return ' | pnmscale -reduce ' . $scale . ' 2>/dev/null ';
717             } else {
718                 return ' | pnmscale -nomix -reduce ' . $scale . ' 2>/dev/null ';
719             }
720         } else {
721             return '';
722         }
723     }
724     
725     function checkPrivs($filename) {
726         // $$$ we assume here that requests for the title, cover or preview
727         //     come in via BookReaderPreview.php which will be re-run with
728         //     privileges after we return the 403
729         if (!is_readable($filename)) {
730             header('HTTP/1.1 403 Forbidden');
731             exit(0);
732         }
733     }
734     
735     // Given file path (inside archive) and output file extension, return a filename
736     // suitable for Content-disposition header
737     function filenameForClient($filePath, $ext) {
738         $pathParts = pathinfo($filePath);
739         if ('jpeg' == $ext) {
740             $ext = 'jpg';
741         }
742         return $pathParts['filename'] . '.' . $ext;
743     }
744     
745     // Returns the nearest power of 2 reduction factor that results in a larger image
746     function nearestPow2Reduce($desiredDimension, $sourceDimension) {
747         $ratio = floatval($sourceDimension) / floatval($desiredDimension);
748         return $this->nearestPow2ForScale($ratio);
749     }
750     
751     // Returns nearest power of 2 reduction factor that results in a larger image
752     function nearestPow2ForScale($scale) {
753         $scale = intval($scale);
754         if ($scale <= 1) {
755             return 0;
756         }
757         $binStr = decbin($scale); // convert to binary string. e.g. 5 -> '101'
758         return strlen($binStr) - 1;
759     }
760     
761     /*
762      * Parses a page request like "page5_r2.jpg" or "cover_t.jpg" to corresponding
763      * page type, size, reduce, and format
764      */
765     function parsePageRequest($pageRequest, $bookPrefix) {
766     
767         // Will hold parsed results
768         $pageInfo = array();
769         
770         // Normalize
771         $pageRequest = strtolower($pageRequest);
772         
773         // Pull off extension
774         if (preg_match('#(.*)\.([^.]+)$#', $pageRequest, $matches) === 1) {
775             $pageRequest = $matches[1];
776             $extension = $matches[2];
777             if ($extension == 'jpeg') {
778                 $extension = 'jpg';
779             }
780         } else {
781             $extension = 'jpg';
782         }
783         $pageInfo['extension'] = $extension;
784         
785         // Split parts out
786         $parts = explode('_', $pageRequest);
787
788         // Remove book prefix if it was included (historical)
789         if ($parts[0] == $bookPrefix) {
790             array_shift($parts);
791         }
792         
793         if (count($parts) === 0) {
794             $this->BRfatal('No page type specified');
795         }
796         $page = array_shift($parts);
797         
798         $pageTypes = array(
799             'page' => 'str',
800             'n' => 'num',
801             'cover' => 'single',
802             'preview' => 'single',
803             'title' => 'single',
804             'leaf' => 'num'
805         );
806         
807         // Look for known page types
808         foreach ( $pageTypes as $pageName => $kind ) {
809             if ( preg_match('#^(' . $pageName . ')(.*)#', $page, $matches) === 1 ) {
810                 $pageInfo['type'] = $matches[1];
811                 switch ($kind) {
812                     case 'str':
813                         $pageInfo['value'] = $matches[2];
814                         break;
815                     case 'num':
816                         $pageInfo['value'] = intval($matches[2]);
817                         break;
818                     case 'single':
819                         break;
820                 }
821             }
822         }
823         
824         if ( !array_key_exists('type', $pageInfo) ) {
825             $this->BRfatal('Unrecognized page type');
826         }
827         
828         // Look for other known parts
829         foreach ($parts as $part) {
830             if ( array_key_exists($part, self::$imageSizes) ) {
831                 $pageInfo['size'] = $part;
832                 continue;
833             }
834         
835             // Key must be alpha, value must start with digit and contain digits, alpha, ',' or '.'
836             // Should prevent injection of strange values into the redirect to datanode
837             if ( preg_match('#^([a-z]+)(\d[a-z0-9,.]*)#', $part, $matches) === 0) {
838                 // Not recognized
839                 continue;
840             }
841             
842             $key = $matches[1];
843             $value = $matches[2];
844             
845             if ( array_key_exists($key, self::$imageUrlKeys) ) {
846                 $pageInfo[self::$imageUrlKeys[$key]] = $value;
847                 continue;
848             }
849             
850             // If we hit here, was unrecognized (no action)
851         }
852         
853         return $pageInfo;
854     }
855     
856     // Clean up temporary files and resources
857     function cleanup() {
858         foreach($this->tempFiles as $tempFile) {
859             unlink($tempFile);
860         }
861         $this->tempFiles = array();
862     }
863     
864 }
865
866 ?>