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. */
125 if (! array_key_exists('coverIndices', $metadata)) {
126 $this->BRfatal("No cover asserted in book");
128 $imageIndex = $metadata['coverIndices'][0]; // $$$ TODO add support for other covers
132 case 'cover': // Show our best guess if cover is requested
134 // Cover page if book was published >= 1923
139 if ( array_key_exists('date', $metadata) && array_key_exists('coverIndices', $metadata) ) {
140 if ($brm->parseYear($metadata['date']) >= 1923) {
141 $imageIndex = $metadata['coverIndices'][0];
145 if (array_key_exists('titleIndex', $metadata)) {
146 $imageIndex = $metadata['titleIndex'];
149 if (array_key_exists('coverIndices', $metadata)) {
150 $imageIndex = $metadata['coverIndices'][0];
159 // Accessible index page
160 $imageIndex = intval($pageInfo['value']);
165 $index = array_search($pageInfo['value'], $metadata['pageNums']);
166 if ($index === FALSE) {
168 $this->BRfatal("Page not found");
172 $imageIndex = $index;
176 // Leaf explicitly specified
177 $leaf = $pageInfo['value'];
181 // Shouldn't be possible
182 $this->BRfatal("Unrecognized page type requested");
187 if (is_null($leaf)) {
188 // Leaf was not explicitly set -- look it up
189 $leaf = $brm->leafForIndex($imageIndex, $metadata['leafNums']);
193 'zip' => $metadata['zip'],
194 'file' => $brm->imageFilePath($leaf, $metadata['subPrefix'], $metadata['imageFormat']),
195 'ext' => 'jpg', // XXX should pass through ext
198 // remove non-passthrough keys from pageInfo
199 unset($pageInfo['type']);
200 unset($pageInfo['value']);
202 // add pageinfo to request
203 $requestEnv = array_merge($pageInfo, $requestEnv);
205 // Return image data - will check privs
206 $this->serveRequest($requestEnv);
211 * Returns a page image when all parameters such as the image stack location are
216 * Get info about requested image (input)
217 * Get info about requested output format
218 * Determine processing parameters
221 * Clean up temporary files
223 function serveRequest($requestEnv) {
225 // Make sure cleanup happens
226 register_shutdown_function ( array( $this, 'cleanup') );
228 // Process some of the request parameters
229 $zipPath = $requestEnv['zip'];
230 $file = $requestEnv['file'];
231 if (array_key_exists('ext', $requestEnv)) {
232 $ext = $requestEnv['ext']; // Will get santized below
237 if (isset($requestEnv['callback'])) {
238 // validate callback is valid JS identifier (only)
239 $callback = $requestEnv['callback'];
240 $identifierPatt = '/^[[:alpha:]$_]([[:alnum:]$_])*$/';
241 if (! preg_match($identifierPatt, $callback)) {
242 $this->BRfatal('Invalid callback');
248 if ( !file_exists($zipPath) ) {
249 $this->BRfatal('Image stack does not exist at ' . $zipPath);
251 // Make sure the image stack is readable - return 403 if not
252 $this->checkPrivs($zipPath);
255 // Get the image size and depth
256 $imageInfo = $this->getImageInfo($zipPath, $file);
258 // Output json if requested
259 if ('json' == $ext) {
260 // $$$ we should determine the output size first based on requested scale
261 $this->outputJSON($imageInfo, $callback); // $$$ move to BookReaderRequest
265 // Unfortunately kakadu requires us to know a priori if the
266 // output file should be .ppm or .pgm. By decompressing to
267 // .bmp kakadu will write a file we can consistently turn into
268 // .pnm. Really kakadu should support .pnm as the file output
269 // extension and automatically write ppm or pgm format as
271 $this->decompressToBmp = true; // $$$ shouldn't be necessary if we use file info to determine output format
272 if ($this->decompressToBmp) {
273 $stdoutLink = '/tmp/stdout.bmp';
275 $stdoutLink = '/tmp/stdout.ppm';
278 $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
280 // Rotate is currently only supported for jp2 since it does not add server load
281 $allowedRotations = array("0", "90", "180", "270");
282 $rotate = $requestEnv['rotate'];
283 if ( !in_array($rotate, $allowedRotations) ) {
287 // Image conversion options
289 $jpegOptions = '-quality 75';
291 // The pbmreduce reduction factor produces an image with dimension 1/n
292 // The kakadu reduction factor produces an image with dimension 1/(2^n)
294 // We interpret the requested size and scale, look at image format, and determine the
295 // actual scaling to be returned to the client. We generally return the largest
296 // power of 2 reduction that is larger than the requested size in order to reduce
297 // image processing load on our cluster. The client should then scale to their final
301 // If a named size is provided, we size the full image to that size
302 // If x or y is set, we interpret the supplied width/height as the size of image region to crop to
303 // If x and y are not set and both width and height are set, we size the full image "within" the width/height
304 // 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
305 // If none of the above apply, we use the whole image
307 // Crop region, if empty whole image is used
312 if (isset($requestEnv['scale'])) {
313 $scale = $requestEnv['scale'];
315 $powReduce = $this->nearestPow2ForScale($scale);
316 // ensure integer scale
317 $scale = pow(2, $powReduce);
319 if ( isset($requestEnv['size']) ) {
320 // Set scale from named size (e.g. 'large') if set
321 $size = $requestEnv['size'];
322 if ( $size && array_key_exists($size, self::$imageSizes)) {
323 $srcRatio = floatval($imageInfo['width']) / floatval($imageInfo['height']);
326 $dimension = 'width';
328 $dimension = 'height';
330 $powReduce = $this->nearestPow2Reduce(self::$imageSizes[$size], $imageInfo[$dimension]);
331 $scale = pow(2, $powReduce);
334 } else if ( isset($requestEnv['x']) || isset($requestEnv['y']) ) {
335 // x,y is crop region origin, width,height is size of crop region
336 foreach (array('x', 'y', 'width', 'height') as $key) {
337 if (array_key_exists($key, $requestEnv)) {
338 $region[$key] = $requestEnv[$key];
342 } else if ( isset($requestEnv['width']) && isset($requestEnv['height']) ) {
343 // proportional scaling within requested width/height
345 $width = $this->intAmount($requestEnv['width'], $imageInfo['width']);
346 $height = $this->intAmount($requestEnv['height'], $imageInfo['height']);
348 $srcAspect = floatval($imageInfo['width']) / floatval($imageInfo['height']);
349 $fitAspect = floatval($width) / floatval($height);
351 if ($srcAspect > $fitAspect) {
352 // Source image is wide compared to fit
353 $powReduce = $this->nearestPow2Reduce($width, $imageInfo['width']);
355 $powReduce = $this->nearestPow2Reduce($height, $imageInfo['height']);
357 $scale = pow(2, $poweReduce);
359 } else if ( isset($requestEnv['width']) ) {
361 $width = $this->intAmount($requestEnv['width'], $imageInfo['width']);
362 $powReduce = $this->nearestPow2Reduce($width, $imageInfo['width']);
363 $scale = pow(2, $powReduce);
365 } else if ( isset($requestEnv['height'])) {
367 $height = $this->intAmount($requestEnv['height'], $imageInfo['height']);
368 $powReduce = $this->nearestPow2Reduce($height, $imageInfo['height']);
369 $scale = pow(2, $powReduce);
372 $regionDimensions = $this->getRegionDimensions($imageInfo, $region);
379 print('regionDimensions');
380 print_r($regionDimensions);
382 print_r($this->getRegionDimensionsAsFloat($imageInfo, $region));
387 // Override depending on source image format
388 // $$$ consider doing a 302 here instead, to make better use of the browser cache
389 // Limit scaling for 1-bit images. See https://bugs.edge.launchpad.net/bookreader/+bug/486011
390 if (1 == $imageInfo['bits']) {
396 // Hard limit so there are some black pixels to use!
404 if (!file_exists($stdoutLink))
406 system('ln -s /dev/stdout ' . $stdoutLink);
409 putenv('LD_LIBRARY_PATH=/petabox/sw/lib/kakadu');
411 $unzipCmd = $this->getUnarchiveCommand($zipPath, $file);
413 $decompressCmd = $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink);
415 // Non-integer scaling is currently disabled on the cluster
416 // if (isset($_REQUEST['height'])) {
417 // $cmd .= " | pnmscale -height {$_REQUEST['height']} ";
422 $compressCmd = ' | pnmtopng ' . $pngOptions;
428 $compressCmd = ' | pnmtojpeg ' . $jpegOptions;
429 $ext = 'jpeg'; // for matching below
434 if (($ext == $fileExt) && ($scale == 1) && ($rotate === "0")) {
435 // Just pass through original data if same format and size
438 $cmd = $unzipCmd . $decompressCmd . $compressCmd;
443 $filenameForClient = $this->filenameForClient($file, $ext);
447 //if (! $this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
449 $tempFile = $this->getTempFilename($ext);
450 array_push($this->tempFiles, $tempFile);
452 // error_log("bookreader running " . $cmd);
453 $imageCreated = $this->createOutputImage($cmd, $tempFile, $errorMessage);
454 if (! $imageCreated) {
455 // $$$ automated reporting
456 trigger_error('BookReader Processing Error: ' . $cmd . ' -- ' . $errorMessage, E_USER_WARNING);
458 // Try some content-specific recovery
460 if ($imageInfo['type'] == 'jp2') {
461 $records = $this->getJp2Records($zipPath, $file);
462 if (array_key_exists('Clevels', $records)) {
463 $maxReduce = intval($records['Clevels']);
464 trigger_error("BookReader using max reduce $maxReduce from jp2 records");
469 $powReduce = min($powReduce, $maxReduce);
470 $reduce = pow(2, $powReduce);
472 $cmd = $unzipCmd . $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) . $compressCmd;
473 trigger_error('BookReader rerunning with new cmd: ' . $cmd, E_USER_WARNING);
475 $tempFile = $this->getTempFilename($ext);
476 array_push($this->tempFiles, $tempFile);
477 $imageCreated = $this->createOutputImage($cmd, $tempFile, $errorMessage);
482 trigger_error('BookReader fallback image processing also failed: ' . $errorMessage, E_USER_WARNING);
487 $this->BRfatal("Problem processing image - command failed:\n " . $cmd);
494 $headers = array('Content-type: '. self::$MIMES[$ext],
495 'Cache-Control: max-age=15552000',
496 'Content-disposition: inline; filename=' . $filenameForClient,
497 'Content-Length: ' . filesize($tempFile));
499 foreach($headers as $header) {
503 flush(); // attempt to send header to client
510 function getUnarchiveCommand($archivePath, $file)
512 $lowerPath = strtolower($archivePath);
513 if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
514 $suffix = $matches[1];
516 if ($suffix == 'zip') {
518 . escapeshellarg($archivePath)
519 . ' ' . escapeshellarg($file);
520 } else if ($suffix == 'tar') {
521 return ' ( 7z e -so '
522 . escapeshellarg($archivePath)
523 . ' ' . escapeshellarg($file) . ' 2>/dev/null ) ';
525 $this->BRfatal('Incompatible archive format');
529 $this->BRfatal('Bad image stack path');
532 $this->BRfatal('Bad image stack path or archive format');
537 * Returns the image type associated with the file extension.
539 function imageExtensionToType($extension)
542 if (array_key_exists($extension, self::$EXTENSIONS)) {
543 return self::$EXTENSIONS[$extension];
545 $this->BRfatal('Unknown image extension');
550 * Get the image information. The returned associative array fields will
551 * vary depending on the image type. The basic keys are width, height, type
554 function getImageInfo($zipPath, $file)
556 return $this->getImageInfoFromExif($zipPath, $file); // this is fast
559 $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
560 $type = imageExtensionToType($fileExt);
564 return getImageInfoFromJp2($zipPath, $file);
567 return getImageInfoFromExif($zipPath, $file);
572 // Get the records of of JP2 as returned by kdu_expand
573 function getJp2Records($zipPath, $file)
576 $cmd = $this->getUnarchiveCommand($zipPath, $file)
577 . ' | ' . $this->kduExpand
578 . ' -no_seek -quiet -i /dev/stdin -record /dev/stdout';
582 foreach ($output as $line) {
583 $elems = explode("=", $line, 2);
584 if (1 == count($elems)) {
585 // delimiter not found
588 $records[$elems[0]] = $elems[1];
595 * Get the image width, height and depth using the EXIF information.
597 function getImageInfoFromExif($zipPath, $file)
600 // We look for all the possible tags of interest then act on the
601 // ones presumed present based on the file type
602 $tagsToGet = ' -ImageWidth -ImageHeight -FileType' // all formats
603 . ' -BitsPerComponent -ColorSpace' // jp2
604 . ' -BitDepth' // png
605 . ' -BitsPerSample'; // tiff
607 $cmd = $this->getUnarchiveCommand($zipPath, $file)
608 . ' | '. $this->exiftool . ' -S -fast' . $tagsToGet . ' -';
612 foreach ($output as $line) {
613 $keyValue = explode(": ", $line);
614 $tags[$keyValue[0]] = $keyValue[1];
617 $width = intval($tags["ImageWidth"]);
618 $height = intval($tags["ImageHeight"]);
619 $type = strtolower($tags["FileType"]);
622 if (strcmp($type,'jpx') == 0) {
628 $bits = intval($tags["BitsPerComponent"]);
631 $bits = intval($tags["BitsPerSample"]);
637 $bits = intval($tags["BitDepth"]);
640 $this->BRfatal("Unsupported image type $type for file $file in $zipPath");
645 $retval = Array('width' => $width, 'height' => $height,
646 'bits' => $bits, 'type' => $type);
652 * Output JSON given the imageInfo associative array
654 function outputJSON($imageInfo, $callback)
656 header('Content-type: text/plain');
657 $jsonOutput = json_encode($imageInfo);
659 $jsonOutput = $callback . '(' . $jsonOutput . ');';
664 function getDecompressCmd($srcInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) {
666 switch ($srcInfo['type']) {
668 $regionAsFloat = $this->getRegionDimensionsAsFloat($srcInfo, $region);
669 $regionString = sprintf("{%f,%f},{%f,%f}", $regionAsFloat['y'], $regionAsFloat['x'], $regionAsFloat['h'], $regionAsFloat['w']);
671 " | " . $this->kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -region $regionString -i /dev/stdin -o " . $stdoutLink;
672 if ($this->decompressToBmp) {
673 // We suppress output since bmptopnm always outputs on stderr
674 $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
679 // We need to create a temporary file for tifftopnm since it cannot
680 // work on a pipe (the file must be seekable).
681 // We use the BookReaderTiff prefix to give a hint in case things don't
683 $tempFile = tempnam("/tmp", "BookReaderTiff");
684 array_push($this->tempFiles, $tempFile);
686 // $$$ look at bit depth when reducing
688 ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . $this->reduceCommand($scale);
692 $decompressCmd = ' | ( jpegtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
696 $decompressCmd = ' | ( pngtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
700 // Formats handled by ImageMagick
704 $region = $this->getRegionDimensions($srcInfo, $region);
705 $regionString = sprintf('[%dx%d+%d+%d]', $region['w'], $region['h'], $region['x'], $region['y']);
707 // The argument to ImageMagick's scale command is a "geometry". We pass in the new width/height
708 $scaleString = ' -scale ' . sprintf("%dx%d", $region['w'] / $scale, $region['h'] / $scale);
711 if ($rotate && $rotate != '0') {
712 $rotateString = ' -rotate ' . $rotate; // was previously checked to be a known value
715 $decompressCmd = ' | convert -quiet -' . $regionString . $scaleString . $rotateString . ' pnm:-';
719 $this->BRfatal('Unknown image type: ' . $imageType);
723 return $decompressCmd;
727 // If the command has its initial output on stdout the headers will be emitted followed
728 // by the stdout output. If initial output is on stderr an error message will be
732 // true - if command emits stdout and has zero exit code
733 // false - command has initial output on stderr or non-zero exit code
734 // &$errorMessage - error string if there was an error
736 // $$$ Tested with our command-line image processing. May be deadlocks for
737 // other cases, e.g. if there are warnings on stderr
738 function passthruIfSuccessful($headers, $cmd, &$errorMessage)
744 $descriptorspec = array(
745 0 => array("pipe", "r"), // stdin is a pipe that the child will read from
746 1 => array("pipe", "w"), // stdout is a pipe that the child will write to
747 2 => array("pipe", "w"), // stderr is a pipe to write to
753 $process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env);
755 if (is_resource($process)) {
756 // $pipes now looks like this:
757 // 0 => writeable handle connected to child stdin
758 // 1 => readable handle connected to child stdout
759 // 2 => readable handle connected to child stderr
765 // check whether we get input first on stdout or stderr
766 $read = array($stdout, $stderr);
770 $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
771 if (false === $numChanged) {
773 $errorMessage = 'Select failed';
775 error_log('BookReader select failed!');
777 if (in_array($stderr, $read)) {
778 // Either content in stderr, or stderr is closed (could read 0 bytes)
779 $error = stream_get_contents($stderr);
782 $errorMessage = $error;
789 // It is important that you close any pipes before calling
790 // proc_close in order to avoid a deadlock
791 proc_close($process);
797 $output = fopen('php://output', 'w');
798 foreach($headers as $header) {
801 stream_copy_to_stream($pipes[1], $output);
802 fclose($output); // okay since tied to special php://output
811 // It is important that you close any pipes before calling
812 // proc_close in order to avoid a deadlock
813 $cmdRet = proc_close($process);
816 $errorMessage .= "Command failed with result code " . $cmdRet;
822 function createOutputImage($cmd, $tempFile, &$errorMessage) {
823 $fullCmd = $cmd . " > " . $tempFile;
824 system($fullCmd); // $$$ better error handling
825 return file_exists($tempFile) && filesize($tempFile) > 0;
828 function BRfatal($string) {
830 throw new Exception("Image error: $string");
833 // Returns true if using a power node
834 // XXX change to "on red box" - not working for new Xeon
835 function onPowerNode() {
836 exec("lspci | fgrep -c Realtek", $output, $return);
837 if ("0" != $output[0]) {
840 exec("egrep -q AMD /proc/cpuinfo", $output, $return);
848 function reduceCommand($scale) {
850 if ($this->onPowerNode()) {
851 return ' | pnmscale -reduce ' . $scale . ' 2>/dev/null ';
853 return ' | pnmscale -nomix -reduce ' . $scale . ' 2>/dev/null ';
860 function checkPrivs($filename) {
861 // $$$ we assume here that requests for the title, cover or preview
862 // come in via BookReaderPreview.php which will be re-run with
863 // privileges after we return the 403
864 if (!is_readable($filename)) {
865 header('HTTP/1.1 403 Forbidden');
870 // Given file path (inside archive) and output file extension, return a filename
871 // suitable for Content-disposition header
872 function filenameForClient($filePath, $ext) {
873 $pathParts = pathinfo($filePath);
874 if ('jpeg' == $ext) {
877 return $pathParts['filename'] . '.' . $ext;
880 // Returns the nearest power of 2 reduction factor that results in a larger image
881 function nearestPow2Reduce($desiredDimension, $sourceDimension) {
882 $ratio = floatval($sourceDimension) / floatval($desiredDimension);
883 return $this->nearestPow2ForScale($ratio);
886 // Returns nearest power of 2 reduction factor that results in a larger image
887 function nearestPow2ForScale($scale) {
888 $scale = intval($scale);
892 $binStr = decbin($scale); // convert to binary string. e.g. 5 -> '101'
893 $largerPow2 = strlen($binStr) - 1;
899 * Parses a page request like "page5_r2.jpg" or "cover_t.jpg" to corresponding
900 * page type, size, reduce, and format
902 function parsePageRequest($pageRequest, $bookPrefix) {
904 // Will hold parsed results
908 $pageRequest = strtolower($pageRequest);
910 // Pull off extension
911 if (preg_match('#(.*)\.([^.]+)$#', $pageRequest, $matches) === 1) {
912 $pageRequest = $matches[1];
913 $extension = $matches[2];
914 if ($extension == 'jpeg') {
920 $pageInfo['extension'] = $extension;
923 $parts = explode('_', $pageRequest);
925 // Remove book prefix if it was included (historical)
926 if ($parts[0] == $bookPrefix) {
930 if (count($parts) === 0) {
931 $this->BRfatal('No page type specified');
933 $page = array_shift($parts);
939 'preview' => 'single',
944 // Look for known page types
945 foreach ( $pageTypes as $pageName => $kind ) {
946 if ( preg_match('#^(' . $pageName . ')(.*)#', $page, $matches) === 1 ) {
947 $pageInfo['type'] = $matches[1];
950 $pageInfo['value'] = $matches[2];
953 $pageInfo['value'] = intval($matches[2]);
961 if ( !array_key_exists('type', $pageInfo) ) {
962 $this->BRfatal('Unrecognized page type');
965 // Look for other known parts
966 foreach ($parts as $part) {
967 if ( array_key_exists($part, self::$imageSizes) ) {
968 $pageInfo['size'] = $part;
972 // Key must be alpha, value must start with digit and contain digits, alpha, ',' or '.'
973 // Should prevent injection of strange values into the redirect to datanode
974 if ( preg_match('#^([a-z]+)(\d[a-z0-9,.]*)#', $part, $matches) === 0) {
980 $value = $matches[2];
982 if ( array_key_exists($key, self::$imageUrlKeys) ) {
983 $pageInfo[self::$imageUrlKeys[$key]] = $value;
987 // If we hit here, was unrecognized (no action)
993 function getRegionDimensions($sourceDimensions, $regionDimensions) {
994 // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
995 // in terms of full resolution image.
996 // Note: this will clip the returned dimensions to fit within the source image
999 if (array_key_exists('x', $regionDimensions)) {
1000 $sourceX = $this->intAmount($regionDimensions['x'], $sourceDimensions['width']);
1002 $sourceX = $this->clamp(0, $sourceDimensions['width'] - 2, $sourceX); // Allow at least one pixel
1005 if (array_key_exists('y', $regionDimensions)) {
1006 $sourceY = $this->intAmount($regionDimensions['y'], $sourceDimensions['height']);
1008 $sourceY = $this->clamp(0, $sourceDimensions['height'] - 2, $sourceY); // Allow at least one pixel
1010 $sourceWidth = $sourceDimensions['width'] - $sourceX;
1011 if (array_key_exists('width', $regionDimensions)) {
1012 $sourceWidth = $this->intAmount($regionDimensions['width'], $sourceDimensions['width']);
1014 $sourceWidth = $this->clamp(1, max(1, $sourceDimensions['width'] - $sourceX), $sourceWidth);
1016 $sourceHeight = $sourceDimensions['height'] - $sourceY;
1017 if (array_key_exists('height', $regionDimensions)) {
1018 $sourceHeight = $this->intAmount($regionDimensions['height'], $sourceDimensions['height']);
1020 $sourceHeight = $this->clamp(1, max(1, $sourceDimensions['height'] - $sourceY), $sourceHeight);
1022 return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
1025 function getRegionDimensionsAsFloat($sourceDimensions, $regionDimensions) {
1026 // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
1027 // in terms of full resolution image.
1028 // Note: this will clip the returned dimensions to fit within the source image
1031 if (array_key_exists('x', $regionDimensions)) {
1032 $sourceX = $this->floatAmount($regionDimensions['x'], $sourceDimensions['width']);
1034 $sourceX = $this->clamp(0.0, 1.0, $sourceX);
1037 if (array_key_exists('y', $regionDimensions)) {
1038 $sourceY = $this->floatAmount($regionDimensions['y'], $sourceDimensions['height']);
1040 $sourceY = $this->clamp(0.0, 1.0, $sourceY);
1042 $sourceWidth = 1 - $sourceX;
1043 if (array_key_exists('width', $regionDimensions)) {
1044 $sourceWidth = $this->floatAmount($regionDimensions['width'], $sourceDimensions['width']);
1046 $sourceWidth = $this->clamp(0.0, 1.0, $sourceWidth);
1048 $sourceHeight = 1 - $sourceY;
1049 if (array_key_exists('height', $regionDimensions)) {
1050 $sourceHeight = $this->floatAmount($regionDimensions['height'], $sourceDimensions['height']);
1052 $sourceHeight = $this->clamp(0.0, 1.0, $sourceHeight);
1054 return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
1057 function intAmount($stringValue, $maximum) {
1058 // Returns integer amount for string like "5" (5 units) or "0.5" (50%)
1059 if (strpos($stringValue, '.') === false) {
1060 // No decimal, assume int
1061 return intval($stringValue);
1064 return floatval($stringValue) * $maximum + 0.5;
1067 function floatAmount($stringValue, $maximum) {
1068 // Returns float amount (0.0 to 1.0) for string like "0.4" (40%) or "4" (40% if max is 10)
1069 if (strpos($stringValue, ".") === false) {
1070 // No decimal, assume int value out of maximum
1071 return floatval($stringValue) / $maximum;
1074 // Given float - just pass through
1075 return floatval($stringValue);
1078 function clamp($minValue, $maxValue, $observedValue) {
1079 if ($observedValue < $minValue) {
1083 if ($observedValue > $maxValue) {
1087 return $observedValue;
1090 // Get the directory for temporary files. Use the fast in-RAM tmp if available.
1091 function getTempDir() {
1092 $fastbr = '/var/tmp/fast/bookreader';
1094 if (is_writeable($fastbr)) {
1095 // Our directory in fast tmp already exists
1098 // Check for fast tmp and make our directory
1099 $fast = '/var/tmp/fast';
1100 if (is_writeable($fast)) {
1101 if (mkdir($fastbr)) {
1109 // All else failed -- system tmp that should get cleaned on reboot
1113 function getTempFilename($ext) {
1114 return tempnam($this->getTempDir(), "BookReaderImages");
1117 // Clean up temporary files and resources
1118 function cleanup() {
1119 foreach($this->tempFiles as $tempFile) {
1122 $this->tempFiles = array();
1126 function cleanTmp() {
1127 system('find /var/tmp/fast -name "BookReaderImages*" -cmin +10 -exec rm {} \;');
1128 system('find /var/tmp/fast/bookreader -name "BookReaderImages*" -cmin +10 -exec rm {} \;');