Initial commit

This commit is contained in:
isUnknown 2025-10-03 07:46:23 +02:00
commit efa5624dab
687 changed files with 162710 additions and 0 deletions

View file

@ -0,0 +1,70 @@
<?php
namespace Kirby\Image;
use Stringable;
/**
* Small class which hold info about the camera
*
* @package Kirby Image
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Camera implements Stringable
{
protected string|null $make;
protected string|null $model;
public function __construct(array $exif)
{
$this->make = $exif['Make'] ?? null;
$this->model = $exif['Model'] ?? null;
}
/**
* Returns the make of the camera
*/
public function make(): string|null
{
return $this->make;
}
/**
* Returns the camera model
*/
public function model(): string|null
{
return $this->model;
}
/**
* Converts the object into a nicely readable array
*/
public function toArray(): array
{
return [
'make' => $this->make,
'model' => $this->model
];
}
/**
* Returns the full make + model name
*/
public function __toString(): string
{
return trim($this->make . ' ' . $this->model);
}
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*/
public function __debugInfo(): array
{
return $this->toArray();
}
}

View file

@ -0,0 +1,150 @@
<?php
namespace Kirby\Image;
use Exception;
use Kirby\Image\Darkroom\GdLib;
use Kirby\Image\Darkroom\ImageMagick;
use Kirby\Image\Darkroom\Imagick;
/**
* A wrapper around resizing and cropping
* via GDLib, ImageMagick or other libraries.
*
* @package Kirby Image
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Darkroom
{
public static array $types = [
'gd' => GdLib::class,
'imagick' => Imagick::class,
'im' => ImageMagick::class
];
public function __construct(
protected array $settings = []
) {
$this->settings = [...$this->defaults(), ...$settings];
}
/**
* Creates a new Darkroom instance
* for the given type/driver
*
* @throws \Exception
*/
public static function factory(string $type, array $settings = []): static
{
if (isset(static::$types[$type]) === false) {
throw new Exception(message: 'Invalid Darkroom type');
}
return new static::$types[$type]($settings);
}
/**
* Returns the default thumb settings
*/
protected function defaults(): array
{
return [
'blur' => false,
'crop' => false,
'format' => null,
'grayscale' => false,
'height' => null,
'quality' => 90,
'scaleHeight' => null,
'scaleWidth' => null,
'sharpen' => null,
'width' => null,
];
}
/**
* Normalizes all thumb options
*/
protected function options(array $options = []): array
{
$options = [
...$this->settings,
...$options,
// ensure quality isn't unset by provided options
'quality' => $options['quality'] ?? $this->settings['quality']
];
// normalize the crop option
if ($options['crop'] === true) {
$options['crop'] = 'center';
}
// normalize the blur option
if ($options['blur'] === true) {
$options['blur'] = 10;
}
// normalize the grayscale option
if (isset($options['greyscale']) === true) {
$options['grayscale'] = $options['greyscale'];
unset($options['greyscale']);
}
// normalize the bw option
if (isset($options['bw']) === true) {
$options['grayscale'] = $options['bw'];
unset($options['bw']);
}
// normalize the sharpen option
if ($options['sharpen'] === true) {
$options['sharpen'] = 50;
}
return $options;
}
/**
* Calculates the dimensions of the final thumb based
* on the given options and returns a full array with
* all the final options to be used for the image generator
*/
public function preprocess(string $file, array $options = []): array
{
$options = $this->options($options);
$image = new Image($file);
$options['sourceWidth'] = $image->width();
$options['sourceHeight'] = $image->height();
$dimensions = $image->dimensions();
$thumbDimensions = $dimensions->thumb($options);
$options['width'] = $thumbDimensions->width();
$options['height'] = $thumbDimensions->height();
// scale ratio compared to the source dimensions
$options['scaleWidth'] = Focus::ratio(
$options['width'],
$options['sourceWidth']
);
$options['scaleHeight'] = Focus::ratio(
$options['height'],
$options['sourceHeight']
);
return $options;
}
/**
* This method must be replaced by the driver to run the
* actual image processing job.
*/
public function process(string $file, array $options = []): array
{
return $this->preprocess($file, $options);
}
}

View file

