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