- Add `normalizeColor` method in `dynamicimages_asset_t` for consistent color input handling across strings, arrays, and defaults. - Update text overlay to use the new `normalizeColor` method, improving flexibility and robustness. - Refactor `machine_1` button step counter to calculate font size dynamically and use RGBA colors. - Adjust `drawStepCounterOnButton` to improve text alignment and styling.
267 lines
9.8 KiB
PHP
267 lines
9.8 KiB
PHP
<?php
|
|
|
|
namespace dynamicimages\traits;
|
|
|
|
trait dynamicimages_asset_t
|
|
{
|
|
/**
|
|
* In-memory Imagick instance for this asset (lazy-loaded)
|
|
*/
|
|
private ?\Imagick $memoryImage = null;
|
|
|
|
/**
|
|
* Ensure the in-memory image is loaded from disk.
|
|
*/
|
|
private function ensureMemoryImageLoaded(): void
|
|
{
|
|
if ($this->memoryImage instanceof \Imagick) {
|
|
return;
|
|
}
|
|
if (!isset($this->path) || !is_readable($this->path)) {
|
|
throw new \RuntimeException('Asset path is not readable: ' . ($this->path ?? '(unset)'));
|
|
}
|
|
$img = new \Imagick();
|
|
$img->readImage($this->path);
|
|
// Normalize to RGBA with alpha channel enabled
|
|
$img->setImageAlphaChannel(\Imagick::ALPHACHANNEL_SET);
|
|
$img->setImageFormat('png');
|
|
$this->memoryImage = $img;
|
|
}
|
|
|
|
/**
|
|
* Normalize a color input to a string acceptable by ImagickPixel.
|
|
* Supports:
|
|
* - string color names or hex (returned as-is after trim)
|
|
* - arrays: [r,g,b] or [r,g,b,a] or ['r'=>..,'g'=>..,'b'=>..,'a'=>..]
|
|
*/
|
|
private function normalizeColor(mixed $color): string
|
|
{
|
|
if (is_array($color)) {
|
|
$r = $color['r'] ?? ($color[0] ?? null);
|
|
$g = $color['g'] ?? ($color[1] ?? null);
|
|
$b = $color['b'] ?? ($color[2] ?? null);
|
|
$a = $color['a'] ?? ($color[3] ?? 1.0);
|
|
|
|
if ($r === null || $g === null || $b === null) {
|
|
throw new \InvalidArgumentException('Color array must contain r, g, b values.');
|
|
}
|
|
|
|
$r = max(0, min(255, (int)$r));
|
|
$g = max(0, min(255, (int)$g));
|
|
$b = max(0, min(255, (int)$b));
|
|
$a = (float)$a;
|
|
if (!is_finite($a)) { $a = 1.0; }
|
|
$a = max(0.0, min(1.0, $a));
|
|
|
|
return sprintf('rgba(%d,%d,%d,%.3f)', $r, $g, $b, $a);
|
|
}
|
|
|
|
if (is_string($color)) {
|
|
return trim($color);
|
|
}
|
|
|
|
// Fallback to black
|
|
return '#000000';
|
|
}
|
|
|
|
/**
|
|
* Resize the asset in-memory without modifying the source file.
|
|
*
|
|
* @param int $width Target width in pixels
|
|
* @param int $height Target height in pixels
|
|
* @param bool $keepAspect If true, keeps aspect ratio using Imagick's best fit
|
|
* @param string|null $fit When keeping aspect: 'contain' (default) fits inside box, 'cover' fills box and then crops center
|
|
* @return $this
|
|
*/
|
|
public function resize(int $width, int $height, bool $keepAspect = false, ?string $fit = null): self
|
|
{
|
|
if (!extension_loaded('imagick')) {
|
|
throw new \RuntimeException('Imagick extension is required to resize assets.');
|
|
}
|
|
if ($width <= 0 || $height <= 0) {
|
|
throw new \InvalidArgumentException('Resize width and height must be positive integers.');
|
|
}
|
|
$this->ensureMemoryImageLoaded();
|
|
|
|
// Normalize alpha and format
|
|
$this->memoryImage->setImageAlphaChannel(\Imagick::ALPHACHANNEL_SET);
|
|
$this->memoryImage->setImageFormat('png');
|
|
|
|
if ($keepAspect) {
|
|
$mode = strtolower($fit ?? 'contain');
|
|
if ($mode === 'cover') {
|
|
// Scale to cover, then center-crop
|
|
$origW = (int)$this->memoryImage->getImageWidth();
|
|
$origH = (int)$this->memoryImage->getImageHeight();
|
|
$scale = max($width / $origW, $height / $origH);
|
|
$newW = (int)ceil($origW * $scale);
|
|
$newH = (int)ceil($origH * $scale);
|
|
$this->memoryImage->resizeImage($newW, $newH, \Imagick::FILTER_LANCZOS, 1.0);
|
|
// Center crop to target
|
|
$x = max(0, (int)floor(($newW - $width) / 2));
|
|
$y = max(0, (int)floor(($newH - $height) / 2));
|
|
$this->memoryImage->cropImage($width, $height, $x, $y);
|
|
$this->memoryImage->setImagePage(0, 0, 0, 0);
|
|
} else {
|
|
// contain: best-fit inside box, may leave empty space around when composited
|
|
$this->memoryImage->thumbnailImage($width, $height, true, true);
|
|
}
|
|
} else {
|
|
// Force exact size (no aspect preserve)
|
|
$this->memoryImage->resizeImage($width, $height, \Imagick::FILTER_LANCZOS, 1.0);
|
|
}
|
|
|
|
// Update cached size if present on host class
|
|
if (property_exists($this, 'width')) {
|
|
$this->width = (int)$this->memoryImage->getImageWidth();
|
|
}
|
|
if (property_exists($this, 'height')) {
|
|
$this->height = (int)$this->memoryImage->getImageHeight();
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Rotate the asset in-memory without modifying the source file.
|
|
*
|
|
* @param float $degrees Rotation angle in degrees (clockwise)
|
|
* @param string $background Background color for exposed areas (default transparent)
|
|
* @return $this
|
|
*/
|
|
public function rotate(float $degrees, string $background = 'transparent'): self
|
|
{
|
|
if (!extension_loaded('imagick')) {
|
|
throw new \RuntimeException('Imagick extension is required to rotate assets.');
|
|
}
|
|
$this->ensureMemoryImageLoaded();
|
|
$bg = new \ImagickPixel($background === 'transparent' ? 'transparent' : $background);
|
|
$this->memoryImage->rotateImage($bg, $degrees);
|
|
// Reset virtual canvas/page so top-left becomes (0,0) and rotation is treated around center
|
|
$this->memoryImage->setImagePage(0, 0, 0, 0);
|
|
// Update cached size info if the class maintains it
|
|
if (method_exists($this, 'getWidth') && method_exists($this, 'getHeight')) {
|
|
if (property_exists($this, 'width')) {
|
|
$this->width = (int)$this->memoryImage->getImageWidth();
|
|
}
|
|
if (property_exists($this, 'height')) {
|
|
$this->height = (int)$this->memoryImage->getImageHeight();
|
|
}
|
|
}
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Get a clone of the in-memory Imagick image if available; otherwise null.
|
|
*/
|
|
public function getMemoryImage(): ?\Imagick
|
|
{
|
|
if ($this->memoryImage instanceof \Imagick) {
|
|
return clone $this->memoryImage;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Clear the in-memory image to free resources.
|
|
* @return void
|
|
*/
|
|
public function clearMemoryImage(): void
|
|
{
|
|
if ($this->memoryImage instanceof \Imagick) {
|
|
$this->memoryImage->clear();
|
|
$this->memoryImage->destroy();
|
|
}
|
|
$this->memoryImage = null;
|
|
}
|
|
|
|
/**
|
|
* Draw a text overlay on top of the in-memory image.
|
|
*
|
|
* Supported options:
|
|
* - font_size (int): default 16
|
|
* - font_color (string hex or color name): default '#000000'
|
|
* - font_path (string): absolute path to a .ttf/.otf font file (recommended)
|
|
* - x_offset (int): additional X shift in pixels (default 0)
|
|
* - y_offset (int): additional Y shift in pixels (default 0)
|
|
* - align ('left'|'center'|'right'): horizontal anchor within image (default 'left')
|
|
* - valign ('top'|'center'|'bottom'): vertical anchor within image (default 'top')
|
|
*
|
|
* @param string $text
|
|
* @param array $options
|
|
* @return $this
|
|
*/
|
|
public function addTextOverlay(string $text, array $options = []): self
|
|
{
|
|
if (!extension_loaded('imagick')) {
|
|
throw new \RuntimeException('Imagick extension is required to add text overlay.');
|
|
}
|
|
|
|
$this->ensureMemoryImageLoaded();
|
|
|
|
$fontSize = (int)($options['font_size'] ?? 16);
|
|
$fontColor = $this->normalizeColor($options['font_color'] ?? '#000000');
|
|
$fontPath = $options['font_path'] ?? null;
|
|
$xOffset = (int)($options['x_offset'] ?? 0);
|
|
$yOffset = (int)($options['y_offset'] ?? 0);
|
|
$align = strtolower((string)($options['align'] ?? 'left'));
|
|
$valign = strtolower((string)($options['valign'] ?? 'top'));
|
|
|
|
$draw = new \ImagickDraw();
|
|
$draw->setFontSize($fontSize);
|
|
if (!empty($fontPath)) {
|
|
if (!is_readable($fontPath)) {
|
|
throw new \RuntimeException('Font file is not readable: ' . $fontPath);
|
|
}
|
|
$draw->setFont($fontPath);
|
|
}
|
|
try {
|
|
$draw->setFillColor(new \ImagickPixel($fontColor));
|
|
} catch (\Throwable $e) {
|
|
throw new \RuntimeException('Unable to construct ImagickPixel from font_color option', 0, $e);
|
|
}
|
|
|
|
// Measure text to compute alignment offsets
|
|
$metrics = $this->memoryImage->queryFontMetrics($draw, $text, false);
|
|
$textW = (int)ceil($metrics['textWidth'] ?? 0);
|
|
$textH = (int)ceil(($metrics['ascender'] ?? 0) - ($metrics['descender'] ?? 0));
|
|
$asc = (float)($metrics['ascender'] ?? 0);
|
|
$desc = (float)($metrics['descender'] ?? 0);
|
|
|
|
$imgW = (int)$this->memoryImage->getImageWidth();
|
|
$imgH = (int)$this->memoryImage->getImageHeight();
|
|
|
|
// Horizontal anchor
|
|
switch ($align) {
|
|
case 'center':
|
|
$x = (int)floor(($imgW - $textW) / 2);
|
|
break;
|
|
case 'right':
|
|
$x = (int)($imgW - $textW);
|
|
break;
|
|
default:
|
|
$x = 0;
|
|
}
|
|
|
|
// Vertical anchor: Imagick uses baseline Y for annotateImage
|
|
switch ($valign) {
|
|
case 'center':
|
|
// center box then move to baseline by adding ascender
|
|
$y = (int)floor(($imgH - $textH) / 2 + $asc);
|
|
break;
|
|
case 'bottom':
|
|
// bottom aligned baseline: image height minus small descender
|
|
$y = (int)floor($imgH - max(0, -$desc));
|
|
break;
|
|
default: // top
|
|
$y = (int)ceil($asc);
|
|
}
|
|
|
|
$x += $xOffset;
|
|
$y += $yOffset;
|
|
|
|
$this->memoryImage->annotateImage($draw, $x, $y, 0.0, $text);
|
|
|
|
return $this;
|
|
}
|
|
} |