Files
api/services/nginx/app/modules/dynamicimages/traits/dynamicimages_asset_t.php
T
Jeppe Bundgaard 790e0fbeec Normalize color handling and enhance text overlay logic
- 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.
2026-01-28 16:16:53 +01:00

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;
}
}