@ -0,0 +1,130 @@
<?php
namespace Kirby\Image\Darkroom;
use claviska\SimpleImage;
use Kirby\Filesystem\Mime;
use Kirby\Image\Darkroom;
use Kirby\Image\Focus;
/**
* GdLib darkroom driver
*
* @package Kirby Image
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class GdLib extends Darkroom
{
/**
* Processes the image with the SimpleImage library
*/
public function process(string $file, array $options = []): array
{
$options = $this->preprocess($file, $options);
$mime = $this->mime($options);
$image = new SimpleImage();
$image->fromFile($file);
$image->autoOrient();
$image = $this->resize($image, $options);
$image = $this->blur($image, $options);
$image = $this->grayscale($image, $options);
$image = $this->sharpen($image, $options);
$image->toFile($file, $mime, $options);
return $options;
}
/**
* Wrapper around SimpleImage's resize and crop methods
*/
protected function resize(SimpleImage $image, array $options): SimpleImage
{
// just resize, no crop
if ($options['crop'] === false) {
return $image->resize($options['width'], $options['height']);
}
// crop based on focus point
if (Focus::isFocalPoint($options['crop']) === true) {
// get crop coords for focal point:
// if image needs to be cropped, crop before resizing
if ($focus = Focus::coords(
$options['crop'],
$options['sourceWidth'],
$options['sourceHeight'],
$options['width'],
$options['height']
)) {
$image->crop(
$focus['x1'],
$focus['y1'],
$focus['x2'],
$focus['y2']
);
}
return $image->thumbnail($options['width'], $options['height']);
}
// normal crop with crop anchor
return $image->thumbnail(
$options['width'],
$options['height'] ?? $options['width'],
$options['crop']
);
}
/**
* Applies the correct blur settings for SimpleImage
*/
protected function blur(SimpleImage $image, array $options): SimpleImage
{
if ($options['blur'] === false) {
return $image;
}
return $image->blur('gaussian', (int)$options['blur']);
}
/**
* Applies grayscale conversion if activated in the options.
*/
protected function grayscale(SimpleImage $image, array $options): SimpleImage
{
if ($options['grayscale'] === false) {
return $image;
}
return $image->desaturate();
}
/**
* Applies sharpening if activated in the options.
*/
protected function sharpen(SimpleImage $image, array $options): SimpleImage
{
if (is_int($options['sharpen']) === false) {
return $image;
}
return $image->sharpen($options['sharpen']);
}
/**
* Returns mime type based on `format` option
*/
protected function mime(array $options): string|null
{
if ($options['format'] === null) {
return null;
}
return Mime::fromExtension($options['format']);
}
}

View file

@ -0,0 +1,236 @@
<?php
namespace Kirby\Image\Darkroom;
use Exception;
use Kirby\Filesystem\F;
use Kirby\Image\Darkroom;
use Kirby\Image\Focus;
/**
* Legacy ImageMagick driver using the convert CLI
*
* @package Kirby Image
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* @deprecated 5.1.0 Use `imagick` in the `thumbs.driver` config option instead
* @todo Remove in 7.0.0
*/
class ImageMagick extends Darkroom
{
/**
* Applies the blur settings
*/
protected function blur(string $file, array $options): string|null
{
if ($options['blur'] !== false) {
return '-blur ' . escapeshellarg('0x' . $options['blur']);
}
return null;
}
/**
* Keep animated gifs
*/
protected function coalesce(string $file, array $options): string|null
{
if (F::extension($file) === 'gif') {
return '-coalesce';
}
return null;
}
/**
* Creates the convert command with the right path to the binary file
*/
protected function convert(string $file, array $options): string
{
$command = escapeshellarg($options['bin']);
// default is limiting to single-threading to keep CPU usage sane
$command .= ' -limit thread ' . escapeshellarg($options['threads']);
// append input file
return $command . ' ' . escapeshellarg($file);
}
/**
* Returns additional default parameters for imagemagick
*/
protected function defaults(): array
{
return parent::defaults() + [
'bin' => 'convert',
'interlace' => false,
'threads' => 1,
];
}
/**
* Applies the correct settings for grayscale images
*/
protected function grayscale(string $file, array $options): string|null
{
if ($options['grayscale'] === true) {
return '-colorspace gray';
}
return null;
}
/**
* Applies sharpening if activated in the options.
*/
protected function sharpen(string $file, array $options): string|null
{
if (is_int($options['sharpen']) === false) {
return null;
}
$amount = max(1, min(100, $options['sharpen'])) / 100;
return '-sharpen ' . escapeshellarg('0x' . $amount);
}
/**
* Applies the correct settings for interlaced JPEGs if
* activated via options
*/
protected function interlace(string $file, array $options): string|null
{
if ($options['interlace'] === true) {
return '-interlace line';
}
return null;
}
/**
* Creates and runs the full imagemagick command
* to process the image
*
* @throws \Exception
*/
public function process(string $file, array $options = []): array
{
$options = $this->preprocess($file, $options);
$command = [];
$command[] = $this->convert($file, $options);
$command[] = $this->strip($file, $options);
$command[] = $this->interlace($file, $options);
$command[] = $this->coalesce($file, $options);
$command[] = $this->grayscale($file, $options);
$command[] = '-auto-orient';
$command[] = $this->resize($file, $options);
$command[] = $this->quality($file, $options);
$command[] = $this->blur($file, $options);
$command[] = $this->sharpen($file, $options);
$command[] = $this->save($file, $options);
// remove all null values and join the parts
$command = implode(' ', array_filter($command));
// try to execute the command
exec($command, $output, $return);
// log broken commands
if ($return !== 0) {
throw new Exception(message: 'The imagemagick convert command could not be executed: ' . $command);
}
return $options;
}
/**
* Applies the correct JPEG compression quality settings
*/
protected function quality(string $file, array $options): string
{
return '-quality ' . escapeshellarg($options['quality']);
}
/**
* Creates the correct options to crop or resize the image
* and translates the crop positions for imagemagick
*/
protected function resize(string $file, array $options): string
{
// simple resize
if ($options['crop'] === false) {
return '-thumbnail ' . escapeshellarg(sprintf('%sx%s!', $options['width'], $options['height']));
}
// crop based on focus point
if (Focus::isFocalPoint($options['crop']) === true) {
if ($focus = Focus::coords(
$options['crop'],
$options['sourceWidth'],
$options['sourceHeight'],
$options['width'],
$options['height']
)) {
return sprintf(
'-crop %sx%s+%s+%s -resize %sx%s^',
$focus['width'],
$focus['height'],
$focus['x1'],
$focus['y1'],
$options['width'],
$options['height']
);
}
}
// translate the gravity option into something imagemagick understands
$gravity = match ($options['crop'] ?? null) {
'top left' => 'NorthWest',
'top' => 'North',
'top right' => 'NorthEast',
'left' => 'West',
'right' => 'East',
'bottom left' => 'SouthWest',
'bottom' => 'South',
'bottom right' => 'SouthEast',
default => 'Center'
};
$command = '-thumbnail ' . escapeshellarg(sprintf('%sx%s^', $options['width'], $options['height']));
$command .= ' -gravity ' . escapeshellarg($gravity);
$command .= ' -crop ' . escapeshellarg(sprintf('%sx%s+0+0', $options['width'], $options['height']));
return $command;
}
/**
* Creates the option for the output file
*/
protected function save(string $file, array $options): string
{
if ($options['format'] !== null) {
$file = pathinfo($file, PATHINFO_DIRNAME) . '/' . pathinfo($file, PATHINFO_FILENAME) . '.' . $options['format'];
}
return escapeshellarg($file);
}
/**
* Removes all metadata from the image
*/
protected function strip(string $file, array $options): string
{
if (F::extension($file) === 'png') {
// ImageMagick does not support keeping ICC profiles while
// stripping other privacy- and security-related information,
// such as GPS data; so discard all color profiles for PNG files
// (tested with ImageMagick 7.0.11-14 Q16 x86_64 2021-05-31)
return '-strip';
}
return '';
}
}

