<?PHP
#
#   FILE:  SPTImage.php
#
#   Part of the Collection Workflow Integration System (CWIS)
#   Copyright 2002-2013 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu/cwis/
#

/**
* Encapsulates a full-size, preview, and thumbnail image.
*/
class SPTImage
{

    # ---- PUBLIC INTERFACE --------------------------------------------------

    /** path containg the symlinks mapping images/fields to resources */
    const CACHE_PATH = "local/data/caches/ImageLinks";

    const SIZE_FULL = 3;
    const SIZE_PREVIEW = 2;
    const SIZE_THUMBNAIL = 1;

    /**
    * Object constructor. This loads an image if an ID is given or copies an
    * image if either an SPTImage object or file path are passed in.
    * @param mixed $ImageIdOrFileNameOrImageObj Image ID, image file name, or
    *      SPTImage.
    * @param int $MaxWidth Maximum width of the full-size image.
    * @param int $MaxHeight Maximum height of the full-size image.
    * @param int $MaxPreviewWidth Maximum width of the preview image.
    * @param int $MaxPreviewHeight Maximum height of the preview image.
    * @param int $MaxThumbnailWidth Maximum width of the thumbnail image.
    * @param int $MaxThumbnailHeight Maximum height of the thumbnail image.
    */
    public function __construct($ImageIdOrFileNameOrImageObj,
                      $MaxWidth = NULL, $MaxHeight = NULL,
                      $MaxPreviewWidth = NULL, $MaxPreviewHeight = NULL,
                      $MaxThumbnailWidth = NULL, $MaxThumbnailHeight = NULL)
    {
        # clear error status (0 = AI_OKAY)
        $this->ErrorStatus = 0;

        # trigger the Image class file to be autoloaded since some parts of this
        # class (SPTImage) use constants defined in it but don't construct Image
        # objects
        new Image(NULL);

        # create and save a database handle for our use
        $this->DB = new Database();

        # if image object was passed in
        if (is_object($ImageIdOrFileNameOrImageObj)
                && method_exists($ImageIdOrFileNameOrImageObj, "SPTImage"))
        {
            # create copy of image passed in
            $this->CreateCopyOfImage($ImageIdOrFileNameOrImageObj);
        }
        # else if image ID was passed in
        elseif (($ImageIdOrFileNameOrImageObj > 0)
                && preg_match("/[0-9]+/", $ImageIdOrFileNameOrImageObj))
        {
            # load info on existing image
            $this->LoadImageInfo($ImageIdOrFileNameOrImageObj);
        }
        # else assume that value passed in is file name
        else
        {
            # create new image from named file
            $this->CreateNewImage($ImageIdOrFileNameOrImageObj,
                                  $MaxWidth, $MaxHeight,
                                  $MaxPreviewWidth, $MaxPreviewHeight,
                                  $MaxThumbnailWidth, $MaxThumbnailHeight);
        }
    }

    /**
    * Get the ID of the image in the database.
    * @return Returns the ID of the image in the database.
    */
    public function Id()
    {
        return $this->Id;
    }

    /**
    * Get the path to the image.
    * @return Returns the path to the image.
    */
    public function Url()
    {
        $Url = $this->FileName;
        $SignalResult = $GLOBALS["AF"]->SignalEvent(
                "EVENT_IMAGE_URL_FILTER", array(
                        "Url" => $Url,
                        "ImageSize" => "Full"));
        $Url = $SignalResult["Url"];
        return $Url;
    }

    /**
    * Get the path to the preview image for this image.
    * @return Returns the path to the preview image for this image.
    */
    public function PreviewUrl()
    {
        $Url = $this->PreviewFileName;
        $SignalResult = $GLOBALS["AF"]->SignalEvent(
                "EVENT_IMAGE_URL_FILTER", array(
                        "Url" => $Url,
                        "ImageSize" => "Preview"));
        $Url = $SignalResult["Url"];
        return $Url;
    }

    /**
    * Get the path to the thumbnail image for this image.
    * @return Returns the path to the thumbnail image for this image.
    */
    public function ThumbnailUrl()
    {
        $Url = $this->ThumbnailFileName;
        $SignalResult = $GLOBALS["AF"]->SignalEvent(
                "EVENT_IMAGE_URL_FILTER", array(
                        "Url" => $Url,
                        "ImageSize" => "Thumbnail"));
        $Url = $SignalResult["Url"];
        return $Url;
    }

