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 ($_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 // Process some of the request parameters
228 $zipPath = $requestEnv['zip'];
229 $file = $requestEnv['file'];
231 $ext = $requestEnv['ext'];
236 if (isset($requestEnv['callback'])) {
237 // validate callback is valid JS identifier (only)
238 $callback = $requestEnv['callback'];
239 $identifierPatt = '/^[[:alpha:]$_]([[:alnum:]$_])*$/';
240 if (! preg_match($identifierPatt, $callback)) {
241 $this->BRfatal('Invalid callback');
247 if ( !file_exists($zipPath) ) {
248 $this->BRfatal('Image stack does not exist at ' . $zipPath);
250 // Make sure the image stack is readable - return 403 if not
251 $this->checkPrivs($zipPath);
254 // Get the image size and depth
255 $imageInfo = $this->getImageInfo($zipPath, $file);
257 // Output json if requested
258 if ('json' == $ext) {
259 // $$$ we should determine the output size first based on requested scale
260 $this->outputJSON($imageInfo, $callback); // $$$ move to BookReaderRequest
264 // Unfortunately kakadu requires us to know a priori if the
265 // output file should be .ppm or .pgm. By decompressing to
266 // .bmp kakadu will write a file we can consistently turn into
267 // .pnm. Really kakadu should support .pnm as the file output
268 // extension and automatically write ppm or pgm format as
270 $this->decompressToBmp = true; // $$$ shouldn't be necessary if we use file info to determine output format
271 if ($this->decompressToBmp) {
272 $stdoutLink = '/tmp/stdout.bmp';
274 $stdoutLink = '/tmp/stdout.ppm';
277 $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
279 // Rotate is currently only supported for jp2 since it does not add server load
280 $allowedRotations = array("0", "90", "180", "270");
281 $rotate = $requestEnv['rotate'];
282 if ( !in_array($rotate, $allowedRotations) ) {
286 // Image conversion options
288 $jpegOptions = '-quality 75';
290 // The pbmreduce reduction factor produces an image with dimension 1/n
291 // The kakadu reduction factor produces an image with dimension 1/(2^n)
293 // We interpret the requested size and scale, look at image format, and determine the
294 // actual scaling to be returned to the client. We generally return the largest
295 // power of 2 reduction that is larger than the requested size in order to reduce
296 // image processing load on our cluster. The client should then scale to their final
299 // Set scale from height or width if set and no x or y specified
300 if ( isset($requestEnv['height']) && !isset($requestEnv['x']) && !isset($requestEnv['y']) ) {
301 // No x or y specified, use height for scaling
302 $powReduce = $this->nearestPow2Reduce($requestEnv['height'], $imageInfo['height']);
303 $scale = pow(2, $powReduce);
304 } else if ( isset($requestEnv['width']) && !isset($requestEnv['x']) && !isset($requestEnv['y']) ) {
305 // No x or y specified, use width for scaling
306 $powReduce = $this->nearestPow2Reduce($requestEnv['width'], $imageInfo['width']);
307 $scale = pow(2, $powReduce);
310 // Set scale from named size (e.g. 'large') if set
311 $size = $requestEnv['size'];
312 if ( $size && array_key_exists($size, self::$imageSizes)) {
313 $srcRatio = floatval($imageInfo['width']) / floatval($imageInfo['height']);
316 $dimension = 'width';
318 $dimension = 'height';
320 $powReduce = $this->nearestPow2Reduce(self::$imageSizes[$size], $imageInfo[$dimension]);
321 $scale = pow(2, $powReduce);
324 // No named size - use explicit scale, if given
325 $scale = $requestEnv['scale'];
329 $powReduce = $this->nearestPow2ForScale($scale);
330 // ensure integer scale
331 $scale = pow(2, $powReduce);
335 // Only extract a specific region if x or y were set
337 if (isset($reqeuestEnv['x']) || isset($requestEnv['y'])) {
338 foreach (array('x', 'y', 'width', 'height') as $key) {
339 if (array_key_exists($key, $requestEnv)) {
340 $region[$key] = $requestEnv[$key];
344 $regionDimensions = $this->getRegionDimensions($imageInfo, $region);
351 print('regionDimensions');
352 print_r($regionDimensions);
354 print_r($this->getRegionDimensionsAsFloat($imageInfo, $region));
359 // Override depending on source image format
360 // $$$ consider doing a 302 here instead, to make better use of the browser cache
361 // Limit scaling for 1-bit images. See https://bugs.edge.launchpad.net/bookreader/+bug/486011
362 if (1 == $imageInfo['bits']) {
368 // Hard limit so there are some black pixels to use!
376 if (!file_exists($stdoutLink))
378 system('ln -s /dev/stdout ' . $stdoutLink);
381 putenv('LD_LIBRARY_PATH=/petabox/sw/lib/kakadu');
383 $unzipCmd = $this->getUnarchiveCommand($zipPath, $file);
385 $decompressCmd = $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink);
387 // Non-integer scaling is currently disabled on the cluster
388 // if (isset($_REQUEST['height'])) {
389 // $cmd .= " | pnmscale -height {$_REQUEST['height']} ";
394 $compressCmd = ' | pnmtopng ' . $pngOptions;
400 $compressCmd = ' | pnmtojpeg ' . $jpegOptions;
401 $ext = 'jpeg'; // for matching below
406 if (($ext == $fileExt) && ($scale == 1) && ($rotate === "0")) {
407 // Just pass through original data if same format and size
410 $cmd = $unzipCmd . $decompressCmd . $compressCmd;
415 $filenameForClient = $this->filenameForClient($file, $ext);
417 $headers = array('Content-type: '. self::$MIMES[$ext],
418 'Cache-Control: max-age=15552000',
419 'Content-disposition: inline; filename=' . $filenameForClient);
424 if (! $this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
425 // $$$ automated reporting
426 trigger_error('BookReader Processing Error: ' . $cmd . ' -- ' . $errorMessage, E_USER_WARNING);
428 // Try some content-specific recovery
430 if ($imageInfo['type'] == 'jp2') {
431 $records = $this->getJp2Records($zipPath, $file);
432 if (array_key_exists('Clevels', $records)) {
433 $maxReduce = intval($records['Clevels']);
434 trigger_error("BookReader using max reduce $maxReduce from jp2 records");
439 $powReduce = min($powReduce, $maxReduce);
440 $reduce = pow(2, $powReduce);
442 $cmd = $unzipCmd . $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) . $compressCmd;
443 trigger_error('BookReader rerunning with new cmd: ' . $cmd, E_USER_WARNING);
444 if ($this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
448 trigger_error('BookReader fallback image processing also failed: ' . $errorMessage, E_USER_WARNING);
453 $this->BRfatal('Problem processing image - command failed');
460 function getUnarchiveCommand($archivePath, $file)
462 $lowerPath = strtolower($archivePath);
463 if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
464 $suffix = $matches[1];
466 if ($suffix == 'zip') {
468 . escapeshellarg($archivePath)
469 . ' ' . escapeshellarg($file);
470 } else if ($suffix == 'tar') {
471 return ' ( 7z e -so '
472 . escapeshellarg($archivePath)
473 . ' ' . escapeshellarg($file) . ' 2>/dev/null ) ';
475 $this->BRfatal('Incompatible archive format');
479 $this->BRfatal('Bad image stack path');
482 $this->BRfatal('Bad image stack path or archive format');
487 * Returns the image type associated with the file extension.
489 function imageExtensionToType($extension)
492 if (array_key_exists($extension, self::$EXTENSIONS)) {
493 return self::$EXTENSIONS[$extension];
495 $this->BRfatal('Unknown image extension');
500 * Get the image information. The returned associative array fields will
501 * vary depending on the image type. The basic keys are width, height, type
504 function getImageInfo($zipPath, $file)
506 return $this->getImageInfoFromExif($zipPath, $file); // this is fast
509 $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
510 $type = imageExtensionToType($fileExt);
514 return getImageInfoFromJp2($zipPath, $file);
517 return getImageInfoFromExif($zipPath, $file);
522 // Get the records of of JP2 as returned by kdu_expand
523 function getJp2Records($zipPath, $file)
526 $cmd = $this->getUnarchiveCommand($zipPath, $file)
527 . ' | ' . $this->kduExpand
528 . ' -no_seek -quiet -i /dev/stdin -record /dev/stdout';
532 foreach ($output as $line) {
533 $elems = explode("=", $line, 2);
534 if (1 == count($elems)) {
535 // delimiter not found
538 $records[$elems[0]] = $elems[1];
545 * Get the image width, height and depth using the EXIF information.
547 function getImageInfoFromExif($zipPath, $file)
550 // We look for all the possible tags of interest then act on the
551 // ones presumed present based on the file type
552 $tagsToGet = ' -ImageWidth -ImageHeight -FileType' // all formats
553 . ' -BitsPerComponent -ColorSpace' // jp2
554 . ' -BitDepth' // png
555 . ' -BitsPerSample'; // tiff
557 $cmd = $this->getUnarchiveCommand($zipPath, $file)
558 . ' | '. $this->exiftool . ' -S -fast' . $tagsToGet . ' -';
562 foreach ($output as $line) {
563 $keyValue = explode(": ", $line);
564 $tags[$keyValue[0]] = $keyValue[1];
567 $width = intval($tags["ImageWidth"]);
568 $height = intval($tags["ImageHeight"]);
569 $type = strtolower($tags["FileType"]);
573 $bits = intval($tags["BitsPerComponent"]);
576 $bits = intval($tags["BitsPerSample"]);
582 $bits = intval($tags["BitDepth"]);
585 $this->BRfatal("Unsupported image type $type for file $file in $zipPath");
590 $retval = Array('width' => $width, 'height' => $height,
591 'bits' => $bits, 'type' => $type);
597 * Output JSON given the imageInfo associative array
599 function outputJSON($imageInfo, $callback)
601 header('Content-type: text/plain');
602 $jsonOutput = json_encode($imageInfo);
604 $jsonOutput = $callback . '(' . $jsonOutput . ');';
609 function getDecompressCmd($srcInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) {
611 switch ($srcInfo['type']) {
613 $regionAsFloat = $this->getRegionDimensionsAsFloat($srcInfo, $region);
614 $regionString = sprintf("{%f,%f},{%f,%f}", $regionAsFloat['y'], $regionAsFloat['x'], $regionAsFloat['h'], $regionAsFloat['w']);
616 " | " . $this->kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -region $regionString -i /dev/stdin -o " . $stdoutLink;
617 if ($this->decompressToBmp) {
618 // We suppress output since bmptopnm always outputs on stderr
619 $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
624 // We need to create a temporary file for tifftopnm since it cannot
625 // work on a pipe (the file must be seekable).
626 // We use the BookReaderTiff prefix to give a hint in case things don't
628 $tempFile = tempnam("/tmp", "BookReaderTiff");
629 array_push($this->tempFiles, $tempFile);
631 // $$$ look at bit depth when reducing
633 ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . $this->reduceCommand($scale);
637 $decompressCmd = ' | ( jpegtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
641 $decompressCmd = ' | ( pngtopnm 2>/dev/null ) ' . $this->reduceCommand($scale);
645 // Formats handled by ImageMagick
649 $region = $this->getRegionDimensions($srcInfo, $region);
650 $regionString = sprintf('[%dx%d+%d+%d]', $region['w'], $region['h'], $region['x'], $region['y']);
652 // The argument to ImageMagick's scale command is a "geometry". We pass in the new width/height
653 $scaleString = sprintf("%dx%d", $region['w'] / $scale, $region['h'] / $scale);
655 $decompressCmd = ' | convert -' . $regionString . ' -scale ' . $scaleString . ' pnm:-';
659 $this->BRfatal('Unknown image type: ' . $imageType);
662 return $decompressCmd;
666 // If the command has its initial output on stdout the headers will be emitted followed
667 // by the stdout output. If initial output is on stderr an error message will be
671 // true - if command emits stdout and has zero exit code
672 // false - command has initial output on stderr or non-zero exit code
673 // &$errorMessage - error string if there was an error
675 // $$$ Tested with our command-line image processing. May be deadlocks for
677 function passthruIfSuccessful($headers, $cmd, &$errorMessage)
682 $descriptorspec = array(
683 0 => array("pipe", "r"), // stdin is a pipe that the child will read from
684 1 => array("pipe", "w"), // stdout is a pipe that the child will write to
685 2 => array("pipe", "w"), // stderr is a pipe to write to
691 $process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env);
693 if (is_resource($process)) {
694 // $pipes now looks like this:
695 // 0 => writeable handle connected to child stdin
696 // 1 => readable handle connected to child stdout
697 // 2 => readable handle connected to child stderr
703 // check whether we get input first on stdout or stderr
704 $read = array($stdout, $stderr);
708 $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
709 if (false === $numChanged) {
711 $errorMessage = 'Select failed';
713 error_log('BookReader select failed!');
715 if (in_array($stderr, $read)) {
716 // Either content in stderr, or stderr is closed (could read 0 bytes)
717 $error = stream_get_contents($stderr);
720 $errorMessage = $error;
727 // It is important that you close any pipes before calling
728 // proc_close in order to avoid a deadlock
729 proc_close($process);
735 $output = fopen('php://output', 'w');
736 foreach($headers as $header) {
739 stream_copy_to_stream($pipes[1], $output);
740 fclose($output); // okay since tied to special php://output
749 // It is important that you close any pipes before calling
750 // proc_close in order to avoid a deadlock
751 $cmdRet = proc_close($process);
754 $errorMessage .= "Command failed with result code " . $cmdRet;
760 function BRfatal($string) {
762 throw new Exception("Image error: $string");
765 // Returns true if using a power node
766 // XXX change to "on red box" - not working for new Xeon
767 function onPowerNode() {
768 exec("lspci | fgrep -c Realtek", $output, $return);
769 if ("0" != $output[0]) {
772 exec("egrep -q AMD /proc/cpuinfo", $output, $return);
780 function reduceCommand($scale) {
782 if ($this->onPowerNode()) {
783 return ' | pnmscale -reduce ' . $scale . ' 2>/dev/null ';
785 return ' | pnmscale -nomix -reduce ' . $scale . ' 2>/dev/null ';
792 function checkPrivs($filename) {
793 // $$$ we assume here that requests for the title, cover or preview
794 // come in via BookReaderPreview.php which will be re-run with
795 // privileges after we return the 403
796 if (!is_readable($filename)) {
797 header('HTTP/1.1 403 Forbidden');
802 // Given file path (inside archive) and output file extension, return a filename
803 // suitable for Content-disposition header
804 function filenameForClient($filePath, $ext) {
805 $pathParts = pathinfo($filePath);
806 if ('jpeg' == $ext) {
809 return $pathParts['filename'] . '.' . $ext;
812 // Returns the nearest power of 2 reduction factor that results in a larger image
813 function nearestPow2Reduce($desiredDimension, $sourceDimension) {
814 $ratio = floatval($sourceDimension) / floatval($desiredDimension);
815 return $this->nearestPow2ForScale($ratio);
818 // Returns nearest power of 2 reduction factor that results in a larger image
819 function nearestPow2ForScale($scale) {
820 $scale = intval($scale);
824 $binStr = decbin($scale); // convert to binary string. e.g. 5 -> '101'
825 return strlen($binStr) - 1;
829 * Parses a page request like "page5_r2.jpg" or "cover_t.jpg" to corresponding
830 * page type, size, reduce, and format
832 function parsePageRequest($pageRequest, $bookPrefix) {
834 // Will hold parsed results
838 $pageRequest = strtolower($pageRequest);
840 // Pull off extension
841 if (preg_match('#(.*)\.([^.]+)$#', $pageRequest, $matches) === 1) {
842 $pageRequest = $matches[1];
843 $extension = $matches[2];
844 if ($extension == 'jpeg') {
850 $pageInfo['extension'] = $extension;
853 $parts = explode('_', $pageRequest);
855 // Remove book prefix if it was included (historical)
856 if ($parts[0] == $bookPrefix) {
860 if (count($parts) === 0) {
861 $this->BRfatal('No page type specified');
863 $page = array_shift($parts);
869 'preview' => 'single',
874 // Look for known page types
875 foreach ( $pageTypes as $pageName => $kind ) {
876 if ( preg_match('#^(' . $pageName . ')(.*)#', $page, $matches) === 1 ) {
877 $pageInfo['type'] = $matches[1];
880 $pageInfo['value'] = $matches[2];
883 $pageInfo['value'] = intval($matches[2]);
891 if ( !array_key_exists('type', $pageInfo) ) {
892 $this->BRfatal('Unrecognized page type');
895 // Look for other known parts
896 foreach ($parts as $part) {
897 if ( array_key_exists($part, self::$imageSizes) ) {
898 $pageInfo['size'] = $part;
902 // Key must be alpha, value must start with digit and contain digits, alpha, ',' or '.'
903 // Should prevent injection of strange values into the redirect to datanode
904 if ( preg_match('#^([a-z]+)(\d[a-z0-9,.]*)#', $part, $matches) === 0) {
910 $value = $matches[2];
912 if ( array_key_exists($key, self::$imageUrlKeys) ) {
913 $pageInfo[self::$imageUrlKeys[$key]] = $value;
917 // If we hit here, was unrecognized (no action)
923 function getRegionDimensions($sourceDimensions, $regionDimensions) {
924 // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
925 // in terms of full resolution image.
926 // Note: this will clip the returned dimensions to fit within the source image
929 if (array_key_exists('x', $regionDimensions)) {
930 $sourceX = $this->intAmount($regionDimensions['x'], $sourceDimensions['width']);
932 $sourceX = $this->clamp(0, $sourceDimensions['width'] - 2, $sourceX); // Allow at least one pixel
935 if (array_key_exists('y', $regionDimensions)) {
936 $sourceY = $this->intAmount($regionDimensions['y'], $sourceDimensions['height']);
938 $sourceY = $this->clamp(0, $sourceDimensions['height'] - 2, $sourceY); // Allow at least one pixel
940 $sourceWidth = $sourceDimensions['width'] - $sourceX;
941 if (array_key_exists('width', $regionDimensions)) {
942 $sourceWidth = $this->intAmount($regionDimensions['width'], $sourceDimensions['width']);
944 $sourceWidth = $this->clamp(1, max(1, $sourceDimensions['width'] - $sourceX), $sourceWidth);
946 $sourceHeight = $sourceDimensions['height'] - $sourceY;
947 if (array_key_exists('height', $regionDimensions)) {
948 $sourceHeight = $this->intAmount($regionDimensions['height'], $sourceDimensions['height']);
950 $sourceHeight = $this->clamp(1, max(1, $sourceDimensions['height'] - $sourceY), $sourceHeight);
952 return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
955 function getRegionDimensionsAsFloat($sourceDimensions, $regionDimensions) {
956 // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
957 // in terms of full resolution image.
958 // Note: this will clip the returned dimensions to fit within the source image
961 if (array_key_exists('x', $regionDimensions)) {
962 $sourceX = $this->floatAmount($regionDimensions['x'], $sourceDimensions['width']);
964 $sourceX = $this->clamp(0.0, 1.0, $sourceX);
967 if (array_key_exists('y', $regionDimensions)) {
968 $sourceY = $this->floatAmount($regionDimensions['y'], $sourceDimensions['height']);
970 $sourceY = $this->clamp(0.0, 1.0, $sourceY);
972 $sourceWidth = 1 - $sourceX;
973 if (array_key_exists('width', $regionDimensions)) {
974 $sourceWidth = $this->floatAmount($regionDimensions['width'], $sourceDimensions['width']);
976 $sourceWidth = $this->clamp(0.0, 1.0, $sourceWidth);
978 $sourceHeight = 1 - $sourceY;
979 if (array_key_exists('height', $regionDimensions)) {
980 $sourceHeight = $this->floatAmount($regionDimensions['height'], $sourceDimensions['height']);
982 $sourceHeight = $this->clamp(0.0, 1.0, $sourceHeight);
984 return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
987 function intAmount($stringValue, $maximum) {
988 // Returns integer amount for string like "5" (5 units) or "0.5" (50%)
989 if (strpos($stringValue, '.') === false) {
990 // No decimal, assume int
991 return intval($stringValue);
994 return floatval($stringValue) * $maximum + 0.5;
997 function floatAmount($stringValue, $maximum) {
998 // Returns float amount (0.0 to 1.0) for string like "0.4" (40%) or "4" (40% if max is 10)
999 if (strpos($stringValue, ".") === false) {
1000 // No decimal, assume int value out of maximum
1001 return floatval($stringValue) / $maximum;
1004 // Given float - just pass through
1005 return floatval($stringValue);
1008 function clamp($minValue, $maxValue, $observedValue) {
1009 if ($observedValue < $minValue) {
1013 if ($observedValue > $maxValue) {
1017 return $observedValue;
1020 // Clean up temporary files and resources
1021 function cleanup() {
1022 foreach($this->tempFiles as $tempFile) {
1025 $this->tempFiles = array();