View file

@ -0,0 +1,292 @@
<?php
namespace Kirby\Image\Darkroom;
use Exception;
use Imagick as Image;
use Kirby\Image\Darkroom;
use Kirby\Image\Focus;
/**
* Imagick darkroom driver
*
* @package Kirby Image
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
*/
class Imagick extends Darkroom
{
protected function autoOrient(Image $image): Image
{
switch ($image->getImageOrientation()) {
case Image::ORIENTATION_TOPLEFT:
break;
case Image::ORIENTATION_TOPRIGHT:
$image->flopImage();
break;
case Image::ORIENTATION_BOTTOMRIGHT:
$image->rotateImage('#000', 180);
break;
case Image::ORIENTATION_BOTTOMLEFT:
$image->flopImage();
$image->rotateImage('#000', 180);
break;
case Image::ORIENTATION_LEFTTOP:
$image->flopImage();
$image->rotateImage('#000', -90);
break;
case Image::ORIENTATION_RIGHTTOP:
$image->rotateImage('#000', 90);
break;
case Image::ORIENTATION_RIGHTBOTTOM:
$image->flopImage();
$image->rotateImage('#000', 90);
break;
case Image::ORIENTATION_LEFTBOTTOM:
$image->rotateImage('#000', -90);
break;
default: // Invalid orientation
break;
}
$image->setImageOrientation(Image::ORIENTATION_TOPLEFT);
return $image;
}
/**
* Applies the blur settings
*/
protected function blur(Image $image, array $options): Image
{
if ($options['blur'] !== false) {
$image->blurImage(0.0, $options['blur']);
}
return $image;
}
/**
* Keep animated gifs
*/
protected function coalesce(Image $image): Image
{
if ($image->getImageMimeType() === 'image/gif') {
return $image->coalesceImages();
}
return $image;
}
/**
* Returns additional default parameters for imagemagick
*/
protected function defaults(): array
{
return parent::defaults() + [
'interlace' => false,
'profiles' => ['icc', 'icm'],
'threads' => 1,
];
}
/**
* Applies the correct settings for grayscale images
*/
protected function grayscale(Image $image, array $options): Image
{
if ($options['grayscale'] === true) {
$image->setImageColorspace(Image::COLORSPACE_GRAY);
}
return $image;
}
/**
* Applies the correct settings for interlaced JPEGs if
* activated via options
*/
protected function interlace(Image $image, array $options): Image
{
if ($options['interlace'] === true) {
$image->setInterlaceScheme(Image::INTERLACE_LINE);
}
return $image;
}
/**
* Creates and runs the full imagemagick command
* to process the image
*
* @throws \Exception
*/
public function process(string $file, array $options = []): array
{
$options = $this->preprocess($file, $options);
$image = new Image($file);
$image = $this->threads($image, $options);
$image = $this->interlace($image, $options);
$image = $this->coalesce($image);
$image = $this->grayscale($image, $options);
$image = $this->autoOrient($image);
$image = $this->resize($image, $options);
$image = $this->quality($image, $options);
$image = $this->blur($image, $options);
$image = $this->sharpen($image, $options);
$image = $this->strip($image, $options);
if ($this->save($image, $file, $options) === false) {
// @codeCoverageIgnoreStart
throw new Exception(message: 'The imagemagick result could not be generated');
// @codeCoverageIgnoreEnd
}
return $options;
}
/**
* Applies the correct JPEG compression quality settings
*/
protected function quality(Image $image, array $options): Image
{
$image->setImageCompressionQuality($options['quality']);
return $image;
}
/**
* Creates the correct options to crop or resize the image
* and translates the crop positions for imagemagick
*/
protected function resize(Image $image, array $options): Image
{
// simple resize
if ($options['crop'] === false) {
$image->thumbnailImage(
$options['width'],
$options['height'],
true
);
return $image;
}
// crop based on focus point
if (Focus::isFocalPoint($options['crop']) === true) {
if ($focus = Focus::coords(
$options['crop'],
$options['sourceWidth'],
$options['sourceHeight'],
$options['width'],
$options['height']
)) {
$image->cropImage(
$focus['width'],
$focus['height'],
$focus['x1'],
$focus['y1']
);
$image->thumbnailImage(
$options['width'],
$options['height'],
true
);
return $image;
}
}
// translate the gravity option into something imagemagick understands
$gravity = match ($options['crop'] ?? null) {
'top left' => Image::GRAVITY_NORTHWEST,
'top' => Image::GRAVITY_NORTH,
'top right' => Image::GRAVITY_NORTHEAST,
'left' => Image::GRAVITY_WEST,
'right' => Image::GRAVITY_EAST,
'bottom left' => Image::GRAVITY_SOUTHWEST,
'bottom' => Image::GRAVITY_SOUTH,
'bottom right' => Image::GRAVITY_SOUTHEAST,
default => Image::GRAVITY_CENTER
};
$landscape = $options['width'] >= $options['height'];
$image->thumbnailImage(
$landscape ? $options['width'] : $image->getImageWidth(),
$landscape ? $image->getImageHeight() : $options['height'],
true
);
$image->setGravity($gravity);
$image->cropImage($options['width'], $options['height'], 0, 0);
return $image;
}
/**
* Creates the option for the output file
*/
protected function save(Image $image, string $file, array $options): bool
{
if ($options['format'] !== null) {
$file = pathinfo($file, PATHINFO_DIRNAME) . '/' . pathinfo($file, PATHINFO_FILENAME) . '.' . $options['format'];
}
return $image->writeImages($file, true);
}
/**
* Applies sharpening if activated in the options.
*/
protected function sharpen(Image $image, array $options): Image
{
if (is_int($options['sharpen']) === false) {
return $image;
}
$amount = max(1, min(100, $options['sharpen'])) / 100;
$image->sharpenImage(0.0, $amount);
return $image;
}
/**
* Removes all metadata but ICC profiles from the image
*/
protected function strip(Image $image, array $options): Image
{
// strip all profiles but the ICC profile
$profiles = $image->getImageProfiles('*', false);
foreach ($profiles as $profile) {
if (in_array($profile, $options['profiles'] ?? [], true) === false) {
$image->removeImageProfile($profile);
}
}
// strip all properties
$properties = $image->getImageProperties('*', false);
foreach ($properties as $property) {
$image->deleteImageProperty($property);
}
return $image;
}
/**
* Sets thread limit
*/
protected function threads(Image $image, array $options): Image
{
$image->setResourceLimit(
Image::RESOURCETYPE_THREAD,
$options['threads']
);
return $image;
}
}