    /**
    * Get the format of the image. The value will be one IMGTYPE_* constants
    * from the Image class.
    * @return Returns the format of the image.
    */
    public function Format()
    {
        return $this->Format;
    }

    /**
    * Get the MIME type for the image.
    * @return Returns the MIME type for the image.
    */
    public function Mimetype()
    {
        $Image = new Image($this->FileName);
        return $Image->Mimetype();
    }

    /**
    * Get the height of the image.
    * @return Returns the height of the image.
    */
    public function Height()
    {
        return $this->Height;
    }

    /**
    * Get the width of the image.
    * @return Returns the width of the image.
    */
    public function Width()
    {
        return $this->Width;
    }

    /**
    * Get the height of the preview image for this image.
    * @return Returns the height of the preview image for this image.
    */
    public function PreviewHeight()
    {
        return $this->PreviewHeight;
    }

    /**
    * Get the width of the preview image for this image.
    * @return Returns the width of the preview image for this image.
    */
    public function PreviewWidth()
    {
        return $this->PreviewWidth;
    }

    /**
    * Get the height of the thumbnail image for this image.
    * @return Returns the height of the thumbnail image for this image.
    */
    public function ThumbnailHeight()
    {
        return $this->ThumbnailHeight;
    }

    /**
    * Get the width of the thumbnail image for this image.
    * @return Returns the width of the thumbnail image for this image.
    */
    public function ThumbnailWidth()
    {
        return $this->ThumbnailWidth;
    }

    /**
    * Get the path to the (full-size) image storage directory.
    * @return Returns the path to the full-size image storage directory.
    */
    public static function ImageStorageDirectory()
    {
        # for each possible storage location
        foreach (self::$ImageStorageLocations as $Dir)
        {
            # if location exists
            if (is_dir($Dir))
            {
                # return location to caller
                return $Dir;
            }
        }

        # return default (most preferred) location to caller
        return self::$ImageStorageLocations[0];
    }

    /**
    * Get the path to the preview image storage directory.
    * @return Returns the path to the preview image storage directory.
    */
    public static function PreviewStorageDirectory()
    {
        # for each possible storage location
        foreach (self::$PreviewStorageLocations as $Dir)
        {
            # if location exists
            if (is_dir($Dir))
            {
                # return location to caller
                return $Dir;
            }
        }

        # return default (most preferred) location to caller
        return self::$PreviewStorageLocations[0];
    }

    /**
    * Get the path to the thumbnail image storage directory.
    * @return Returns the path to the thumbnail image storage directory.
    */
    public static function ThumbnailStorageDirectory()
    {
        # for each possible storage location
        foreach (self::$ThumbnailStorageLocations as $Dir)
        {
            # if location exists
            if (is_dir($Dir))
            {
                # return location to caller
                return $Dir;
            }
        }

        # return default (most preferred) location to caller
        return self::$ThumbnailStorageLocations[0];
    }

    /**
    * Get the path to the full-size image.
    * @return Returns the path to the full-size image.
    */
    public function GetLink()
    {
        return $this->FileName;
    }

    /**
    * Get or set the alternate text value for the image.
    * @param string $NewValue New alternate text value. This parameter is
    *      optional.
    * @return Returns the current alternate text value.
    */
    public function AltText($NewValue = NULL)
    {
        # if new value supplied and new value differs from existing value
        if (($NewValue !== NULL) && ($NewValue != $this->AltText))
        {
            # save new value to database
            $this->DB->Query("UPDATE Images SET"
                             ." AltText = '".addslashes($NewValue)."'"
                             ." WHERE ImageId = ".$this->Id);

            # save new value locally
            $this->AltText = $NewValue;
        }

        # return attribute value to caller
        return $this->AltText;
    }

    /**
    * Get or set the link target value for the image.
    * @param string $NewValue New value for link target (OPTIONAL)
    * @return current LinkTarget value
    */
    public function LinkTarget($NewValue = NULL)
    {
        if (($NewValue !== NULL) && ($NewValue != $this->LinkTarget))
        {
            $this->DB->Query("UPDATE Images SET"
                             ." LinkTarget = '".addslashes($NewValue)."'"
                             ." WHERE ImageId = ".$this->Id);
            $this->LinkTarget = $NewValue;
        }

        return $this->LinkTarget;
    }

