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