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