    /**
    * Delete the image, that is, remove its record from the database and delete
    * the associated image files from the file system.
    */
    public function Delete()
    {
        # delete base image file
        if (file_exists($this->FileName)) {  unlink($this->FileName);  }

        # delete preview image file
        if (file_exists($this->PreviewFileName)) {  unlink($this->PreviewFileName);  }

        # delete thumbnail image file
        if (file_exists($this->ThumbnailFileName)) {  unlink($this->ThumbnailFileName);  }

        # delete image info record in database
        $this->DB->Query("DELETE FROM Images WHERE ImageId = ".$this->Id);
    }

    /**
    * Get the error status set by the constructor.
    * @return Returns the error status set by the constructor.
    */
    public function Status()
    {
        return $this->ErrorStatus;
    }

    /**
    * Check that the image storage directories are available, creating them and
    * attempting to change their permissions if possible.
    * @return Returns an array of error codes or NULL if no errors are found.
    */
    public static function CheckDirectories()
    {
        # determine paths
        $ImagePath = self::ImageStorageDirectory();
        $PreviewPath = self::PreviewStorageDirectory();
        $ThumbnailPath = self::ThumbnailStorageDirectory();

        # assume everything will be okay
        $ErrorsFound = NULL;

        # check base image directory
        if (!is_dir($ImagePath) || !is_writable($ImagePath))
        {
            if (!is_dir($ImagePath))
            {
                @mkdir($ImagePath, 0755);
            }
            else
            {
                @chmod($ImagePath, 0755);
            }
            if (!is_dir($ImagePath))
            {
                $ErrorsFound[] = "Image Storage Directory Not Found";
            }
            elseif (!is_writable($ImagePath))
            {
                $ErrorsFound[] = "Image Storage Directory Not Writable";
            }
        }

        # check preview directory
        if (!is_dir($PreviewPath) || !is_writable($PreviewPath))
        {
            if (!is_dir($PreviewPath))
            {
                @mkdir($PreviewPath, 0755);
            }
            else
            {
                @chmod($PreviewPath, 0755);
            }
            if (!is_dir($PreviewPath))
            {
                $ErrorsFound[] = "Preview Storage Directory Not Found";
            }
            elseif (!is_writable($PreviewPath))
            {
                $ErrorsFound[] = "Preview Storage Directory Not Writable";
            }
        }

        # check thumbnail directory
        if (!is_dir($ThumbnailPath) || !is_writable($ThumbnailPath))
        {
            if (!is_dir($ThumbnailPath))
            {
                @mkdir($ThumbnailPath, 0755);
            }
            else
            {
                @chmod($ThumbnailPath, 0755);
            }
            if (!is_dir($ThumbnailPath))
            {
                $ErrorsFound[] = "Thumbnail Storage Directory Not Found";
            }
            elseif (!is_writable($ThumbnailPath))
            {
                $ErrorsFound[] = "Thumbnail Storage Directory Not Writable";
            }
        }

        # return any errors found to caller
        return $ErrorsFound;
    }