View file

@ -0,0 +1,409 @@
<?php
namespace Kirby\Image;
use Kirby\Toolkit\Str;
use Stringable;
/**
* The Dimension class is used to provide additional
* methods for images and possibly other objects with
* width and height to recalculate the size,
* get the ratio or just the width and height.
*
* @package Kirby Image
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Dimensions implements Stringable
{
public function __construct(
public int $width,
public int $height
) {
}
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Echos the dimensions as width × height
*/
public function __toString(): string
{
return $this->width . ' × ' . $this->height;
}
/**
* Crops the dimensions by width and height
*
* @return $this
*/
public function crop(int $width, int|null $height = null): static
{
$this->width = $width;
$this->height = $width;
if ($height !== 0 && $height !== null) {
$this->height = $height;
}
return $this;
}
/**
* Returns the height
*/
public function height(): int
{
return $this->height;
}
/**
* Recalculates the width and height to fit into the given box.
*
* ```php
* $dimensions = new Dimensions(1200, 768);
* $dimensions->fit(500);
*
* echo $dimensions->width();
* // output: 500
*
* echo $dimensions->height();
* // output: 320
* ```
*
* @param int $box the max width and/or height
* @param bool $force If true, the dimensions will be
* upscaled to fit the box if smaller
* @return $this object with recalculated dimensions
*/
public function fit(int $box, bool $force = false): static
{
if ($this->width === 0 || $this->height === 0) {
$this->width = $box;
$this->height = $box;
return $this;
}
$ratio = $this->ratio();
if ($this->width > $this->height) {
// wider than tall
if ($this->width > $box || $force === true) {
$this->width = $box;
}
$this->height = (int)round($this->width / $ratio);
} elseif ($this->height > $this->width) {
// taller than wide
if ($this->height > $box || $force === true) {
$this->height = $box;
}
$this->width = (int)round($this->height * $ratio);
} elseif ($this->width > $box) {
// width = height but bigger than box
$this->width = $box;
$this->height = $box;
}
return $this;
}
/**
* Recalculates the width and height to fit the given height
*
* ```php
* $dimensions = new Dimensions(1200, 768);
* $dimensions->fitHeight(500);
*
* echo $dimensions->width();
* // output: 781
*
* echo $dimensions->height();
* // output: 500
* ```
*
* @param int|null $fit the max height
* @param bool $force If true, the dimensions will be
* upscaled to fit the box if smaller
* @return $this object with recalculated dimensions
*/
public function fitHeight(
int|null $fit = null,
bool $force = false
): static {
return $this->fitSize('height', $fit, $force);
}
/**
* Helper for fitWidth and fitHeight methods
*
* @param string $ref reference (width or height)
* @param int|null $fit the max width
* @param bool $force If true, the dimensions will be
* upscaled to fit the box if smaller
* @return $this object with recalculated dimensions
*/
protected function fitSize(
string $ref,
int|null $fit = null,
bool $force = false
): static {
if ($fit === 0 || $fit === null) {
return $this;
}
if ($this->$ref <= $fit && !$force) {
return $this;
}
$ratio = $this->ratio();
$mode = $ref === 'width';
$this->width = $mode ? $fit : (int)round($fit * $ratio);
$this->height = !$mode ? $fit : (int)round($fit / $ratio);
return $this;
}
/**
* Recalculates the width and height to fit the given width
*
* ```php
* $dimensions = new Dimensions(1200, 768);
* $dimensions->fitWidth(500);
*
* echo $dimensions->width();
* // output: 500
*
* echo $dimensions->height();
* // output: 320
* ```
*
* @param int|null $fit the max width
* @param bool $force If true, the dimensions will be
* upscaled to fit the box if smaller
* @return $this object with recalculated dimensions
*/
public function fitWidth(
int|null $fit = null,
bool $force = false
): static {
return $this->fitSize('width', $fit, $force);
}
/**
* Recalculates the dimensions by the width and height
*
* @param int|null $width the max height
* @param int|null $height the max width
* @return $this
*/
public function fitWidthAndHeight(
int|null $width = null,
int|null $height = null,
bool $force = false
): static {
if ($this->width > $this->height) {
$this->fitWidth($width, $force);
// do another check for the max height
if ($this->height > $height) {
$this->fitHeight($height);
}
} else {
$this->fitHeight($height, $force);
// do another check for the max width
if ($this->width > $width) {
$this->fitWidth($width);
}
}
return $this;
}
/**
* Detect the dimensions for an image file
*/
public static function forImage(Image $image): static
{
if ($image->exists() === false) {
return new static(0, 0);
}
$orientation = $image->exif()->orientation();
$size = $image->imagesize();
return match ($orientation) {
// 5-8 = rotated
5, 6, 7, 8 => new static($size[1] ?? 1, $size[0] ?? 0),
// 1 = normal; 2-4 = flipped
default => new static($size[0] ?? 0, $size[1] ?? 1)
};
}
/**
* Detect the dimensions for a svg file
*/
public static function forSvg(string $root): static
{
// avoid xml errors
libxml_use_internal_errors(true);
$content = file_get_contents($root);
$height = 0;
$width = 0;
$xml = simplexml_load_string($content);
if ($xml !== false) {
$attr = $xml->attributes();
$rawWidth = $attr->width;
$width = (int)$rawWidth;
$rawHeight = $attr->height;
$height = (int)$rawHeight;
// use viewbox values if direct attributes are 0
// or based on percentages
if (empty($attr->viewBox) === false) {
$box = explode(' ', $attr->viewBox);
// when using viewbox values, make sure to subtract
// first two box values from last two box values
// to retrieve the absolute dimensions
if (Str::endsWith($rawWidth, '%') === true || $width === 0) {
$width = (int)($box[2] ?? 0) - (int)($box[0] ?? 0);
}
if (Str::endsWith($rawHeight, '%') === true || $height === 0) {
$height = (int)($box[3] ?? 0) - (int)($box[1] ?? 0);
}
}
}
return new static($width, $height);
}
/**
* Checks if the dimensions are landscape
*/
public function landscape(): bool
{
return $this->width > $this->height;
}
/**
* Returns a string representation of the orientation
*/
public function orientation(): string|false
{
if (!$this->ratio()) {
return false;
}
if ($this->portrait() === true) {
return 'portrait';
}
if ($this->landscape() === true) {
return 'landscape';
}
return 'square';
}
/**
* Checks if the dimensions are portrait
*/
public function portrait(): bool
{
return $this->height > $this->width;
}
/**
* Calculates and returns the ratio
*
* ```php
* $dimensions = new Dimensions(1200, 768);
* echo $dimensions->ratio();
* // output: 1.5625
* ```
*/
public function ratio(): float
{
if ($this->width !== 0 && $this->height !== 0) {
return $this->width / $this->height;
}
return 0.0;
}
/**
* Resizes image
* @return $this
*/
public function resize(
int|null $width = null,
int|null $height = null,
bool $force = false
): static {
return $this->fitWidthAndHeight($width, $height, $force);
}
/**
* Checks if the dimensions are square
*/
public function square(): bool
{
return $this->width === $this->height;
}
/**
* Resize and crop
*
* @return $this
*/
public function thumb(array $options = []): static
{
$width = $options['width'] ?? null;
$height = $options['height'] ?? null;
$crop = $options['crop'] ?? false;
$method = $crop !== false ? 'crop' : 'resize';
if ($width === null && $height === null) {
return $this;
}
return $this->$method($width, $height);
}
/**
* Converts the dimensions object
* to a plain PHP array
*/
public function toArray(): array
{
return [
'width' => $this->width(),
'height' => $this->height(),
'ratio' => $this->ratio(),
'orientation' => $this->orientation(),
];
}
/**
* Returns the width
*/
public function width(): int
{
return $this->width;
}
}

