e8d3877658b7606fca605877fb2e6689e9025408
[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         if (isset($requestEnv['height'])) {
272             $powReduce = $this->nearestPow2Reduce($requestEnv['height'], $imageInfo['height']);
273             $scale = pow(2, $powReduce);
274         } else if (isset($requestEnv['width'])) {
275             $powReduce = $this->nearestPow2Reduce($requestEnv['width'], $imageInfo['width']);
276             $scale = pow(2, $powReduce);
277
278         } else {
279             // $$$ could be cleaner
280             // Provide next smaller power of two reduction
281             $scale = $requestEnv['scale'];
282             if (!$scale) {
283                 $scale = 1;
284             }
285             if (array_key_exists($scale, self::$imageSizes)) {
286                 $srcRatio = floatval($imageInfo['width']) / floatval($imageInfo['height']);
287                 if ($srcRatio > 1) {
288                     // wide
289                     $dimension = 'width';
290                 } else {
291                     $dimension = 'height';
292                 }
293                 $powReduce = $this->nearestPow2Reduce($this->imageSizes[$scale], $imageInfo[$dimension]);
294             } else {
295                 $powReduce = $this->nearestPow2ForScale($scale);
296             }
297             $scale = pow(2, $powReduce);
298         }
299         
300         // Override depending on source image format
301         // $$$ consider doing a 302 here instead, to make better use of the browser cache
302         // Limit scaling for 1-bit images.  See https://bugs.edge.launchpad.net/bookreader/+bug/486011
303         if (1 == $imageInfo['bits']) {
304             if ($scale > 1) {
305                 $scale /= 2;
306                 $powReduce -= 1;
307                 
308                 // Hard limit so there are some black pixels to use!
309                 if ($scale > 4) {
310                     $scale = 4;
311                     $powReduce = 2;
312                 }
313             }
314         }
315         
316         if (!file_exists($stdoutLink)) 
317         {  
318           system('ln -s /dev/stdout ' . $stdoutLink);  
319         }
320         
321         putenv('LD_LIBRARY_PATH=/petabox/sw/lib/kakadu');
322         
323         $unzipCmd  = $this->getUnarchiveCommand($zipPath, $file);
324         
325         $decompressCmd = $this->getDecompressCmd($imageInfo['type'], $powReduce, $rotate, $scale, $stdoutLink);
326                
327         // Non-integer scaling is currently disabled on the cluster
328         // if (isset($_REQUEST['height'])) {
329         //     $cmd .= " | pnmscale -height {$_REQUEST['height']} ";
330         // }
331         
332         switch ($ext) {
333             case 'png':
334                 $compressCmd = ' | pnmtopng ' . $pngOptions;
335                 break;
336                 
337             case 'jpeg':
338             case 'jpg':
339             default:
340                 $compressCmd = ' | pnmtojpeg ' . $jpegOptions;
341                 $ext = 'jpeg'; // for matching below
342                 break;
343         
344         }
345         
346         if (($ext == $fileExt) && ($scale == 1) && ($rotate === "0")) {
347             // Just pass through original data if same format and size
348             $cmd = $unzipCmd;
349         } else {
350             $cmd = $unzipCmd . $decompressCmd . $compressCmd;
351         }
352         
353         // print $cmd;
354         
355         $filenameForClient = $this->filenameForClient($file, $ext);
356         
357         $headers = array('Content-type: '. self::$MIMES[$ext],
358                          'Cache-Control: max-age=15552000',
359                          'Content-disposition: inline; filename=' . $filenameForClient);
360                           
361         
362         $errorMessage = '';
363         if (! $this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
364             // $$$ automated reporting
365             trigger_error('BookReader Processing Error: ' . $cmd . ' -- ' . $errorMessage, E_USER_WARNING);
366             
367             // Try some content-specific recovery
368             $recovered = false;    
369             if ($imageInfo['type'] == 'jp2') {
370                 $records = $this->getJp2Records($zipPath, $file);
371                 if ($powReduce > intval($records['Clevels'])) {
372                     $powReduce = $records['Clevels'];
373                     $reduce = pow(2, $powReduce);
374                 } else {
375                     $reduce = 1;
376                     $powReduce = 0;
377                 }
378                  
379                 $cmd = $unzipCmd . $this->getDecompressCmd($imageInfo['type'], $powReduce, $rotate, $scale, $stdoutLink) . $compressCmd;
380                 if ($this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
381                     $recovered = true;
382                 } else {
383                     trigger_error('BookReader fallback image processing also failed: ' . $errorMessage, E_USER_WARNING);
384                 }
385             }
386             
387             if (! $recovered) {
388                 $this->BRfatal('Problem processing image - command failed');
389             }
390         }
391         
392         if (isset($tempFile)) {
393             unlink($tempFile);
394         }
395     }    
396     
397     function getUnarchiveCommand($archivePath, $file)
398     {
399         $lowerPath = strtolower($archivePath);
400         if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
401             $suffix = $matches[1];
402             
403             if ($suffix == 'zip') {
404                 return 'unzip -p '
405                     . escapeshellarg($archivePath)
406                     . ' ' . escapeshellarg($file);
407             } else if ($suffix == 'tar') {
408                 return ' ( 7z e -so '
409                     . escapeshellarg($archivePath)
410                     . ' ' . escapeshellarg($file) . ' 2>/dev/null ) ';
411             } else {
412                 $this->BRfatal('Incompatible archive format');
413             }
414     
415         } else {
416             $this->BRfatal('Bad image stack path');
417         }
418         
419         $this->BRfatal('Bad image stack path or archive format');
420         
421     }
422     
423     /*
424      * Returns the image type associated with the file extension.
425      */
426     function imageExtensionToType($extension)
427     {
428         
429         if (array_key_exists($extension, self::$EXTENSIONS)) {
430             return self::$EXTENSIONS[$extension];
431         } else {
432             $this->BRfatal('Unknown image extension');
433         }            
434     }
435     
436     /*
437      * Get the image information.  The returned associative array fields will
438      * vary depending on the image type.  The basic keys are width, height, type
439      * and bits.
440      */
441     function getImageInfo($zipPath, $file)
442     {
443         return $this->getImageInfoFromExif($zipPath, $file); // this is fast
444         
445         /*
446         $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
447         $type = imageExtensionToType($fileExt);
448         
449         switch ($type) {
450             case "jp2":
451                 return getImageInfoFromJp2($zipPath, $file);
452                 
453             default:
454                 return getImageInfoFromExif($zipPath, $file);
455         }
456         */
457     }
458     
459     // Get the records of of JP2 as returned by kdu_expand
460     function getJp2Records($zipPath, $file)
461     {
462         
463         $cmd = $this->getUnarchiveCommand($zipPath, $file)
464                  . ' | ' . $this->kduExpand
465                  . ' -no_seek -quiet -i /dev/stdin -record /dev/stdout';
466         exec($cmd, $output);
467         
468         $records = Array();
469         foreach ($output as $line) {
470             $elems = explode("=", $line, 2);
471             if (1 == count($elems)) {
472                 // delimiter not found
473                 continue;
474             }
475             $records[$elems[0]] = $elems[1];
476         }
477         
478         return $records;
479     }
480     
481     /*
482      * Get the image width, height and depth using the EXIF information.
483      */
484     function getImageInfoFromExif($zipPath, $file)
485     {
486         
487         // We look for all the possible tags of interest then act on the
488         // ones presumed present based on the file type
489         $tagsToGet = ' -ImageWidth -ImageHeight -FileType'        // all formats
490                      . ' -BitsPerComponent -ColorSpace'          // jp2
491                      . ' -BitDepth'                              // png
492                      . ' -BitsPerSample';                        // tiff
493                             
494         $cmd = $this->getUnarchiveCommand($zipPath, $file)
495             . ' | '. $this->exiftool . ' -S -fast' . $tagsToGet . ' -';
496         exec($cmd, $output);
497         
498         $tags = Array();
499         foreach ($output as $line) {
500             $keyValue = explode(": ", $line);
501             $tags[$keyValue[0]] = $keyValue[1];
502         }
503         
504         $width = intval($tags["ImageWidth"]);
505         $height = intval($tags["ImageHeight"]);
506         $type = strtolower($tags["FileType"]);
507         
508         switch ($type) {
509             case "jp2":
510                 $bits = intval($tags["BitsPerComponent"]);
511                 break;
512             case "tiff":
513                 $bits = intval($tags["BitsPerSample"]);
514                 break;
515             case "jpeg":
516                 $bits = 8;
517                 break;
518             case "png":
519                 $bits = intval($tags["BitDepth"]);
520                 break;
521             default:
522                 $this->BRfatal("Unsupported image type $type for file $file in $zipPath");
523                 break;
524         }
525        
526        
527         $retval = Array('width' => $width, 'height' => $height,
528             'bits' => $bits, 'type' => $type);
529         
530         return $retval;
531     }
532     
533     /*
534      * Output JSON given the imageInfo associative array
535      */
536     function outputJSON($imageInfo, $callback)
537     {
538         header('Content-type: text/plain');
539         $jsonOutput = json_encode($imageInfo);
540         if ($callback) {
541             $jsonOutput = $callback . '(' . $jsonOutput . ');';
542         }
543         echo $jsonOutput;
544     }
545     
546     function getDecompressCmd($imageType, $powReduce, $rotate, $scale, $stdoutLink) {
547         
548         switch ($imageType) {
549             case 'jp2':
550                 $decompressCmd = 
551                     " | " . $this->kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -i /dev/stdin -o " . $stdoutLink;
552                 if ($this->decompressToBmp) {
553                     // We suppress output since bmptopnm always outputs on stderr
554                     $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
555                 }
556                 break;
557         
558             case 'tiff':
559                 // We need to create a temporary file for tifftopnm since it cannot
560                 // work on a pipe (the file must be seekable).
561                 // We use the BookReaderTiff prefix to give a hint in case things don't
562                 // get cleaned up.
563                 $tempFile = tempnam("/tmp", "BookReaderTiff");
564             
565                 // $$$ look at bit depth when reducing
566                 $decompressCmd = 
567                     ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . $this->reduceCommand($scale);
568                 break;
569          
570             case 'jpeg':
571                 $decompressCmd = ' | ( jpegtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
572                 break;
573         
574             case 'png':
575                 $decompressCmd = ' | ( pngtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
576                 break;
577                 
578             default:
579                 $this->BRfatal('Unknown image type: ' . $imageType);
580                 break;
581         }
582         return $decompressCmd;
583     }
584     
585     // If the command has its initial output on stdout the headers will be emitted followed
586     // by the stdout output.  If initial output is on stderr an error message will be
587     // returned.
588     // 
589     // Returns:
590     //   true - if command emits stdout and has zero exit code
591     //   false - command has initial output on stderr or non-zero exit code
592     //   &$errorMessage - error string if there was an error
593     //
594     // $$$ Tested with our command-line image processing.  May be deadlocks for
595     //     other cases.
596     function passthruIfSuccessful($headers, $cmd, &$errorMessage)
597     {
598         $retVal = false;
599         $errorMessage = '';
600         
601         $descriptorspec = array(
602            0 => array("pipe", "r"),  // stdin is a pipe that the child will read from
603            1 => array("pipe", "w"),  // stdout is a pipe that the child will write to
604            2 => array("pipe", "w"),   // stderr is a pipe to write to
605         );
606         
607         $cwd = NULL;
608         $env = NULL;
609         
610         $process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env);
611         
612         if (is_resource($process)) {
613             // $pipes now looks like this:
614             // 0 => writeable handle connected to child stdin
615             // 1 => readable handle connected to child stdout
616             // 2 => readable handle connected to child stderr
617         
618             $stdin = $pipes[0];        
619             $stdout = $pipes[1];
620             $stderr = $pipes[2];
621             
622             // check whether we get input first on stdout or stderr
623             $read = array($stdout, $stderr);
624             $write = NULL;
625             $except = NULL;
626             $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
627             if (false === $numChanged) {
628                 // select failed
629                 $errorMessage = 'Select failed';
630                 $retVal = false;
631             }
632             if ($read[0] == $stdout && (1 == $numChanged)) {
633                 // Got output first on stdout (only)
634                 // $$$ make sure we get all stdout
635                 $output = fopen('php://output', 'w');
636                 foreach($headers as $header) {
637                     header($header);
638                 }
639                 stream_copy_to_stream($pipes[1], $output);
640                 fclose($output); // okay since tied to special php://output
641                 $retVal = true;
642             } else {
643                 // Got output on stderr
644                 // $$$ make sure we get all stderr
645                 $errorMessage = stream_get_contents($stderr);
646                 $retVal = false;
647             }
648     
649             fclose($stderr);
650             fclose($stdout);
651             fclose($stdin);
652     
653             
654             // It is important that you close any pipes before calling
655             // proc_close in order to avoid a deadlock
656             $cmdRet = proc_close($process);
657             if (0 != $cmdRet) {
658                 $retVal = false;
659                 $errorMessage .= "Command failed with result code " . $cmdRet;
660             }
661         }
662         return $retVal;
663     }
664     
665     function BRfatal($string) {
666         throw new Exception("Image error: $string");
667     }
668     
669     // Returns true if using a power node
670     function onPowerNode() {
671         exec("lspci | fgrep -c Realtek", $output, $return);
672         if ("0" != $output[0]) {
673             return true;
674         } else {
675             exec("egrep -q AMD /proc/cpuinfo", $output, $return);
676             if ($return == 0) {
677                 return true;
678             }
679         }
680         return false;
681     }
682     
683     function reduceCommand($scale) {
684         if (1 != $scale) {
685             if ($this->onPowerNode()) {
686                 return ' | pnmscale -reduce ' . $scale . ' 2>/dev/null ';
687             } else {
688                 return ' | pnmscale -nomix -reduce ' . $scale . ' 2>/dev/null ';
689             }
690         } else {
691             return '';
692         }
693     }
694     
695     function checkPrivs($filename) {
696         if (!is_readable($filename)) {
697             header('HTTP/1.1 403 Forbidden');
698             exit(0);
699         }
700     }
701     
702     // Given file path (inside archive) and output file extension, return a filename
703     // suitable for Content-disposition header
704     function filenameForClient($filePath, $ext) {
705         $pathParts = pathinfo($filePath);
706         if ('jpeg' == $ext) {
707             $ext = 'jpg';
708         }
709         return $pathParts['filename'] . '.' . $ext;
710     }
711     
712     // Returns the nearest power of 2 reduction factor that results in a larger image
713     function nearestPow2Reduce($desiredDimension, $sourceDimension) {
714         $ratio = floatval($sourceDimension) / floatval($desiredDimension);
715         return $this->nearestPow2ForScale($ratio);
716     }
717     
718     // Returns nearest power of 2 reduction factor that results in a larger image
719     function nearestPow2ForScale($scale) {
720         $scale = intval($scale);
721         if ($scale <= 1) {
722             return 0;
723         }
724         $binStr = decbin($scale); // convert to binary string. e.g. 5 -> '101'
725         return strlen($binStr) - 1;
726     }
727     
728     /*
729      * Parses a page request like "page5_r2.jpg" or "cover_t.jpg" to corresponding
730      * page type, size, reduce, and format
731      */
732     function parsePageRequest($pageRequest, $bookPrefix) {
733     
734         // Will hold parsed results
735         $pageInfo = array();
736         
737         // Normalize
738         $pageRequest = strtolower($pageRequest);
739         
740         // Pull off extension
741         if (preg_match('#(.*)\.([^.]+)$#', $pageRequest, $matches) === 1) {
742             $pageRequest = $matches[1];
743             $extension = $matches[2];
744             if ($extension == 'jpeg') {
745                 $extension = 'jpg';
746             }
747         } else {
748             $extension = 'jpg';
749         }
750         $pageInfo['extension'] = $extension;
751         
752         // Split parts out
753         $parts = explode('_', $pageRequest);
754
755         // Remove book prefix if it was included (historical)
756         if ($parts[0] == $bookPrefix) {
757             array_shift($parts);
758         }
759         
760         if (count($parts) === 0) {
761             $this->BRfatal('No page type specified');
762         }
763         $page = array_shift($parts);
764         
765         $pageTypes = array(
766             'page' => 'str',
767             'n' => 'num',
768             'cover' => 'single',
769             'preview' => 'single',
770             'title' => 'single'
771         );
772         
773         // Look for known page types
774         foreach ( $pageTypes as $pageName => $kind ) {
775             if ( preg_match('#^(' . $pageName . ')(.*)#', $page, $matches) === 1 ) {
776                 $pageInfo['type'] = $matches[1];
777                 switch ($kind) {
778                     case 'str':
779                         $pageInfo['value'] = $matches[2];
780                         break;
781                     case 'num':
782                         $pageInfo['value'] = intval($matches[2]);
783                         break;
784                     case 'single':
785                         break;
786                 }
787             }
788         }
789         
790         if ( !array_key_exists('type', $pageInfo) ) {
791             $this->BRfatal('Unrecognized page type');
792         }
793         
794         // Look for other known parts
795         foreach ($parts as $part) {
796             if ( in_array($part, self::$imageSizes) ) {
797                 $pageInfo['size'] = $part;
798                 continue;
799             }
800         
801             // Key must be alpha, value must start with digit and contain digits, alpha, ',' or '.'
802             // Should prevent injection of strange values into the redirect to datanode
803             if ( preg_match('#^([a-z]+)(\d[a-z0-9,.]*)#', $part, $matches) === 0) {
804                 // Not recognized
805                 continue;
806             }
807             
808             $key = $matches[1];
809             $value = $matches[2];
810             
811             if ( array_key_exists($key, self::$imageUrlKeys) ) {
812                 $pageInfo[self::$imageUrlKeys[$key]] = $value;
813                 continue;
814             }
815             
816             // If we hit here, was unrecognized (no action)
817         }
818         
819         return $pageInfo;
820     }
821     
822 }
823
824 ?>