    /**
    * Resize the full-size, preview, and thumbnail images based on the given
    * dimension restrictions.
    * @param int $MaxWidth Maximum width of the full-size image.
    * @param int $MaxHeight Maximum height of the full-size image.
    * @param int $MaxPreviewWidth Maximum width of the preview image.
    * @param int $MaxPreviewHeight Maximum height of the preview image.
    * @param int $MaxThumbnailWidth Maximum width of the thumbnail image.
    * @param int $MaxThumbnailHeight Maximum height of the thumbnail image.
    */
    public function Resize($MaxWidth, $MaxHeight,
                           $MaxPreviewWidth, $MaxPreviewHeight,
                           $MaxThumbnailWidth, $MaxThumbnailHeight)
    {
        $SrcImage = new Image($this->FileName);

        # scale the original image if necessary
        $MaxWidth = min($MaxWidth, $SrcImage->XSize());
        $MaxHeight = min($MaxHeight, $SrcImage->YSize());
        $SrcImage->ScaleTo($MaxWidth, $MaxHeight, TRUE);

        # save and reload image info
        $SrcImage->SaveAs($this->FileName);
        $SrcImage = new Image($this->FileName);

        # retrieve image width and height
        $this->Height = $SrcImage->YSize();
        $this->Width = $SrcImage->XSize();

        # generate preview image and calculate width and height
        $MaxPreviewWidth = min($MaxPreviewWidth, $this->Width);
        $MaxPreviewHeight = min($MaxPreviewHeight, $this->Height);
        $SrcImage->ScaleTo($MaxPreviewWidth, $MaxPreviewHeight, TRUE);
        $SrcImage->SaveAs($this->PreviewFileName);
        if (($this->Width * $MaxPreviewHeight)
                > ($this->Height * $MaxPreviewWidth))
        {
            $this->PreviewWidth = $MaxPreviewWidth;
            $this->PreviewHeight =
                    ($MaxPreviewWidth * $SrcImage->YSize()) / $SrcImage->XSize();
        }
        else
        {
            $this->PreviewWidth =
                    ($MaxPreviewHeight * $SrcImage->XSize()) / $SrcImage->YSize();
            $this->PreviewHeight = $MaxPreviewHeight;
        }

        # generate thumbnail image and calculate width and height
        $MaxThumbnailWidth = min($MaxThumbnailWidth, $this->Width);
        $MaxThumbnailHeight = min($MaxThumbnailHeight, $this->Height);
        $SrcImage->ScaleTo($MaxThumbnailWidth, $MaxThumbnailHeight, TRUE);
        $SrcImage->SaveAs($this->ThumbnailFileName);
        if (($this->Width * $MaxThumbnailHeight)
                > ($this->Height * $MaxThumbnailWidth))
        {
            $this->ThumbnailWidth = $MaxThumbnailWidth;
            $this->ThumbnailHeight =
                    ($MaxThumbnailWidth * $SrcImage->YSize()) / $SrcImage->XSize();
        }
        else
        {
            $this->ThumbnailWidth =
                ($MaxThumbnailHeight * $SrcImage->XSize()) / $SrcImage->YSize();
            $this->ThumbnailHeight = $MaxThumbnailHeight;
        }

        # save image attributes to database
        $this->SaveImageInfo();
    }

    /**
    * Get the URL pointing to an image.
    * @param int $ResourceId Source resource.
    * @param int $FieldId Source field.
    * @param int $Index Image number w/in this field.
    * @param int $Size Image size as an SPTImage::SIZE_ const.
    */
    public function GetImageUrlForResource($ResourceId, $FieldId, $Index, $Size)
    {
        StdLib::CheckMyCaller(
            "Resource",
            "Attempt to get ImageLinks at %FILE%:%LINE%."
            ."  (ImageLinks many only be fetched by Resource.)");

        switch ($Size)
        {
            case self::SIZE_FULL:
                $Suffix = "f";
                break;
            case self::SIZE_PREVIEW:
                $Suffix = "p";
                break;
            case self::SIZE_THUMBNAIL:
                $Suffix = "t";
                break;
            default:
                throw new Exception("Unknown image size requested");
        }

        $Files = array(
            "t" => $this->ThumbnailFileName,
            "p" => $this->PreviewFileName,
            "f" => $this->FileName);

        # default to returning the path to the file
        $ReturnValue = $Files[$Suffix];

        # but if we have CleanURL support
        if ($GLOBALS["AF"]->HtaccessSupport())
        {
            # make sure our ImageLinks dir exists
            if (!is_dir(self::CACHE_PATH))
            {
                mkdir(self::CACHE_PATH);
            }

            # create symlinks for this field if they don't already exist
            $BaseDir = dirname(__DIR__)."/";
            foreach ($Files as $FileSuffix => $SrcFile)
            {
                $LinkName =
                    implode("_", array($ResourceId, $FieldId, $Index, $FileSuffix))
                    .".".Image::Extension($this->Format());

                if (!file_exists(self::CACHE_PATH."/".$LinkName))
                {
                    symlink($BaseDir.$SrcFile, self::CACHE_PATH."/".$LinkName);
                }

                # if this is the requested symlink, generate a url to return
                if ($Suffix == $FileSuffix)
                {
                    $ReturnValue = OurBaseUrl()."viewimage/".$LinkName;
                }
            }
        }

        return $ReturnValue;
    }

