Add support for dynamic image composition with Imagick in dynamicimages module
- Update `Dockerfile` to install `Imagick` dependencies and enable the extension. - Enhance `dynamicimages_image_t` with canvas lifecycle methods (`initImageCanvas`, `clearImage`, etc.), transformations (`resize`, `crop`, `rotate`, etc.), and explicit asset compositing. - Extend `dynamicimages_image_i` interface with corresponding method signatures. - Refactor `machine_1` to utilize dynamic image canvas and layered asset drawing.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user