216
kirby/src/Image/Exif.php Normal file
View file

@ -0,0 +1,216 @@
<?php
namespace Kirby\Image;
use Kirby\Toolkit\A;
use Kirby\Toolkit\V;
/**
* Reads exif data from a given image object
*
* @package Kirby Image
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Exif
{
/**
* The raw exif array
*/
protected array $data = [];
protected string|null $aperture = null;
protected Camera|null $camera = null;
protected string|null $exposure = null;
protected string|null $focalLength = null;
protected bool|null $isColor = null;
protected array|string|null $iso = null;
protected Location|null $location = null;
protected string|null $timestamp = null;
protected int $orientation;
public function __construct(
protected Image $image
) {
$this->data = $this->read($image->root());
$this->aperture = $this->computed()['ApertureFNumber'] ?? null;
$this->exposure = $this->data['ExposureTime'] ?? null;
$this->focalLength = $this->parseFocalLength();
$this->isColor = V::accepted($this->computed()['IsColor'] ?? null);
$this->iso = $this->data['ISOSpeedRatings'] ?? null;
$this->orientation = $this->data['Orientation'] ?? 1;
$this->timestamp = $this->parseTimestamp();
}
/**
* Returns the raw data array from the parser
*/
public function data(): array
{
return $this->data;
}
/**
* Returns the Camera object
*/
public function camera(): Camera
{
return $this->camera ??= new Camera($this->data);
}
/**
* Returns the location object
*/
public function location(): Location
{
return $this->location ??= new Location($this->data);
}
/**
* Returns the timestamp
*/
public function timestamp(): string|null
{
return $this->timestamp;
}
/**
* Returns the exposure
*/
public function exposure(): string|null
{
return $this->exposure;
}
/**
* Returns the aperture
*/
public function aperture(): string|null
{
return $this->aperture;
}
/**
* Returns the iso value
*/
public function iso(): string|null
{
if (is_array($this->iso) === true) {
return A::first($this->iso);
}
return $this->iso;
}
/**
* Checks if this is a color picture
*/
public function isColor(): bool|null
{
return $this->isColor;
}
/**
* Checks if this is a bw picture
*/
public function isBW(): bool|null
{
return ($this->isColor !== null) ? $this->isColor === false : null;
}
/**
* Returns the focal length
*/
public function focalLength(): string|null
{
return $this->focalLength;
}
/**
* Read the exif data of the image object if possible
*/
public static function read(string $root): array
{
// @codeCoverageIgnoreStart
if (function_exists('exif_read_data') === false) {
return [];
}
// @codeCoverageIgnoreEnd
$data = @exif_read_data($root);
return is_array($data) ? $data : [];
}
/**
* Get all computed data
*/
protected function computed(): array
{
return $this->data['COMPUTED'] ?? [];
}
/**
* Returns the exif orientation
*/
public function orientation(): int
{
return $this->orientation;
}
/**
* Return the timestamp when the picture has been taken
*/
protected function parseTimestamp(): string
{
if (isset($this->data['DateTimeOriginal']) === true) {
if ($time = strtotime($this->data['DateTimeOriginal'])) {
return (string)$time;
}
}
return $this->data['FileDateTime'] ?? $this->image->modified();
}
/**
* Return the focal length
*/
protected function parseFocalLength(): string|null
{
return
$this->data['FocalLength'] ??
$this->data['FocalLengthIn35mmFilm'] ??
null;
}
/**
* Converts the object into a nicely readable array
*/
public function toArray(): array
{
return [
'camera' => $this->camera()->toArray(),
'location' => $this->location()->toArray(),
'timestamp' => $this->timestamp(),
'exposure' => $this->exposure(),
'aperture' => $this->aperture(),
'iso' => $this->iso(),
'focalLength' => $this->focalLength(),
'isColor' => $this->isColor()
];
}
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*/
public function __debugInfo(): array
{
return [
...$this->toArray(),
'camera' => $this->camera(),
'location' => $this->location()
];
}
}