    /**
    * Remove symlinks used for to cache image mappings.
    * @param int $ResourceId Source resource.
    * @param int $FieldId Source field.
    */
    public static function ClearImageSymlinksForResource($ResourceId, $FieldId)
    {
        StdLib::CheckMyCaller(
            "Resource",
            "Attempt to delete ImageLinks of a resource at %FILE%:%LINE%."
            ."  (ImageLinks many only be cleared by Resource.)");

        if (!is_dir(self::CACHE_PATH))
        {
            return;
        }

        $Files = glob(self::CACHE_PATH."/".$ResourceId."_".$FieldId."_*");
        foreach ($Files as $file)
        {
            if (file_exists($File) && is_link($File))
            {
                unlink($File);
            }
        }
    }

    /**
    * Remove all symlinks used for a cached image mapping.
    */
    public static function ClearImageSymlinks()
    {
        if (!is_dir(self::CACHE_PATH))
        {
            return;
        }

        foreach (scandir(self::CACHE_PATH) as $Entry)
        {
            $Link = self::CACHE_PATH."/".$Entry;
            if (is_link($Link))
            {
                unlink($Link);
            }
        }
    }

    /**
    * Expire old symlinks used for cached image mappings.
    */
    public static function ExpireImageSymlinks()
    {
        if (!is_dir(self::CACHE_PATH))
        {
            return;
        }

        foreach (scandir(self::CACHE_PATH) as $Entry)
        {
            $Link = self::CACHE_PATH."/".$Entry;
            if (is_link($Link))
            {
                $link_info = lstat($Link);

                if (time() - $link_info['ctime'] > self::$SymlinkExpirationTime)
                {
                    unlink($Link);
                }
            }
        }
    }

    # ---- PRIVATE INTERFACE -------------------------------------------------

    private $Id;
    private $FileName;
    private $PreviewFileName;
    private $ThumbnailFileName;
    private $Format;
    private $AltText;
    private $LinkTarget;
    private $Url;
    private $PreviewUrl;
    private $ThumbnailUrl;
    private $Height;
    private $Width;
    private $PreviewHeight;
    private $PreviewWidth;
    private $ThumbnailHeight;
    private $ThumbnailWidth;
    private $DB;
    private $ErrorStatus;

    static private $SymlinkExpirationTime = 86400;

    /** File storage directories, in decreasing order of preference. */
    static private $ImageStorageLocations = array(
            "local/data/images",
            "ImageStorage",
            );
    static private $PreviewStorageLocations = array(
            "local/data/images/previews",
            "ImageStorage/Previews",
            );
    static private $ThumbnailStorageLocations = array(
            "local/data/images/thumbnails",
            "ImageStorage/Thumbnails",
            );

