diff --git a/services/nginx/app/modules/dynamicimages/images/machine_1.php b/services/nginx/app/modules/dynamicimages/images/machine_1.php index 213a7227..e92cb41f 100644 --- a/services/nginx/app/modules/dynamicimages/images/machine_1.php +++ b/services/nginx/app/modules/dynamicimages/images/machine_1.php @@ -25,7 +25,14 @@ class machine_1 extends dynamicimages_image new dynamicimages_asset(self::getAssetPath(self::IMAGE_WASH_PROGRAMS_THUMB)), new dynamicimages_asset(self::getAssetPath(self::IMAGE_WASH_PROGRAMS)), ], self::asset_path . str_replace('\\', '/', __CLASS__) . '/'); - echo 'Asset path set for machine_1: ' . $this->module_asset_path . PHP_EOL; } + public function setup(): void + { + $this->initImageCanvas(6000, 4500); + // By default, the background should be shown with the programs on top and then on top of that the thumb + $this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0); + $this->drawAsset($this->getAsset(self::IMAGE_WASH_PROGRAMS), 0, 0); + $this->drawAsset($this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB), 0, 0); + } } \ No newline at end of file diff --git a/services/nginx/app/modules/dynamicimages/interfaces/dynamicimages_image_i.php b/services/nginx/app/modules/dynamicimages/interfaces/dynamicimages_image_i.php index bf97c4ac..d51abe4e 100644 --- a/services/nginx/app/modules/dynamicimages/interfaces/dynamicimages_image_i.php +++ b/services/nginx/app/modules/dynamicimages/interfaces/dynamicimages_image_i.php @@ -3,6 +3,7 @@ namespace dynamicimages\interfaces; use dynamicimages\classes\dynamicimages_asset; +use dynamicimages\helpers\dynamicimages_tool; interface dynamicimages_image_i { @@ -23,4 +24,65 @@ interface dynamicimages_image_i * @return self */ public function addAsset(dynamicimages_asset $asset): self; + + // ===== Canvas lifecycle (internal image property) ===== + /** + * Initialize a blank Imagick canvas with the specified size. + * Background can be 'transparent' or any valid color string. + */ + public function initImageCanvas(int $width, int $height, string $background = 'transparent'): self; + + /** + * Clear and destroy the current canvas, if any. + */ + public function clearImage(): self; + + /** + * Return current canvas size as [width => int, height => int]. + * Throws if canvas is not initialized. + * @return array{width:int,height:int} + */ + public function getCanvasSize(): array; + + // ===== Explicit asset compositing ===== + /** + * Draw an asset onto the canvas at (x,y) with given opacity (0..1). + * Requires the canvas to be initialized first via initImageCanvas(). + */ + public function drawAsset(dynamicimages_asset $asset, int $x, int $y, float $opacity = 1.0): self; + + // ===== Transformations on the temporary image object ===== + public function resize(int $width, int $height): self; + public function crop(int $width, int $height, int $x, int $y): self; + public function rotate(float $degrees, string $background = 'transparent'): self; + /** Set global canvas opacity (0..1). */ + public function setOpacity(float $opacity): self; + + /** + * Set tools to apply to the image + * @param dynamicimages_tool[] $tools + * @return self + */ + public function setTools(array $tools): self; + + /** + * Add a single tool to the list + * @param dynamicimages_tool $tool + * @return self + */ + public function addTool(dynamicimages_tool $tool): self; + + /** + * Get all tools assigned to this image + * @return dynamicimages_tool[] + */ + public function getTools(): array; + + /** + * Export the composed image as base64 data URI + * @param string|null $format Optional target format (e.g. 'png', 'jpeg') + * @param int $quality Quality for lossy formats (0-100) + * @return string base64 data URI string (e.g. data:image/png;base64,....) + */ + public function exportAsBase64(?string $format = null, int $quality = 90): string; } \ No newline at end of file diff --git a/services/nginx/app/modules/dynamicimages/traits/dynamicimages_image_t.php b/services/nginx/app/modules/dynamicimages/traits/dynamicimages_image_t.php index d3048f4c..b4bfcbad 100644 --- a/services/nginx/app/modules/dynamicimages/traits/dynamicimages_image_t.php +++ b/services/nginx/app/modules/dynamicimages/traits/dynamicimages_image_t.php @@ -3,11 +3,24 @@ namespace dynamicimages\traits; use dynamicimages\classes\dynamicimages_asset; +use dynamicimages\helpers\dynamicimages_tool; use dynamicimages\interfaces\dynamicimages_image_i; trait dynamicimages_image_t { + /** + * Internal Imagick canvas (temporary, changeable image object) + */ + private ?\Imagick $image = null; + private ?int $canvasWidth = null; + private ?int $canvasHeight = null; + + /** + * @var dynamicimages_tool[] + */ + private array $tools = []; + /** * @inheritDoc */ @@ -34,6 +47,32 @@ trait dynamicimages_image_t return $this; } + /** + * @inheritDoc + */ + public function setTools(array $tools): dynamicimages_image_i + { + $this->tools = $tools; + return $this; + } + + /** + * @inheritDoc + */ + public function addTool(dynamicimages_tool $tool): dynamicimages_image_i + { + $this->tools[] = $tool; + return $this; + } + + /** + * @inheritDoc + */ + public function getTools(): array + { + return $this->tools; + } + /** * Debug */ @@ -55,4 +94,192 @@ trait dynamicimages_image_t $this->module_asset_path = $asset_path; return $this; } + + // ===== Canvas lifecycle ===== + public function initImageCanvas(int $width, int $height, string $background = 'transparent'): dynamicimages_image_i + { + if (!extension_loaded('imagick')) { + throw new \RuntimeException('Imagick extension is required to initialize image canvas.'); + } + if ($width <= 0 || $height <= 0) { + throw new \InvalidArgumentException('Canvas width and height must be positive integers.'); + } + $bg = new \ImagickPixel($background === 'transparent' ? 'transparent' : $background); + $this->image = new \Imagick(); + $this->image->newImage($width, $height, $bg, 'png'); // ensure alpha channel + $this->image->setImageFormat('png'); + $this->canvasWidth = $width; + $this->canvasHeight = $height; + return $this; + } + + public function clearImage(): dynamicimages_image_i + { + if ($this->image instanceof \Imagick) { + $this->image->clear(); + $this->image->destroy(); + } + $this->image = null; + $this->canvasWidth = null; + $this->canvasHeight = null; + return $this; + } + + public function getCanvasSize(): array + { + $this->assertCanvasInitialized(); + return ['width' => $this->canvasWidth, 'height' => $this->canvasHeight]; + } + + // ===== Explicit asset compositing ===== + public function drawAsset(dynamicimages_asset $asset, int $x, int $y, float $opacity = 1.0): dynamicimages_image_i + { + $this->assertCanvasInitialized(); + if ($opacity < 0.0 || $opacity > 1.0) { + throw new \InvalidArgumentException('Opacity must be between 0.0 and 1.0'); + } + $layer = new \Imagick(); + if (!is_readable($asset->getPath())) { + throw new \RuntimeException('Asset is not readable: ' . $asset->getPath()); + } + $layer->readImage($asset->getPath()); + // Normalize to support alpha + $layer->setImageAlphaChannel(\Imagick::ALPHACHANNEL_SET); + if ($opacity < 1.0) { + $layer->evaluateImage(\Imagick::EVALUATE_MULTIPLY, $opacity, \Imagick::CHANNEL_ALPHA); + } + $this->image->compositeImage($layer, \Imagick::COMPOSITE_DEFAULT, $x, $y); + $layer->clear(); + $layer->destroy(); + return $this; + } + + // ===== Transformations on the temporary image object ===== + public function resize(int $width, int $height): dynamicimages_image_i + { + $this->assertCanvasInitialized(); + if ($width <= 0 || $height <= 0) { + throw new \InvalidArgumentException('Resize width and height must be positive integers.'); + } + $this->image->resizeImage($width, $height, \Imagick::FILTER_LANCZOS, 1.0); + $this->canvasWidth = $width; + $this->canvasHeight = $height; + return $this; + } + + public function crop(int $width, int $height, int $x, int $y): dynamicimages_image_i + { + $this->assertCanvasInitialized(); + if ($width <= 0 || $height <= 0) { + throw new \InvalidArgumentException('Crop width and height must be positive integers.'); + } + $this->image->cropImage($width, $height, $x, $y); + // Reset canvas virtual page to avoid offsets + $this->image->setImagePage(0, 0, 0, 0); + $this->canvasWidth = $width; + $this->canvasHeight = $height; + return $this; + } + + public function rotate(float $degrees, string $background = 'transparent'): dynamicimages_image_i + { + $this->assertCanvasInitialized(); + $bg = new \ImagickPixel($background === 'transparent' ? 'transparent' : $background); + $this->image->rotateImage($bg, $degrees); + // Update stored dimensions + $this->canvasWidth = (int)$this->image->getImageWidth(); + $this->canvasHeight = (int)$this->image->getImageHeight(); + return $this; + } + + public function setOpacity(float $opacity): dynamicimages_image_i + { + $this->assertCanvasInitialized(); + if ($opacity < 0.0 || $opacity > 1.0) { + throw new \InvalidArgumentException('Opacity must be between 0.0 and 1.0'); + } + $this->image->setImageAlphaChannel(\Imagick::ALPHACHANNEL_SET); + $this->image->evaluateImage(\Imagick::EVALUATE_MULTIPLY, $opacity, \Imagick::CHANNEL_ALPHA); + return $this; + } + + private function assertCanvasInitialized(): void + { + if (!$this->image instanceof \Imagick) { + throw new \RuntimeException('Canvas is not initialized. Call initImageCanvas() first.'); + } + } + + /** + * Export the first asset as a base64 data URI. If no assets exist, throws an exception. + */ + public function exportAsBase64(?string $format = null, int $quality = 90): string + { + // If a canvas is initialized, export that as PNG by default + if ($this->image instanceof \Imagick) { + $img = clone $this->image; + $img->setImageFormat('png'); + // Quality influences compression for PNG differently; keep as hint + if ($format !== null && strtolower($format) !== 'png') { + // For now we only support PNG for composed images as requested + } + // Strip metadata to reduce size + $img->stripImage(); + $blob = $img->getImageBlob(); + $img->clear(); + $img->destroy(); + return 'data:image/png;base64,' . base64_encode($blob); + } + + // Fallback: export first asset as-is + if (empty($this->assets)) { + throw new \RuntimeException('No assets available to export.'); + } + $asset = $this->assets[0]; + $path = $asset->getPath(); + if (!is_readable($path)) { + throw new \RuntimeException('Asset is not readable: ' . $path); + } + $imgInfo = @getimagesize($path); + $mime = is_array($imgInfo) && isset($imgInfo['mime']) ? $imgInfo['mime'] : 'application/octet-stream'; + $data = file_get_contents($path); + if ($data === false) { + throw new \RuntimeException('Failed to read asset: ' . $path); + } + return 'data:' . $mime . ';base64,' . base64_encode($data); + } + + public function getAsset(string $asset_name): ?dynamicimages_asset + { + foreach ($this->assets as $asset) { + if (basename($asset->getPath()) === $asset_name) { + return $asset; + } + } + return null; + } + + /** + * Return the image file as a response directly to the browser + * + */ + public function outputImage(?string $format = null, int $quality = 90): void + { + $dataUri = $this->exportAsBase64($format, $quality); + // Extract mime type and base64 data + if (preg_match('/^data:(image\/[a-zA-Z0-9+.-]+);base64,(.*)$/', $dataUri, $matches)) { + $mimeType = $matches[1]; + $base64Data = $matches[2]; + // Decode base64 data + $imageData = base64_decode($base64Data); + if ($imageData !== false) { + // Send appropriate headers + header('Content-Type: ' . $mimeType); + header('Content-Length: ' . strlen($imageData)); + // Output the image data + echo $imageData; + exit; + } + } + } } \ No newline at end of file diff --git a/services/php/Dockerfile b/services/php/Dockerfile index d9dc660a..430b4eec 100644 --- a/services/php/Dockerfile +++ b/services/php/Dockerfile @@ -13,12 +13,17 @@ RUN apt-get update && apt-get install -y \ libpng-dev \ libonig-dev \ libxml2-dev \ + libmagickwand-dev \ + imagemagick \ + pkg-config \ zip \ git \ curl \ libzip-dev \ default-mysql-client \ - && docker-php-ext-install mbstring exif pcntl bcmath gd pdo_mysql mysqli zip + && docker-php-ext-install mbstring exif pcntl bcmath gd pdo_mysql mysqli zip \ + && pecl install imagick \ + && docker-php-ext-enable imagick # Copy application files into the container COPY /nginx/app /var/www/html