110
kirby/src/Image/Focus.php Normal file
View file

@ -0,0 +1,110 @@
<?php
namespace Kirby\Image;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* @package Kirby Image
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Focus
{
/**
* Generates crop coordinates based on focal point
*/
public static function coords(
string $crop,
int $sourceWidth,
int $sourceHeight,
int $width,
int $height
): array|null {
[$x, $y] = static::parse($crop);
// determine aspect ratios
$ratioSource = static::ratio($sourceWidth, $sourceHeight);
$ratioThumb = static::ratio($width, $height);
// no cropping necessary
if ($ratioSource == $ratioThumb) {
return null;
}
// defaults
$width = $sourceWidth;
$height = $sourceHeight;
if ($ratioThumb > $ratioSource) {
$height = $sourceWidth / $ratioThumb;
} else {
$width = $sourceHeight * $ratioThumb;
}
// calculate focus for original image
$x = $sourceWidth * $x;
$y = $sourceHeight * $y;
$x1 = max(0, $x - $width / 2);
$y1 = max(0, $y - $height / 2);
// off canvas?
if ($x1 + $width > $sourceWidth) {
$x1 = $sourceWidth - $width;
}
if ($y1 + $height > $sourceHeight) {
$y1 = $sourceHeight - $height;
}
return [
'x1' => (int)floor($x1),
'y1' => (int)floor($y1),
'x2' => (int)floor($x1 + $width),
'y2' => (int)floor($y1 + $height),
'width' => (int)floor($width),
'height' => (int)floor($height),
];
}
public static function isFocalPoint(string $value): bool
{
return Str::contains($value, '%') === true;
}
/**
* Transforms the focal point's string value (from content field)
* to a [x, y] array (values 0.0-1.0)
*/
public static function parse(string $value): array
{
// support for former Focus plugin
if (Str::startsWith($value, '{') === true) {
$focus = json_decode($value);
return [$focus->x, $focus->y];
}
preg_match_all("/(\d{1,3}\.?\d*)[%|,|\s]*/", $value, $points);
return A::map(
$points[1],
function ($point) {
$point = (float)$point;
$point = $point > 1 ? $point / 100 : $point;
return round($point, 3);
}
);
}
/**
* Calculates the image ratio
*/
public static function ratio(int $width, int $height): float
{
return $height !== 0 ? $width / $height : 0;
}
}