    /**
    * Create a new image from a file. Some resizing may take place on the new
    * image based on the maximum dimensions given.
    * @param string $FileName Path to the image file to copy.
    * @param int $MaxWidth Maximum width of the full-size image.
    * @param int $MaxHeight Maximum height of the full-size image.
    * @param int $MaxPreviewWidth Maximum width of the preview image.
    * @param int $MaxPreviewHeight Maximum height of the preview image.
    * @param int $MaxThumbnailWidth Maximum width of the thumbnail image.
    * @param int $MaxThumbnailHeight Maximum height of the thumbnail image.
    */
    private function CreateNewImage($FileName, $MaxWidth, $MaxHeight,
            $MaxPreviewWidth, $MaxPreviewHeight, $MaxThumbnailWidth, $MaxThumbnailHeight)
    {
        # if file does not exist or is not readable
        if (!is_readable($FileName))
        {
            # set error status
            $this->ErrorStatus = AI_FILEUNREADABLE;
        }
        else
        {
            # if image is invalid or unsupported type
            $SrcImage = new Image($FileName);
            $this->Format = $SrcImage->Type();
            if ($SrcImage->Status() != AI_OKAY)
            {
                # set error status
                $this->ErrorStatus = $SrcImage->Status();
            }
            else
            {
                # generate new image ID
                $this->Id = $this->GenerateNewImageId();

                # generate and set file names
                $this->SetFileNames();

                # if our image file name differs from file name passed in
                if (realpath($this->FileName) != realpath($FileName))
                {
                    # create image file
                    $SrcImage->SaveAs($this->FileName);

                    # if create failed set error status and bail out
                    if ($SrcImage->Status() != AI_OKAY)
                    {
                        $this->DB->Query("DELETE FROM Images WHERE ImageId = "
                                .intval($this->Id));
                        $this->ErrorStatus = $SrcImage->Status();
                        return;
                    }
                }

                # scale the original image if necessary
                $MaxWidth = min($MaxWidth, $SrcImage->XSize());
                $MaxHeight = min($MaxHeight, $SrcImage->YSize());

                # change the minimum width if the height is the limiting factor
                if ($SrcImage->YSize() * $MaxWidth / $SrcImage->XSize() > $MaxHeight)
                {
                    $MaxWidth = round(
                        $SrcImage->XSize() * $MaxHeight / $SrcImage->YSize());
                }

                # change the minimum height since the width is the limiting factor
                else
                {
                    $MaxHeight = round(
                        $SrcImage->YSize() * $MaxWidth / $SrcImage->XSize());
                }

                # scale the image
                $SrcImage->ScaleTo($MaxWidth, $MaxHeight, TRUE);

                # save and reload image info
                $SrcImage->SaveAs($this->FileName);
                $SrcImage = new Image($this->FileName);

                # retrieve image width and height
                $this->Height = $SrcImage->YSize();
                $this->Width = $SrcImage->XSize();

                # create the preview and thumbnail images
                foreach (array("Preview", "Thumbnail") as $ImageType)
                {
                    # variable name strings to use in the variable variables below
                    $MaxWidthVar = "Max".$ImageType."Width";
                    $MaxHeightVar = "Max".$ImageType."Height";

                    # find the mininum values for the width and height
                    $$MaxWidthVar = min($$MaxWidthVar, $this->Width);
                    $$MaxHeightVar= min($$MaxHeightVar, $this->Height);

                    # change the minimum width if the height is the limiting factor
                    if ($this->Height * $$MaxWidthVar / $this->Width > $$MaxHeightVar)
                    {
                        $$MaxWidthVar =
                            round($this->Width * $$MaxHeightVar / $this->Height);
                    }

                    # change the minimum height since the width is the limiting factor
                    else
                    {
                        $$MaxHeightVar =
                            round($this->Height * $$MaxWidthVar / $this->Width);
                    }

                    # scale the image and save it to a new file
                    $SrcImage->ScaleTo($$MaxWidthVar, $$MaxHeightVar, TRUE);
                    $SrcImage->SaveAs($this->{$ImageType."FileName"});

                    # scaling/saving failed
                    if ($SrcImage->Status() != AI_OKAY)
                    {
                        $this->DB->Query("DELETE FROM Images WHERE ImageId = "
                                .intval($this->Id));
                        $this->ErrorStatus = $SrcImage->Status();
                        return;
                    }

                    # save the dimensions
                    $this->{$ImageType."Width"} = $$MaxWidthVar;
                    $this->{$ImageType."Height"} = $$MaxHeightVar;
                }

                # save image attributes to database
                $this->SaveImageInfo();
            }
        }
    }

    /**
    * Load the information for an image from the database.
    * @param int $ImageId ID of an image in the database.
    */
    private function LoadImageInfo($ImageId)
    {
        # save image ID
        $this->Id = $ImageId;

        # load image record from database
        $this->DB->Query("SELECT * FROM Images WHERE ImageId = ".$ImageId);

        # if the ID is invalid
        if (!$this->DB->NumRowsSelected())
        {
            $this->ErrorStatus = AI_INTERNALERROR;
            return;
        }

        $Record = $this->DB->FetchRow();

        # load in values from record
        $this->Format          = $Record["Format"];
        $this->AltText         = $Record["AltText"];
        $this->LinkTarget      = $Record["LinkTarget"];
        $this->Height          = $Record["Height"];
        $this->Width           = $Record["Width"];
        $this->PreviewHeight   = $Record["PreviewHeight"];
        $this->PreviewWidth    = $Record["PreviewWidth"];
        $this->ThumbnailHeight = $Record["ThumbnailHeight"];
        $this->ThumbnailWidth  = $Record["ThumbnailWidth"];

        # generate file names
        $this->SetFileNames();
    }

