Progress on image region support
[bookreader.git] / BookReaderIA / datanode / BookReaderImages.inc.php
index f6236ea..dab21a5 100644 (file)
@@ -24,9 +24,11 @@ the MIME type is "image/jpeg".
     along with BookReader.  If not, see <http://www.gnu.org/licenses/>.
 */
 
+require_once("BookReaderMeta.inc.php");
+
 class BookReaderImages
 {
-    public $MIMES = array('gif' => 'image/gif',
+    public static $MIMES = array('gif' => 'image/gif',
                    'jp2' => 'image/jp2',
                    'jpg' => 'image/jpeg',
                    'jpeg' => 'image/jpeg',
@@ -34,7 +36,7 @@ class BookReaderImages
                    'tif' => 'image/tiff',
                    'tiff' => 'image/tiff');
                    
-    public $EXTENSIONS = array('gif' => 'gif',
+    public static $EXTENSIONS = array('gif' => 'gif',
                         'jp2' => 'jp2',
                         'jpeg' => 'jpeg',
                         'jpg' => 'jpeg',
@@ -43,18 +45,174 @@ class BookReaderImages
                         'tiff' => 'tiff');
     
     // Width when generating thumbnails
-    public $imageSizes = array(
+    public static $imageSizes = array(
         'thumb' => 100,
-        'small' => 240,
-        'medium' => 500,
-        'large' => 1024,
+        'small' => 256,
+        'medium' => 512,
+        'large' => 2048,
+    );
+
+    // Keys in the image permalink urls, e.g. http://www.archive.org/download/itemid/page/cover_{keyval}_{keyval}.jpg
+    public static $imageUrlKeys = array(
+        //'r' => 'reduce', // pow of 2 reduction
+        's' => 'scale', // $$$ scale is downscaling factor in BookReaderImages but most people call this "reduce"
+        'region' => 'region',
+        'tile' => 'tile',
+        'w' => 'width',
+        'h' => 'height',
+        'x' => 'x',
+        'y' => 'y',
+        'rotate' => 'rotate'
     );
     
     // Paths to command-line tools
     var $exiftool = '/petabox/sw/books/exiftool/exiftool';
     var $kduExpand = '/petabox/sw/bin/kdu_expand';
     
+    // Name of temporary files, to be cleaned at exit
+    var $tempFiles = array();
+    
+    /*
+     * Serve an image request that requires looking up the book metadata
+     *
+     * Code path:
+     *   - Get book metadata
+     *   - Parse the requested page (e.g. cover_t.jpg, n5_r4.jpg) to determine which page type,
+     *       size and format (etc) is being requested
+     *   - Determine the leaf number corresponding to the page
+     *   - Determine scaling values
+     *   - Serve image request now that all information has been gathered
+     */
+
+    function serveLookupRequest($requestEnv) {
+        $brm = new BookReaderMeta();
+        try {
+            $metadata = $brm->buildMetadata($_REQUEST['id'], $_REQUEST['itemPath'], $_REQUEST['subPrefix'], $_REQUEST['server']);
+        } catch (Exception $e) {
+            $this->BRfatal($e->getMessage());
+        }
+        
+        $page = $_REQUEST['page'];
+
+        // Index of image to return
+        $imageIndex = null;
+
+        // deal with subPrefix
+        if ($_REQUEST['subPrefix']) {
+            $parts = explode('/', $_REQUEST['subPrefix']);
+            $bookId = $parts[count($parts) - 1 ];
+        } else {
+            $bookId = $_REQUEST['id'];
+        }
+        
+        $pageInfo = $this->parsePageRequest($page, $bookId);
+
+        $basePage = $pageInfo['type'];
+        
+        $leaf = null;
+        $region = null;
+        switch ($basePage) {
+        
+            case 'title':
+                if (! array_key_exists('titleIndex', $metadata)) {
+                    $this->BRfatal("No title page asserted in book");
+                }
+                $imageIndex = $metadata['titleIndex'];
+                break;
+            
+            /* Old 'cover' behaviour where it would show cover 0 if it exists or return 404.
+               Could be re-added as cover0, cover1, etc
+            case 'cover':
+                if (! array_key_exists('coverIndices', $metadata)) {
+                    $this->BRfatal("No cover asserted in book");
+                }
+                $imageIndex = $metadata['coverIndices'][0]; // $$$ TODO add support for other covers
+                break;
+            */
+            
+            case 'preview':
+            case 'cover': // Show our best guess if cover is requested
+                // Preference is:
+                //   Cover page if book was published >= 1950
+                //   Title page
+                //   Cover page
+                //   Page 0
+                         
+                if ( array_key_exists('date', $metadata) && array_key_exists('coverIndices', $metadata) ) {
+                    if ($brm->parseYear($metadata['date']) >= 1950) {
+                        $imageIndex = $metadata['coverIndices'][0];                
+                        break;
+                    }
+                }
+                if (array_key_exists('titleIndex', $metadata)) {
+                    $imageIndex = $metadata['titleIndex'];
+                    break;
+                }
+                if (array_key_exists('coverIndices', $metadata)) {
+                    $imageIndex = $metadata['coverIndices'][0];
+                    break;
+                }
+                
+                // First page
+                $imageIndex = 0;
+                break;
+                
+            case 'n':
+                // Accessible index page
+                $imageIndex = intval($pageInfo['value']);
+                break;
+                
+            case 'page':
+                // Named page
+                $index = array_search($pageInfo['value'], $metadata['pageNums']);
+                if ($index === FALSE) {
+                    // Not found
+                    $this->BRfatal("Page not found");
+                    break;
+                }
+                
+                $imageIndex = $index;
+                break;
+                
+            case 'leaf':
+                // Leaf explicitly specified
+                $leaf = $pageInfo['value'];
+                break;
+                                
+            default:
+                // Shouldn't be possible
+                $this->BRfatal("Unrecognized page type requested");
+                break;
+                
+        }
+        
+        if (is_null($leaf)) {
+            // Leaf was not explicitly set -- look it up
+            $leaf = $brm->leafForIndex($imageIndex, $metadata['leafNums']);
+        }
+        
+        $requestEnv = array(
+            'zip' => $metadata['zip'],
+            'file' => $brm->imageFilePath($leaf, $metadata['subPrefix'], $metadata['imageFormat']),
+            'ext' => 'jpg', // XXX should pass through ext
+        );
+        
+        // remove non-passthrough keys from pageInfo
+        unset($pageInfo['type']);
+        unset($pageInfo['value']);
+        
+        // add pageinfo to request
+        $requestEnv = array_merge($pageInfo, $requestEnv);
+
+        // Return image data - will check privs        
+        $this->serveRequest($requestEnv);
+    
+    }
+    
     /*
+     * Returns a page image when all parameters such as the image stack location are
+     * passed in.
+     * 
      * Approach:
      * 
      * Get info about requested image (input)
@@ -64,8 +222,8 @@ class BookReaderImages
      * Return image data
      * Clean up temporary files
      */
-     
      function serveRequest($requestEnv) {
+     
         // Process some of the request parameters
         $zipPath  = $requestEnv['zip'];
         $file     = $requestEnv['file'];
@@ -87,7 +245,7 @@ class BookReaderImages
         }
 
         if ( !file_exists($zipPath) ) {
-            $this->BRfatal('Image stack does not exist');
+            $this->BRfatal('Image stack does not exist at ' . $zipPath);
         }
         // Make sure the image stack is readable - return 403 if not
         $this->checkPrivs($zipPath);
@@ -96,6 +254,26 @@ class BookReaderImages
         // Get the image size and depth
         $imageInfo = $this->getImageInfo($zipPath, $file);
         
+        $region = array();
+        foreach (array('x', 'y', 'width', 'height') as $key) {
+            if (array_key_exists($key, $requestEnv)) {
+                $region[$key] = $requestEnv[$key];
+            }
+        }
+        $regionDimensions = $this->getRegionDimensions($imageInfo, $region);    
+        
+        /*
+        print('imageInfo');
+        print_r($imageInfo);
+        print('region');
+        print_r($region);
+        print('regionDimensions');
+        print_r($regionDimensions);
+        print('asFloat');
+        print_r($this->getRegionDimensionsAsFloat($imageInfo, $region));
+        die(-1);
+        */
+        
         // Output json if requested
         if ('json' == $ext) {
             // $$$ we should determine the output size first based on requested scale
@@ -130,22 +308,26 @@ class BookReaderImages
         $jpegOptions = '-quality 75';
         
         // The pbmreduce reduction factor produces an image with dimension 1/n
-        // The kakadu reduction factor produceds an image with dimension 1/(2^n)
-        if (isset($requestEnv['height'])) {
+        // The kakadu reduction factor produces an image with dimension 1/(2^n)
+        
+        // We interpret the requested size and scale, look at image format, and determine the
+        // actual scaling to be returned to the client.  We generally return the largest
+        // power of 2 reduction that is larger than the requested size in order to reduce
+        // image processing load on our cluster.  The client should then scale to their final
+        // needed size.
+        
+        // Set scale from height or width if set and no x or y specified
+        if ( isset($requestEnv['height']) && !isset($requestEnv['x']) && !isset($requestEnv['y']) ) {
             $powReduce = $this->nearestPow2Reduce($requestEnv['height'], $imageInfo['height']);
             $scale = pow(2, $powReduce);
-        } else if (isset($requestEnv['width'])) {
+        } else if ( isset($requestEnv['width']) && !isset($requestEnv['x']) && !isset($requestEnv['y']) ) {
             $powReduce = $this->nearestPow2Reduce($requestEnv['width'], $imageInfo['width']);
             $scale = pow(2, $powReduce);
 
         } else {
-            // $$$ could be cleaner
-            // Provide next smaller power of two reduction
-            $scale = $requestEnv['scale'];
-            if (!$scale) {
-                $scale = 1;
-            }
-            if (array_key_exists($scale, $this->imageSizes)) {
+            // Set scale from named size (e.g. 'large') if set
+            $size = $requestEnv['size'];
+            if ( $size && array_key_exists($size, self::$imageSizes)) {
                 $srcRatio = floatval($imageInfo['width']) / floatval($imageInfo['height']);
                 if ($srcRatio > 1) {
                     // wide
@@ -153,17 +335,26 @@ class BookReaderImages
                 } else {
                     $dimension = 'height';
                 }
-                $powReduce = $this->nearestPow2Reduce($this->imageSizes[$scale], $imageInfo[$dimension]);
+                $powReduce = $this->nearestPow2Reduce(self::$imageSizes[$size], $imageInfo[$dimension]);
+                $scale = pow(2, $powReduce);
+                
             } else {
+                // No named size - use explicit scale, if given
+                $scale = $requestEnv['scale'];
+                if (!$scale) {
+                    $scale = 1;
+                }
                 $powReduce = $this->nearestPow2ForScale($scale);
-            }
-            $scale = pow(2, $powReduce);
+                // ensure integer scale
+                $scale = pow(2, $powReduce);
+            }            
         }
         
         // Override depending on source image format
         // $$$ consider doing a 302 here instead, to make better use of the browser cache
         // Limit scaling for 1-bit images.  See https://bugs.edge.launchpad.net/bookreader/+bug/486011
         if (1 == $imageInfo['bits']) {
+            
             if ($scale > 1) {
                 $scale /= 2;
                 $powReduce -= 1;
@@ -185,7 +376,7 @@ class BookReaderImages
         
         $unzipCmd  = $this->getUnarchiveCommand($zipPath, $file);
         
-        $decompressCmd = $this->getDecompressCmd($imageInfo['type'], $powReduce, $rotate, $scale, $stdoutLink);
+        $decompressCmd = $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink);
                
         // Non-integer scaling is currently disabled on the cluster
         // if (isset($_REQUEST['height'])) {
@@ -217,32 +408,37 @@ class BookReaderImages
         
         $filenameForClient = $this->filenameForClient($file, $ext);
         
-        $headers = array('Content-type: '. $MIMES[$ext], // XXX is nginx swallowing this?
+        $headers = array('Content-type: '. self::$MIMES[$ext],
                          'Cache-Control: max-age=15552000',
                          'Content-disposition: inline; filename=' . $filenameForClient);
                           
         
         $errorMessage = '';
+        
         if (! $this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
             // $$$ automated reporting
             trigger_error('BookReader Processing Error: ' . $cmd . ' -- ' . $errorMessage, E_USER_WARNING);
             
             // Try some content-specific recovery
-            $recovered = false;    
+            $recovered = false;
             if ($imageInfo['type'] == 'jp2') {
                 $records = $this->getJp2Records($zipPath, $file);
-                if ($powReduce > intval($records['Clevels'])) {
-                    $powReduce = $records['Clevels'];
-                    $reduce = pow(2, $powReduce);
+                if (array_key_exists('Clevels', $records)) {
+                    $maxReduce = intval($records['Clevels']);
+                    trigger_error("BookReader using max reduce $maxReduce from jp2 records");
                 } else {
-                    $reduce = 1;
-                    $powReduce = 0;
+                    $maxReduce = 0;
                 }
-                 
-                $cmd = $unzipCmd . $this->getDecompressCmd($imageInfo['type'], $powReduce, $rotate, $scale, $stdoutLink) . $compressCmd;
+                
+                $powReduce = min($powReduce, $maxReduce);
+                $reduce = pow(2, $powReduce);
+                
+                $cmd = $unzipCmd . $this->getDecompressCmd($imageInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) . $compressCmd;
+                trigger_error('BookReader rerunning with new cmd: ' . $cmd, E_USER_WARNING);
                 if ($this->passthruIfSuccessful($headers, $cmd, $errorMessage)) { // $$$ move to BookReaderRequest
                     $recovered = true;
                 } else {
+                    $this->cleanup();
                     trigger_error('BookReader fallback image processing also failed: ' . $errorMessage, E_USER_WARNING);
                 }
             }
@@ -252,9 +448,7 @@ class BookReaderImages
             }
         }
         
-        if (isset($tempFile)) {
-            unlink($tempFile);
-        }
+        $this->cleanup();
     }    
     
     function getUnarchiveCommand($archivePath, $file)
@@ -289,8 +483,8 @@ class BookReaderImages
     function imageExtensionToType($extension)
     {
         
-        if (array_key_exists($extension, $this->EXTENSIONS)) {
-            return $this->EXTENSIONS[$extension];
+        if (array_key_exists($extension, self::$EXTENSIONS)) {
+            return self::$EXTENSIONS[$extension];
         } else {
             $this->BRfatal('Unknown image extension');
         }            
@@ -382,7 +576,7 @@ class BookReaderImages
                 $bits = intval($tags["BitDepth"]);
                 break;
             default:
-                $this->BRfatal("Unsupported image type");
+                $this->BRfatal("Unsupported image type $type for file $file in $zipPath");
                 break;
         }
        
@@ -406,12 +600,14 @@ class BookReaderImages
         echo $jsonOutput;
     }
     
-    function getDecompressCmd($imageType, $powReduce, $rotate, $scale, $stdoutLink) {
+    function getDecompressCmd($srcInfo, $powReduce, $rotate, $scale, $region, $stdoutLink) {
         
-        switch ($imageType) {
+        switch ($srcInfo['type']) {
             case 'jp2':
+                $regionAsFloat = $this->getRegionDimensionsAsFloat($srcInfo, $region);
+                $regionString = sprintf("{%f,%f},{%f,%f}", $regionAsFloat['y'], $regionAsFloat['x'], $regionAsFloat['h'], $regionAsFloat['w']);
                 $decompressCmd = 
-                    " | " . $this->kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -i /dev/stdin -o " . $stdoutLink;
+                    " | " . $this->kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -region $regionString -i /dev/stdin -o " . $stdoutLink;
                 if ($this->decompressToBmp) {
                     // We suppress output since bmptopnm always outputs on stderr
                     $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
@@ -424,6 +620,7 @@ class BookReaderImages
                 // We use the BookReaderTiff prefix to give a hint in case things don't
                 // get cleaned up.
                 $tempFile = tempnam("/tmp", "BookReaderTiff");
+                array_push($this->tempFiles, $tempFile);
             
                 // $$$ look at bit depth when reducing
                 $decompressCmd = 
@@ -445,6 +642,7 @@ class BookReaderImages
         return $decompressCmd;
     }
     
+    
     // If the command has its initial output on stdout the headers will be emitted followed
     // by the stdout output.  If initial output is on stderr an error message will be
     // returned.
@@ -486,15 +684,34 @@ class BookReaderImages
             $read = array($stdout, $stderr);
             $write = NULL;
             $except = NULL;
+            
             $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
             if (false === $numChanged) {
                 // select failed
                 $errorMessage = 'Select failed';
                 $retVal = false;
-            }
-            if ($read[0] == $stdout && (1 == $numChanged)) {
-                // Got output first on stdout (only)
-                // $$$ make sure we get all stdout
+                error_log('BookReader select failed!');
+            } else {            
+                if (in_array($stderr, $read)) {
+                    // Either content in stderr, or stderr is closed (could read 0 bytes)
+                    $error = stream_get_contents($stderr);
+                    if ($error) {
+                    
+                        $errorMessage = $error;
+                        $retVal = false;
+                        
+                        fclose($stderr);
+                        fclose($stdout);
+                        fclose($stdin);
+                        
+                        // It is important that you close any pipes before calling
+                        // proc_close in order to avoid a deadlock
+                        proc_close($process);
+                        return $retVal;             
+                    }
+                }
+                
                 $output = fopen('php://output', 'w');
                 foreach($headers as $header) {
                     header($header);
@@ -502,11 +719,6 @@ class BookReaderImages
                 stream_copy_to_stream($pipes[1], $output);
                 fclose($output); // okay since tied to special php://output
                 $retVal = true;
-            } else {
-                // Got output on stderr
-                // $$$ make sure we get all stderr
-                $errorMessage = stream_get_contents($stderr);
-                $retVal = false;
             }
     
             fclose($stderr);
@@ -526,12 +738,12 @@ class BookReaderImages
     }
     
     function BRfatal($string) {
+        $this->cleanup();
         throw new Exception("Image error: $string");
-        //echo "alert('$string');\n";
-        //die(-1);
     }
     
     // Returns true if using a power node
+    // XXX change to "on red box" - not working for new Xeon
     function onPowerNode() {
         exec("lspci | fgrep -c Realtek", $output, $return);
         if ("0" != $output[0]) {
@@ -558,6 +770,9 @@ class BookReaderImages
     }
     
     function checkPrivs($filename) {
+        // $$$ we assume here that requests for the title, cover or preview
+        //     come in via BookReaderPreview.php which will be re-run with
+        //     privileges after we return the 403
         if (!is_readable($filename)) {
             header('HTTP/1.1 403 Forbidden');
             exit(0);
@@ -590,6 +805,206 @@ class BookReaderImages
         return strlen($binStr) - 1;
     }
     
+    /*
+     * Parses a page request like "page5_r2.jpg" or "cover_t.jpg" to corresponding
+     * page type, size, reduce, and format
+     */
+    function parsePageRequest($pageRequest, $bookPrefix) {
+    
+        // Will hold parsed results
+        $pageInfo = array();
+        
+        // Normalize
+        $pageRequest = strtolower($pageRequest);
+        
+        // Pull off extension
+        if (preg_match('#(.*)\.([^.]+)$#', $pageRequest, $matches) === 1) {
+            $pageRequest = $matches[1];
+            $extension = $matches[2];
+            if ($extension == 'jpeg') {
+                $extension = 'jpg';
+            }
+        } else {
+            $extension = 'jpg';
+        }
+        $pageInfo['extension'] = $extension;
+        
+        // Split parts out
+        $parts = explode('_', $pageRequest);
+
+        // Remove book prefix if it was included (historical)
+        if ($parts[0] == $bookPrefix) {
+            array_shift($parts);
+        }
+        
+        if (count($parts) === 0) {
+            $this->BRfatal('No page type specified');
+        }
+        $page = array_shift($parts);
+        
+        $pageTypes = array(
+            'page' => 'str',
+            'n' => 'num',
+            'cover' => 'single',
+            'preview' => 'single',
+            'title' => 'single',
+            'leaf' => 'num'
+        );
+        
+        // Look for known page types
+        foreach ( $pageTypes as $pageName => $kind ) {
+            if ( preg_match('#^(' . $pageName . ')(.*)#', $page, $matches) === 1 ) {
+                $pageInfo['type'] = $matches[1];
+                switch ($kind) {
+                    case 'str':
+                        $pageInfo['value'] = $matches[2];
+                        break;
+                    case 'num':
+                        $pageInfo['value'] = intval($matches[2]);
+                        break;
+                    case 'single':
+                        break;
+                }
+            }
+        }
+        
+        if ( !array_key_exists('type', $pageInfo) ) {
+            $this->BRfatal('Unrecognized page type');
+        }
+        
+        // Look for other known parts
+        foreach ($parts as $part) {
+            if ( array_key_exists($part, self::$imageSizes) ) {
+                $pageInfo['size'] = $part;
+                continue;
+            }
+        
+            // Key must be alpha, value must start with digit and contain digits, alpha, ',' or '.'
+            // Should prevent injection of strange values into the redirect to datanode
+            if ( preg_match('#^([a-z]+)(\d[a-z0-9,.]*)#', $part, $matches) === 0) {
+                // Not recognized
+                continue;
+            }
+            
+            $key = $matches[1];
+            $value = $matches[2];
+            
+            if ( array_key_exists($key, self::$imageUrlKeys) ) {
+                $pageInfo[self::$imageUrlKeys[$key]] = $value;
+                continue;
+            }
+            
+            // If we hit here, was unrecognized (no action)
+        }
+        
+        return $pageInfo;
+    }
+    
+    function getRegionDimensions($sourceDimensions, $regionDimensions) {
+        // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
+        // in terms of full resolution image.
+        // Note: this will clip the returned dimensions to fit within the source image
+
+        $sourceX = 0;
+        if (array_key_exists('x', $regionDimensions)) {
+            $sourceX = $this->intAmount($regionDimensions['x'], $sourceDimensions['width']);
+        }
+        $sourceX = $this->clamp(0, $sourceDimensions['width'] - 2, $sourceX); // Allow at least one pixel
+        
+        $sourceY = 0;
+        if (array_key_exists('y', $regionDimensions)) {
+            $sourceY = $this->intAmount($regionDimensions['y'], $sourceDimensions['height']);
+        }
+        $sourceY = $this->clamp(0, $sourceDimensions['height'] - 2, $sourceY); // Allow at least one pixel
+        
+        $sourceWidth = $sourceDimensions['width'] - $sourceX;
+        if (array_key_exists('width', $regionDimensions)) {
+            $sourceWidth = $this->intAmount($regionDimensions['width'], $sourceDimensions['width']);
+        }
+        $sourceWidth = $this->clamp(1, max(1, $sourceDimensions['width'] - $sourceX), $sourceWidth);
+        
+        $sourceHeight = $sourceDimensions['height'] - $sourceY;
+        if (array_key_exists('height', $regionDimensions)) {
+            $sourceHeight = $this->intAmount($regionDimensions['height'], $sourceDimensions['height']);
+        }
+        $sourceHeight = $this->clamp(1, max(1, $sourceDimensions['height'] - $sourceY), $sourceHeight);
+        
+        return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
+    }
+
+    function getRegionDimensionsAsFloat($sourceDimensions, $regionDimensions) {
+        // Return region dimensions as { 'x' => xOffset, 'y' => yOffset, 'w' => width, 'h' => height }
+        // in terms of full resolution image.
+        // Note: this will clip the returned dimensions to fit within the source image
+    
+        $sourceX = 0;
+        if (array_key_exists('x', $regionDimensions)) {
+            $sourceX = $this->floatAmount($regionDimensions['x'], $sourceDimensions['width']);
+        }
+        $sourceX = $this->clamp(0.0, 1.0, $sourceX);
+        
+        $sourceY = 0;
+        if (array_key_exists('y', $regionDimensions)) {
+            $sourceY = $this->floatAmount($regionDimensions['y'], $sourceDimensions['height']);
+        }
+        $sourceY = $this->clamp(0.0, 1.0, $sourceY);
+        
+        $sourceWidth = 1 - $sourceX;
+        if (array_key_exists('width', $regionDimensions)) {
+            $sourceWidth = $this->floatAmount($regionDimensions['width'], $sourceDimensions['width']);
+        }
+        $sourceWidth = $this->clamp(0.0, 1.0, $sourceWidth);
+        
+        $sourceHeight = 1 - $sourceY;
+        if (array_key_exists('height', $regionDimensions)) {
+            $sourceHeight = $this->floatAmount($regionDimensions['height'], $sourceDimensions['height']);
+        }
+        $sourceHeight = $this->clamp(0.0, 1.0, $sourceHeight);
+        
+        return array('x' => $sourceX, 'y' => $sourceY, 'w' => $sourceWidth, 'h' => $sourceHeight);
+    }
+    
+    function intAmount($stringValue, $maximum) {
+        // Returns integer amount for string like "5" (5 units) or "0.5" (50%)
+        if (strpos($stringValue, '.') === false) {
+            // No decimal, assume int
+            return intval($stringValue);
+        }
+        
+        return floatval($stringValue) * $maximum + 0.5;
+    }
+    
+    function floatAmount($stringValue, $maximum) {
+        // Returns float amount (0.0 to 1.0) for string like "0.4" (40%) or "4" (40% if max is 10)
+        if (strpos($stringValue, ".") === false) {
+            // No decimal, assume int value out of maximum
+            return floatval($stringValue) / $maximum;
+        }
+        
+        // Given float - just pass through
+        return floatval($stringValue);
+    }
+    
+    function clamp($minValue, $maxValue, $observedValue) {
+        if ($observedValue < $minValue) {
+            return $minValue;
+        }
+        
+        if ($observedValue > $maxValue) {
+            return $maxValue;
+        }
+        
+        return $observedValue;
+    }
+    
+    // Clean up temporary files and resources
+    function cleanup() {
+        foreach($this->tempFiles as $tempFile) {
+            unlink($tempFile);
+        }
+        $this->tempFiles = array();
+    }
+    
 }
 
 ?>
\ No newline at end of file