4 Copyright(c) 2008-2010 Internet Archive. Software license AGPL version 3.
6 This file is part of BookReader. The full source code can be found at GitHub:
7 http://github.com/openlibrary/bookreader
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".
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.
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.
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/>.
27 require_once("BookReaderMeta.inc.php");
29 class BookReaderImages
31 public static $MIMES = array('gif' => 'image/gif',
33 'jpg' => 'image/jpeg',
34 'jpeg' => 'image/jpeg',
36 'tif' => 'image/tiff',
37 'tiff' => 'image/tiff');
39 public static $EXTENSIONS = array('gif' => 'gif',
47 // Width when generating thumbnails
48 public static $imageSizes = array(
55 // Keys in the image permalink urls, e.g. http://www.archive.org/download/itemid/page/cover_{keyval}_{keyval}.jpg
56 public static $imageUrlKeys = array(
57 //'r' => 'reduce', // pow of 2 reduction
58 's' => 'scale', // $$$ scale is downscaling factor in BookReaderImages but most people call this "reduce"
68 // Paths to command-line tools
69 var $exiftool = '/petabox/sw/books/exiftool/exiftool';
70 var $kduExpand = '/petabox/sw/bin/kdu_expand';
72 // Name of temporary files, to be cleaned at exit
73 var $tempFiles = array();
76 * Serve an image request that requires looking up the book metadata
80 * - Parse the requested page (e.g. cover_t.jpg, n5_r4.jpg) to determine which page type,
81 * size and format (etc) is being requested
82 * - Determine the leaf number corresponding to the page
83 * - Determine scaling values
84 * - Serve image request now that all information has been gathered
87 function serveLookupRequest($requestEnv) {
88 $brm = new BookReaderMeta();
90 $metadata = $brm->buildMetadata($_REQUEST['id'], $_REQUEST['itemPath'], $_REQUEST['subPrefix'], $_REQUEST['server']);
91 } catch (Exception $e) {
92 $this->BRfatal($e->getMessage());
95 $page = $_REQUEST['page'];
97 // Index of image to return
100 // deal with subPrefix
101 if (array_key_exists($_REQUEST, 'subPrefix') && $_REQUEST['subPrefix']) {
102 $parts = explode('/', $_REQUEST['subPrefix']);
103 $bookId = $parts[count($parts) - 1 ];
105 $bookId = $_REQUEST['id'];
108 $pageInfo = $this->parsePageRequest($page, $bookId);
110 $basePage = $pageInfo['type'];
117 if (! array_key_exists('titleIndex', $metadata)) {
118 $this->BRfatal("No title page asserted in book");
120 $imageIndex = $metadata['titleIndex'];
123 /* Old 'cover' behaviour where it would show cover 0 if it exists or return 404.
124 Could be re-added as cover0, cover1, etc
126 if (! array_key_exists('coverIndices', $metadata)) {
127 $this->BRfatal("No cover asserted in book");
129 $imageIndex = $metadata['coverIndices'][0]; // $$$ TODO add support for other covers
134 case 'cover': // Show our best guess if cover is requested
136 // Cover page if book was published >= 1950
141 if ( array_key_exists('date', $metadata) && array_key_exists('coverIndices', $metadata) ) {
142 if ($brm->parseYear($metadata['date']) >= 1950) {
143 $imageIndex = $metadata['coverIndices'][0];
147 if (array_key_exists('titleIndex', $metadata)) {
148 $imageIndex = $metadata['titleIndex'];
151 if (array_key_exists('coverIndices', $metadata)) {
152 $imageIndex = $metadata['coverIndices'][0];
161 // Accessible index page
162 $imageIndex = intval($pageInfo['value']);
167 $index = array_search($pageInfo['value'], $metadata['pageNums']);
168 if ($index === FALSE) {
170 $this->BRfatal("Page not found");
174 $imageIndex = $index;
178 // Leaf explicitly specified
179 $leaf = $pageInfo['value'];
183 // Shouldn't be possible
184 $this->BRfatal("Unrecognized page type requested");
189 if (is_null($leaf)) {
190 // Leaf was not explicitly set -- look it up
191 $leaf = $brm->leafForIndex($imageIndex, $metadata['leafNums']);
195 'zip' => $metadata['zip'],
196 'file' => $brm->imageFilePath($leaf, $metadata['subPrefix'], $metadata['imageFormat']),
197 'ext' => 'jpg', // XXX should pass through ext
200 // remove non-passthrough keys from pageInfo
201 unset($pageInfo['type']);
202 unset($pageInfo['value']);
204 // add pageinfo to request
205 $requestEnv = array_merge($pageInfo, $requestEnv);
207 // Return image data - will check privs
208 $this->serveRequest($requestEnv);
213 * Returns a page image when all parameters such as the image stack location are
218 * Get info about requested image (input)
219 * Get info about requested output format
220 * Determine processing parameters
223 * Clean up temporary files
225 function serveRequest($requestEnv) {
227 // Make sure cleanup happens
228 register_shutdown_function ( array( $this, 'cleanup') );
230 // Process some of the request parameters
231 $zipPath = $requestEnv['zip'];
232 $file = $requestEnv['file'];
233 if (array_key_exists('ext', $requestEnv)) {
234 $ext = $requestEnv['ext']; // Will get santized below
239 if (isset($requestEnv['callback'])) {
240 // validate callback is valid JS identifier (only)
241 $callback = $requestEnv['callback'];
242 $identifierPatt = '/^[[:alpha:]$_]([[:alnum:]$_])*$/';
243 if (! preg_match($identifierPatt, $callback)) {
244 $this->BRfatal('Invalid callback');
250 if ( !file_exists($zipPath) ) {
251 $this->BRfatal('Image stack does not exist at ' . $zipPath);
253 // Make sure the image stack is readable - return 403 if not
254 $this->checkPrivs($zipPath);
257 // Get the image size and depth
258 $imageInfo = $this->getImageInfo($zipPath, $file);
260 // Output json if requested
261 if ('json' == $ext) {
262 // $$$ we should determine the output size first based on requested scale
263 $this->outputJSON($imageInfo, $callback); // $$$ move to BookReaderRequest
267 // Unfortunately kakadu requires us to know a priori if the
268 // output file should be .ppm or .pgm. By decompressing to
269 // .bmp kakadu will write a file we can consistently turn into
270 // .pnm. Really kakadu should support .pnm as the file output
271 // extension and automatically write ppm or pgm format as
273 $this->decompressToBmp = true; // $$$ shouldn't be necessary if we use file info to determine output format
274 if ($this->decompressToBmp) {
275 $stdoutLink = '/tmp/stdout.bmp';
277 $stdoutLink = '/tmp/stdout.ppm';
280 $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
282 // Rotate is currently only supported for jp2 since it does not add server load
283 $allowedRotations = array("0", "90", "180", "270");
284 $rotate = $requestEnv['rotate'];
285 if ( !in_array($rotate, $allowedRotations) ) {
289 // Image conversion options
291 $jpegOptions = '-quality 75';
293 // The pbmreduce reduction factor produces an image with dimension 1/n
294 // The kakadu reduction factor produces an image with dimension 1/(2^n)
296 // We interpret the requested size and scale, look at image format, and determine the
297 // actual scaling to be returned to the client. We generally return the largest
298 // power of 2 reduction that is larger than the requested size in order to reduce
299 // image processing load on our cluster. The client should then scale to their final
303 // If a named size is provided, we size the full image to that size
304 // If x or y is set, we interpret the supplied width/height as the size of image region to crop to
305 // If x and y are not set and both width and height are set, we size the full image "within" the width/height
306 // If x and y are not set and only one of width and height are set, we size the full image to that width or height
307 // If none of the above apply, we use the whole image
309 // Crop region, if empty whole image is used
314 if (isset($requestEnv['scale'])) {
315 $scale = $requestEnv['scale'];
317 $powReduce = $this->nearestPow2ForScale($scale);
318 // ensure integer scale
319 $scale = pow(2, $powReduce);
321 if ( isset($requestEnv['size']) ) {
322 // Set scale from named size (e.g. 'large') if set
323 $size = $requestEnv['size'];
324 if ( $size && array_key_exists($size, self::$imageSizes)) {
325 $srcRatio = floatval($imageInfo['width']) / floatval($imageInfo['height']);
328 $dimension = 'width';
330 $dimension = 'height';
332 $powReduce = $this->nearestPow2Reduce(self::$imageSizes[$size], $imageInfo[$dimension]);
333 $scale = pow(2, $powReduce);
336 } else if ( isset($requestEnv['x']) || isset($requestEnv['y']) ) {
337 // x,y is crop region origin, width,height is size of crop region
338 foreach (array('x', 'y', 'width', 'height') as $key) {
339 if (array_key_exists($key, $requestEnv)) {
340 $region[$key] = $requestEnv[$key];
344 } else if ( isset($requestEnv['width']) && isset($requestEnv['height']) ) {
345 // proportional scaling within requested width/height
347 $width = $this->intAmount($requestEnv['width'], $imageInfo['width']);
348 $height = $this->intAmount($requestEnv['height'], $imageInfo['height']);
350 $srcAspect = floatval($imageInfo['width']) / floatval($imageInfo['height']);
351 $fitAspect = floatval($width) / floatval($height);
353 if ($srcAspect > $fitAspect) {
354 // Source image is wide compared to fit
355 $powReduce = $this->nearestPow2Reduce($width, $imageInfo['width']);
357 $powReduce = $this->nearestPow2Reduce($height, $imageInfo['height']);
359 $scale = pow(2, $poweReduce);
361 } else if ( isset($requestEnv['width']) ) {
363 $width = $this->intAmount($requestEnv['width'], $imageInfo['width']);
364 $powReduce = $this->nearestPow2Reduce($width, $imageInfo['width']);
365 $scale = pow(2, $powReduce);
367 } else if ( isset($requestEnv['height'])) {
369 $height = $this->intAmount($requestEnv['height'], $imageInfo['height']);
370 $powReduce = $this->nearestPow2Reduce($height, $imageInfo['height']);
371 $scale = pow(2, $powReduce);
374 $regionDimensions = $this->getRegionDimensions($imageInfo, $region);
381 print('regionDimensions');
382 print_r($regionDimensions);
384 print_r($this->getRegionDimensionsAsFloat($imageInfo, $region));
389 // Override depending on source image format
390 // $$$ consider doing a 302 here instead, to make better use of the browser cache
391 // Limit scaling for 1-bit images. See https://bugs.edge.launchpad.net/bookreader/+bug/486011
392 if (1 == $imageInfo['bits']) {
398 // Hard limit so there are some black pixels to use!
406 if (!file_exists($stdoutLink))
408 system('ln -s /dev/stdout ' . $stdoutLink);
411 putenv('LD_LIBRARY_PATH=/petabox/sw/lib/kakadu');
413 $unzipCmd = $this->getUnarchiveCommand($zipPath, $file);
415 $decompressCmd = $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink);
417 // Non-integer scaling is currently disabled on the cluster
418 // if (isset($_REQUEST['height'])) {
419 // $cmd .= " | pnmscale -height {$_REQUEST['height']} ";
424 $compressCmd = ' | pnmtopng ' . $pngOptions;
430 $compressCmd = ' | pnmtojpeg ' . $jpegOptions;
431 $ext = 'jpeg'; // for matching below
436 if (($ext == $fileExt) && ($scale == 1) && ($rotate === "0")) {
437 // Just pass through original data if same format and size
440 $cmd = $unzipCmd . $decompressCmd . $compressCmd;
445 $filenameForClient = $this->filenameForClient($file, $ext);
449 //if (! $this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
451 $tempFile = $this->getTempFilename($ext);
452 array_push($this->tempFiles, $tempFile);
454 // error_log("bookreader running " . $cmd);
455 $imageCreated = $this->createOutputImage($cmd, $tempFile, $errorMessage);
456 if (! $imageCreated) {
457 // $$$ automated reporting
458 trigger_error('BookReader Processing Error: ' . $cmd . ' -- ' . $errorMessage, E_USER_WARNING);
460 // Try some content-specific recovery
462 if ($imageInfo['type'] == 'jp2') {
463 $records = $this->getJp2Records($zipPath, $file);
464 if (array_key_exists('Clevels', $records)) {
465 $maxReduce = intval($records['Clevels']);
466 trigger_error("BookReader using max reduce $maxReduce from jp2 records");
471 $powReduce = min($powReduce, $maxReduce);
472 $reduce = pow(2, $powReduce);
474 $cmd = $unzipCmd . $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) . $compressCmd;
475 trigger_error('BookReader rerunning with new cmd: ' . $cmd, E_USER_WARNING);
477 //if ($this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
478 $imageCreated = $this->createOutputImage($cmd, $tempFile, $errorMessage);
483 trigger_error('BookReader fallback image processing also failed: ' . $errorMessage, E_USER_WARNING);
488 $this->BRfatal("Problem processing image - command failed:\n " . $cmd);
495 $headers = array('Content-type: '. self::$MIMES[$ext],
496 'Cache-Control: max-age=15552000',
497 'Content-disposition: inline; filename=' . $filenameForClient,
498 'Content-Length: ' . filesize($tempFile));
500 foreach($headers as $header) {
504 flush(); // attempt to send header to client
511 function getUnarchiveCommand($archivePath, $file)
513 $lowerPath = strtolower($archivePath);
514 if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
515 $suffix = $matches[1];
517 if ($suffix == 'zip') {
519 . escapeshellarg($archivePath)
520 . ' ' . escapeshellarg($file);
521 } else if ($suffix == 'tar') {
522 return ' ( 7z e -so '
523 . escapeshellarg($archivePath)
524 . ' ' . escapeshellarg($file) . ' 2>/dev/null ) ';
526 $this->BRfatal('Incompatible archive format');
530 $this->BRfatal('Bad image stack path');
533 $this->BRfatal('Bad image stack path or archive format');
538 * Returns the image type associated with the file extension.
540 function imageExtensionToType($extension)
543 if (array_key_exists($extension, self::$EXTENSIONS)) {
544 return self::$EXTENSIONS[$extension];
546 $this->BRfatal('Unknown image extension');
551 * Get the image information. The returned associative array fields will
552 * vary depending on the image type. The basic keys are width, height, type
555 function getImageInfo($zipPath, $file)
557 return $this->getImageInfoFromExif($zipPath, $file); // this is fast
560 $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
561 $type = imageExtensionToType($fileExt);
565 return getImageInfoFromJp2($zipPath, $file);
568 return getImageInfoFromExif($zipPath, $file);
573 // Get the records of of JP2 as returned by kdu_expand
574 function getJp2Records($zipPath, $file)
577 $cmd = $this->getUnarchiveCommand($zipPath, $file)
578 . ' | ' . $this->kduExpand
579 . ' -no_seek -quiet -i /dev/stdin -record /dev/stdout';
583 foreach ($output as $line) {
584 $elems = explode("=", $line, 2);
585 if (1 == count($elems)) {
586 // delimiter not found
589 $records[$elems[0]] = $elems[1];
596 * Get the image width, height and depth using the EXIF information.
598 function getImageInfoFromExif($zipPath, $file)
601 // We look for all the possible tags of interest then act on the
602 // ones presumed present based on the file type
603 $tagsToGet = ' -ImageWidth -ImageHeight -FileType' // all formats
604 . ' -BitsPerComponent -ColorSpace' // jp2
605 . ' -BitDepth' // png
606 . ' -BitsPerSample'; // tiff
608 $cmd = $this->getUnarchiveCommand($zipPath, $file)
609 . ' | '. $this->exiftool . ' -S -fast' . $tagsToGet . ' -';
613 foreach ($output as $line) {
614 $keyValue = explode(": ", $line);
615 $tags[$keyValue[0]] = $keyValue[1];
618 $width = intval($tags["ImageWidth"]);
619 $height = intval($tags["ImageHeight"]);
620 $type = strtolower($tags["FileType"]);
623 if (strcmp($type,'jpx') == 0) {
629 $bits = intval($tags["BitsPerComponent"]);
632 $bits = intval($tags["BitsPerSample"]);
638 $bits = intval($tags["BitDepth"]);
641 $this->BRfatal("Unsupported image type $type for file $file in $zipPath");
646 $retval = Array('width' => $width, 'height' => $height,
647 'bits' => $bits, 'type' => $type);
653 * Output JSON given the imageInfo associative array
655 function outputJSON($imageInfo, $callback)
657 header('Content-type: text/plain');
658 $jsonOutput = json_encode($imageInfo);
660 $jsonOutput = $callback . '(' . $jsonOutput . ');';
665 function getDecompressCmd($srcInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) {
667 switch ($srcInfo['type']) {
669 $regionAsFloat = $this->getRegionDimensionsAsFloat($srcInfo, $region);
670 $regionString = sprintf("{%f,%f},{%f,%f}", $regionAsFloat['y'], $regionAsFloat['x'], $regionAsFloat['h'], $regionAsFloat['w']);
672 " | " . $this->kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -region $regionString -i /dev/stdin -o " . $stdoutLink;
673 if ($this->decompressToBmp) {
674 // We suppress output since bmptopnm always outputs on stderr
675 $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
680 // We need to create a temporary file for tifftopnm since it cannot
681 // work on a pipe (the file must be seekable).
682 // We use the BookReaderTiff prefix to give a hint in case things don't
684 $tempFile = tempnam("/tmp", "BookReaderTiff");
685 array_push($this->tempFiles, $tempFile);
687 // $$$ look at bit depth when reducing
689 ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . $this->reduceCommand($scale);
693 $decompressCmd = ' | ( jpegtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
697 $decompressCmd = ' | ( pngtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
701 // Formats handled by ImageMagick
705 $region = $this->getRegionDimensions($srcInfo, $region);
706 $regionString = sprintf('[%dx%d+%d+%d]', $region['w'], $region['h'], $region['x'], $region['y']);
708 // The argument to ImageMagick's scale command is a "geometry". We pass in the new width/height
709 $scaleString = ' -scale ' . sprintf("%dx%d", $region['w'] / $scale, $region['h'] / $scale);
712 if ($rotate && $rotate != '0') {
713 $rotateString = ' -rotate ' . $rotate; // was previously checked to be a known value
716 $decompressCmd = ' | convert -quiet -' . $regionString . $scaleString . $rotateString . ' pnm:-';
720 $this->BRfatal('Unknown image type: ' . $imageType);
724 return $decompressCmd;
728 // If the command has its initial output on stdout the headers will be emitted followed
729 // by the stdout output. If initial output is on stderr an error message will be
733 // true - if command emits stdout and has zero exit code
734 // false - command has initial output on stderr or non-zero exit code
735 // &$errorMessage - error string if there was an error
737 // $$$ Tested with our command-line image processing. May be deadlocks for
738 // other cases, e.g. if there are warnings on stderr
739 function passthruIfSuccessful($headers, $cmd, &$errorMessage)
745 $descriptorspec = array(
746 0 => array("pipe", "r"), // stdin is a pipe that the child will read from
747 1 => array("pipe", "w"), // stdout is a pipe that the child will write to
748 2 => array("pipe", "w"), // stderr is a pipe to write to
754 $process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env);
756 if (is_resource($process)) {
757 // $pipes now looks like this:
758 // 0 => writeable handle connected to child stdin
759 // 1 => readable handle connected to child stdout
760 // 2 => readable handle connected to child stderr
766 // check whether we get input first on stdout or stderr
767 $read = array($stdout, $stderr);
771 $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
772 if (false === $numChanged) {
774 $errorMessage = 'Select failed';
776 error_log('BookReader select failed!');
778 if (in_array($stderr, $read)) {
779 // Either content in stderr, or stderr is closed (could read 0 bytes)
780 $error = stream_get_contents($stderr);
783 $errorMessage = $error;
790 // It is important that you close any pipes before calling
791 // proc_close in order to avoid a deadlock
792 proc_close($process);
798 $output = fopen('php://output', 'w');
799 foreach($headers as $header) {
802 stream_copy_to_stream($pipes[1], $output);
803 fclose($output); // okay since tied to special php://output
812 // It is important that you close any pipes before calling
813 // proc_close in order to avoid a deadlock
814 $cmdRet = proc_close($process);
817 $errorMessage .= "Command failed with result code " . $cmdRet;
823 function createOutputImage($cmd, $tempFile, &$errorMessage) {
824 $fullCmd = $cmd . " > " . $tempFile;
825 system($fullCmd); // $$$ better error handling
826 return file_exists($tempFile) && filesize($tempFile) > 0;
829 function BRfatal($string) {
831 throw new Exception("Image error: $string");
834 // Returns true if using a power node
835 // XXX change to "on red box" - not working for new Xeon
836 function onPowerNode() {
837 exec("lspci | fgrep -c Realtek", $output, $return);
838 if ("0" != $output[0]) {
841 exec("egrep -q AMD /proc/cpuinfo", $output, $return);
849 function reduceCommand($scale) {
851 if ($this->onPowerNode()) {
852 return ' | pnmscale -reduce ' . $scale . ' 2>/dev/null ';
854 return ' | pnmscale -nomix -reduce ' . $scale . ' 2>/dev/null ';
861 function checkPrivs($filename) {
862 // $$$ we assume here that requests for the title, cover or preview
863 // come in via BookReaderPreview.php which will be re-run with
864 // privileges after we return the 403
865 if (!is_readable($filename)) {
866 header('HTTP/1.1 403 Forbidden');
871 // Given file path (inside archive) and output file extension, return a filename
872 // suitable for Content-disposition header
873 function filenameForClient($filePath, $ext) {
874 $pathParts = pathinfo($filePath);
875 if ('jpeg' == $ext) {
878 return $pathParts['filename'] . '.' . $ext;
881 // Returns the nearest power of 2 reduction factor that results in a larger image
882 function nearestPow2Reduce($desiredDimension, $sourceDimension) {
883 $ratio = floatval($sourceDimension) / floatval($desiredDimension);
884 return $this->nearestPow2ForScale($ratio);
887 // Returns nearest power of 2 reduction factor that results in a larger image
888 function nearestPow2ForScale($scale) {
889 $scale = intval($scale);
893 $binStr = decbin($scale); // convert to binary string. e.g. 5 -> '101'
894 $largerPow2 = strlen($binStr) - 1;
900 * Parses a page request like "page5_r2.jpg" or "cover_t.jpg" to corresponding
901 * page type, size, reduce, and format
903 function parsePageRequest($pageRequest, $bookPrefix) {
905 // Will hold parsed results
909 $pageRequest = strtolower($pageRequest);
911 // Pull off extension
912 if (preg_match('#(.*)\.([^.]+)$#', $pageRequest, $matches) === 1) {
913 $pageRequest = $matches[1];
914 $extension = $matches[2];
915 if ($extension == 'jpeg') {
921 $pageInfo['extension'] = $extension;
924 $parts = explode('_', $pageRequest);
926 // Remove book prefix if it was included (historical)
927 if ($parts[0] == $bookPrefix) {
931 if (count($parts) === 0) {
932 $this->BRfatal('No page type specified');
934 $page = array_shift($parts);
940 'preview' => 'single',
945 // Look for known page types
946 foreach ( $pageTypes as $pageName => $kind ) {
947 if ( preg_match('#^(' . $pageName . ')(.*)#', $page, $matches) === 1 ) {
948 $pageInfo['type'] = $matches[1];
951 $pageInfo['value'] = $matches[2];
954 $pageInfo['value'] = intval($matches[2]);
962 if ( !array_key_exists('type', $pageInfo) ) {
963 $this->BRfatal('Unrecognized page type');
966 // Look for other known parts
967 foreach ($parts as $part) {
968 if ( array_key_exists($part, self::$imageSizes) ) {
969 $pageInfo['size'] = $part;
973 // Key must be alpha, value must start with digit and contain digits, alpha, ',' or '.'
974 // Should prevent injection of strange values into the redirect to datanode
975 if ( preg_match('#^([a-z]+)(\d[a-z0-9,.]*)#', $part, $matches) === 0) {
981 $value = $matches[2];
983 if ( array_key_exists($key, self::$imageUrlKeys) ) {
984 $pageInfo[self::$imageUrlKeys[$key]] = $value;
988 // If we hit here, was unrecognized (no action)
994 function getRegionDimensions($sourceDimensions, $regionDimensions) {
995 // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
996 // in terms of full resolution image.
997 // Note: this will clip the returned dimensions to fit within the source image
1000 if (array_key_exists('x', $regionDimensions)) {
1001 $sourceX = $this->intAmount($regionDimensions['x'], $sourceDimensions['width']);
1003 $sourceX = $this->clamp(0, $sourceDimensions['width'] - 2, $sourceX); // Allow at least one pixel
1006 if (array_key_exists('y', $regionDimensions)) {
1007 $sourceY = $this->intAmount($regionDimensions['y'], $sourceDimensions['height']);
1009 $sourceY = $this->clamp(0, $sourceDimensions['height'] - 2, $sourceY); // Allow at least one pixel
1011 $sourceWidth = $sourceDimensions['width'] - $sourceX;
1012 if (array_key_exists('width', $regionDimensions)) {
1013 $sourceWidth = $this->intAmount($regionDimensions['width'], $sourceDimensions['width']);
1015 $sourceWidth = $this->clamp(1, max(1, $sourceDimensions['width'] - $sourceX), $sourceWidth);
1017 $sourceHeight = $sourceDimensions['height'] - $sourceY;
1018 if (array_key_exists('height', $regionDimensions)) {
1019 $sourceHeight = $this->intAmount($regionDimensions['height'], $sourceDimensions['height']);
1021 $sourceHeight = $this->clamp(1, max(1, $sourceDimensions['height'] - $sourceY), $sourceHeight);
1023 return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
1026 function getRegionDimensionsAsFloat($sourceDimensions, $regionDimensions) {
1027 // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
1028 // in terms of full resolution image.
1029 // Note: this will clip the returned dimensions to fit within the source image
1032 if (array_key_exists('x', $regionDimensions)) {
1033 $sourceX = $this->floatAmount($regionDimensions['x'], $sourceDimensions['width']);
1035 $sourceX = $this->clamp(0.0, 1.0, $sourceX);
1038 if (array_key_exists('y', $regionDimensions)) {
1039 $sourceY = $this->floatAmount($regionDimensions['y'], $sourceDimensions['height']);
1041 $sourceY = $this->clamp(0.0, 1.0, $sourceY);
1043 $sourceWidth = 1 - $sourceX;
1044 if (array_key_exists('width', $regionDimensions)) {
1045 $sourceWidth = $this->floatAmount($regionDimensions['width'], $sourceDimensions['width']);
1047 $sourceWidth = $this->clamp(0.0, 1.0, $sourceWidth);
1049 $sourceHeight = 1 - $sourceY;
1050 if (array_key_exists('height', $regionDimensions)) {
1051 $sourceHeight = $this->floatAmount($regionDimensions['height'], $sourceDimensions['height']);
1053 $sourceHeight = $this->clamp(0.0, 1.0, $sourceHeight);
1055 return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
1058 function intAmount($stringValue, $maximum) {
1059 // Returns integer amount for string like "5" (5 units) or "0.5" (50%)
1060 if (strpos($stringValue, '.') === false) {
1061 // No decimal, assume int
1062 return intval($stringValue);
1065 return floatval($stringValue) * $maximum + 0.5;
1068 function floatAmount($stringValue, $maximum) {
1069 // Returns float amount (0.0 to 1.0) for string like "0.4" (40%) or "4" (40% if max is 10)
1070 if (strpos($stringValue, ".") === false) {
1071 // No decimal, assume int value out of maximum
1072 return floatval($stringValue) / $maximum;
1075 // Given float - just pass through
1076 return floatval($stringValue);
1079 function clamp($minValue, $maxValue, $observedValue) {
1080 if ($observedValue < $minValue) {
1084 if ($observedValue > $maxValue) {
1088 return $observedValue;
1091 // Get the directory for temporary files. Use the fast in-RAM tmp if available.
1092 function getTempDir() {
1093 $fastbr = '/var/tmp/fast/bookreader';
1095 if (is_writeable($fastbr)) {
1096 // Our directory in fast tmp already exists
1099 // Check for fast tmp and make our directory
1100 $fast = '/var/tmp/fast';
1101 if (is_writeable($fast)) {
1102 if (mkdir($fastbr)) {
1110 // All else failed -- system tmp that should get cleaned on reboot
1114 function getTempFilename($ext) {
1115 return tempnam($this->getTempDir(), "BookReaderImages");
1118 // Clean up temporary files and resources
1119 function cleanup() {
1120 foreach($this->tempFiles as $tempFile) {
1123 $this->tempFiles = array();
1127 function cleanTmp() {
1128 system('find /var/tmp/fast -name "BookReaderImages*" -cmin +10 -exec rm {} \;');
1129 system('find /var/tmp/fast/bookreader -name "BookReaderImages*" -cmin +10 -exec rm {} \;');