    /**
    * Create a copy of an image represented by an Image object.
    * @param Image $SrcImage Image object
    */
    private function CreateCopyOfImage($SrcImage)
    {
        $Image = new Image($SrcImage->Url());
        if ($Image->Status() != AI_OKAY)
        {
            # set error status
            $this->ErrorStatus = $Image->Status();
            return;
        }

        # generate new image ID
        $this->Id = $this->GenerateNewImageId();

        # generate file names
        $this->SetFileNames();

        # copy attributes from source image
        $this->Format = $SrcImage->Format();
        $this->AltText = $SrcImage->AltText();
        $this->Width = $SrcImage->Width();
        $this->Height = $SrcImage->Height();
        $this->PreviewWidth = $SrcImage->PreviewWidth();
        $this->PreviewHeight = $SrcImage->PreviewHeight();
        $this->ThumbnailWidth = $SrcImage->ThumbnailWidth();
        $this->ThumbnailHeight = $SrcImage->ThumbnailHeight();

        # copy source image files
        copy($SrcImage->Url(), $this->FileName);
        copy($SrcImage->PreviewUrl(), $this->PreviewFileName);
        copy($SrcImage->ThumbnailUrl(), $this->ThumbnailFileName);

        # save image attributes to database
        $this->SaveImageInfo();
    }

    /**
    * Generate and save image, preview image, and thumbnail image file names.
    * This requires that the image ID and format to be set beforehand.
    */
    private function SetFileNames()
    {
        if (Image::Extension($this->Format))
        {
            $FileExtension = Image::Extension($this->Format);
        }
        else
        {
            $FileExtension = "";
        }

        $this->FileName = $this->DetermineFileName(
                self::$ImageStorageLocations, "Img--", $FileExtension);
        $this->PreviewFileName = $this->DetermineFileName(
                self::$PreviewStorageLocations, "Preview--", $FileExtension);
        $this->ThumbnailFileName = $this->DetermineFileName(
                self::$ThumbnailStorageLocations, "Thumb--", $FileExtension);
    }

    /**
    * Determine file name, looking for the file in specified locations,
    * and returning the file name in the first location if the file is
    * note found elsewhere.
    * @param array $Locations List of directories to search.
    * @param string $Prefix Prefix to prepend to file name.
    * @param string $Extension File extension to append to file name.
    * @return string File name with appropriate leading path.
    */
    private function DetermineFileName($Locations, $Prefix, $Extension)
    {
        # build base name for file
        $BaseName = $Prefix.sprintf("%08d.", $this->Id).$Extension;

        # for each possible location
        foreach ($Locations as $Dir)
        {
            # build full file name for location
            $FileName = $Dir."/".$BaseName;

            # if file exists in location return full file name
            if (file_exists($FileName)) {  return $FileName;  }
        }

        # for each possible location
        foreach ($Locations as $Dir)
        {
            # build full file name for location
            $FileName = $Dir."/".$BaseName;

            # if location is writable return full file name
            if (is_dir($Dir) && is_writable($Dir)) {  return $FileName;  }
        }

        # return full file name for default location
        return $Locations[0]."/".$BaseName;
    }

    /**
    * Get an unused ID for a new image.
    * @return Returns an unused ID for a new image.
    */
    private function GenerateNewImageId()
    {
        # add new entry to database
        $this->DB->Query("INSERT INTO Images (AltText) VALUES ('')");

        # return ID of inserted image
        return $this->DB->LastInsertId();
    }

    /**
    * Store image attributes in the database.
    */
    private function SaveImageInfo()
    {
        # update existing image record
        $this->DB->Query("UPDATE Images SET"
                         ." Format = '"         .$this->Format."',"
                         ." AltText = '"        .addslashes($this->AltText)."',"
                         ." Height = '"         .$this->Height."',"
                         ." Width = '"          .$this->Width."',"
                         ." PreviewHeight = '"  .$this->PreviewHeight."',"
                         ." PreviewWidth = '"   .$this->PreviewWidth."',"
                         ." ThumbnailHeight = '".$this->ThumbnailHeight."',"
                         ." ThumbnailWidth = '" .$this->ThumbnailWidth."'"
                         ." WHERE ImageId = ".$this->Id);
    }
}
