resolved conflicts

This commit is contained in:
2025-03-31 03:46:05 +03:00
6454 changed files with 1520539 additions and 9 deletions
+39
View File
@@ -0,0 +1,39 @@
{
"name": "nette/schema",
"description": "📐 Nette Schema: validating data structures against a given Schema.",
"keywords": ["nette", "config"],
"homepage": "https://nette.org",
"license": ["BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"require": {
"php": "8.1 - 8.4",
"nette/utils": "^4.0"
},
"require-dev": {
"nette/tester": "^2.5.2",
"tracy/tracy": "^2.8",
"phpstan/phpstan-nette": "^1.0"
},
"autoload": {
"classmap": ["src/"]
},
"minimum-stability": "dev",
"scripts": {
"phpstan": "phpstan analyse",
"tester": "tester tests -s"
},
"extra": {
"branch-alias": {
"dev-master": "1.3-dev"
}
}
}
+537
View File
@@ -0,0 +1,537 @@
Nette Schema
************
[![Downloads this Month](https://img.shields.io/packagist/dm/nette/schema.svg)](https://packagist.org/packages/nette/schema)
[![Tests](https://github.com/nette/schema/workflows/Tests/badge.svg?branch=master)](https://github.com/nette/schema/actions)
[![Coverage Status](https://coveralls.io/repos/github/nette/schema/badge.svg?branch=master)](https://coveralls.io/github/nette/schema?branch=master)
[![Latest Stable Version](https://poser.pugx.org/nette/schema/v/stable)](https://github.com/nette/schema/releases)
[![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/schema/blob/master/license.md)
Introduction
============
A practical library for validation and normalization of data structures against a given schema with a smart & easy-to-understand API.
Documentation can be found on the [website](https://doc.nette.org/schema).
Installation:
```shell
composer require nette/schema
```
It requires PHP version 8.1 and supports PHP up to 8.4.
[Support Me](https://github.com/sponsors/dg)
--------------------------------------------
Do you like Nette Schema? Are you looking forward to the new features?
[![Buy me a coffee](https://files.nette.org/icons/donation-3.svg)](https://github.com/sponsors/dg)
Thank you!
Basic Usage
-----------
In variable `$schema` we have a validation schema (what exactly this means and how to create it we will say later) and in variable `$data` we have a data structure that we want to validate and normalize. This can be, for example, data sent by the user through an API, configuration file, etc.
The task is handled by the [Nette\Schema\Processor](https://api.nette.org/schema/master/Nette/Schema/Processor.html) class, which processes the input and either returns normalized data or throws an [Nette\Schema\ValidationException](https://api.nette.org/schema/master/Nette/Schema/ValidationException.html) exception on error.
```php
$processor = new Nette\Schema\Processor;
try {
$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
echo 'Data is invalid: ' . $e->getMessage();
}
```
Method `$e->getMessages()` returns array of all message strings and `$e->getMessageObjects()` return all messages as [Nette\Schema\Message](https://api.nette.org/schema/master/Nette/Schema/Message.html) objects.
Defining Schema
---------------
And now let's create a schema. The class [Nette\Schema\Expect](https://api.nette.org/schema/master/Nette/Schema/Expect.html) is used to define it, we actually define expectations of what the data should look like. Let's say that the input data must be a structure (e.g. an array) containing elements `processRefund` of type bool and `refundAmount` of type int.
```php
use Nette\Schema\Expect;
$schema = Expect::structure([
'processRefund' => Expect::bool(),
'refundAmount' => Expect::int(),
]);
```
We believe that the schema definition looks clear, even if you see it for the very first time.
Lets send the following data for validation:
```php
$data = [
'processRefund' => true,
'refundAmount' => 17,
];
$normalized = $processor->process($schema, $data); // OK, it passes
```
The output, i.e. the value `$normalized`, is the object `stdClass`. If we want the output to be an array, we add a cast to schema `Expect::structure([...])->castTo('array')`.
All elements of the structure are optional and have a default value `null`. Example:
```php
$data = [
'refundAmount' => 17,
];
$normalized = $processor->process($schema, $data); // OK, it passes
// $normalized = {'processRefund' => null, 'refundAmount' => 17}
```
The fact that the default value is `null` does not mean that it would be accepted in the input data `'processRefund' => null`. No, the input must be boolean, i.e. only `true` or `false`. We would have to explicitly allow `null` via `Expect::bool()->nullable()`.
An item can be made mandatory using `Expect::bool()->required()`. We change the default value to `false` using `Expect::bool()->default(false)` or shortly using `Expect::bool(false)`.
And what if we wanted to accept `1` and `0` besides booleans? Then we list the allowed values, which we will also normalize to boolean:
```php
$schema = Expect::structure([
'processRefund' => Expect::anyOf(true, false, 1, 0)->castTo('bool'),
'refundAmount' => Expect::int(),
]);
$normalized = $processor->process($schema, $data);
is_bool($normalized->processRefund); // true
```
Now you know the basics of how the schema is defined and how the individual elements of the structure behave. We will now show what all the other elements can be used in defining a schema.
Data Types: type()
------------------
All standard PHP data types can be listed in the schema:
```php
Expect::string($default = null)
Expect::int($default = null)
Expect::float($default = null)
Expect::bool($default = null)
Expect::null()
Expect::array($default = [])
```
And then all types [supported by the Validators](https://doc.nette.org/validators#toc-validation-rules) via `Expect::type('scalar')` or abbreviated `Expect::scalar()`. Also class or interface names are accepted, e.g. `Expect::type('AddressEntity')`.
You can also use union notation:
```php
Expect::type('bool|string|array')
```
The default value is always `null` except for `array` and `list`, where it is an empty array. (A list is an array indexed in ascending order of numeric keys from zero, that is, a non-associative array).
Array of Values: arrayOf() listOf()
-----------------------------------
The array is too general structure, it is more useful to specify exactly what elements it can contain. For example, an array whose elements can only be strings:
```php
$schema = Expect::arrayOf('string');
$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ERROR: 123 is not a string
```
The second parameter can be used to specify keys (since version 1.2):
```php
$schema = Expect::arrayOf('string', 'int');
$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ERROR: 'a' is not int
```
The list is an indexed array:
```php
$schema = Expect::listOf('string');
$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERROR: 123 is not a string
$processor->process($schema, ['key' => 'a']); // ERROR: is not a list
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERROR: is not a list
```
The parameter can also be a schema, so we can write:
```php
Expect::arrayOf(Expect::bool())
```
The default value is an empty array. If you specify a default value, it will be merged with the passed data. This can be disabled using `mergeDefaults(false)`.
Enumeration: anyOf()
--------------------
`anyOf()` is a set of values or schemas that a value can be. Here's how to write an array of elements that can be either `'a'`, `true`, or `null`:
```php
$schema = Expect::listOf(
Expect::anyOf('a', true, null),
);
$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ERROR: false does not belong there
```
The enumeration elements can also be schemas:
```php
$schema = Expect::listOf(
Expect::anyOf(Expect::string(), true, null),
);
$processor->process($schema, ['foo', true, null, 'bar']); // OK
$processor->process($schema, [123]); // ERROR
```
The `anyOf()` method accepts variants as individual parameters, not as array. To pass it an array of values, use the unpacking operator `anyOf(...$variants)`.
The default value is `null`. Use the `firstIsDefault()` method to make the first element the default:
```php
// default is 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();
```
Structures
----------
Structures are objects with defined keys. Each of these key => value pairs is referred to as a "property":
Structures accept arrays and objects and return objects `stdClass` (unless you change it with `castTo('array')`, etc.).
By default, all properties are optional and have a default value of `null`. You can define mandatory properties using `required()`:
```php
$schema = Expect::structure([
'required' => Expect::string()->required(),
'optional' => Expect::string(), // the default value is null
]);
$processor->process($schema, ['optional' => '']);
// ERROR: option 'required' is missing
$processor->process($schema, ['required' => 'foo']);
// OK, returns {'required' => 'foo', 'optional' => null}
```
If you do not want to output properties with only a default value, use `skipDefaults()`:
```php
$schema = Expect::structure([
'required' => Expect::string()->required(),
'optional' => Expect::string(),
])->skipDefaults();
$processor->process($schema, ['required' => 'foo']);
// OK, returns {'required' => 'foo'}
```
Although `null` is the default value of the `optional` property, it is not allowed in the input data (the value must be a string). Properties accepting `null` are defined using `nullable()`:
```php
$schema = Expect::structure([
'optional' => Expect::string(),
'nullable' => Expect::string()->nullable(),
]);
$processor->process($schema, ['optional' => null]);
// ERROR: 'optional' expects to be string, null given.
$processor->process($schema, ['nullable' => null]);
// OK, returns {'optional' => null, 'nullable' => null}
```
By default, there can be no extra items in the input data:
```php
$schema = Expect::structure([
'key' => Expect::string(),
]);
$processor->process($schema, ['additional' => 1]);
// ERROR: Unexpected item 'additional'
```
Which we can change with `otherItems()`. As a parameter, we will specify the schema for each extra element:
```php
$schema = Expect::structure([
'key' => Expect::string(),
])->otherItems(Expect::int());
$processor->process($schema, ['additional' => 1]); // OK
$processor->process($schema, ['additional' => true]); // ERROR
```
Deprecations
------------
You can deprecate property using the `deprecated([string $message])` method. Deprecation notices are returned by `$processor->getWarnings()`:
```php
$schema = Expect::structure([
'old' => Expect::int()->deprecated('The item %path% is deprecated'),
]);
$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["The item 'old' is deprecated"]
```
Ranges: min() max()
-------------------
Use `min()` and `max()` to limit the number of elements for arrays:
```php
// array, at least 10 items, maximum 20 items
Expect::array()->min(10)->max(20);
```
For strings, limit their length:
```php
// string, at least 10 characters long, maximum 20 characters
Expect::string()->min(10)->max(20);
```
For numbers, limit their value:
```php
// integer, between 10 and 20 inclusive
Expect::int()->min(10)->max(20);
```
Of course, it is possible to mention only `min()`, or only `max()`:
```php
// string, maximum 20 characters
Expect::string()->max(20);
```
Regular Expressions: pattern()
------------------------------
Using `pattern()`, you can specify a regular expression which the **whole** input string must match (i.e. as if it were wrapped in characters `^` a `$`):
```php
// just 9 digits
Expect::string()->pattern('\d{9}');
```
Custom Assertions: assert()
---------------------------
You can add any other restrictions using `assert(callable $fn)`.
```php
$countIsEven = fn($v) => count($v) % 2 === 0;
$schema = Expect::arrayOf('string')
->assert($countIsEven); // the count must be even
$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERROR: 3 is not even
```
Or
```php
Expect::string()->assert('is_file'); // the file must exist
```
You can add your own description for each assertion. It will be part of the error message.
```php
$schema = Expect::arrayOf('string')
->assert($countIsEven, 'Even items in array');
$processor->process($schema, ['a', 'b', 'c']);
// Failed assertion "Even items in array" for item with value array.
```
The method can be called repeatedly to add multiple constraints. It can be intermixed with calls to `transform()` and `castTo()`.
Transformation: transform()
---------------------------
Successfully validated data can be modified using a custom function:
```php
// conversion to uppercase:
Expect::string()->transform(fn(string $s) => strtoupper($s));
```
The method can be called repeatedly to add multiple transformations. It can be intermixed with calls to `assert()` and `castTo()`. The operations will be executed in the order in which they are declared:
```php
Expect::type('string|int')
->castTo('string')
->assert('ctype_lower', 'All characters must be lowercased')
->transform(fn(string $s) => strtoupper($s)); // conversion to uppercase
```
The `transform()` method can both transform and validate the value simultaneously. This is often simpler and less redundant than chaining `transform()` and `assert()`. For this purpose, the function receives a [Nette\Schema\Context](https://api.nette.org/schema/master/Nette/Schema/Context.html) object with an `addError()` method, which can be used to add information about validation issues:
```php
Expect::string()
->transform(function (string $s, Nette\Schema\Context $context) {
if (!ctype_lower($s)) {
$context->addError('All characters must be lowercased', 'my.case.error');
return null;
}
return strtoupper($s);
});
```
Casting: castTo()
-----------------
Successfully validated data can be cast:
```php
Expect::scalar()->castTo('string');
```
In addition to native PHP types, you can also cast to classes. It distinguishes whether it is a simple class without a constructor or a class with a constructor. If the class has no constructor, an instance of it is created and all elements of the structure are written to its properties:
```php
class Info
{
public bool $processRefund;
public int $refundAmount;
}
Expect::structure([
'processRefund' => Expect::bool(),
'refundAmount' => Expect::int(),
])->castTo(Info::class);
// creates '$obj = new Info' and writes to $obj->processRefund and $obj->refundAmount
```
If the class has a constructor, the elements of the structure are passed as named parameters to the constructor:
```php
class Info
{
public function __construct(
public bool $processRefund,
public int $refundAmount,
) {
}
}
// creates $obj = new Info(processRefund: ..., refundAmount: ...)
```
Casting combined with a scalar parameter creates an object and passes the value as the sole parameter to the constructor:
```php
Expect::string()->castTo(DateTime::class);
// creates new DateTime(...)
```
Normalization: before()
-----------------------
Prior to the validation itself, the data can be normalized using the method `before()`. As an example, let's have an element that must be an array of strings (eg `['a', 'b', 'c']`), but receives input in the form of a string `a b c`:
```php
$explode = fn($v) => explode(' ', $v);
$schema = Expect::arrayOf('string')
->before($explode);
$normalized = $processor->process($schema, 'a b c');
// OK, returns ['a', 'b', 'c']
```
Mapping to Objects: from()
--------------------------
You can generate structure schema from the class. Example:
```php
class Config
{
/** @var string */
public $name;
/** @var string|null */
public $password;
/** @var bool */
public $admin = false;
}
$schema = Expect::from(new Config);
$data = [
'name' => 'jeff',
];
$normalized = $processor->process($schema, $data);
// $normalized instanceof Config
// $normalized = {'name' => 'jeff', 'password' => null, 'admin' => false}
```
If you are using PHP 7.4 or higher, you can use native types:
```php
class Config
{
public string $name;
public ?string $password;
public bool $admin = false;
}
$schema = Expect::from(new Config);
```
Anonymous classes are also supported:
```php
$schema = Expect::from(new class {
public string $name;
public ?string $password;
public bool $admin = false;
});
```
Because the information obtained from the class definition may not be sufficient, you can add a custom schema for the elements with the second parameter:
```php
$schema = Expect::from(new Config, [
'name' => Expect::string()->pattern('\w:.*'),
]);
```
@@ -0,0 +1,210 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Schema\Elements;
use Nette;
use Nette\Schema\Context;
use Nette\Schema\Helpers;
use Nette\Schema\Schema;
final class Structure implements Schema
{
use Base;
/** @var Schema[] */
private array $items;
/** for array|list */
private ?Schema $otherItems = null;
/** @var array{?int, ?int} */
private array $range = [null, null];
private bool $skipDefaults = false;
/**
* @param Schema[] $shape
*/
public function __construct(array $shape)
{
(function (Schema ...$items) {})(...array_values($shape));
$this->items = $shape;
$this->castTo('object');
$this->required = true;
}
public function default(mixed $value): self
{
throw new Nette\InvalidStateException('Structure cannot have default value.');
}
public function min(?int $min): self
{
$this->range[0] = $min;
return $this;
}
public function max(?int $max): self
{
$this->range[1] = $max;
return $this;
}
public function otherItems(string|Schema $type = 'mixed'): self
{
$this->otherItems = $type instanceof Schema ? $type : new Type($type);
return $this;
}
public function skipDefaults(bool $state = true): self
{
$this->skipDefaults = $state;
return $this;
}
public function extend(array|self $shape): self
{
$shape = $shape instanceof self ? $shape->items : $shape;
return new self(array_merge($this->items, $shape));
}
public function getShape(): array
{
return $this->items;
}
/********************* processing ****************d*g**/
public function normalize(mixed $value, Context $context): mixed
{
if ($prevent = (is_array($value) && isset($value[Helpers::PreventMerging]))) {
unset($value[Helpers::PreventMerging]);
}
$value = $this->doNormalize($value, $context);
if (is_object($value)) {
$value = (array) $value;
}
if (is_array($value)) {
foreach ($value as $key => $val) {
$itemSchema = $this->items[$key] ?? $this->otherItems;
if ($itemSchema) {
$context->path[] = $key;
$value[$key] = $itemSchema->normalize($val, $context);
array_pop($context->path);
}
}
if ($prevent) {
$value[Helpers::PreventMerging] = true;
}
}
return $value;
}
public function merge(mixed $value, mixed $base): mixed
{
if (is_array($value) && isset($value[Helpers::PreventMerging])) {
unset($value[Helpers::PreventMerging]);
$base = null;
}
if (is_array($value) && is_array($base)) {
$index = $this->otherItems === null ? null : 0;
foreach ($value as $key => $val) {
if ($key === $index) {
$base[] = $val;
$index++;
} else {
$base[$key] = array_key_exists($key, $base) && ($itemSchema = $this->items[$key] ?? $this->otherItems)
? $itemSchema->merge($val, $base[$key])
: $val;
}
}
return $base;
}
return $value ?? $base;
}
public function complete(mixed $value, Context $context): mixed
{
if ($value === null) {
$value = []; // is unable to distinguish null from array in NEON
}
$this->doDeprecation($context);
$isOk = $context->createChecker();
Helpers::validateType($value, 'array', $context);
$isOk() && Helpers::validateRange($value, $this->range, $context);
$isOk() && $this->validateItems($value, $context);
$isOk() && $value = $this->doTransform($value, $context);
return $isOk() ? $value : null;
}
private function validateItems(array &$value, Context $context): void
{
$items = $this->items;
if ($extraKeys = array_keys(array_diff_key($value, $items))) {
if ($this->otherItems) {
$items += array_fill_keys($extraKeys, $this->otherItems);
} else {
$keys = array_map('strval', array_keys($items));
foreach ($extraKeys as $key) {
$hint = Nette\Utils\Helpers::getSuggestion($keys, (string) $key);
$context->addError(
'Unexpected item %path%' . ($hint ? ", did you mean '%hint%'?" : '.'),
Nette\Schema\Message::UnexpectedItem,
['hint' => $hint],
)->path[] = $key;
}
}
}
foreach ($items as $itemKey => $itemVal) {
$context->path[] = $itemKey;
if (array_key_exists($itemKey, $value)) {
$value[$itemKey] = $itemVal->complete($value[$itemKey], $context);
} else {
$default = $itemVal->completeDefault($context); // checks required item
if (!$context->skipDefaults && !$this->skipDefaults) {
$value[$itemKey] = $default;
}
}
array_pop($context->path);
}
}
public function completeDefault(Context $context): mixed
{
return $this->required
? $this->complete([], $context)
: null;
}
}
@@ -0,0 +1,208 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Schema\Elements;
use Nette\Schema\Context;
use Nette\Schema\DynamicParameter;
use Nette\Schema\Helpers;
use Nette\Schema\Schema;
final class Type implements Schema
{
use Base;
private string $type;
private ?Schema $itemsValue = null;
private ?Schema $itemsKey = null;
/** @var array{?float, ?float} */
private array $range = [null, null];
private ?string $pattern = null;
private bool $merge = true;
public function __construct(string $type)
{
$defaults = ['list' => [], 'array' => []];
$this->type = $type;
$this->default = strpos($type, '[]') ? [] : $defaults[$type] ?? null;
}
public function nullable(): self
{
$this->type = 'null|' . $this->type;
return $this;
}
public function mergeDefaults(bool $state = true): self
{
$this->merge = $state;
return $this;
}
public function dynamic(): self
{
$this->type = DynamicParameter::class . '|' . $this->type;
return $this;
}
public function min(?float $min): self
{
$this->range[0] = $min;
return $this;
}
public function max(?float $max): self
{
$this->range[1] = $max;
return $this;
}
/**
* @internal use arrayOf() or listOf()
*/
public function items(string|Schema $valueType = 'mixed', string|Schema|null $keyType = null): self
{
$this->itemsValue = $valueType instanceof Schema
? $valueType
: new self($valueType);
$this->itemsKey = $keyType instanceof Schema || $keyType === null
? $keyType
: new self($keyType);
return $this;
}
public function pattern(?string $pattern): self
{
$this->pattern = $pattern;
return $this;
}
/********************* processing ****************d*g**/
public function normalize(mixed $value, Context $context): mixed
{
if ($prevent = (is_array($value) && isset($value[Helpers::PreventMerging]))) {
unset($value[Helpers::PreventMerging]);
}
$value = $this->doNormalize($value, $context);
if (is_array($value) && $this->itemsValue) {
$res = [];
foreach ($value as $key => $val) {
$context->path[] = $key;
$context->isKey = true;
$key = $this->itemsKey
? $this->itemsKey->normalize($key, $context)
: $key;
$context->isKey = false;
$res[$key] = $this->itemsValue->normalize($val, $context);
array_pop($context->path);
}
$value = $res;
}
if ($prevent && is_array($value)) {
$value[Helpers::PreventMerging] = true;
}
return $value;
}
public function merge(mixed $value, mixed $base): mixed
{
if (is_array($value) && isset($value[Helpers::PreventMerging])) {
unset($value[Helpers::PreventMerging]);
return $value;
}
if (is_array($value) && is_array($base) && $this->itemsValue) {
$index = 0;
foreach ($value as $key => $val) {
if ($key === $index) {
$base[] = $val;
$index++;
} else {
$base[$key] = array_key_exists($key, $base)
? $this->itemsValue->merge($val, $base[$key])
: $val;
}
}
return $base;
}
return Helpers::merge($value, $base);
}
public function complete(mixed $value, Context $context): mixed
{
$merge = $this->merge;
if (is_array($value) && isset($value[Helpers::PreventMerging])) {
unset($value[Helpers::PreventMerging]);
$merge = false;
}
if ($value === null && is_array($this->default)) {
$value = []; // is unable to distinguish null from array in NEON
}
$this->doDeprecation($context);
$isOk = $context->createChecker();
Helpers::validateType($value, $this->type, $context);
$isOk() && Helpers::validateRange($value, $this->range, $context, $this->type);
$isOk() && $value !== null && $this->pattern !== null && Helpers::validatePattern($value, $this->pattern, $context);
$isOk() && is_array($value) && $this->validateItems($value, $context);
$isOk() && $merge && $value = Helpers::merge($value, $this->default);
$isOk() && $value = $this->doTransform($value, $context);
if (!$isOk()) {
return null;
}
if ($value instanceof DynamicParameter) {
$expected = $this->type . ($this->range === [null, null] ? '' : ':' . implode('..', $this->range));
$context->dynamics[] = [$value, str_replace(DynamicParameter::class . '|', '', $expected), $context->path];
}
return $value;
}
private function validateItems(array &$value, Context $context): void
{
if (!$this->itemsValue) {
return;
}
$res = [];
foreach ($value as $key => $val) {
$context->path[] = $key;
$context->isKey = true;
$key = $this->itemsKey ? $this->itemsKey->complete($key, $context) : $key;
$context->isKey = false;
$res[$key] = $this->itemsValue->complete($val, $context);
array_pop($context->path);
}
$value = $res;
}
}
@@ -0,0 +1,118 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Schema;
use Nette;
use Nette\Schema\Elements\AnyOf;
use Nette\Schema\Elements\Structure;
use Nette\Schema\Elements\Type;
/**
* Schema generator.
*
* @method static Type scalar($default = null)
* @method static Type string($default = null)
* @method static Type int($default = null)
* @method static Type float($default = null)
* @method static Type bool($default = null)
* @method static Type null()
* @method static Type list($default = [])
* @method static Type mixed($default = null)
* @method static Type email($default = null)
* @method static Type unicode($default = null)
*/
final class Expect
{
public static function __callStatic(string $name, array $args): Type
{
$type = new Type($name);
if ($args) {
$type->default($args[0]);
}
return $type;
}
public static function type(string $type): Type
{
return new Type($type);
}
public static function anyOf(mixed ...$set): AnyOf
{
return new AnyOf(...$set);
}
/**
* @param Schema[] $shape
*/
public static function structure(array $shape): Structure
{
return new Structure($shape);
}
public static function from(object $object, array $items = []): Structure
{
$ro = new \ReflectionObject($object);
$props = $ro->hasMethod('__construct')
? $ro->getMethod('__construct')->getParameters()
: $ro->getProperties();
foreach ($props as $prop) {
$item = &$items[$prop->getName()];
if (!$item) {
$type = Helpers::getPropertyType($prop) ?? 'mixed';
$item = new Type($type);
if ($prop instanceof \ReflectionProperty ? $prop->isInitialized($object) : $prop->isOptional()) {
$def = ($prop instanceof \ReflectionProperty ? $prop->getValue($object) : $prop->getDefaultValue());
if (is_object($def)) {
$item = static::from($def);
} elseif ($def === null && !Nette\Utils\Validators::is(null, $type)) {
$item->required();
} else {
$item->default($def);
}
} else {
$item->required();
}
}
}
return (new Structure($items))->castTo($ro->getName());
}
/**
* @param mixed[] $shape
*/
public static function array(?array $shape = []): Structure|Type
{
return Nette\Utils\Arrays::first($shape ?? []) instanceof Schema
? (new Structure($shape))->castTo('array')
: (new Type('array'))->default($shape);
}
public static function arrayOf(string|Schema $valueType, string|Schema|null $keyType = null): Type
{
return (new Type('array'))->items($valueType, $keyType);
}
public static function listOf(string|Schema $type): Type
{
return (new Type('list'))->items($type);
}
}
+51
View File
@@ -0,0 +1,51 @@
{
"name": "nette/utils",
"description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
"keywords": ["nette", "images", "json", "password", "validation", "utility", "string", "array", "core", "slugify", "utf-8", "unicode", "paginator", "datetime"],
"homepage": "https://nette.org",
"license": ["BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"require": {
"php": "8.0 - 8.4"
},
"require-dev": {
"nette/tester": "^2.5",
"tracy/tracy": "^2.9",
"phpstan/phpstan": "^1.0",
"jetbrains/phpstorm-attributes": "dev-master"
},
"conflict": {
"nette/finder": "<3",
"nette/schema": "<1.2.2"
},
"suggest": {
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-mbstring": "to use Strings::lower() etc...",
"ext-gd": "to use Image",
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
},
"autoload": {
"classmap": ["src/"]
},
"minimum-stability": "dev",
"scripts": {
"phpstan": "phpstan analyse",
"tester": "tester tests -s"
},
"extra": {
"branch-alias": {
"dev-master": "4.0-dev"
}
}
}
+55
View File
@@ -0,0 +1,55 @@
[![Nette Utils](https://github.com/nette/utils/assets/194960/c33fdb74-0652-4cad-ac6e-c1ce0d29e32a)](https://doc.nette.org/en/utils)
[![Downloads this Month](https://img.shields.io/packagist/dm/nette/utils.svg)](https://packagist.org/packages/nette/utils)
[![Tests](https://github.com/nette/utils/workflows/Tests/badge.svg?branch=master)](https://github.com/nette/utils/actions)
[![Coverage Status](https://coveralls.io/repos/github/nette/utils/badge.svg?branch=master)](https://coveralls.io/github/nette/utils?branch=master)
[![Latest Stable Version](https://poser.pugx.org/nette/utils/v/stable)](https://github.com/nette/utils/releases)
[![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/utils/blob/master/license.md)
Introduction
------------
In package nette/utils you will find a set of useful classes for everyday use:
✅ [Arrays](https://doc.nette.org/utils/arrays)<br>
✅ [Callback](https://doc.nette.org/utils/callback) - PHP callbacks<br>
✅ [Filesystem](https://doc.nette.org/utils/filesystem) - copying, renaming, …<br>
✅ [Finder](https://doc.nette.org/utils/finder) - finds files and directories<br>
✅ [Floats](https://doc.nette.org/utils/floats) - floating point numbers<br>
✅ [Helper Functions](https://doc.nette.org/utils/helpers)<br>
✅ [HTML elements](https://doc.nette.org/utils/html-elements) - generate HTML<br>
✅ [Images](https://doc.nette.org/utils/images) - crop, resize, rotate images<br>
✅ [Iterables](https://doc.nette.org/utils/iterables) <br>
✅ [JSON](https://doc.nette.org/utils/json) - encoding and decoding<br>
✅ [Generating Random Strings](https://doc.nette.org/utils/random)<br>
✅ [Paginator](https://doc.nette.org/utils/paginator) - pagination math<br>
✅ [PHP Reflection](https://doc.nette.org/utils/reflection)<br>
✅ [Strings](https://doc.nette.org/utils/strings) - useful text functions<br>
✅ [SmartObject](https://doc.nette.org/utils/smartobject) - PHP object enhancements<br>
✅ [Type](https://doc.nette.org/utils/type) - PHP data type<br>
✅ [Validation](https://doc.nette.org/utils/validators) - validate inputs<br>
 <!---->
Installation
------------
The recommended way to install is via Composer:
```
composer require nette/utils
```
Nette Utils 4.0 is compatible with PHP 8.0 to 8.4.
 <!---->
[Support Me](https://github.com/sponsors/dg)
--------------------------------------------
Do you like Nette Utils? Are you looking forward to the new features?
[![Buy me a coffee](https://files.nette.org/icons/donation-3.svg)](https://github.com/sponsors/dg)
Thank you!
@@ -0,0 +1,150 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Iterators;
use Nette;
/**
* Smarter caching iterator.
*
* @property-read bool $first
* @property-read bool $last
* @property-read bool $empty
* @property-read bool $odd
* @property-read bool $even
* @property-read int $counter
* @property-read mixed $nextKey
* @property-read mixed $nextValue
*/
class CachingIterator extends \CachingIterator implements \Countable
{
use Nette\SmartObject;
private int $counter = 0;
public function __construct(iterable|\stdClass $iterable)
{
$iterable = $iterable instanceof \stdClass
? new \ArrayIterator($iterable)
: Nette\Utils\Iterables::toIterator($iterable);
parent::__construct($iterable, 0);
}
/**
* Is the current element the first one?
*/
public function isFirst(?int $gridWidth = null): bool
{
return $this->counter === 1 || ($gridWidth && $this->counter !== 0 && (($this->counter - 1) % $gridWidth) === 0);
}
/**
* Is the current element the last one?
*/
public function isLast(?int $gridWidth = null): bool
{
return !$this->hasNext() || ($gridWidth && ($this->counter % $gridWidth) === 0);
}
/**
* Is the iterator empty?
*/
public function isEmpty(): bool
{
return $this->counter === 0;
}
/**
* Is the counter odd?
*/
public function isOdd(): bool
{
return $this->counter % 2 === 1;
}
/**
* Is the counter even?
*/
public function isEven(): bool
{
return $this->counter % 2 === 0;
}
/**
* Returns the counter.
*/
public function getCounter(): int
{
return $this->counter;
}
/**
* Returns the count of elements.
*/
public function count(): int
{
$inner = $this->getInnerIterator();
if ($inner instanceof \Countable) {
return $inner->count();
} else {
throw new Nette\NotSupportedException('Iterator is not countable.');
}
}
/**
* Forwards to the next element.
*/
public function next(): void
{
parent::next();
if (parent::valid()) {
$this->counter++;
}
}
/**
* Rewinds the Iterator.
*/
public function rewind(): void
{
parent::rewind();
$this->counter = parent::valid() ? 1 : 0;
}
/**
* Returns the next key.
*/
public function getNextKey(): mixed
{
return $this->getInnerIterator()->key();
}
/**
* Returns the next element.
*/
public function getNextValue(): mixed
{
return $this->getInnerIterator()->current();
}
}
@@ -0,0 +1,33 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Iterators;
/**
* @deprecated use Nette\Utils\Iterables::map()
*/
class Mapper extends \IteratorIterator
{
/** @var callable */
private $callback;
public function __construct(\Traversable $iterator, callable $callback)
{
parent::__construct($iterator);
$this->callback = $callback;
}
public function current(): mixed
{
return ($this->callback)(parent::current(), parent::key());
}
}
@@ -0,0 +1,553 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Utils;
use JetBrains\PhpStorm\Language;
use Nette;
use function is_array, is_int, is_object, count;
/**
* Array tools library.
*/
class Arrays
{
use Nette\StaticClass;
/**
* Returns item from array. If it does not exist, it throws an exception, unless a default value is set.
* @template T
* @param array<T> $array
* @param array-key|array-key[] $key
* @param ?T $default
* @return ?T
* @throws Nette\InvalidArgumentException if item does not exist and default value is not provided
*/
public static function get(array $array, string|int|array $key, mixed $default = null): mixed
{
foreach (is_array($key) ? $key : [$key] as $k) {
if (is_array($array) && array_key_exists($k, $array)) {
$array = $array[$k];
} else {
if (func_num_args() < 3) {
throw new Nette\InvalidArgumentException("Missing item '$k'.");
}
return $default;
}
}
return $array;
}
/**
* Returns reference to array item. If the index does not exist, new one is created with value null.
* @template T
* @param array<T> $array
* @param array-key|array-key[] $key
* @return ?T
* @throws Nette\InvalidArgumentException if traversed item is not an array
*/
public static function &getRef(array &$array, string|int|array $key): mixed
{
foreach (is_array($key) ? $key : [$key] as $k) {
if (is_array($array) || $array === null) {
$array = &$array[$k];
} else {
throw new Nette\InvalidArgumentException('Traversed item is not an array.');
}
}
return $array;
}
/**
* Recursively merges two fields. It is useful, for example, for merging tree structures. It behaves as
* the + operator for array, ie. it adds a key/value pair from the second array to the first one and retains
* the value from the first array in the case of a key collision.
* @template T1
* @template T2
* @param array<T1> $array1
* @param array<T2> $array2
* @return array<T1|T2>
*/
public static function mergeTree(array $array1, array $array2): array
{
$res = $array1 + $array2;
foreach (array_intersect_key($array1, $array2) as $k => $v) {
if (is_array($v) && is_array($array2[$k])) {
$res[$k] = self::mergeTree($v, $array2[$k]);
}
}
return $res;
}
/**
* Returns zero-indexed position of given array key. Returns null if key is not found.
*/
public static function getKeyOffset(array $array, string|int $key): ?int
{
return Helpers::falseToNull(array_search(self::toKey($key), array_keys($array), strict: true));
}
/**
* @deprecated use getKeyOffset()
*/
public static function searchKey(array $array, $key): ?int
{
return self::getKeyOffset($array, $key);
}
/**
* Tests an array for the presence of value.
*/
public static function contains(array $array, mixed $value): bool
{
return in_array($value, $array, true);
}
/**
* Returns the first item (matching the specified predicate if given). If there is no such item, it returns result of invoking $else or null.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param ?callable(V, K, array<K, V>): bool $predicate
* @return ?V
*/
public static function first(array $array, ?callable $predicate = null, ?callable $else = null): mixed
{
$key = self::firstKey($array, $predicate);
return $key === null
? ($else ? $else() : null)
: $array[$key];
}
/**
* Returns the last item (matching the specified predicate if given). If there is no such item, it returns result of invoking $else or null.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param ?callable(V, K, array<K, V>): bool $predicate
* @return ?V
*/
public static function last(array $array, ?callable $predicate = null, ?callable $else = null): mixed
{
$key = self::lastKey($array, $predicate);
return $key === null
? ($else ? $else() : null)
: $array[$key];
}
/**
* Returns the key of first item (matching the specified predicate if given) or null if there is no such item.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param ?callable(V, K, array<K, V>): bool $predicate
* @return ?K
*/
public static function firstKey(array $array, ?callable $predicate = null): int|string|null
{
if (!$predicate) {
return array_key_first($array);
}
foreach ($array as $k => $v) {
if ($predicate($v, $k, $array)) {
return $k;
}
}
return null;
}
/**
* Returns the key of last item (matching the specified predicate if given) or null if there is no such item.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param ?callable(V, K, array<K, V>): bool $predicate
* @return ?K
*/
public static function lastKey(array $array, ?callable $predicate = null): int|string|null
{
return $predicate
? self::firstKey(array_reverse($array, preserve_keys: true), $predicate)
: array_key_last($array);
}
/**
* Inserts the contents of the $inserted array into the $array immediately after the $key.
* If $key is null (or does not exist), it is inserted at the beginning.
*/
public static function insertBefore(array &$array, string|int|null $key, array $inserted): void
{
$offset = $key === null ? 0 : (int) self::getKeyOffset($array, $key);
$array = array_slice($array, 0, $offset, preserve_keys: true)
+ $inserted
+ array_slice($array, $offset, count($array), preserve_keys: true);
}
/**
* Inserts the contents of the $inserted array into the $array before the $key.
* If $key is null (or does not exist), it is inserted at the end.
*/
public static function insertAfter(array &$array, string|int|null $key, array $inserted): void
{
if ($key === null || ($offset = self::getKeyOffset($array, $key)) === null) {
$offset = count($array) - 1;
}
$array = array_slice($array, 0, $offset + 1, preserve_keys: true)
+ $inserted
+ array_slice($array, $offset + 1, count($array), preserve_keys: true);
}
/**
* Renames key in array.
*/
public static function renameKey(array &$array, string|int $oldKey, string|int $newKey): bool
{
$offset = self::getKeyOffset($array, $oldKey);
if ($offset === null) {
return false;
}
$val = &$array[$oldKey];
$keys = array_keys($array);
$keys[$offset] = $newKey;
$array = array_combine($keys, $array);
$array[$newKey] = &$val;
return true;
}
/**
* Returns only those array items, which matches a regular expression $pattern.
* @param string[] $array
* @return string[]
*/
public static function grep(
array $array,
#[Language('RegExp')]
string $pattern,
bool|int $invert = false,
): array
{
$flags = $invert ? PREG_GREP_INVERT : 0;
return Strings::pcre('preg_grep', [$pattern, $array, $flags]);
}
/**
* Transforms multidimensional array to flat array.
*/
public static function flatten(array $array, bool $preserveKeys = false): array
{
$res = [];
$cb = $preserveKeys
? function ($v, $k) use (&$res): void { $res[$k] = $v; }
: function ($v) use (&$res): void { $res[] = $v; };
array_walk_recursive($array, $cb);
return $res;
}
/**
* Checks if the array is indexed in ascending order of numeric keys from zero, a.k.a list.
* @return ($value is list ? true : false)
*/
public static function isList(mixed $value): bool
{
return is_array($value) && (PHP_VERSION_ID < 80100
? !$value || array_keys($value) === range(0, count($value) - 1)
: array_is_list($value)
);
}
/**
* Reformats table to associative tree. Path looks like 'field|field[]field->field=field'.
* @param string|string[] $path
*/
public static function associate(array $array, $path): array|\stdClass
{
$parts = is_array($path)
? $path
: preg_split('#(\[\]|->|=|\|)#', $path, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
if (!$parts || $parts === ['->'] || $parts[0] === '=' || $parts[0] === '|') {
throw new Nette\InvalidArgumentException("Invalid path '$path'.");
}
$res = $parts[0] === '->' ? new \stdClass : [];
foreach ($array as $rowOrig) {
$row = (array) $rowOrig;
$x = &$res;
for ($i = 0; $i < count($parts); $i++) {
$part = $parts[$i];
if ($part === '[]') {
$x = &$x[];
} elseif ($part === '=') {
if (isset($parts[++$i])) {
$x = $row[$parts[$i]];
$row = null;
}
} elseif ($part === '->') {
if (isset($parts[++$i])) {
if ($x === null) {
$x = new \stdClass;
}
$x = &$x->{$row[$parts[$i]]};
} else {
$row = is_object($rowOrig) ? $rowOrig : (object) $row;
}
} elseif ($part !== '|') {
$x = &$x[(string) $row[$part]];
}
}
if ($x === null) {
$x = $row;
}
}
return $res;
}
/**
* Normalizes array to associative array. Replace numeric keys with their values, the new value will be $filling.
*/
public static function normalize(array $array, mixed $filling = null): array
{
$res = [];
foreach ($array as $k => $v) {
$res[is_int($k) ? $v : $k] = is_int($k) ? $filling : $v;
}
return $res;
}
/**
* Returns and removes the value of an item from an array. If it does not exist, it throws an exception,
* or returns $default, if provided.
* @template T
* @param array<T> $array
* @param ?T $default
* @return ?T
* @throws Nette\InvalidArgumentException if item does not exist and default value is not provided
*/
public static function pick(array &$array, string|int $key, mixed $default = null): mixed
{
if (array_key_exists($key, $array)) {
$value = $array[$key];
unset($array[$key]);
return $value;
} elseif (func_num_args() < 3) {
throw new Nette\InvalidArgumentException("Missing item '$key'.");
} else {
return $default;
}
}
/**
* Tests whether at least one element in the array passes the test implemented by the provided function.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param callable(V, K, array<K, V>): bool $predicate
*/
public static function some(iterable $array, callable $predicate): bool
{
foreach ($array as $k => $v) {
if ($predicate($v, $k, $array)) {
return true;
}
}
return false;
}
/**
* Tests whether all elements in the array pass the test implemented by the provided function.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param callable(V, K, array<K, V>): bool $predicate
*/
public static function every(iterable $array, callable $predicate): bool
{
foreach ($array as $k => $v) {
if (!$predicate($v, $k, $array)) {
return false;
}
}
return true;
}
/**
* Returns a new array containing all key-value pairs matching the given $predicate.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param callable(V, K, array<K, V>): bool $predicate
* @return array<K, V>
*/
public static function filter(array $array, callable $predicate): array
{
$res = [];
foreach ($array as $k => $v) {
if ($predicate($v, $k, $array)) {
$res[$k] = $v;
}
}
return $res;
}
/**
* Returns an array containing the original keys and results of applying the given transform function to each element.
* @template K of int|string
* @template V
* @template R
* @param array<K, V> $array
* @param callable(V, K, array<K, V>): R $transformer
* @return array<K, R>
*/
public static function map(iterable $array, callable $transformer): array
{
$res = [];
foreach ($array as $k => $v) {
$res[$k] = $transformer($v, $k, $array);
}
return $res;
}
/**
* Returns an array containing new keys and values generated by applying the given transform function to each element.
* If the function returns null, the element is skipped.
* @template K of int|string
* @template V
* @template ResK of int|string
* @template ResV
* @param array<K, V> $array
* @param callable(V, K, array<K, V>): ?array{ResK, ResV} $transformer
* @return array<ResK, ResV>
*/
public static function mapWithKeys(array $array, callable $transformer): array
{
$res = [];
foreach ($array as $k => $v) {
$pair = $transformer($v, $k, $array);
if ($pair) {
$res[$pair[0]] = $pair[1];
}
}
return $res;
}
/**
* Invokes all callbacks and returns array of results.
* @param callable[] $callbacks
*/
public static function invoke(iterable $callbacks, ...$args): array
{
$res = [];
foreach ($callbacks as $k => $cb) {
$res[$k] = $cb(...$args);
}
return $res;
}
/**
* Invokes method on every object in an array and returns array of results.
* @param object[] $objects
*/
public static function invokeMethod(iterable $objects, string $method, ...$args): array
{
$res = [];
foreach ($objects as $k => $obj) {
$res[$k] = $obj->$method(...$args);
}
return $res;
}
/**
* Copies the elements of the $array array to the $object object and then returns it.
* @template T of object
* @param T $object
* @return T
*/
public static function toObject(iterable $array, object $object): object
{
foreach ($array as $k => $v) {
$object->$k = $v;
}
return $object;
}
/**
* Converts value to array key.
*/
public static function toKey(mixed $value): int|string
{
return key([$value => null]);
}
/**
* Returns copy of the $array where every item is converted to string
* and prefixed by $prefix and suffixed by $suffix.
* @param string[] $array
* @return string[]
*/
public static function wrap(array $array, string $prefix = '', string $suffix = ''): array
{
$res = [];
foreach ($array as $k => $v) {
$res[$k] = $prefix . $v . $suffix;
}
return $res;
}
}
@@ -0,0 +1,137 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Utils;
use Nette;
use function is_array, is_object, is_string;
/**
* PHP callable tools.
*/
final class Callback
{
use Nette\StaticClass;
/**
* Invokes internal PHP function with own error handler.
*/
public static function invokeSafe(string $function, array $args, callable $onError): mixed
{
$prev = set_error_handler(function ($severity, $message, $file) use ($onError, &$prev, $function): ?bool {
if ($file === __FILE__) {
$msg = ini_get('html_errors')
? Html::htmlToText($message)
: $message;
$msg = preg_replace("#^$function\\(.*?\\): #", '', $msg);
if ($onError($msg, $severity) !== false) {
return null;
}
}
return $prev ? $prev(...func_get_args()) : false;
});
try {
return $function(...$args);
} finally {
restore_error_handler();
}
}
/**
* Checks that $callable is valid PHP callback. Otherwise throws exception. If the $syntax is set to true, only verifies
* that $callable has a valid structure to be used as a callback, but does not verify if the class or method actually exists.
* @return callable
* @throws Nette\InvalidArgumentException
*/
public static function check(mixed $callable, bool $syntax = false)
{
if (!is_callable($callable, $syntax)) {
throw new Nette\InvalidArgumentException(
$syntax
? 'Given value is not a callable type.'
: sprintf("Callback '%s' is not callable.", self::toString($callable)),
);
}
return $callable;
}
/**
* Converts PHP callback to textual form. Class or method may not exists.
*/
public static function toString(mixed $callable): string
{
if ($callable instanceof \Closure) {
$inner = self::unwrap($callable);
return '{closure' . ($inner instanceof \Closure ? '}' : ' ' . self::toString($inner) . '}');
} else {
is_callable(is_object($callable) ? [$callable, '__invoke'] : $callable, true, $textual);
return $textual;
}
}
/**
* Returns reflection for method or function used in PHP callback.
* @param callable $callable type check is escalated to ReflectionException
* @throws \ReflectionException if callback is not valid
*/
public static function toReflection($callable): \ReflectionMethod|\ReflectionFunction
{
if ($callable instanceof \Closure) {
$callable = self::unwrap($callable);
}
if (is_string($callable) && str_contains($callable, '::')) {
return new ReflectionMethod(...explode('::', $callable, 2));
} elseif (is_array($callable)) {
return new ReflectionMethod($callable[0], $callable[1]);
} elseif (is_object($callable) && !$callable instanceof \Closure) {
return new ReflectionMethod($callable, '__invoke');
} else {
return new \ReflectionFunction($callable);
}
}
/**
* Checks whether PHP callback is function or static method.
*/
public static function isStatic(callable $callable): bool
{
return is_string(is_array($callable) ? $callable[0] : $callable);
}
/**
* Unwraps closure created by Closure::fromCallable().
*/
public static function unwrap(\Closure $closure): callable|array
{
$r = new \ReflectionFunction($closure);
$class = $r->getClosureScopeClass()?->name;
if (str_ends_with($r->name, '}')) {
return $closure;
} elseif (($obj = $r->getClosureThis()) && $obj::class === $class) {
return [$obj, $r->name];
} elseif ($class) {
return [$class, $r->name];
} else {
return $r->name;
}
}
}
@@ -0,0 +1,831 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Utils;
use Nette;
/**
* Basic manipulation with images. Supported types are JPEG, PNG, GIF, WEBP, AVIF and BMP.
*
* <code>
* $image = Image::fromFile('nette.jpg');
* $image->resize(150, 100);
* $image->sharpen();
* $image->send();
* </code>
*
* @method Image affine(array $affine, ?array $clip = null)
* @method void alphaBlending(bool $enable)
* @method void antialias(bool $enable)
* @method void arc(int $centerX, int $centerY, int $width, int $height, int $startAngle, int $endAngle, ImageColor $color)
* @method int colorAllocate(int $red, int $green, int $blue)
* @method int colorAllocateAlpha(int $red, int $green, int $blue, int $alpha)
* @method int colorAt(int $x, int $y)
* @method int colorClosest(int $red, int $green, int $blue)
* @method int colorClosestAlpha(int $red, int $green, int $blue, int $alpha)
* @method int colorClosestHWB(int $red, int $green, int $blue)
* @method void colorDeallocate(int $color)
* @method int colorExact(int $red, int $green, int $blue)
* @method int colorExactAlpha(int $red, int $green, int $blue, int $alpha)
* @method void colorMatch(Image $image2)
* @method int colorResolve(int $red, int $green, int $blue)
* @method int colorResolveAlpha(int $red, int $green, int $blue, int $alpha)
* @method void colorSet(int $index, int $red, int $green, int $blue, int $alpha = 0)
* @method array colorsForIndex(int $color)
* @method int colorsTotal()
* @method int colorTransparent(?int $color = null)
* @method void convolution(array $matrix, float $div, float $offset)
* @method void copy(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH)
* @method void copyMerge(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH, int $pct)
* @method void copyMergeGray(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH, int $pct)
* @method void copyResampled(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $dstW, int $dstH, int $srcW, int $srcH)
* @method void copyResized(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $dstW, int $dstH, int $srcW, int $srcH)
* @method Image cropAuto(int $mode = IMG_CROP_DEFAULT, float $threshold = .5, ?ImageColor $color = null)
* @method void ellipse(int $centerX, int $centerY, int $width, int $height, ImageColor $color)
* @method void fill(int $x, int $y, ImageColor $color)
* @method void filledArc(int $centerX, int $centerY, int $width, int $height, int $startAngle, int $endAngle, ImageColor $color, int $style)
* @method void filledEllipse(int $centerX, int $centerY, int $width, int $height, ImageColor $color)
* @method void filledPolygon(array $points, ImageColor $color)
* @method void filledRectangle(int $x1, int $y1, int $x2, int $y2, ImageColor $color)
* @method void fillToBorder(int $x, int $y, ImageColor $borderColor, ImageColor $color)
* @method void filter(int $filter, ...$args)
* @method void flip(int $mode)
* @method array ftText(float $size, float $angle, int $x, int $y, ImageColor $color, string $fontFile, string $text, array $options = [])
* @method void gammaCorrect(float $inputgamma, float $outputgamma)
* @method array getClip()
* @method int getInterpolation()
* @method int interlace(?bool $enable = null)
* @method bool isTrueColor()
* @method void layerEffect(int $effect)
* @method void line(int $x1, int $y1, int $x2, int $y2, ImageColor $color)
* @method void openPolygon(array $points, ImageColor $color)
* @method void paletteCopy(Image $source)
* @method void paletteToTrueColor()
* @method void polygon(array $points, ImageColor $color)
* @method void rectangle(int $x1, int $y1, int $x2, int $y2, ImageColor $color)
* @method mixed resolution(?int $resolutionX = null, ?int $resolutionY = null)
* @method Image rotate(float $angle, ImageColor $backgroundColor)
* @method void saveAlpha(bool $enable)
* @method Image scale(int $newWidth, int $newHeight = -1, int $mode = IMG_BILINEAR_FIXED)
* @method void setBrush(Image $brush)
* @method void setClip(int $x1, int $y1, int $x2, int $y2)
* @method void setInterpolation(int $method = IMG_BILINEAR_FIXED)
* @method void setPixel(int $x, int $y, ImageColor $color)
* @method void setStyle(array $style)
* @method void setThickness(int $thickness)
* @method void setTile(Image $tile)
* @method void trueColorToPalette(bool $dither, int $ncolors)
* @method array ttfText(float $size, float $angle, int $x, int $y, ImageColor $color, string $fontfile, string $text, array $options = [])
* @property-read positive-int $width
* @property-read positive-int $height
* @property-read \GdImage $imageResource
*/
class Image
{
use Nette\SmartObject;
/** Prevent from getting resized to a bigger size than the original */
public const ShrinkOnly = 0b0001;
/** Resizes to a specified width and height without keeping aspect ratio */
public const Stretch = 0b0010;
/** Resizes to fit into a specified width and height and preserves aspect ratio */
public const OrSmaller = 0b0000;
/** Resizes while bounding the smaller dimension to the specified width or height and preserves aspect ratio */
public const OrBigger = 0b0100;
/** Resizes to the smallest possible size to completely cover specified width and height and reserves aspect ratio */
public const Cover = 0b1000;
/** @deprecated use Image::ShrinkOnly */
public const SHRINK_ONLY = self::ShrinkOnly;
/** @deprecated use Image::Stretch */
public const STRETCH = self::Stretch;
/** @deprecated use Image::OrSmaller */
public const FIT = self::OrSmaller;
/** @deprecated use Image::OrBigger */
public const FILL = self::OrBigger;
/** @deprecated use Image::Cover */
public const EXACT = self::Cover;
/** @deprecated use Image::EmptyGIF */
public const EMPTY_GIF = self::EmptyGIF;
/** image types */
public const
JPEG = ImageType::JPEG,
PNG = ImageType::PNG,
GIF = ImageType::GIF,
WEBP = ImageType::WEBP,
AVIF = ImageType::AVIF,
BMP = ImageType::BMP;
public const EmptyGIF = "GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;";
private const Formats = [ImageType::JPEG => 'jpeg', ImageType::PNG => 'png', ImageType::GIF => 'gif', ImageType::WEBP => 'webp', ImageType::AVIF => 'avif', ImageType::BMP => 'bmp'];
private \GdImage $image;
/**
* Returns RGB color (0..255) and transparency (0..127).
* @deprecated use ImageColor::rgb()
*/
public static function rgb(int $red, int $green, int $blue, int $transparency = 0): array
{
return [
'red' => max(0, min(255, $red)),
'green' => max(0, min(255, $green)),
'blue' => max(0, min(255, $blue)),
'alpha' => max(0, min(127, $transparency)),
];
}
/**
* Reads an image from a file and returns its type in $type.
* @throws Nette\NotSupportedException if gd extension is not loaded
* @throws UnknownImageFileException if file not found or file type is not known
*/
public static function fromFile(string $file, ?int &$type = null): static
{
self::ensureExtension();
$type = self::detectTypeFromFile($file);
if (!$type) {
throw new UnknownImageFileException(is_file($file) ? "Unknown type of file '$file'." : "File '$file' not found.");
}
return self::invokeSafe('imagecreatefrom' . self::Formats[$type], $file, "Unable to open file '$file'.", __METHOD__);
}
/**
* Reads an image from a string and returns its type in $type.
* @throws Nette\NotSupportedException if gd extension is not loaded
* @throws ImageException
*/
public static function fromString(string $s, ?int &$type = null): static
{
self::ensureExtension();
$type = self::detectTypeFromString($s);
if (!$type) {
throw new UnknownImageFileException('Unknown type of image.');
}
return self::invokeSafe('imagecreatefromstring', $s, 'Unable to open image from string.', __METHOD__);
}
private static function invokeSafe(string $func, string $arg, string $message, string $callee): static
{
$errors = [];
$res = Callback::invokeSafe($func, [$arg], function (string $message) use (&$errors): void {
$errors[] = $message;
});
if (!$res) {
throw new ImageException($message . ' Errors: ' . implode(', ', $errors));
} elseif ($errors) {
trigger_error($callee . '(): ' . implode(', ', $errors), E_USER_WARNING);
}
return new static($res);
}
/**
* Creates a new true color image of the given dimensions. The default color is black.
* @param positive-int $width
* @param positive-int $height
* @throws Nette\NotSupportedException if gd extension is not loaded
*/
public static function fromBlank(int $width, int $height, ImageColor|array|null $color = null): static
{
self::ensureExtension();
if ($width < 1 || $height < 1) {
throw new Nette\InvalidArgumentException('Image width and height must be greater than zero.');
}
$image = new static(imagecreatetruecolor($width, $height));
if ($color) {
$image->alphablending(false);
$image->filledrectangle(0, 0, $width - 1, $height - 1, $color);
$image->alphablending(true);
}
return $image;
}
/**
* Returns the type of image from file.
* @return ImageType::*|null
*/
public static function detectTypeFromFile(string $file, &$width = null, &$height = null): ?int
{
[$width, $height, $type] = @getimagesize($file); // @ - files smaller than 12 bytes causes read error
return isset(self::Formats[$type]) ? $type : null;
}
/**
* Returns the type of image from string.
* @return ImageType::*|null
*/
public static function detectTypeFromString(string $s, &$width = null, &$height = null): ?int
{
[$width, $height, $type] = @getimagesizefromstring($s); // @ - strings smaller than 12 bytes causes read error
return isset(self::Formats[$type]) ? $type : null;
}
/**
* Returns the file extension for the given image type.
* @param ImageType::* $type
* @return value-of<self::Formats>
*/
public static function typeToExtension(int $type): string
{
if (!isset(self::Formats[$type])) {
throw new Nette\InvalidArgumentException("Unsupported image type '$type'.");
}
return self::Formats[$type];
}
/**
* Returns the image type for given file extension.
* @return ImageType::*
*/
public static function extensionToType(string $extension): int
{
$extensions = array_flip(self::Formats) + ['jpg' => ImageType::JPEG];
$extension = strtolower($extension);
if (!isset($extensions[$extension])) {
throw new Nette\InvalidArgumentException("Unsupported file extension '$extension'.");
}
return $extensions[$extension];
}
/**
* Returns the mime type for the given image type.
* @param ImageType::* $type
*/
public static function typeToMimeType(int $type): string
{
return 'image/' . self::typeToExtension($type);
}
/**
* @param ImageType::* $type
*/
public static function isTypeSupported(int $type): bool
{
self::ensureExtension();
return (bool) (imagetypes() & match ($type) {
ImageType::JPEG => IMG_JPG,
ImageType::PNG => IMG_PNG,
ImageType::GIF => IMG_GIF,
ImageType::WEBP => IMG_WEBP,
ImageType::AVIF => 256, // IMG_AVIF,
ImageType::BMP => IMG_BMP,
default => 0,
});
}
/** @return ImageType[] */
public static function getSupportedTypes(): array
{
self::ensureExtension();
$flag = imagetypes();
return array_filter([
$flag & IMG_GIF ? ImageType::GIF : null,
$flag & IMG_JPG ? ImageType::JPEG : null,
$flag & IMG_PNG ? ImageType::PNG : null,
$flag & IMG_WEBP ? ImageType::WEBP : null,
$flag & 256 ? ImageType::AVIF : null, // IMG_AVIF
$flag & IMG_BMP ? ImageType::BMP : null,
]);
}
/**
* Wraps GD image.
*/
public function __construct(\GdImage $image)
{
$this->setImageResource($image);
imagesavealpha($image, true);
}
/**
* Returns image width.
* @return positive-int
*/
public function getWidth(): int
{
return imagesx($this->image);
}
/**
* Returns image height.
* @return positive-int
*/
public function getHeight(): int
{
return imagesy($this->image);
}
/**
* Sets image resource.
*/
protected function setImageResource(\GdImage $image): static
{
$this->image = $image;
return $this;
}
/**
* Returns image GD resource.
*/
public function getImageResource(): \GdImage
{
return $this->image;
}
/**
* Scales an image. Width and height accept pixels or percent.
* @param int-mask-of<self::OrSmaller|self::OrBigger|self::Stretch|self::Cover|self::ShrinkOnly> $mode
*/
public function resize(int|string|null $width, int|string|null $height, int $mode = self::OrSmaller): static
{
if ($mode & self::Cover) {
return $this->resize($width, $height, self::OrBigger)->crop('50%', '50%', $width, $height);
}
[$newWidth, $newHeight] = static::calculateSize($this->getWidth(), $this->getHeight(), $width, $height, $mode);
if ($newWidth !== $this->getWidth() || $newHeight !== $this->getHeight()) { // resize
$newImage = static::fromBlank($newWidth, $newHeight, ImageColor::rgb(0, 0, 0, 0))->getImageResource();
imagecopyresampled(
$newImage,
$this->image,
0,
0,
0,
0,
$newWidth,
$newHeight,
$this->getWidth(),
$this->getHeight(),
);
$this->image = $newImage;
}
if ($width < 0 || $height < 0) {
imageflip($this->image, $width < 0 ? ($height < 0 ? IMG_FLIP_BOTH : IMG_FLIP_HORIZONTAL) : IMG_FLIP_VERTICAL);
}
return $this;
}
/**
* Calculates dimensions of resized image. Width and height accept pixels or percent.
* @param int-mask-of<self::OrSmaller|self::OrBigger|self::Stretch|self::Cover|self::ShrinkOnly> $mode
*/
public static function calculateSize(
int $srcWidth,
int $srcHeight,
$newWidth,
$newHeight,
int $mode = self::OrSmaller,
): array
{
if ($newWidth === null) {
} elseif (self::isPercent($newWidth)) {
$newWidth = (int) round($srcWidth / 100 * abs($newWidth));
$percents = true;
} else {
$newWidth = abs($newWidth);
}
if ($newHeight === null) {
} elseif (self::isPercent($newHeight)) {
$newHeight = (int) round($srcHeight / 100 * abs($newHeight));
$mode |= empty($percents) ? 0 : self::Stretch;
} else {
$newHeight = abs($newHeight);
}
if ($mode & self::Stretch) { // non-proportional
if (!$newWidth || !$newHeight) {
throw new Nette\InvalidArgumentException('For stretching must be both width and height specified.');
}
if ($mode & self::ShrinkOnly) {
$newWidth = min($srcWidth, $newWidth);
$newHeight = min($srcHeight, $newHeight);
}
} else { // proportional
if (!$newWidth && !$newHeight) {
throw new Nette\InvalidArgumentException('At least width or height must be specified.');
}
$scale = [];
if ($newWidth > 0) { // fit width
$scale[] = $newWidth / $srcWidth;
}
if ($newHeight > 0) { // fit height
$scale[] = $newHeight / $srcHeight;
}
if ($mode & self::OrBigger) {
$scale = [max($scale)];
}
if ($mode & self::ShrinkOnly) {
$scale[] = 1;
}
$scale = min($scale);
$newWidth = (int) round($srcWidth * $scale);
$newHeight = (int) round($srcHeight * $scale);
}
return [max($newWidth, 1), max($newHeight, 1)];
}
/**
* Crops image. Arguments accepts pixels or percent.
*/
public function crop(int|string $left, int|string $top, int|string $width, int|string $height): static
{
[$r['x'], $r['y'], $r['width'], $r['height']]
= static::calculateCutout($this->getWidth(), $this->getHeight(), $left, $top, $width, $height);
if (gd_info()['GD Version'] === 'bundled (2.1.0 compatible)') {
$this->image = imagecrop($this->image, $r);
imagesavealpha($this->image, true);
} else {
$newImage = static::fromBlank($r['width'], $r['height'], ImageColor::rgb(0, 0, 0, 0))->getImageResource();
imagecopy($newImage, $this->image, 0, 0, $r['x'], $r['y'], $r['width'], $r['height']);
$this->image = $newImage;
}
return $this;
}
/**
* Calculates dimensions of cutout in image. Arguments accepts pixels or percent.
*/
public static function calculateCutout(
int $srcWidth,
int $srcHeight,
int|string $left,
int|string $top,
int|string $newWidth,
int|string $newHeight,
): array
{
if (self::isPercent($newWidth)) {
$newWidth = (int) round($srcWidth / 100 * $newWidth);
}
if (self::isPercent($newHeight)) {
$newHeight = (int) round($srcHeight / 100 * $newHeight);
}
if (self::isPercent($left)) {
$left = (int) round(($srcWidth - $newWidth) / 100 * $left);
}
if (self::isPercent($top)) {
$top = (int) round(($srcHeight - $newHeight) / 100 * $top);
}
if ($left < 0) {
$newWidth += $left;
$left = 0;
}
if ($top < 0) {
$newHeight += $top;
$top = 0;
}
$newWidth = min($newWidth, $srcWidth - $left);
$newHeight = min($newHeight, $srcHeight - $top);
return [$left, $top, $newWidth, $newHeight];
}
/**
* Sharpens image a little bit.
*/
public function sharpen(): static
{
imageconvolution($this->image, [ // my magic numbers ;)
[-1, -1, -1],
[-1, 24, -1],
[-1, -1, -1],
], 16, 0);
return $this;
}
/**
* Puts another image into this image. Left and top accepts pixels or percent.
* @param int<0, 100> $opacity 0..100
*/
public function place(self $image, int|string $left = 0, int|string $top = 0, int $opacity = 100): static
{
$opacity = max(0, min(100, $opacity));
if ($opacity === 0) {
return $this;
}
$width = $image->getWidth();
$height = $image->getHeight();
if (self::isPercent($left)) {
$left = (int) round(($this->getWidth() - $width) / 100 * $left);
}
if (self::isPercent($top)) {
$top = (int) round(($this->getHeight() - $height) / 100 * $top);
}
$output = $input = $image->image;
if ($opacity < 100) {
$tbl = [];
for ($i = 0; $i < 128; $i++) {
$tbl[$i] = round(127 - (127 - $i) * $opacity / 100);
}
$output = imagecreatetruecolor($width, $height);
imagealphablending($output, false);
if (!$image->isTrueColor()) {
$input = $output;
imagefilledrectangle($output, 0, 0, $width, $height, imagecolorallocatealpha($output, 0, 0, 0, 127));
imagecopy($output, $image->image, 0, 0, 0, 0, $width, $height);
}
for ($x = 0; $x < $width; $x++) {
for ($y = 0; $y < $height; $y++) {
$c = \imagecolorat($input, $x, $y);
$c = ($c & 0xFFFFFF) + ($tbl[$c >> 24] << 24);
\imagesetpixel($output, $x, $y, $c);
}
}
imagealphablending($output, true);
}
imagecopy(
$this->image,
$output,
$left,
$top,
0,
0,
$width,
$height,
);
return $this;
}
/**
* Calculates the bounding box for a TrueType text. Returns keys left, top, width and height.
*/
public static function calculateTextBox(
string $text,
string $fontFile,
float $size,
float $angle = 0,
array $options = [],
): array
{
self::ensureExtension();
$box = imagettfbbox($size, $angle, $fontFile, $text, $options);
return [
'left' => $minX = min([$box[0], $box[2], $box[4], $box[6]]),
'top' => $minY = min([$box[1], $box[3], $box[5], $box[7]]),
'width' => max([$box[0], $box[2], $box[4], $box[6]]) - $minX + 1,
'height' => max([$box[1], $box[3], $box[5], $box[7]]) - $minY + 1,
];
}
/**
* Draw a rectangle.
*/
public function rectangleWH(int $x, int $y, int $width, int $height, ImageColor $color): void
{
if ($width !== 0 && $height !== 0) {
$this->rectangle($x, $y, $x + $width + ($width > 0 ? -1 : 1), $y + $height + ($height > 0 ? -1 : 1), $color);
}
}
/**
* Draw a filled rectangle.
*/
public function filledRectangleWH(int $x, int $y, int $width, int $height, ImageColor $color): void
{
if ($width !== 0 && $height !== 0) {
$this->filledRectangle($x, $y, $x + $width + ($width > 0 ? -1 : 1), $y + $height + ($height > 0 ? -1 : 1), $color);
}
}
/**
* Saves image to the file. Quality is in the range 0..100 for JPEG (default 85), WEBP (default 80) and AVIF (default 30) and 0..9 for PNG (default 9).
* @param ImageType::*|null $type
* @throws ImageException
*/
public function save(string $file, ?int $quality = null, ?int $type = null): void
{
$type ??= self::extensionToType(pathinfo($file, PATHINFO_EXTENSION));
$this->output($type, $quality, $file);
}
/**
* Outputs image to string. Quality is in the range 0..100 for JPEG (default 85), WEBP (default 80) and AVIF (default 30) and 0..9 for PNG (default 9).
* @param ImageType::* $type
*/
public function toString(int $type = ImageType::JPEG, ?int $quality = null): string
{
return Helpers::capture(function () use ($type, $quality): void {
$this->output($type, $quality);
});
}
/**
* Outputs image to string.
*/
public function __toString(): string
{
return $this->toString();
}
/**
* Outputs image to browser. Quality is in the range 0..100 for JPEG (default 85), WEBP (default 80) and AVIF (default 30) and 0..9 for PNG (default 9).
* @param ImageType::* $type
* @throws ImageException
*/
public function send(int $type = ImageType::JPEG, ?int $quality = null): void
{
header('Content-Type: ' . self::typeToMimeType($type));
$this->output($type, $quality);
}
/**
* Outputs image to browser or file.
* @param ImageType::* $type
* @throws ImageException
*/
private function output(int $type, ?int $quality, ?string $file = null): void
{
switch ($type) {
case ImageType::JPEG:
$quality = $quality === null ? 85 : max(0, min(100, $quality));
$success = @imagejpeg($this->image, $file, $quality); // @ is escalated to exception
break;
case ImageType::PNG:
$quality = $quality === null ? 9 : max(0, min(9, $quality));
$success = @imagepng($this->image, $file, $quality); // @ is escalated to exception
break;
case ImageType::GIF:
$success = @imagegif($this->image, $file); // @ is escalated to exception
break;
case ImageType::WEBP:
$quality = $quality === null ? 80 : max(0, min(100, $quality));
$success = @imagewebp($this->image, $file, $quality); // @ is escalated to exception
break;
case ImageType::AVIF:
$quality = $quality === null ? 30 : max(0, min(100, $quality));
$success = @imageavif($this->image, $file, $quality); // @ is escalated to exception
break;
case ImageType::BMP:
$success = @imagebmp($this->image, $file); // @ is escalated to exception
break;
default:
throw new Nette\InvalidArgumentException("Unsupported image type '$type'.");
}
if (!$success) {
throw new ImageException(Helpers::getLastError() ?: 'Unknown error');
}
}
/**
* Call to undefined method.
* @throws Nette\MemberAccessException
*/
public function __call(string $name, array $args): mixed
{
$function = 'image' . $name;
if (!function_exists($function)) {
ObjectHelpers::strictCall(static::class, $name);
}
foreach ($args as $key => $value) {
if ($value instanceof self) {
$args[$key] = $value->getImageResource();
} elseif ($value instanceof ImageColor || (is_array($value) && isset($value['red']))) {
$args[$key] = $this->resolveColor($value);
}
}
$res = $function($this->image, ...$args);
return $res instanceof \GdImage
? $this->setImageResource($res)
: $res;
}
public function __clone()
{
ob_start(function () {});
imagepng($this->image, null, 0);
$this->setImageResource(imagecreatefromstring(ob_get_clean()));
}
private static function isPercent(int|string &$num): bool
{
if (is_string($num) && str_ends_with($num, '%')) {
$num = (float) substr($num, 0, -1);
return true;
} elseif (is_int($num) || $num === (string) (int) $num) {
$num = (int) $num;
return false;
}
throw new Nette\InvalidArgumentException("Expected dimension in int|string, '$num' given.");
}
/**
* Prevents serialization.
*/
public function __sleep(): array
{
throw new Nette\NotSupportedException('You cannot serialize or unserialize ' . self::class . ' instances.');
}
public function resolveColor(ImageColor|array $color): int
{
$color = $color instanceof ImageColor ? $color->toRGBA() : array_values($color);
return imagecolorallocatealpha($this->image, ...$color) ?: imagecolorresolvealpha($this->image, ...$color);
}
private static function ensureExtension(): void
{
if (!extension_loaded('gd')) {
throw new Nette\NotSupportedException('PHP extension GD is not loaded.');
}
}
}
@@ -0,0 +1,238 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Utils;
use Nette;
/**
* Utilities for iterables.
*/
final class Iterables
{
use Nette\StaticClass;
/**
* Tests for the presence of value.
*/
public static function contains(iterable $iterable, mixed $value): bool
{
foreach ($iterable as $v) {
if ($v === $value) {
return true;
}
}
return false;
}
/**
* Tests for the presence of key.
*/
public static function containsKey(iterable $iterable, mixed $key): bool
{
foreach ($iterable as $k => $v) {
if ($k === $key) {
return true;
}
}
return false;
}
/**
* Returns the first item (matching the specified predicate if given). If there is no such item, it returns result of invoking $else or null.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @param ?callable(V, K, iterable<K, V>): bool $predicate
* @return ?V
*/
public static function first(iterable $iterable, ?callable $predicate = null, ?callable $else = null): mixed
{
foreach ($iterable as $k => $v) {
if (!$predicate || $predicate($v, $k, $iterable)) {
return $v;
}
}
return $else ? $else() : null;
}
/**
* Returns the key of first item (matching the specified predicate if given). If there is no such item, it returns result of invoking $else or null.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @param ?callable(V, K, iterable<K, V>): bool $predicate
* @return ?K
*/
public static function firstKey(iterable $iterable, ?callable $predicate = null, ?callable $else = null): mixed
{
foreach ($iterable as $k => $v) {
if (!$predicate || $predicate($v, $k, $iterable)) {
return $k;
}
}
return $else ? $else() : null;
}
/**
* Tests whether at least one element in the iterator passes the test implemented by the provided function.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @param callable(V, K, iterable<K, V>): bool $predicate
*/
public static function some(iterable $iterable, callable $predicate): bool
{
foreach ($iterable as $k => $v) {
if ($predicate($v, $k, $iterable)) {
return true;
}
}
return false;
}
/**
* Tests whether all elements in the iterator pass the test implemented by the provided function.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @param callable(V, K, iterable<K, V>): bool $predicate
*/
public static function every(iterable $iterable, callable $predicate): bool
{
foreach ($iterable as $k => $v) {
if (!$predicate($v, $k, $iterable)) {
return false;
}
}
return true;
}
/**
* Iterator that filters elements according to a given $predicate. Maintains original keys.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @param callable(V, K, iterable<K, V>): bool $predicate
* @return \Generator<K, V>
*/
public static function filter(iterable $iterable, callable $predicate): \Generator
{
foreach ($iterable as $k => $v) {
if ($predicate($v, $k, $iterable)) {
yield $k => $v;
}
}
}
/**
* Iterator that transforms values by calling $transformer. Maintains original keys.
* @template K
* @template V
* @template R
* @param iterable<K, V> $iterable
* @param callable(V, K, iterable<K, V>): R $transformer
* @return \Generator<K, R>
*/
public static function map(iterable $iterable, callable $transformer): \Generator
{
foreach ($iterable as $k => $v) {
yield $k => $transformer($v, $k, $iterable);
}
}
/**
* Iterator that transforms keys and values by calling $transformer. If it returns null, the element is skipped.
* @template K
* @template V
* @template ResV
* @template ResK
* @param iterable<K, V> $iterable
* @param callable(V, K, iterable<K, V>): ?array{ResV, ResK} $transformer
* @return \Generator<ResV, ResK>
*/
public static function mapWithKeys(iterable $iterable, callable $transformer): \Generator
{
foreach ($iterable as $k => $v) {
$pair = $transformer($v, $k, $iterable);
if ($pair) {
yield $pair[0] => $pair[1];
}
}
}
/**
* Wraps around iterator and caches its keys and values during iteration.
* This allows the data to be re-iterated multiple times.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @return \IteratorAggregate<K, V>
*/
public static function memoize(iterable $iterable): iterable
{
return new class (self::toIterator($iterable)) implements \IteratorAggregate {
public function __construct(
private \Iterator $iterator,
private array $cache = [],
) {
}
public function getIterator(): \Generator
{
if (!$this->cache) {
$this->iterator->rewind();
}
$i = 0;
while (true) {
if (isset($this->cache[$i])) {
[$k, $v] = $this->cache[$i];
} elseif ($this->iterator->valid()) {
$k = $this->iterator->key();
$v = $this->iterator->current();
$this->iterator->next();
$this->cache[$i] = [$k, $v];
} else {
break;
}
yield $k => $v;
$i++;
}
}
};
}
/**
* Creates an iterator from anything that is iterable.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @return \Iterator<K, V>
*/
public static function toIterator(iterable $iterable): \Iterator
{
return match (true) {
$iterable instanceof \Iterator => $iterable,
$iterable instanceof \IteratorAggregate => self::toIterator($iterable->getIterator()),
is_array($iterable) => new \ArrayIterator($iterable),
};
}
}
@@ -0,0 +1,322 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Utils;
use Nette;
/**
* PHP reflection helpers.
*/
final class Reflection
{
use Nette\StaticClass;
/** @deprecated use Nette\Utils\Validator::isBuiltinType() */
public static function isBuiltinType(string $type): bool
{
return Validators::isBuiltinType($type);
}
/** @deprecated use Nette\Utils\Validator::isClassKeyword() */
public static function isClassKeyword(string $name): bool
{
return Validators::isClassKeyword($name);
}
/** @deprecated use native ReflectionParameter::getDefaultValue() */
public static function getParameterDefaultValue(\ReflectionParameter $param): mixed
{
if ($param->isDefaultValueConstant()) {
$const = $orig = $param->getDefaultValueConstantName();
$pair = explode('::', $const);
if (isset($pair[1])) {
$pair[0] = Type::resolve($pair[0], $param);
try {
$rcc = new \ReflectionClassConstant($pair[0], $pair[1]);
} catch (\ReflectionException $e) {
$name = self::toString($param);
throw new \ReflectionException("Unable to resolve constant $orig used as default value of $name.", 0, $e);
}
return $rcc->getValue();
} elseif (!defined($const)) {
$const = substr((string) strrchr($const, '\\'), 1);
if (!defined($const)) {
$name = self::toString($param);
throw new \ReflectionException("Unable to resolve constant $orig used as default value of $name.");
}
}
return constant($const);
}
return $param->getDefaultValue();
}
/**
* Returns a reflection of a class or trait that contains a declaration of given property. Property can also be declared in the trait.
*/
public static function getPropertyDeclaringClass(\ReflectionProperty $prop): \ReflectionClass
{
foreach ($prop->getDeclaringClass()->getTraits() as $trait) {
if ($trait->hasProperty($prop->name)
// doc-comment guessing as workaround for insufficient PHP reflection
&& $trait->getProperty($prop->name)->getDocComment() === $prop->getDocComment()
) {
return self::getPropertyDeclaringClass($trait->getProperty($prop->name));
}
}
return $prop->getDeclaringClass();
}
/**
* Returns a reflection of a method that contains a declaration of $method.
* Usually, each method is its own declaration, but the body of the method can also be in the trait and under a different name.
*/
public static function getMethodDeclaringMethod(\ReflectionMethod $method): \ReflectionMethod
{
// file & line guessing as workaround for insufficient PHP reflection
$decl = $method->getDeclaringClass();
if ($decl->getFileName() === $method->getFileName()
&& $decl->getStartLine() <= $method->getStartLine()
&& $decl->getEndLine() >= $method->getEndLine()
) {
return $method;
}
$hash = [$method->getFileName(), $method->getStartLine(), $method->getEndLine()];
if (($alias = $decl->getTraitAliases()[$method->name] ?? null)
&& ($m = new \ReflectionMethod(...explode('::', $alias, 2)))
&& $hash === [$m->getFileName(), $m->getStartLine(), $m->getEndLine()]
) {
return self::getMethodDeclaringMethod($m);
}
foreach ($decl->getTraits() as $trait) {
if ($trait->hasMethod($method->name)
&& ($m = $trait->getMethod($method->name))
&& $hash === [$m->getFileName(), $m->getStartLine(), $m->getEndLine()]
) {
return self::getMethodDeclaringMethod($m);
}
}
return $method;
}
/**
* Finds out if reflection has access to PHPdoc comments. Comments may not be available due to the opcode cache.
*/
public static function areCommentsAvailable(): bool
{
static $res;
return $res ?? $res = (bool) (new \ReflectionMethod(self::class, __FUNCTION__))->getDocComment();
}
public static function toString(\Reflector $ref): string
{
if ($ref instanceof \ReflectionClass) {
return $ref->name;
} elseif ($ref instanceof \ReflectionMethod) {
return $ref->getDeclaringClass()->name . '::' . $ref->name . '()';
} elseif ($ref instanceof \ReflectionFunction) {
return PHP_VERSION_ID >= 80200 && $ref->isAnonymous()
? '{closure}()'
: $ref->name . '()';
} elseif ($ref instanceof \ReflectionProperty) {
return self::getPropertyDeclaringClass($ref)->name . '::$' . $ref->name;
} elseif ($ref instanceof \ReflectionParameter) {
return '$' . $ref->name . ' in ' . self::toString($ref->getDeclaringFunction());
} else {
throw new Nette\InvalidArgumentException;
}
}
/**
* Expands the name of the class to full name in the given context of given class.
* Thus, it returns how the PHP parser would understand $name if it were written in the body of the class $context.
* @throws Nette\InvalidArgumentException
*/
public static function expandClassName(string $name, \ReflectionClass $context): string
{
$lower = strtolower($name);
if (empty($name)) {
throw new Nette\InvalidArgumentException('Class name must not be empty.');
} elseif (Validators::isBuiltinType($lower)) {
return $lower;
} elseif ($lower === 'self' || $lower === 'static') {
return $context->name;
} elseif ($lower === 'parent') {
return $context->getParentClass()
? $context->getParentClass()->name
: 'parent';
} elseif ($name[0] === '\\') { // fully qualified name
return ltrim($name, '\\');
}
$uses = self::getUseStatements($context);
$parts = explode('\\', $name, 2);
if (isset($uses[$parts[0]])) {
$parts[0] = $uses[$parts[0]];
return implode('\\', $parts);
} elseif ($context->inNamespace()) {
return $context->getNamespaceName() . '\\' . $name;
} else {
return $name;
}
}
/** @return array<string, class-string> of [alias => class] */
public static function getUseStatements(\ReflectionClass $class): array
{
if ($class->isAnonymous()) {
throw new Nette\NotImplementedException('Anonymous classes are not supported.');
}
static $cache = [];
if (!isset($cache[$name = $class->name])) {
if ($class->isInternal()) {
$cache[$name] = [];
} else {
$code = file_get_contents($class->getFileName());
$cache = self::parseUseStatements($code, $name) + $cache;
}
}
return $cache[$name];
}
/**
* Parses PHP code to [class => [alias => class, ...]]
*/
private static function parseUseStatements(string $code, ?string $forClass = null): array
{
try {
$tokens = \PhpToken::tokenize($code, TOKEN_PARSE);
} catch (\ParseError $e) {
trigger_error($e->getMessage(), E_USER_NOTICE);
$tokens = [];
}
$namespace = $class = null;
$classLevel = $level = 0;
$res = $uses = [];
$nameTokens = [T_STRING, T_NS_SEPARATOR, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED];
while ($token = current($tokens)) {
next($tokens);
switch ($token->id) {
case T_NAMESPACE:
$namespace = ltrim(self::fetch($tokens, $nameTokens) . '\\', '\\');
$uses = [];
break;
case T_CLASS:
case T_INTERFACE:
case T_TRAIT:
case PHP_VERSION_ID < 80100
? T_CLASS
: T_ENUM:
if ($name = self::fetch($tokens, T_STRING)) {
$class = $namespace . $name;
$classLevel = $level + 1;
$res[$class] = $uses;
if ($class === $forClass) {
return $res;
}
}
break;
case T_USE:
while (!$class && ($name = self::fetch($tokens, $nameTokens))) {
$name = ltrim($name, '\\');
if (self::fetch($tokens, '{')) {
while ($suffix = self::fetch($tokens, $nameTokens)) {
if (self::fetch($tokens, T_AS)) {
$uses[self::fetch($tokens, T_STRING)] = $name . $suffix;
} else {
$tmp = explode('\\', $suffix);
$uses[end($tmp)] = $name . $suffix;
}
if (!self::fetch($tokens, ',')) {
break;
}
}
} elseif (self::fetch($tokens, T_AS)) {
$uses[self::fetch($tokens, T_STRING)] = $name;
} else {
$tmp = explode('\\', $name);
$uses[end($tmp)] = $name;
}
if (!self::fetch($tokens, ',')) {
break;
}
}
break;
case T_CURLY_OPEN:
case T_DOLLAR_OPEN_CURLY_BRACES:
case ord('{'):
$level++;
break;
case ord('}'):
if ($level === $classLevel) {
$class = $classLevel = 0;
}
$level--;
}
}
return $res;
}
private static function fetch(array &$tokens, string|int|array $take): ?string
{
$res = null;
while ($token = current($tokens)) {
if ($token->is($take)) {
$res .= $token->text;
} elseif (!$token->is([T_DOC_COMMENT, T_WHITESPACE, T_COMMENT])) {
break;
}
next($tokens);
}
return $res;
}
}
@@ -0,0 +1,728 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Utils;
use JetBrains\PhpStorm\Language;
use Nette;
use function is_array, is_object, strlen;
/**
* String tools library.
*/
class Strings
{
use Nette\StaticClass;
public const TrimCharacters = " \t\n\r\0\x0B\u{A0}\u{2000}\u{2001}\u{2002}\u{2003}\u{2004}\u{2005}\u{2006}\u{2007}\u{2008}\u{2009}\u{200A}\u{200B}";
/** @deprecated use Strings::TrimCharacters */
public const TRIM_CHARACTERS = self::TrimCharacters;
/**
* @deprecated use Nette\Utils\Validator::isUnicode()
*/
public static function checkEncoding(string $s): bool
{
return $s === self::fixEncoding($s);
}
/**
* Removes all invalid UTF-8 characters from a string.
*/
public static function fixEncoding(string $s): string
{
// removes xD800-xDFFF, x110000 and higher
return htmlspecialchars_decode(htmlspecialchars($s, ENT_NOQUOTES | ENT_IGNORE, 'UTF-8'), ENT_NOQUOTES);
}
/**
* Returns a specific character in UTF-8 from code point (number in range 0x0000..D7FF or 0xE000..10FFFF).
* @throws Nette\InvalidArgumentException if code point is not in valid range
*/
public static function chr(int $code): string
{
if ($code < 0 || ($code >= 0xD800 && $code <= 0xDFFF) || $code > 0x10FFFF) {
throw new Nette\InvalidArgumentException('Code point must be in range 0x0 to 0xD7FF or 0xE000 to 0x10FFFF.');
} elseif (!extension_loaded('iconv')) {
throw new Nette\NotSupportedException(__METHOD__ . '() requires ICONV extension that is not loaded.');
}
return iconv('UTF-32BE', 'UTF-8//IGNORE', pack('N', $code));
}
/**
* Returns a code point of specific character in UTF-8 (number in range 0x0000..D7FF or 0xE000..10FFFF).
*/
public static function ord(string $c): int
{
if (!extension_loaded('iconv')) {
throw new Nette\NotSupportedException(__METHOD__ . '() requires ICONV extension that is not loaded.');
}
$tmp = iconv('UTF-8', 'UTF-32BE//IGNORE', $c);
if (!$tmp) {
throw new Nette\InvalidArgumentException('Invalid UTF-8 character "' . ($c === '' ? '' : '\x' . strtoupper(bin2hex($c))) . '".');
}
return unpack('N', $tmp)[1];
}
/**
* @deprecated use str_starts_with()
*/
public static function startsWith(string $haystack, string $needle): bool
{
return str_starts_with($haystack, $needle);
}
/**
* @deprecated use str_ends_with()
*/
public static function endsWith(string $haystack, string $needle): bool
{
return str_ends_with($haystack, $needle);
}
/**
* @deprecated use str_contains()
*/
public static function contains(string $haystack, string $needle): bool
{
return str_contains($haystack, $needle);
}
/**
* Returns a part of UTF-8 string specified by starting position and length. If start is negative,
* the returned string will start at the start'th character from the end of string.
*/
public static function substring(string $s, int $start, ?int $length = null): string
{
if (function_exists('mb_substr')) {
return mb_substr($s, $start, $length, 'UTF-8'); // MB is much faster
} elseif (!extension_loaded('iconv')) {
throw new Nette\NotSupportedException(__METHOD__ . '() requires extension ICONV or MBSTRING, neither is loaded.');
} elseif ($length === null) {
$length = self::length($s);
} elseif ($start < 0 && $length < 0) {
$start += self::length($s); // unifies iconv_substr behavior with mb_substr
}
return iconv_substr($s, $start, $length, 'UTF-8');
}
/**
* Removes control characters, normalizes line breaks to `\n`, removes leading and trailing blank lines,
* trims end spaces on lines, normalizes UTF-8 to the normal form of NFC.
*/
public static function normalize(string $s): string
{
// convert to compressed normal form (NFC)
if (class_exists('Normalizer', false) && ($n = \Normalizer::normalize($s, \Normalizer::FORM_C)) !== false) {
$s = $n;
}
$s = self::unixNewLines($s);
// remove control characters; leave \t + \n
$s = self::pcre('preg_replace', ['#[\x00-\x08\x0B-\x1F\x7F-\x9F]+#u', '', $s]);
// right trim
$s = self::pcre('preg_replace', ['#[\t ]+$#m', '', $s]);
// leading and trailing blank lines
$s = trim($s, "\n");
return $s;
}
/** @deprecated use Strings::unixNewLines() */
public static function normalizeNewLines(string $s): string
{
return self::unixNewLines($s);
}
/**
* Converts line endings to \n used on Unix-like systems.
* Line endings are: \n, \r, \r\n, U+2028 line separator, U+2029 paragraph separator.
*/
public static function unixNewLines(string $s): string
{
return preg_replace("~\r\n?|\u{2028}|\u{2029}~", "\n", $s);
}
/**
* Converts line endings to platform-specific, i.e. \r\n on Windows and \n elsewhere.
* Line endings are: \n, \r, \r\n, U+2028 line separator, U+2029 paragraph separator.
*/
public static function platformNewLines(string $s): string
{
return preg_replace("~\r\n?|\n|\u{2028}|\u{2029}~", PHP_EOL, $s);
}
/**
* Converts UTF-8 string to ASCII, ie removes diacritics etc.
*/
public static function toAscii(string $s): string
{
$iconv = defined('ICONV_IMPL') ? trim(ICONV_IMPL, '"\'') : null;
static $transliterator = null;
if ($transliterator === null) {
if (class_exists('Transliterator', false)) {
$transliterator = \Transliterator::create('Any-Latin; Latin-ASCII');
} else {
trigger_error(__METHOD__ . "(): it is recommended to enable PHP extensions 'intl'.", E_USER_NOTICE);
$transliterator = false;
}
}
// remove control characters and check UTF-8 validity
$s = self::pcre('preg_replace', ['#[^\x09\x0A\x0D\x20-\x7E\xA0-\x{2FF}\x{370}-\x{10FFFF}]#u', '', $s]);
// transliteration (by Transliterator and iconv) is not optimal, replace some characters directly
$s = strtr($s, ["\u{201E}" => '"', "\u{201C}" => '"', "\u{201D}" => '"', "\u{201A}" => "'", "\u{2018}" => "'", "\u{2019}" => "'", "\u{B0}" => '^', "\u{42F}" => 'Ya', "\u{44F}" => 'ya', "\u{42E}" => 'Yu', "\u{44E}" => 'yu', "\u{c4}" => 'Ae', "\u{d6}" => 'Oe', "\u{dc}" => 'Ue', "\u{1e9e}" => 'Ss', "\u{e4}" => 'ae', "\u{f6}" => 'oe', "\u{fc}" => 'ue', "\u{df}" => 'ss']); // „ “ ” ‚ ‘ ’ ° Я я Ю ю Ä Ö Ü ẞ ä ö ü ß
if ($iconv !== 'libiconv') {
$s = strtr($s, ["\u{AE}" => '(R)', "\u{A9}" => '(c)', "\u{2026}" => '...', "\u{AB}" => '<<', "\u{BB}" => '>>', "\u{A3}" => 'lb', "\u{A5}" => 'yen', "\u{B2}" => '^2', "\u{B3}" => '^3', "\u{B5}" => 'u', "\u{B9}" => '^1', "\u{BA}" => 'o', "\u{BF}" => '?', "\u{2CA}" => "'", "\u{2CD}" => '_', "\u{2DD}" => '"', "\u{1FEF}" => '', "\u{20AC}" => 'EUR', "\u{2122}" => 'TM', "\u{212E}" => 'e', "\u{2190}" => '<-', "\u{2191}" => '^', "\u{2192}" => '->', "\u{2193}" => 'V', "\u{2194}" => '<->']); // ® © … « » £ ¥ ² ³ µ ¹ º ¿ ˊ ˍ ˝ ` € ™ ℮ ← ↑ → ↓ ↔
}
if ($transliterator) {
$s = $transliterator->transliterate($s);
// use iconv because The transliterator leaves some characters out of ASCII, eg → ʾ
if ($iconv === 'glibc') {
$s = strtr($s, '?', "\x01"); // temporarily hide ? to distinguish them from the garbage that iconv creates
$s = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
$s = str_replace(['?', "\x01"], ['', '?'], $s); // remove garbage and restore ? characters
} elseif ($iconv === 'libiconv') {
$s = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
} else { // null or 'unknown' (#216)
$s = self::pcre('preg_replace', ['#[^\x00-\x7F]++#', '', $s]); // remove non-ascii chars
}
} elseif ($iconv === 'glibc' || $iconv === 'libiconv') {
// temporarily hide these characters to distinguish them from the garbage that iconv creates
$s = strtr($s, '`\'"^~?', "\x01\x02\x03\x04\x05\x06");
if ($iconv === 'glibc') {
// glibc implementation is very limited. transliterate into Windows-1250 and then into ASCII, so most Eastern European characters are preserved
$s = iconv('UTF-8', 'WINDOWS-1250//TRANSLIT//IGNORE', $s);
$s = strtr(
$s,
"\xa5\xa3\xbc\x8c\xa7\x8a\xaa\x8d\x8f\x8e\xaf\xb9\xb3\xbe\x9c\x9a\xba\x9d\x9f\x9e\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf8\xf9\xfa\xfb\xfc\xfd\xfe\x96\xa0\x8b\x97\x9b\xa6\xad\xb7",
'ALLSSSSTZZZallssstzzzRAAAALCCCEEEEIIDDNNOOOOxRUUUUYTsraaaalccceeeeiiddnnooooruuuuyt- <->|-.',
);
$s = self::pcre('preg_replace', ['#[^\x00-\x7F]++#', '', $s]);
} else {
$s = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
}
// remove garbage that iconv creates during transliteration (eg Ý -> Y')
$s = str_replace(['`', "'", '"', '^', '~', '?'], '', $s);
// restore temporarily hidden characters
$s = strtr($s, "\x01\x02\x03\x04\x05\x06", '`\'"^~?');
} else {
$s = self::pcre('preg_replace', ['#[^\x00-\x7F]++#', '', $s]); // remove non-ascii chars
}
return $s;
}
/**
* Modifies the UTF-8 string to the form used in the URL, ie removes diacritics and replaces all characters
* except letters of the English alphabet and numbers with a hyphens.
*/
public static function webalize(string $s, ?string $charlist = null, bool $lower = true): string
{
$s = self::toAscii($s);
if ($lower) {
$s = strtolower($s);
}
$s = self::pcre('preg_replace', ['#[^a-z0-9' . ($charlist !== null ? preg_quote($charlist, '#') : '') . ']+#i', '-', $s]);
$s = trim($s, '-');
return $s;
}
/**
* Truncates a UTF-8 string to given maximal length, while trying not to split whole words. Only if the string is truncated,
* an ellipsis (or something else set with third argument) is appended to the string.
*/
public static function truncate(string $s, int $maxLen, string $append = "\u{2026}"): string
{
if (self::length($s) > $maxLen) {
$maxLen -= self::length($append);
if ($maxLen < 1) {
return $append;
} elseif ($matches = self::match($s, '#^.{1,' . $maxLen . '}(?=[\s\x00-/:-@\[-`{-~])#us')) {
return $matches[0] . $append;
} else {
return self::substring($s, 0, $maxLen) . $append;
}
}
return $s;
}
/**
* Indents a multiline text from the left. Second argument sets how many indentation chars should be used,
* while the indent itself is the third argument (*tab* by default).
*/
public static function indent(string $s, int $level = 1, string $chars = "\t"): string
{
if ($level > 0) {
$s = self::replace($s, '#(?:^|[\r\n]+)(?=[^\r\n])#', '$0' . str_repeat($chars, $level));
}
return $s;
}
/**
* Converts all characters of UTF-8 string to lower case.
*/
public static function lower(string $s): string
{
return mb_strtolower($s, 'UTF-8');
}
/**
* Converts the first character of a UTF-8 string to lower case and leaves the other characters unchanged.
*/
public static function firstLower(string $s): string
{
return self::lower(self::substring($s, 0, 1)) . self::substring($s, 1);
}
/**
* Converts all characters of a UTF-8 string to upper case.
*/
public static function upper(string $s): string
{
return mb_strtoupper($s, 'UTF-8');
}
/**
* Converts the first character of a UTF-8 string to upper case and leaves the other characters unchanged.
*/
public static function firstUpper(string $s): string
{
return self::upper(self::substring($s, 0, 1)) . self::substring($s, 1);
}
/**
* Converts the first character of every word of a UTF-8 string to upper case and the others to lower case.
*/
public static function capitalize(string $s): string
{
return mb_convert_case($s, MB_CASE_TITLE, 'UTF-8');
}
/**
* Compares two UTF-8 strings or their parts, without taking character case into account. If length is null, whole strings are compared,
* if it is negative, the corresponding number of characters from the end of the strings is compared,
* otherwise the appropriate number of characters from the beginning is compared.
*/
public static function compare(string $left, string $right, ?int $length = null): bool
{
if (class_exists('Normalizer', false)) {
$left = \Normalizer::normalize($left, \Normalizer::FORM_D); // form NFD is faster
$right = \Normalizer::normalize($right, \Normalizer::FORM_D); // form NFD is faster
}
if ($length < 0) {
$left = self::substring($left, $length, -$length);
$right = self::substring($right, $length, -$length);
} elseif ($length !== null) {
$left = self::substring($left, 0, $length);
$right = self::substring($right, 0, $length);
}
return self::lower($left) === self::lower($right);
}
/**
* Finds the common prefix of strings or returns empty string if the prefix was not found.
* @param string[] $strings
*/
public static function findPrefix(array $strings): string
{
$first = array_shift($strings);
for ($i = 0; $i < strlen($first); $i++) {
foreach ($strings as $s) {
if (!isset($s[$i]) || $first[$i] !== $s[$i]) {
while ($i && $first[$i - 1] >= "\x80" && $first[$i] >= "\x80" && $first[$i] < "\xC0") {
$i--;
}
return substr($first, 0, $i);
}
}
}
return $first;
}
/**
* Returns number of characters (not bytes) in UTF-8 string.
* That is the number of Unicode code points which may differ from the number of graphemes.
*/
public static function length(string $s): int
{
return match (true) {
extension_loaded('mbstring') => mb_strlen($s, 'UTF-8'),
extension_loaded('iconv') => iconv_strlen($s, 'UTF-8'),
default => strlen(@utf8_decode($s)), // deprecated
};
}
/**
* Removes all left and right side spaces (or the characters passed as second argument) from a UTF-8 encoded string.
*/
public static function trim(string $s, string $charlist = self::TrimCharacters): string
{
$charlist = preg_quote($charlist, '#');
return self::replace($s, '#^[' . $charlist . ']+|[' . $charlist . ']+$#Du', '');
}
/**
* Pads a UTF-8 string to given length by prepending the $pad string to the beginning.
* @param non-empty-string $pad
*/
public static function padLeft(string $s, int $length, string $pad = ' '): string
{
$length = max(0, $length - self::length($s));
$padLen = self::length($pad);
return str_repeat($pad, (int) ($length / $padLen)) . self::substring($pad, 0, $length % $padLen) . $s;
}
/**
* Pads UTF-8 string to given length by appending the $pad string to the end.
* @param non-empty-string $pad
*/
public static function padRight(string $s, int $length, string $pad = ' '): string
{
$length = max(0, $length - self::length($s));
$padLen = self::length($pad);
return $s . str_repeat($pad, (int) ($length / $padLen)) . self::substring($pad, 0, $length % $padLen);
}
/**
* Reverses UTF-8 string.
*/
public static function reverse(string $s): string
{
if (!extension_loaded('iconv')) {
throw new Nette\NotSupportedException(__METHOD__ . '() requires ICONV extension that is not loaded.');
}
return iconv('UTF-32LE', 'UTF-8', strrev(iconv('UTF-8', 'UTF-32BE', $s)));
}
/**
* Returns part of $haystack before $nth occurence of $needle or returns null if the needle was not found.
* Negative value means searching from the end.
*/
public static function before(string $haystack, string $needle, int $nth = 1): ?string
{
$pos = self::pos($haystack, $needle, $nth);
return $pos === null
? null
: substr($haystack, 0, $pos);
}
/**
* Returns part of $haystack after $nth occurence of $needle or returns null if the needle was not found.
* Negative value means searching from the end.
*/
public static function after(string $haystack, string $needle, int $nth = 1): ?string
{
$pos = self::pos($haystack, $needle, $nth);
return $pos === null
? null
: substr($haystack, $pos + strlen($needle));
}
/**
* Returns position in characters of $nth occurence of $needle in $haystack or null if the $needle was not found.
* Negative value of `$nth` means searching from the end.
*/
public static function indexOf(string $haystack, string $needle, int $nth = 1): ?int
{
$pos = self::pos($haystack, $needle, $nth);
return $pos === null
? null
: self::length(substr($haystack, 0, $pos));
}
/**
* Returns position in characters of $nth occurence of $needle in $haystack or null if the needle was not found.
*/
private static function pos(string $haystack, string $needle, int $nth = 1): ?int
{
if (!$nth) {
return null;
} elseif ($nth > 0) {
if ($needle === '') {
return 0;
}
$pos = 0;
while (($pos = strpos($haystack, $needle, $pos)) !== false && --$nth) {
$pos++;
}
} else {
$len = strlen($haystack);
if ($needle === '') {
return $len;
} elseif ($len === 0) {
return null;
}
$pos = $len - 1;
while (($pos = strrpos($haystack, $needle, $pos - $len)) !== false && ++$nth) {
$pos--;
}
}
return Helpers::falseToNull($pos);
}
/**
* Divides the string into arrays according to the regular expression. Expressions in parentheses will be captured and returned as well.
*/
public static function split(
string $subject,
#[Language('RegExp')]
string $pattern,
bool|int $captureOffset = false,
bool $skipEmpty = false,
int $limit = -1,
bool $utf8 = false,
): array
{
$flags = is_int($captureOffset) // back compatibility
? $captureOffset
: ($captureOffset ? PREG_SPLIT_OFFSET_CAPTURE : 0) | ($skipEmpty ? PREG_SPLIT_NO_EMPTY : 0);
$pattern .= $utf8 ? 'u' : '';
$m = self::pcre('preg_split', [$pattern, $subject, $limit, $flags | PREG_SPLIT_DELIM_CAPTURE]);
return $utf8 && $captureOffset
? self::bytesToChars($subject, [$m])[0]
: $m;
}
/**
* Searches the string for the part matching the regular expression and returns
* an array with the found expression and individual subexpressions, or `null`.
*/
public static function match(
string $subject,
#[Language('RegExp')]
string $pattern,
bool|int $captureOffset = false,
int $offset = 0,
bool $unmatchedAsNull = false,
bool $utf8 = false,
): ?array
{
$flags = is_int($captureOffset) // back compatibility
? $captureOffset
: ($captureOffset ? PREG_OFFSET_CAPTURE : 0) | ($unmatchedAsNull ? PREG_UNMATCHED_AS_NULL : 0);
if ($utf8) {
$offset = strlen(self::substring($subject, 0, $offset));
$pattern .= 'u';
}
if ($offset > strlen($subject)) {
return null;
} elseif (!self::pcre('preg_match', [$pattern, $subject, &$m, $flags, $offset])) {
return null;
} elseif ($utf8 && $captureOffset) {
return self::bytesToChars($subject, [$m])[0];
} else {
return $m;
}
}
/**
* Searches the string for all occurrences matching the regular expression and
* returns an array of arrays containing the found expression and each subexpression.
* @return ($lazy is true ? \Generator<int, array> : array[])
*/
public static function matchAll(
string $subject,
#[Language('RegExp')]
string $pattern,
bool|int $captureOffset = false,
int $offset = 0,
bool $unmatchedAsNull = false,
bool $patternOrder = false,
bool $utf8 = false,
bool $lazy = false,
): array|\Generator
{
if ($utf8) {
$offset = strlen(self::substring($subject, 0, $offset));
$pattern .= 'u';
}
if ($lazy) {
$flags = PREG_OFFSET_CAPTURE | ($unmatchedAsNull ? PREG_UNMATCHED_AS_NULL : 0);
return (function () use ($utf8, $captureOffset, $flags, $subject, $pattern, $offset) {
$counter = 0;
while (
$offset <= strlen($subject) - ($counter ? 1 : 0)
&& self::pcre('preg_match', [$pattern, $subject, &$m, $flags, $offset])
) {
$offset = $m[0][1] + max(1, strlen($m[0][0]));
if (!$captureOffset) {
$m = array_map(fn($item) => $item[0], $m);
} elseif ($utf8) {
$m = self::bytesToChars($subject, [$m])[0];
}
yield $counter++ => $m;
}
})();
}
if ($offset > strlen($subject)) {
return [];
}
$flags = is_int($captureOffset) // back compatibility
? $captureOffset
: ($captureOffset ? PREG_OFFSET_CAPTURE : 0) | ($unmatchedAsNull ? PREG_UNMATCHED_AS_NULL : 0) | ($patternOrder ? PREG_PATTERN_ORDER : 0);
self::pcre('preg_match_all', [
$pattern, $subject, &$m,
($flags & PREG_PATTERN_ORDER) ? $flags : ($flags | PREG_SET_ORDER),
$offset,
]);
return $utf8 && $captureOffset
? self::bytesToChars($subject, $m)
: $m;
}
/**
* Replaces all occurrences matching regular expression $pattern which can be string or array in the form `pattern => replacement`.
*/
public static function replace(
string $subject,
#[Language('RegExp')]
string|array $pattern,
string|callable $replacement = '',
int $limit = -1,
bool $captureOffset = false,
bool $unmatchedAsNull = false,
bool $utf8 = false,
): string
{
if (is_object($replacement) || is_array($replacement)) {
if (!is_callable($replacement, false, $textual)) {
throw new Nette\InvalidStateException("Callback '$textual' is not callable.");
}
$flags = ($captureOffset ? PREG_OFFSET_CAPTURE : 0) | ($unmatchedAsNull ? PREG_UNMATCHED_AS_NULL : 0);
if ($utf8) {
$pattern .= 'u';
if ($captureOffset) {
$replacement = fn($m) => $replacement(self::bytesToChars($subject, [$m])[0]);
}
}
return self::pcre('preg_replace_callback', [$pattern, $replacement, $subject, $limit, 0, $flags]);
} elseif (is_array($pattern) && is_string(key($pattern))) {
$replacement = array_values($pattern);
$pattern = array_keys($pattern);
}
if ($utf8) {
$pattern = array_map(fn($item) => $item . 'u', (array) $pattern);
}
return self::pcre('preg_replace', [$pattern, $replacement, $subject, $limit]);
}
private static function bytesToChars(string $s, array $groups): array
{
$lastBytes = $lastChars = 0;
foreach ($groups as &$matches) {
foreach ($matches as &$match) {
if ($match[1] > $lastBytes) {
$lastChars += self::length(substr($s, $lastBytes, $match[1] - $lastBytes));
} elseif ($match[1] < $lastBytes) {
$lastChars -= self::length(substr($s, $match[1], $lastBytes - $match[1]));
}
$lastBytes = $match[1];
$match[1] = $lastChars;
}
}
return $groups;
}
/** @internal */
public static function pcre(string $func, array $args)
{
$res = Callback::invokeSafe($func, $args, function (string $message) use ($args): void {
// compile-time error, not detectable by preg_last_error
throw new RegexpException($message . ' in pattern: ' . implode(' or ', (array) $args[0]));
});
if (($code = preg_last_error()) // run-time error, but preg_last_error & return code are liars
&& ($res === null || !in_array($func, ['preg_filter', 'preg_replace_callback', 'preg_replace'], true))
) {
throw new RegexpException(preg_last_error_msg()
. ' (pattern: ' . implode(' or ', (array) $args[0]) . ')', $code);
}
return $res;
}
}