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