assets = $assets; return $this; } /** * @inheritDoc */ public function getAssets(): array { return $this->assets; } /** * @inheritDoc */ public function addAsset(dynamicimages_asset $asset): dynamicimages_image_i { $this->assets[] = $asset; 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 */ public function debugAssets(): void { echo 'Assets count: ' . count($this->assets) . PHP_EOL; foreach ($this->assets as $asset) { echo 'Asset path: ' . $asset->getPath() . ' - (Width: ' . $asset->getWidth() . ', Height: ' . $asset->getHeight() . ')' . PHP_EOL; } } public function getAssetPath(string $asset_name): string { return $this->module_asset_path . $asset_name; } /** * Resolve an absolute filesystem path to a font file located under the shared dynamicimages assets/fonts directory. * * Example result: WD . '/modules/dynamicimages/assets/fonts/YourFont.ttf' * * @param string $fontFile * @return string absolute path */ public function getFontPath(string $fontFile): string { $fontFile = ltrim($fontFile, '/\\'); $path = WD . '/' . self::asset_path . 'fonts/' . $fontFile; if (!is_readable($path)) { throw new \RuntimeException('Font not found or not readable: ' . $path); } return $path; } public function setAssetPath(string $asset_path): dynamicimages_image_i { $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'); } // If the asset has an in-memory image (e.g., rotated), use it; otherwise load from path $memoryImage = method_exists($asset, 'getMemoryImage') ? $asset->getMemoryImage() : null; if ($memoryImage instanceof \Imagick) { $layer = $memoryImage; // already a clone from asset } else { if (!is_readable($asset->getPath())) { throw new \RuntimeException('Asset is not readable: ' . $asset->getPath()); } $layer = new \Imagick(); $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; } /** * Draw an asset centered around the given coordinates. * This is useful for rotated layers whose bounding box changes per angle, * but should visually share the same pivot point. */ public function drawAssetCentered(dynamicimages_asset $asset, int $centerX, int $centerY, 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'); } $memoryImage = method_exists($asset, 'getMemoryImage') ? $asset->getMemoryImage() : null; if ($memoryImage instanceof \Imagick) { $layer = $memoryImage; // already a clone from asset } else { if (!is_readable($asset->getPath())) { throw new \RuntimeException('Asset is not readable: ' . $asset->getPath()); } $layer = new \Imagick(); $layer->readImage($asset->getPath()); } $layer->setImageAlphaChannel(\Imagick::ALPHACHANNEL_SET); if ($opacity < 1.0) { $layer->evaluateImage(\Imagick::EVALUATE_MULTIPLY, $opacity, \Imagick::CHANNEL_ALPHA); } $w = (int)$layer->getImageWidth(); $h = (int)$layer->getImageHeight(); $x = (int)floor($centerX - $w / 2); $y = (int)floor($centerY - $h / 2); $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 resizeToMaxWidth(int $maxWidth): dynamicimages_image_i { $this->assertCanvasInitialized(); if ($maxWidth <= 0) { throw new \InvalidArgumentException('Resize max width must be a positive integer.'); } if ($this->canvasWidth === null || $this->canvasHeight === null || $this->canvasWidth <= $maxWidth) { return $this; } $height = (int)round($this->canvasHeight * ($maxWidth / $this->canvasWidth)); return $this->resize($maxWidth, max(1, $height)); } 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); // Reset virtual page so top-left is (0,0) after rotation; rotation occurs around center $this->image->setImagePage(0, 0, 0, 0); // 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; } /** * Draw a straight line on the current canvas. * * @param int $x1 Starting X coordinate * @param int $y1 Starting Y coordinate * @param int $x2 Ending X coordinate * @param int $y2 Ending Y coordinate * @param string $color Any Imagick-compatible color string (e.g., 'red', '#FF0000', 'rgba(255,0,0,0.7)') * @param int $thickness Stroke width in pixels * @param array|null $dash Optional dash pattern (e.g., [10,5] for dashed line) * @return dynamicimages_image_i */ public function drawLine(int $x1, int $y1, int $x2, int $y2, string $color = 'rgba(255,0,0,1.0)', int $thickness = 1, ?array $dash = null): dynamicimages_image_i { $this->assertCanvasInitialized(); $draw = new \ImagickDraw(); $draw->setStrokeColor(new \ImagickPixel($color)); $draw->setStrokeWidth(max(1, $thickness)); $draw->setFillOpacity(0.0); if ($dash !== null && count($dash) > 0) { // Imagick expects an array of floats for dash and a dash offset $draw->setStrokeDashArray(array_map('floatval', $dash)); $draw->setStrokeDashOffset(0); } $draw->line($x1, $y1, $x2, $y2); $this->image->drawImage($draw); $draw->clear(); $draw->destroy(); 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 ($this->image instanceof \Imagick) { return 'data:image/png;base64,' . base64_encode($this->exportBinary($format, $quality)); } // 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 exportBinary(?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 $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); } $data = file_get_contents($path); if ($data === false) { throw new \RuntimeException('Failed to read asset: ' . $path); } return $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 { $mimeType = 'image/png'; if (!$this->image instanceof \Imagick && !empty($this->assets)) { $asset = $this->assets[0]; $path = $asset->getPath(); $imgInfo = is_readable($path) ? @getimagesize($path) : false; $mimeType = is_array($imgInfo) && isset($imgInfo['mime']) ? $imgInfo['mime'] : 'application/octet-stream'; } $imageData = $this->exportBinary($format, $quality); header('Content-Type: ' . $mimeType); header('Content-Length: ' . strlen($imageData)); echo $imageData; exit; } /** * Serve the composed picture directly to the client. Wrapper for outputImage. */ public function servePicture(?string $format = null, int $quality = 90): void { $this->outputImage($format, $quality); } }