233
kirby/src/Image/Image.php Normal file
View file

@ -0,0 +1,233 @@
<?php
namespace Kirby\Image;
use Kirby\Cms\FileVersion;
use Kirby\Content\Content;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\File;
use Kirby\Toolkit\Html;
/**
* A representation of an image file
* with dimensions, optional exif data and
* a connection to our darkroom classes to resize/crop
* images.
*
* Extends the `Kirby\Filesystem\File` class with
* those image-specific methods.
*
* @package Kirby Image
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Image extends File
{
protected Exif|null $exif = null;
protected Dimensions|null $dimensions = null;
public static array $resizableTypes = [
'avif',
'jpg',
'jpeg',
'gif',
'png',
'webp'
];
public static array $viewableTypes = [
'avif',
'jpg',
'jpeg',
'gif',
'png',
'svg',
'webp'
];
/**
* Validation rules to be used for `::match()`
*/
public static array $validations = [
'maxsize' => ['size', 'max'],
'minsize' => ['size', 'min'],
'maxwidth' => ['width', 'max'],
'minwidth' => ['width', 'min'],
'maxheight' => ['height', 'max'],
'minheight' => ['height', 'min'],
'orientation' => ['orientation', 'same']
];
/**
* Returns the `<img>` tag for the image object
*/
public function __toString(): string
{
return $this->html();
}
/**
* Returns the dimensions of the file if possible
*/
public function dimensions(): Dimensions
{
if ($this->dimensions !== null) {
return $this->dimensions;
}
if (in_array($this->mime(), [
'image/avif',
'image/gif',
'image/jpeg',
'image/jp2',
'image/png',
'image/webp'
], true)) {
return $this->dimensions = Dimensions::forImage($this);
}
if ($this->extension() === 'svg') {
return $this->dimensions = Dimensions::forSvg($this->root);
}
return $this->dimensions = new Dimensions(0, 0);
}
/**
* Returns the exif object for this file (if image)
*/
public function exif(): Exif
{
return $this->exif ??= new Exif($this);
}
/**
* Returns the height of the asset
*/
public function height(): int
{
return $this->dimensions()->height();
}
/**
* Converts the file to html
*/
public function html(array $attr = []): string
{
$model = match (true) {
$this->model instanceof FileVersion => $this->model->original(),
default => $this->model
};
// if no alt text explicitly provided,
// try to infer from model content file
if (
$model !== null &&
method_exists($model, 'content') === true &&
$model->content() instanceof Content &&
$model->content()->get('alt')->isNotEmpty() === true
) {
$attr['alt'] ??= $model->content()->get('alt')->value();
}
if ($url = $this->url()) {
return Html::img($url, $attr);
}
throw new LogicException(
message: 'Calling Image::html() requires that the URL property is not null'
);
}
/**
* Returns the PHP imagesize array
*/
public function imagesize(): array
{
return getimagesize($this->root);
}
/**
* Checks if the dimensions of the asset are portrait
*/
public function isPortrait(): bool
{
return $this->dimensions()->portrait();
}
/**
* Checks if the dimensions of the asset are landscape
*/
public function isLandscape(): bool
{
return $this->dimensions()->landscape();
}
/**
* Checks if the dimensions of the asset are square
*/
public function isSquare(): bool
{
return $this->dimensions()->square();
}
/**
* Checks if the file is a resizable image
*/
public function isResizable(): bool
{
return in_array($this->extension(), static::$resizableTypes, true) === true;
}
/**
* Checks if a preview can be displayed for the file
* in the Panel or in the frontend
*/
public function isViewable(): bool
{
return in_array($this->extension(), static::$viewableTypes, true) === true;
}
/**
* Returns the ratio of the asset
*/
public function ratio(): float
{
return $this->dimensions()->ratio();
}
/**
* Returns the orientation as string
* `landscape` | `portrait` | `square`
*/
public function orientation(): string|false
{
return $this->dimensions()->orientation();
}
/**
* Converts the object to an array
*/
public function toArray(): array
{
$array = [
...parent::toArray(),
'dimensions' => $this->dimensions()->toArray(),
'exif' => $this->exif()->toArray(),
];
ksort($array);
return $array;
}
/**
* Returns the width of the asset
*/
public function width(): int
{
return $this->dimensions()->width();
}
}

