File "VipsDriver.php"
Full Path: /var/www/html/back/vendor/spatie/image/src/Drivers/Vips/VipsDriver.php
File size: 24.83 KB
MIME-type: text/x-php
Charset: utf-8
<?php
namespace Spatie\Image\Drivers\Vips;
use Jcupitt\Vips\BandFormat;
use Jcupitt\Vips\Exception;
use Jcupitt\Vips\Image;
use Spatie\Image\Drivers\Concerns\AddsWatermark;
use Spatie\Image\Drivers\Concerns\CalculatesCropOffsets;
use Spatie\Image\Drivers\Concerns\CalculatesFocalCropAndResizeCoordinates;
use Spatie\Image\Drivers\Concerns\CalculatesFocalCropCoordinates;
use Spatie\Image\Drivers\Concerns\GetsOrientationFromExif;
use Spatie\Image\Drivers\Concerns\PerformsFitCrops;
use Spatie\Image\Drivers\Concerns\PerformsOptimizations;
use Spatie\Image\Drivers\Concerns\ValidatesArguments;
use Spatie\Image\Drivers\ImageDriver;
use Spatie\Image\Enums\AlignPosition;
use Spatie\Image\Enums\BorderType;
use Spatie\Image\Enums\ColorFormat;
use Spatie\Image\Enums\Constraint;
use Spatie\Image\Enums\CropPosition;
use Spatie\Image\Enums\Fit;
use Spatie\Image\Enums\FlipDirection;
use Spatie\Image\Enums\Orientation;
use Spatie\Image\Exceptions\CannotOptimizePng;
use Spatie\Image\Exceptions\InvalidFont;
use Spatie\Image\Exceptions\MissingParameter;
use Spatie\Image\Exceptions\UnsupportedImageFormat;
use Spatie\Image\Point;
use Spatie\Image\Size;
class VipsDriver implements ImageDriver
{
use AddsWatermark;
use CalculatesCropOffsets;
use CalculatesFocalCropAndResizeCoordinates;
use CalculatesFocalCropCoordinates;
use GetsOrientationFromExif;
use PerformsFitCrops;
use PerformsOptimizations;
use ValidatesArguments;
protected Image $image;
protected ?string $format = null;
protected int $defaultQuality = 75;
protected ?int $quality = null;
/** @var array<string, mixed> */
protected array $exif = [];
public function new(int $width, int $height, ?string $backgroundColor = null): static
{
$color = new VipsColor($backgroundColor);
$rgba = $color->getArray();
// Create a proper sRGB image with the background color
$image = Image::black($width, $height, ['bands' => 3])
->add([$rgba[0], $rgba[1], $rgba[2]])
->cast(BandFormat::UCHAR)
->copy(['interpretation' => 'srgb']);
// Add alpha channel if color has alpha
if (isset($rgba[3]) && $rgba[3] < 255) {
$alpha = Image::black($width, $height)->add($rgba[3])->cast(BandFormat::UCHAR);
$image = $image->bandjoin($alpha);
}
return (new static)->setImage($image);
}
protected function setImage(Image $image): static
{
$this->image = $image;
return $this;
}
public function loadFile(string $path, bool $autoRotate = true): static
{
$this->optimize = false;
// Use 'access' => 'sequential' to avoid libvips file caching issues
// when the same file path is overwritten between loads
$this->image = Image::newFromFile($path, ['access' => 'sequential']);
$this->setExif($path);
if ($autoRotate) {
$this->autoRotate();
}
return $this;
}
public function driverName(): string
{
return 'vips';
}
public function save(string $path = ''): static
{
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($this->quality && $extension === 'png') {
throw CannotOptimizePng::make();
}
// Q parameter is only supported for JPEG, WebP, AVIF, HEIC
$formatsWithQuality = ['jpg', 'jpeg', 'webp', 'avif', 'heic', 'heif'];
$saveProperties = [];
if (in_array($extension, $formatsWithQuality)) {
$saveProperties['Q'] = $this->quality ?? $this->defaultQuality;
}
try {
$this->image->writeToFile($path, $saveProperties);
} catch (Exception $exception) {
$message = $exception->getMessage();
if (str_contains($message, 'is not a known file format') ||
str_contains($message, 'unsupported') ||
str_contains($message, 'unable to call')) {
throw UnsupportedImageFormat::make($extension ?: $this->format ?? 'unknown', $exception);
}
throw $exception;
}
if ($this->optimize) {
$this->optimizerChain->optimize($path);
}
return $this;
}
public function getWidth(): int
{
return $this->image->width;
}
public function getHeight(): int
{
return $this->image->height;
}
public function brightness(int $brightness): static
{
$brightness = 1 + ($brightness / 100);
$this->image = $this->image->linear([$brightness, $brightness, $brightness], [0, 0, 0]);
return $this;
}
public function gamma(float $gamma): static
{
$this->image = $this->image->gamma(['exponent' => $gamma]);
return $this;
}
public function contrast(float $level): static
{
// Convert level (-100 to 100) to a contrast factor
// level > 0 increases contrast, level < 0 decreases contrast
$factor = (100 + $level) / 100;
// Apply contrast using linear transformation: (pixel - 128) * factor + 128
$this->image = $this->image->linear([$factor, $factor, $factor], [128 * (1 - $factor), 128 * (1 - $factor), 128 * (1 - $factor)]);
return $this;
}
public function blur(int $blur): static
{
$this->image = $this->image->gaussblur($blur / 10);
return $this;
}
public function colorize(int $red, int $green, int $blue): static
{
$overlay = Image::black($this->image->width, $this->image->height)
->add([$red, $green, $blue])
->cast(BandFormat::UCHAR);
$this->image = $this->image->composite2($overlay, 'add');
return $this;
}
public function greyscale(): static
{
$this->image = $this->image->colourspace('b-w');
return $this;
}
public function sepia(): static
{
/* Implementation from https://github.com/libvips/php-vips/issues/104#issuecomment-686348179 */
$sepia = Image::newFromArray([
[0.393, 0.769, 0.189],
[0.349, 0.686, 0.168],
[0.272, 0.534, 0.131],
]);
if ($this->image->hasAlpha()) {
// Separate alpha channel
$imageWithoutAlpha = $this->image->extract_band(0, ['n' => $this->image->bands - 1]);
$alpha = $this->image->extract_band($this->image->bands - 1, ['n' => 1]);
$this->image = $imageWithoutAlpha->recomb($sepia)->bandjoin($alpha);
return $this;
}
$this->image = $this->image->recomb($sepia);
return $this;
}
public function sharpen(float $amount): static
{
if ($amount > 0) {
$this->image = $this->image->sharpen([
'm2' => $amount,
]);
}
return $this;
}
public function getSize(): Size
{
return new Size($this->getWidth(), $this->getHeight());
}
public function fit(
Fit $fit,
?int $desiredWidth = null,
?int $desiredHeight = null,
bool $relative = false,
string $backgroundColor = '#ffffff'
): static {
if ($fit === Fit::Crop) {
return $this->fitCrop($fit, $this->getWidth(), $this->getHeight(), $desiredWidth, $desiredHeight);
}
if ($fit === Fit::FillMax) {
if (is_null($desiredWidth) || is_null($desiredHeight)) {
throw new MissingParameter('Both desiredWidth and desiredHeight must be set when using Fit::FillMax');
}
return $this->fitFillMax($desiredWidth, $desiredHeight, $backgroundColor);
}
$calculatedSize = $fit->calculateSize(
$this->getWidth(),
$this->getHeight(),
$desiredWidth,
$desiredHeight
);
$widthRatio = $calculatedSize->width / $this->image->width;
$heightRatio = $calculatedSize->height / $this->image->height;
$this->image = $this->image->resize($widthRatio, [
'vscale' => $heightRatio,
]);
if ($fit->shouldResizeCanvas()) {
$this->resizeCanvas($desiredWidth, $desiredHeight, AlignPosition::Center, $relative, $backgroundColor);
}
return $this;
}
public function fitFillMax(int $desiredWidth, int $desiredHeight, string $backgroundColor, bool $relative = false): static
{
$this->resize($desiredWidth, $desiredHeight, [Constraint::PreserveAspectRatio]);
$this->resizeCanvas($desiredWidth, $desiredHeight, AlignPosition::Center, $relative, $backgroundColor);
return $this;
}
public function pickColor(int $x, int $y, ColorFormat $colorFormat): mixed
{
$colors = $this->image->getpoint($x, $y);
$color = (new VipsColor)->initFromArray($colors);
return $color->format($colorFormat);
}
public function resizeCanvas(
?int $width = null,
?int $height = null,
?AlignPosition $position = null,
bool $relative = false,
string $backgroundColor = '#000000'
): static {
$position ??= AlignPosition::Center;
$originalWidth = $this->getWidth();
$originalHeight = $this->getHeight();
$width ??= $originalWidth;
$height ??= $originalHeight;
if ($relative) {
$width = $originalWidth + $width;
$height = $originalHeight + $height;
}
$width = $width <= 0
? $width + $originalWidth
: $width;
$height = $height <= 0
? $height + $originalHeight
: $height;
$canvas = $this->new($width, $height, $backgroundColor);
$canvasSize = $canvas->getSize()->align($position);
$imageSize = $this->getSize()->align($position);
$canvasPosition = $imageSize->relativePosition($canvasSize);
$imagePosition = $canvasSize->relativePosition($imageSize);
if ($width <= $originalWidth) {
$destinationX = 0;
$sourceX = $canvasPosition->x;
$sourceWidth = $canvasSize->width;
} else {
$destinationX = $imagePosition->x;
$sourceX = 0;
$sourceWidth = $originalWidth;
}
if ($height <= $originalHeight) {
$destinationY = 0;
$sourceY = $canvasPosition->y;
$sourceHeight = $canvasSize->height;
} else {
$destinationY = $imagePosition->y;
$sourceY = 0;
$sourceHeight = $originalHeight;
}
// Crop the source image if needed
$croppedImage = $this->image->crop($sourceX, $sourceY, $sourceWidth, $sourceHeight);
// Composite the cropped image onto the canvas
$this->image = $canvas->image->composite2($croppedImage, 'over', [
'x' => $destinationX,
'y' => $destinationY,
]);
return $this;
}
public function manualCrop(
int $width,
int $height,
?int $x = 0,
?int $y = 0
): static {
$cropped = new Size($width, $height);
$position = new Point($x ?? 0, $y ?? 0);
if (is_null($x) && is_null($y)) {
$position = $this
->getSize()
->align(AlignPosition::Center)
->relativePosition($cropped->align(AlignPosition::Center));
}
$maxCroppedWidth = $this->getWidth() - $x;
$maxCroppedHeight = $this->getHeight() - $y;
$width = min($cropped->width, $maxCroppedWidth);
$height = min($cropped->height, $maxCroppedHeight);
$this->image = $this->image->crop(
$position->x,
$position->y,
$width,
$height,
);
return $this;
}
public function crop(int $width, int $height, CropPosition $position = CropPosition::Center): static
{
$width = min($width, $this->getWidth());
$height = min($height, $this->getHeight());
[$offsetX, $offsetY] = $this->calculateCropOffsets($width, $height, $position);
$maxWidth = $this->getWidth() - $offsetX;
$maxHeight = $this->getHeight() - $offsetY;
$width = min($width, $maxWidth);
$height = min($height, $maxHeight);
return $this->manualCrop($width, $height, $offsetX, $offsetY);
}
public function focalCrop(int $width, int $height, ?int $cropCenterX = null, ?int $cropCenterY = null): static
{
[$width, $height, $cropCenterX, $cropCenterY] = $this->calculateFocalCropCoordinates(
$width,
$height,
$cropCenterX,
$cropCenterY
);
$this->manualCrop($width, $height, $cropCenterX, $cropCenterY);
return $this;
}
public function focalCropAndResize(int $width, int $height, ?int $cropCenterX = null, ?int $cropCenterY = null): static
{
[$cropWidth, $cropHeight, $cropX, $cropY] = $this->calculateFocalCropAndResizeCoordinates(
$width,
$height,
$cropCenterX,
$cropCenterY
);
$this->manualCrop($cropWidth, $cropHeight, $cropX, $cropY)
->width($width)
->height($height);
return $this;
}
public function base64(string $imageFormat, bool $prefixWithFormat = true): string
{
$contents = base64_encode($this->image->writeToBuffer('.'.$imageFormat));
if ($prefixWithFormat) {
return 'data:image/'.$imageFormat.';base64,'.$contents;
}
return $contents;
}
public function background(string $color): static
{
$backgroundColor = new VipsColor($color);
$rgba = $backgroundColor->getArray();
// Create background with proper sRGB colorspace
$background = Image::black($this->image->width, $this->image->height, ['bands' => 3])
->add([$rgba[0], $rgba[1], $rgba[2]])
->cast(BandFormat::UCHAR)
->copy(['interpretation' => 'srgb']);
// Ensure the current image has an alpha channel for proper compositing
if (! $this->image->hasAlpha()) {
$this->image = $this->image->bandjoin(255);
}
// Add alpha to background
$background = $background->bandjoin(255);
$this->image = $background->composite2($this->image, 'over');
return $this;
}
public function overlay(ImageDriver $bottomImage, ImageDriver $topImage, int $x, int $y): static
{
$bottomImage->insert($topImage, AlignPosition::Center, $x, $y);
$image = $bottomImage->image();
assert($image instanceof Image);
$this->image = $image;
return $this;
}
public function orientation(?Orientation $orientation = null): static
{
if (is_null($orientation)) {
/** @var array{'Orientation'?: int} $exif */
$exif = $this->exif;
$orientation = $this->getOrientationFromExif($exif);
}
$degrees = $orientation->degrees();
if ($degrees === 0) {
return $this;
}
// Map degrees to vips rotation
$this->image = match ($degrees) {
90, -270 => $this->image->rot90(),
180, -180 => $this->image->rot180(),
270, -90 => $this->image->rot270(),
default => $this->image,
};
return $this;
}
public function autoRotate(): void
{
if (! $this->exif || empty($this->exif['Orientation'])) {
return;
}
switch ($this->exif['Orientation']) {
case 8:
$this->image = $this->image->rot90();
break;
case 3:
$this->image = $this->image->rot180();
break;
case 5:
case 7:
case 6:
$this->image = $this->image->rot270();
break;
}
}
public function setExif(string $path): void
{
if (! extension_loaded('exif')) {
return;
}
if (! extension_loaded('fileinfo')) {
return;
}
$fInfo = finfo_open(FILEINFO_RAW);
if ($fInfo) {
$info = finfo_file($fInfo, $path);
}
if (! isset($info) || ! is_string($info) || ! str_contains($info, 'Exif')) {
return;
}
$result = @exif_read_data($path);
if (! is_array($result)) {
$this->exif = [];
return;
}
$this->exif = $result;
}
public function exif(): array
{
return $this->exif;
}
public function flip(FlipDirection $flip): static
{
if ($flip === FlipDirection::Both) {
$this->image = $this->image->flip(FlipDirection::Vertical->value);
$this->image = $this->image->flip(FlipDirection::Horizontal->value);
return $this;
}
$this->image = $this->image->flip($flip->value);
return $this;
}
public function pixelate(int $pixelate): static
{
if ($pixelate === 0) {
return $this;
}
// Resize the image to a smaller size (shrink)
$this->image = $this->image->resize(1 / $pixelate, ['kernel' => 'nearest']);
// Resize the image back to the original size (enlarge)
$this->image = $this->image->resize($pixelate, ['kernel' => 'nearest']);
return $this;
}
public function insert(ImageDriver|string $otherImage, AlignPosition $position = AlignPosition::Center, int $x = 0, int $y = 0, int $alpha = 100): static
{
$this->ensureNumberBetween($alpha, 0, 100, 'alpha');
if (is_string($otherImage)) {
$otherImage = (new self)->loadFile($otherImage);
}
$imageSize = $this->getSize()->align($position, $x, $y);
$watermarkSize = $otherImage->getSize()->align($position);
$target = $imageSize->relativePosition($watermarkSize);
$otherVipsImage = $otherImage->image();
assert($otherVipsImage instanceof Image);
// Apply alpha if not 100%
if ($alpha < 100) {
$alphaFactor = $alpha / 100;
if ($otherVipsImage->hasAlpha()) {
// Multiply existing alpha channel
$bands = $otherVipsImage->bands;
$rgb = $otherVipsImage->extract_band(0, ['n' => $bands - 1]);
$existingAlpha = $otherVipsImage->extract_band($bands - 1);
$newAlpha = $existingAlpha->linear($alphaFactor, 0);
$otherVipsImage = $rgb->bandjoin($newAlpha);
} else {
// Add alpha channel
$alphaChannel = Image::black($otherVipsImage->width, $otherVipsImage->height)
->add(255 * $alphaFactor)
->cast(BandFormat::UCHAR);
$otherVipsImage = $otherVipsImage->bandjoin($alphaChannel);
}
}
$this->image = $this->image->composite2($otherVipsImage, 'over', [
'x' => $target->x,
'y' => $target->y,
]);
return $this;
}
public function text(string $text, int $fontSize, string $color = '000000', int $x = 0, int $y = 0, int $angle = 0, string $fontPath = '', int $width = 0): static
{
if ($fontPath && ! file_exists($fontPath)) {
throw InvalidFont::make($fontPath);
}
$textColor = new VipsColor($color);
$textOptions = [
'dpi' => 72,
'rgba' => true,
];
if ($fontPath) {
$textOptions['fontfile'] = $fontPath;
}
if ($width > 0) {
$text = $this->wrapText($text, $fontSize, $fontPath, $angle, $width);
$textOptions['width'] = $width;
}
// Create text image using Pango markup for font size
$markup = sprintf('<span foreground="#%s" size="%d">%s</span>',
ltrim($color, '#'),
$fontSize * 1024,
htmlspecialchars($text)
);
$textImage = Image::text($markup, $textOptions);
// Apply rotation if needed
if ($angle !== 0) {
$textImage = $textImage->rotate($angle);
}
// Ensure main image has alpha channel for compositing
if (! $this->image->hasAlpha()) {
$this->image = $this->image->bandjoin(255);
}
// Composite text onto image
$this->image = $this->image->composite2($textImage, 'over', [
'x' => $x,
'y' => $y,
]);
return $this;
}
public function wrapText(string $text, int $fontSize, string $fontPath = '', int $angle = 0, int $width = 0): string
{
if ($fontPath && ! file_exists($fontPath)) {
throw InvalidFont::make($fontPath);
}
if ($width <= 0) {
return $text;
}
// Simple word wrapping based on estimated character width
// This is approximate since we don't have access to font metrics
$avgCharWidth = $fontSize * 0.6; // Approximate average character width
$charsPerLine = (int) floor($width / $avgCharWidth);
if ($charsPerLine <= 0) {
return $text;
}
return wordwrap($text, $charsPerLine, "\n", true);
}
public function image(): Image
{
return $this->image;
}
public function resize(int $width, int $height, array $constraints): static
{
$resized = $this->getSize()->resize($width, $height, $constraints);
$widthRatio = $resized->width / $this->image->width;
$heightRatio = $resized->height / $this->image->height;
$this->image = $this->image->resize($widthRatio, [
'vscale' => $heightRatio,
]);
return $this;
}
public function width(int $width, array $constraints = []): static
{
$newHeight = (int) round($width / $this->getSize()->aspectRatio());
$this->resize($width, $newHeight, $constraints);
return $this;
}
public function height(int $height, array $constraints = []): static
{
$newWidth = (int) round($height * $this->getSize()->aspectRatio());
$this->resize($newWidth, $height, $constraints);
return $this;
}
public function border(int $width, BorderType $type, string $color = '000000'): static
{
if ($type === BorderType::Shrink) {
$originalWidth = $this->getWidth();
$originalHeight = $this->getHeight();
$this
->resize(
(int) round($this->getWidth() - ($width * 2)),
(int) round($this->getHeight() - ($width * 2)),
[Constraint::PreserveAspectRatio],
)
->resizeCanvas(
$originalWidth,
$originalHeight,
AlignPosition::Center,
false,
$color,
);
return $this;
}
if ($type === BorderType::Expand) {
$this->resizeCanvas(
(int) round($width * 2),
(int) round($width * 2),
AlignPosition::Center,
true,
$color,
);
return $this;
}
if ($type === BorderType::Overlay) {
$borderColor = new VipsColor($color);
// Create a rectangle border by drawing lines on each edge
$imgWidth = $this->getWidth();
$imgHeight = $this->getHeight();
// Create a mask for the inner area (transparent)
$innerWidth = $imgWidth - ($width * 2);
$innerHeight = $imgHeight - ($width * 2);
if ($innerWidth > 0 && $innerHeight > 0) {
// Create border frame with proper sRGB colorspace
$rgba = $borderColor->getArray();
$borderFrame = Image::black($imgWidth, $imgHeight, ['bands' => 3])
->add([$rgba[0], $rgba[1], $rgba[2]])
->cast(BandFormat::UCHAR)
->copy(['interpretation' => 'srgb']);
$innerMask = Image::black($innerWidth, $innerHeight)->add(0)->cast(BandFormat::UCHAR);
$outerMask = Image::black($imgWidth, $imgHeight)->add(255)->cast(BandFormat::UCHAR);
// Embed inner mask in outer mask
$mask = $outerMask->insert($innerMask, $width, $width);
// Add alpha channel to border frame
$borderFrame = $borderFrame->bandjoin($mask);
// Ensure main image has alpha channel
if (! $this->image->hasAlpha()) {
$this->image = $this->image->bandjoin(255);
}
// Composite border onto image
$this->image = $this->image->composite2($borderFrame, 'over');
}
return $this;
}
}
public function quality(int $quality): static
{
$this->quality = $quality;
return $this;
}
public function format(string $format): static
{
$this->format = $format;
return $this;
}
}