View file

@ -0,0 +1,118 @@
<?php
namespace Kirby\Image;
use Stringable;
/**
* Returns the latitude and longitude values
* for exif location data if available
*
* @package Kirby Image
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Location implements Stringable
{
protected float|null $lat = null;
protected float|null $lng = null;
/**
* Constructor
*
* @param array $exif The entire exif array
*/
public function __construct(array $exif)
{
if (
isset($exif['GPSLatitude']) === true &&
isset($exif['GPSLatitudeRef']) === true &&
isset($exif['GPSLongitude']) === true &&
isset($exif['GPSLongitudeRef']) === true
) {
$this->lat = $this->gps(
$exif['GPSLatitude'],
$exif['GPSLatitudeRef']
);
$this->lng = $this->gps(
$exif['GPSLongitude'],
$exif['GPSLongitudeRef']
);
}
}
/**
* Returns the latitude
*/
public function lat(): float|null
{
return $this->lat;
}
/**
* Returns the longitude
*/
public function lng(): float|null
{
return $this->lng;
}
/**
* Converts the gps coordinates
*/
protected function gps(array $coord, string $hemi): float
{
$degrees = $coord !== [] ? $this->num($coord[0]) : 0;
$minutes = count($coord) > 1 ? $this->num($coord[1]) : 0;
$seconds = count($coord) > 2 ? $this->num($coord[2]) : 0;
$hemi = strtoupper($hemi);
$flip = ($hemi === 'W' || $hemi === 'S') ? -1 : 1;
return $flip * ($degrees + $minutes / 60 + $seconds / 3600);
}
/**
* Converts coordinates to floats
*/
protected function num(string $part): float
{
$parts = explode('/', $part);
if (count($parts) === 1) {
return (float)$parts[0];
}
return (float)($parts[0]) / (float)($parts[1]);
}
/**
* Converts the object into a nicely readable array
*/
public function toArray(): array
{
return [
'lat' => $this->lat(),
'lng' => $this->lng()
];
}
/**
* Echos the entire location as lat, lng
*/
public function __toString(): string
{
return trim($this->lat() . ', ' . $this->lng(), ',');
}
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*/
public function __debugInfo(): array
{
return $this->toArray();
}
}

1614
kirby/src/Image/QrCode.php Normal file

File diff suppressed because it is too large Load diff