Codex's updates...

This commit is contained in:
2026-10-18 19:27:17 -04:00
parent a1643eb1b7
commit 02223083f6
338 changed files with 14757 additions and 7986 deletions
+2 -2
View File
@@ -277,7 +277,7 @@ class InstalledVersions
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C' && is_file(__DIR__ . '/installed.php')) {
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = include __DIR__ . '/installed.php';
} else {
self::$installed = array();
@@ -378,7 +378,7 @@ class InstalledVersions
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C' && is_file(__DIR__ . '/installed.php')) {
if (substr(__DIR__, -8, 1) !== 'C') {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
+2
View File
@@ -1,3 +1,4 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -17,3 +18,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-16
View File
@@ -8,25 +8,9 @@ $baseDir = dirname($vendorDir);
return array(
'ZipStream\\' => array($vendorDir . '/maennchen/zipstream-php/src'),
'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'),
'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'),
'PhpOffice\\PhpSpreadsheet\\' => array($vendorDir . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet'),
'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'),
'Matrix\\' => array($vendorDir . '/markbaker/matrix/classes/src'),
'Composer\\Pcre\\' => array($vendorDir . '/composer/pcre/src'),
'Complex\\' => array($vendorDir . '/markbaker/complex/classes/src'),
'Com\\Tecnick\\Unicode\\Data\\' => array($vendorDir . '/tecnickcom/tc-lib-unicode-data/src'),
'Com\\Tecnick\\Unicode\\' => array($vendorDir . '/tecnickcom/tc-lib-unicode/src'),
'Com\\Tecnick\\Pdf\\Sign\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-sign/src'),
'Com\\Tecnick\\Pdf\\Parser\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-parser/src'),
'Com\\Tecnick\\Pdf\\Page\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-page/src'),
'Com\\Tecnick\\Pdf\\Image\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-image/src'),
'Com\\Tecnick\\Pdf\\Graph\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-graph/src'),
'Com\\Tecnick\\Pdf\\Font\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-font/src'),
'Com\\Tecnick\\Pdf\\Filter\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-filter/src'),
'Com\\Tecnick\\Pdf\\Encrypt\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-encrypt/src'),
'Com\\Tecnick\\Pdf\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf/src'),
'Com\\Tecnick\\File\\' => array($vendorDir . '/tecnickcom/tc-lib-file/src'),
'Com\\Tecnick\\Color\\' => array($vendorDir . '/tecnickcom/tc-lib-color/src'),
'Com\\Tecnick\\Barcode\\' => array($vendorDir . '/tecnickcom/tc-lib-barcode/src'),
);
-81
View File
@@ -14,8 +14,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
'P' =>
array (
'Psr\\SimpleCache\\' => 16,
'Psr\\Http\\Message\\' => 17,
'Psr\\Http\\Client\\' => 16,
'PhpOffice\\PhpSpreadsheet\\' => 25,
'PHPMailer\\PHPMailer\\' => 20,
),
@@ -27,20 +25,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
array (
'Composer\\Pcre\\' => 14,
'Complex\\' => 8,
'Com\\Tecnick\\Unicode\\Data\\' => 25,
'Com\\Tecnick\\Unicode\\' => 20,
'Com\\Tecnick\\Pdf\\Sign\\' => 21,
'Com\\Tecnick\\Pdf\\Parser\\' => 23,
'Com\\Tecnick\\Pdf\\Page\\' => 21,
'Com\\Tecnick\\Pdf\\Image\\' => 22,
'Com\\Tecnick\\Pdf\\Graph\\' => 22,
'Com\\Tecnick\\Pdf\\Font\\' => 21,
'Com\\Tecnick\\Pdf\\Filter\\' => 23,
'Com\\Tecnick\\Pdf\\Encrypt\\' => 24,
'Com\\Tecnick\\Pdf\\' => 16,
'Com\\Tecnick\\File\\' => 17,
'Com\\Tecnick\\Color\\' => 18,
'Com\\Tecnick\\Barcode\\' => 20,
),
);
@@ -53,15 +37,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
array (
0 => __DIR__ . '/..' . '/psr/simple-cache/src',
),
'Psr\\Http\\Message\\' =>
array (
0 => __DIR__ . '/..' . '/psr/http-factory/src',
1 => __DIR__ . '/..' . '/psr/http-message/src',
),
'Psr\\Http\\Client\\' =>
array (
0 => __DIR__ . '/..' . '/psr/http-client/src',
),
'PhpOffice\\PhpSpreadsheet\\' =>
array (
0 => __DIR__ . '/..' . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet',
@@ -82,62 +57,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
array (
0 => __DIR__ . '/..' . '/markbaker/complex/classes/src',
),
'Com\\Tecnick\\Unicode\\Data\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-unicode-data/src',
),
'Com\\Tecnick\\Unicode\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-unicode/src',
),
'Com\\Tecnick\\Pdf\\Sign\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-sign/src',
),
'Com\\Tecnick\\Pdf\\Parser\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-parser/src',
),
'Com\\Tecnick\\Pdf\\Page\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-page/src',
),
'Com\\Tecnick\\Pdf\\Image\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-image/src',
),
'Com\\Tecnick\\Pdf\\Graph\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-graph/src',
),
'Com\\Tecnick\\Pdf\\Font\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-font/src',
),
'Com\\Tecnick\\Pdf\\Filter\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-filter/src',
),
'Com\\Tecnick\\Pdf\\Encrypt\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-encrypt/src',
),
'Com\\Tecnick\\Pdf\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf/src',
),
'Com\\Tecnick\\File\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-file/src',
),
'Com\\Tecnick\\Color\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-color/src',
),
'Com\\Tecnick\\Barcode\\' =>
array (
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-barcode/src',
),
);
public static $classMap = array (
+38 -1204
View File
File diff suppressed because it is too large Load Diff
+15 -168
View File
@@ -1,9 +1,9 @@
<?php return array(
'root' => array(
'name' => '__root__',
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => null,
'pretty_version' => 'dev-development',
'version' => 'dev-development',
'reference' => 'a1643eb1b74d1dae0fda79a68b6f0672d4603a81',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -11,27 +11,27 @@
),
'versions' => array(
'__root__' => array(
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => null,
'pretty_version' => 'dev-development',
'version' => 'dev-development',
'reference' => 'a1643eb1b74d1dae0fda79a68b6f0672d4603a81',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev_requirement' => false,
),
'composer/pcre' => array(
'pretty_version' => '3.3.2',
'version' => '3.3.2.0',
'reference' => 'b2bed4734f0cc156ee1fe9c0da2550420d99a21e',
'pretty_version' => '3.4.0',
'version' => '3.4.0.0',
'reference' => 'd5a341b3fb61f3001970940afb1d332968a183ed',
'type' => 'library',
'install_path' => __DIR__ . '/./pcre',
'aliases' => array(),
'dev_requirement' => false,
),
'maennchen/zipstream-php' => array(
'pretty_version' => '3.2.0',
'version' => '3.2.0.0',
'reference' => '9712d8fa4cdf9240380b01eb4be55ad8dcf71416',
'pretty_version' => '3.2.2',
'version' => '3.2.2.0',
'reference' => '77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e',
'type' => 'library',
'install_path' => __DIR__ . '/../maennchen/zipstream-php',
'aliases' => array(),
@@ -65,41 +65,14 @@
'dev_requirement' => false,
),
'phpoffice/phpspreadsheet' => array(
'pretty_version' => '5.2.0',
'version' => '5.2.0.0',
'reference' => '3b8994b3aac4b61018bc04fc8c441f4fd68c18eb',
'pretty_version' => '5.10.0',
'version' => '5.10.0.0',
'reference' => 'eb18727acf6b1f4cc67145a52ab04f9fd4c28d53',
'type' => 'library',
'install_path' => __DIR__ . '/../phpoffice/phpspreadsheet',
'aliases' => array(),
'dev_requirement' => false,
),
'psr/http-client' => array(
'pretty_version' => '1.0.3',
'version' => '1.0.3.0',
'reference' => 'bb5906edc1c324c9a05aa0873d40117941e5fa90',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/http-client',
'aliases' => array(),
'dev_requirement' => false,
),
'psr/http-factory' => array(
'pretty_version' => '1.1.0',
'version' => '1.1.0.0',
'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/http-factory',
'aliases' => array(),
'dev_requirement' => false,
),
'psr/http-message' => array(
'pretty_version' => '2.0',
'version' => '2.0.0.0',
'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/http-message',
'aliases' => array(),
'dev_requirement' => false,
),
'psr/simple-cache' => array(
'pretty_version' => '3.0.0',
'version' => '3.0.0.0',
@@ -109,132 +82,6 @@
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-barcode' => array(
'pretty_version' => '2.13.2',
'version' => '2.13.2.0',
'reference' => 'c7f3ddbc3df10259c59ddc3016ec421267e25e5d',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-barcode',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-color' => array(
'pretty_version' => '2.13.3',
'version' => '2.13.3.0',
'reference' => '36b0cb2215c446fed65842151d494f2b3081533c',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-color',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-file' => array(
'pretty_version' => '3.7.3',
'version' => '3.7.3.0',
'reference' => 'c582b6d493c26703e95809f6d3e938c79f88885f',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-file',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-pdf' => array(
'pretty_version' => '8.68.3',
'version' => '8.68.3.0',
'reference' => '6b056239c0d9781319a4d009e8ca33d48e94e0ea',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-pdf-encrypt' => array(
'pretty_version' => '2.9.3',
'version' => '2.9.3.0',
'reference' => '9d188937a673af8adc6da1f104c44cf434f7b198',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-encrypt',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-pdf-filter' => array(
'pretty_version' => '2.10.3',
'version' => '2.10.3.0',
'reference' => '48270d50db69852ef58a22b5b034b8a08e5c8464',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-filter',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-pdf-font' => array(
'pretty_version' => '3.13.2',
'version' => '3.13.2.0',
'reference' => '314dfbe240057e1119d4830f35827455f8a76eba',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-font',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-pdf-graph' => array(
'pretty_version' => '2.15.2',
'version' => '2.15.2.0',
'reference' => '4a3408690c65e14b9c86abe97a12c3079eb8a0cd',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-graph',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-pdf-image' => array(
'pretty_version' => '3.12.2',
'version' => '3.12.2.0',
'reference' => 'd7052a7e9216e842bd77395f1dfb443e9bfbcef8',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-image',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-pdf-page' => array(
'pretty_version' => '4.14.2',
'version' => '4.14.2.0',
'reference' => 'ac9adaddf8624b5008da335914a42a318cd7d310',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-page',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-pdf-parser' => array(
'pretty_version' => '3.14.2',
'version' => '3.14.2.0',
'reference' => 'db9c32046a3e8d57c8626e8d3ffc94caa10a0675',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-parser',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-pdf-sign' => array(
'pretty_version' => '1.1.3',
'version' => '1.1.3.0',
'reference' => '4132f1fc4b9c103b1a9ec879e2d7b7f9598918f7',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-sign',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-unicode' => array(
'pretty_version' => '3.0.2',
'version' => '3.0.2.0',
'reference' => 'e0549f1e0bee657a7a5c361d823409655a4ea2c3',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-unicode',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tc-lib-unicode-data' => array(
'pretty_version' => '3.0.2',
'version' => '3.0.2.0',
'reference' => '6b090f51613087c2ed6e576e9543c1feddf2ba51',
'type' => 'library',
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-unicode-data',
'aliases' => array(),
'dev_requirement' => false,
),
'tecnickcom/tcpdf' => array(
'pretty_version' => '6.10.0',
'version' => '6.10.0.0',
+9 -5
View File
@@ -20,12 +20,13 @@
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^8 || ^9",
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2"
"phpunit/phpunit": "^9",
"phpstan/phpstan": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpstan/phpstan-deprecation-rules": "^2"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
"phpstan/phpstan": "<2.2.2"
},
"autoload": {
"psr-4": {
@@ -48,7 +49,10 @@
}
},
"scripts": {
"test": "@php vendor/bin/phpunit",
"test": [
"@php vendor/bin/phpunit",
"@php vendor/bin/phpunit --testsuite phpstan"
],
"phpstan": "@php phpstan analyse"
}
}
@@ -57,18 +57,38 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
{
$args = $node->getArgs();
$patternArg = $args[0] ?? null;
$subjectArg = $args[1] ?? null;
$matchesArg = $args[2] ?? null;
$flagsArg = $args[3] ?? null;
$subjectTypes = new SpecifiedTypes();
if ($patternArg === null) {
return $subjectTypes;
}
if (
$patternArg === null || $matchesArg === null
$subjectArg !== null
&& $context->true()
&& $scope->getType($subjectArg->value)->isString()->yes()
) {
return new SpecifiedTypes();
$subjectType = $this->regexShapeMatcher->matchSubjectExpr($patternArg->value, $scope);
if ($subjectType !== null) {
$subjectTypes = $this->typeSpecifier->create(
$subjectArg->value,
$subjectType,
$context,
$scope,
)->setRootExpr($node);
}
}
if ($matchesArg === null) {
return $subjectTypes;
}
$flagsType = PregMatchFlags::getType($flagsArg, $scope);
if ($flagsType === null) {
return new SpecifiedTypes();
return $subjectTypes;
}
if (stripos($methodReflection->getName(), 'matchAll') !== false) {
@@ -78,7 +98,7 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
}
if ($matchedType === null) {
return new SpecifiedTypes();
return $subjectTypes;
}
if (
@@ -93,27 +113,13 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
$context = $context->negate();
}
// @phpstan-ignore function.alreadyNarrowedType
if (method_exists('PHPStan\Analyser\SpecifiedTypes', 'setRootExpr')) {
$typeSpecifier = $this->typeSpecifier->create(
$matchesArg->value,
$matchedType,
$context,
$scope
)->setRootExpr($node);
return $overwrite ? $typeSpecifier->setAlwaysOverwriteTypes() : $typeSpecifier;
}
// @phpstan-ignore arguments.count
return $this->typeSpecifier->create(
$specifiedTypes = $this->typeSpecifier->create(
$matchesArg->value,
$matchedType,
$context,
// @phpstan-ignore argument.type
$overwrite,
$scope,
$node
);
$scope
)->setRootExpr($node);
return $subjectTypes->unionWith($overwrite ? $specifiedTypes->setAlwaysOverwriteTypes() : $specifiedTypes);
}
}
+2 -2
View File
@@ -395,7 +395,7 @@ class Preg
* @return array<int|string, string>
* @throws UnexpectedNullMatchException
*/
private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod)
private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod): array
{
foreach ($matches as $group => $match) {
if (is_string($match) || (is_array($match) && is_string($match[0]))) {
@@ -414,7 +414,7 @@ class Preg
* @return array<int|string, list<string>>
* @throws UnexpectedNullMatchException
*/
private static function enforceNonNullMatchAll(string $pattern, array $matches, string $variantMethod)
private static function enforceNonNullMatchAll(string $pattern, array $matches, string $variantMethod): array
{
foreach ($matches as $group => $groupMatches) {
foreach ($groupMatches as $match) {
+1
View File
@@ -71,4 +71,5 @@ return $config->setRules([
])
->setFinder($finder)
->setRiskyAllowed(true)
->setUnsupportedPhpVersionAllowed(true)
->setParallelConfig(Runner\Parallel\ParallelConfigFactory::detect());
+1 -1
View File
@@ -1 +1 @@
php 8.4.10
php 8.5.0
+1 -1
View File
@@ -32,7 +32,7 @@
"ext-zip": "*",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"friendsofphp/php-cs-fixer": "^3.16",
"friendsofphp/php-cs-fixer": "^3.86",
"vimeo/psalm": "^6.0",
"brianium/paratest": "^7.7"
},
+245
View File
@@ -0,0 +1,245 @@
Compressing large files to S3 compatible storages
=============
S3 compatible storages usually have a limitation of 5 GiB when using single upload. When generating larger zips, the
solution is to use a `multi-part upload`_.
.. _multi-part upload: <https://github.com/awsdocs/aws-doc-sdk-examples/blob/75a8bf8536d436db91ec5de1ba0ed80fd258e904/php/example_code/s3/s3-multipart-upload-using-lowlevel-php-sdk-api.php>`
We can implement a `PSR-7 stream <https://www.php-fig.org/psr/psr-7/>`_ that buffers ZipStream's output and uploads
to S3 in chunks.
MultipartUploadBufferStream example
---------------
.. code-block:: php
<?php
declare(strict_types=1);
use Aws\S3\S3ClientInterface;
use Psr\Http\Message\StreamInterface;
use function strlen;
use function substr;
final class MultipartUploadBufferStream implements StreamInterface
{
private const int PART_SIZE = 5242880; // 5 MiB in bytes
private string $buffer = '';
private int $bufferSize = 0;
private int $partNumber = 1;
private array $parts = [];
private string $uploadId;
public function __construct(
private readonly string $destinationFileName,
private readonly string $bucket,
private readonly S3ClientInterface $client
) {
$result = $this->client->createMultipartUpload(
[
'Bucket' => $bucket,
'Key' => $destinationFileName,
'StorageClass' => 'REDUCED_REDUNDANCY',
]
);
$this->uploadId = $result['UploadId'];
$this->parts['Parts'] = [];
}
public function write($string): int
{
$chunkSize = strlen($string);
$this->buffer .= $string;
$this->bufferSize += $chunkSize;
if ($this->bufferSize >= self::PART_SIZE) {
$this->uploadPart();
}
return $chunkSize;
}
public function close(): void
{
// Upload remaining closing bytes from zip
$this->uploadPart();
$this->client->completeMultipartUpload([
'Bucket' => $this->bucket,
'Key' => $this->destinationFileName,
'UploadId' => $this->uploadId,
'MultipartUpload' => $this->parts,
]);
$this->buffer = '';
$this->bufferSize = 0;
}
private function uploadPart(): void
{
$result = $this->client->uploadPart([
'Bucket' => $this->bucket,
'Key' => $this->destinationFileName,
'UploadId' => $this->uploadId,
'PartNumber' => $this->partNumber,
'Body' => $this->buffer,
]);
$this->buffer = '';
$this->bufferSize = 0;
$this->parts['Parts'][$this->partNumber] = [
'PartNumber' => $this->partNumber,
'ETag' => $result['ETag'],
];
$this->partNumber++;
$result = null;
gc_collect_cycles(); // To avoid memory leaks. @see github.com/aws/aws-sdk-php/issues/1273
}
public function __toString(): string
{
return $this->getContents();
}
public function getContents(): string
{
$buffer = $this->buffer;
$this->buffer = '';
$this->bufferSize = 0;
return $buffer;
}
public function detach(): null
{
$this->close();
return null;
}
public function getSize(): int
{
return $this->bufferSize;
}
public function isReadable(): bool
{
return true;
}
public function isWritable(): bool
{
return true;
}
public function isSeekable(): bool
{
return false;
}
public function rewind(): void
{
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
{
throw new \RuntimeException('Cannot seek a BufferStream');
}
public function eof(): bool
{
return $this->bufferSize === 0;
}
public function tell(): int
{
throw new \RuntimeException('Cannot determine the position of a BufferStream');
}
public function read($length): string
{
$currentLength = $this->bufferSize;
if ($length >= $currentLength) {
// No need to slice the buffer because we don't have enough data.
$result = $this->buffer;
$this->buffer = '';
$this->bufferSize = 0;
} else {
// Slice up the result to provide a subset of the buffer.
$result = substr($this->buffer, 0, $length);
$this->buffer = substr($this->buffer, $length);
$this->bufferSize -= $length;
}
return $result;
}
public function getMetadata($key = null)
{
return $key ? null : [];
}
}
Then, we can use it with ZipStream to compress the files and upload the parts to the storage.
ZipStream usage
---------------
.. code-block:: php
<?php
declare(strict_types=1);
use Aws\S3\S3Client;
use Aws\Credentials\CredentialProvider;
use MultipartUploadBufferStream;
use ZipStream\ZipStream;
$bucket = 'your bucket name';
$client = new S3Client([
'region' => 'your region',
'version' => 'latest',
'bucketName' => $bucket,
'credentials' => CredentialProvider::defaultProvider(),
]);
$bufferStream = new MultipartUploadBufferStream(
'destination-file.zip',
$bucket,
$client,
);
$zip = new ZipStream(
outputStream: $destination,
defaultCompressionMethod: CompressionMethod::STORE,
defaultEnableZeroHeader: true,
sendHttpHeaders: false,
);
$zip->addFile(
fileName: 'big-file-1.txt',
data: 'File1 data',
);
$zip->addFile(
fileName: 'big-file-2.txt',
data: 'File2 data',
);
$zip->finish();
$destination->close(); // Needed after $zip->finish() to upload the last remaining bytes to S3
You can read more about the logic behind this implementation in the `discussion`_.
.. _discussion: https://github.com/maennchen/ZipStream-PHP/discussions/402
+1
View File
@@ -16,6 +16,7 @@ user, which is much faster. It can work with S3 buckets or any PSR7 Stream.
Nginx
Varnish
ContentLength
LargeFilesToS3
Installation
---------------
+9 -9
View File
@@ -110,8 +110,8 @@ final class File
$detectedSize = $forecastSize ?? ($this->compressedSize > 0 ? $this->compressedSize : null);
if (
$this->isSimulation() &&
$detectedSize !== null
$this->isSimulation()
&& $detectedSize !== null
) {
$this->uncompressedSize = $detectedSize;
$this->compressedSize = $detectedSize;
@@ -202,10 +202,10 @@ final class File
compressionMethod: $this->compressionMethod,
lastModificationDateTime: $this->lastModificationDateTime,
crc32UncompressedData: $this->crc,
compressedSize: $zip64Enabled
compressedSize: ($forceEnableZip64 || $this->compressedSize > 0xFFFFFFFF)
? 0xFFFFFFFF
: $this->compressedSize,
uncompressedSize: $zip64Enabled
uncompressedSize: ($forceEnableZip64 || $this->uncompressedSize > 0xFFFFFFFF)
? 0xFFFFFFFF
: $this->uncompressedSize,
fileName: $this->fileName,
@@ -238,8 +238,8 @@ final class File
// Sets Bit 11: Language encoding flag (EFS). If this bit is set,
// the filename and comment fields for this file
// MUST be encoded using UTF-8. (see APPENDIX D)
if (mb_check_encoding($this->fileName, 'UTF-8') &&
mb_check_encoding($this->comment, 'UTF-8')) {
if (mb_check_encoding($this->fileName, 'UTF-8')
&& mb_check_encoding($this->comment, 'UTF-8')) {
$this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
}
}
@@ -321,9 +321,9 @@ final class File
$deflate = $this->compressionInit();
while (
!feof($this->unpackStream()) &&
($this->maxSize === null || $this->uncompressedSize < $this->maxSize) &&
($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
!feof($this->unpackStream())
&& ($this->maxSize === null || $this->uncompressedSize < $this->maxSize)
&& ($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
) {
$readLength = min(
($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
+6 -6
View File
@@ -29,11 +29,11 @@ abstract class Time
[$year, $month, $day, $hour, $minute, $second] = explode(' ', $dateTime->format('Y n j G i s'));
return
((int) $year << 25) |
((int) $month << 21) |
((int) $day << 16) |
((int) $hour << 11) |
((int) $minute << 5) |
((int) $second >> 1);
((int) $year << 25)
| ((int) $month << 21)
| ((int) $day << 16)
| ((int) $hour << 11)
| ((int) $minute << 5)
| ((int) $second >> 1);
}
}
@@ -23,10 +23,10 @@ abstract class ExtendedInformationExtraField
new PackField(format: 'v', value: self::TAG),
new PackField(
format: 'v',
value: ($originalSize === null ? 0 : 8) +
($compressedSize === null ? 0 : 8) +
($relativeHeaderOffset === null ? 0 : 8) +
($diskStartNumber === null ? 0 : 4)
value: ($originalSize === null ? 0 : 8)
+ ($compressedSize === null ? 0 : 8)
+ ($relativeHeaderOffset === null ? 0 : 8)
+ ($diskStartNumber === null ? 0 : 4)
),
...($originalSize === null ? [] : [
new PackField(format: 'P', value: $originalSize),
+3 -3
View File
@@ -714,9 +714,9 @@ class ZipStream
}
// Add 64bit headers (if applicable)
if (count($this->centralDirectoryRecords) >= 0xFFFF ||
$centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF ||
$sizeOfCentralDirectory > 0xFFFFFFFF) {
if (count($this->centralDirectoryRecords) >= 0xFFFF
|| $centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF
|| $sizeOfCentralDirectory > 0xFFFFFFFF) {
if (!$this->enableZip64) {
throw new OverflowException();
}
+15 -6
View File
@@ -24,7 +24,9 @@ final class CallbackOutputTest extends TestCase
$buf = '';
$zip = new ZipStream(
outputStream: CallbackStreamWrapper::open(
static function (string $chunk) use (&$buf): void { $buf .= $chunk; }
static function (string $chunk) use (&$buf): void {
$buf .= $chunk;
}
),
sendHttpHeaders: false
);
@@ -53,10 +55,14 @@ final class CallbackOutputTest extends TestCase
$buf2 = '';
$stream1 = CallbackStreamWrapper::open(
static function (string $chunk) use (&$buf1): void { $buf1 .= $chunk; }
static function (string $chunk) use (&$buf1): void {
$buf1 .= $chunk;
}
);
$stream2 = CallbackStreamWrapper::open(
static function (string $chunk) use (&$buf2): void { $buf2 .= $chunk; }
static function (string $chunk) use (&$buf2): void {
$buf2 .= $chunk;
}
);
$this->assertIsResource($stream1);
@@ -112,7 +118,8 @@ final class CallbackOutputTest extends TestCase
public function testStreamPositionTracking(): void
{
$stream = CallbackStreamWrapper::open(
static function (string $chunk): void { /* no-op */ }
static function (string $chunk): void { // no-op
}
);
$this->assertSame(0, ftell($stream));
@@ -129,7 +136,8 @@ final class CallbackOutputTest extends TestCase
public function testInvalidModeRejection(): void
{
$stream = CallbackStreamWrapper::open(
static function (string $chunk): void { /* no-op */ }
static function (string $chunk): void { // no-op
}
);
// Close the stream first
@@ -143,7 +151,8 @@ final class CallbackOutputTest extends TestCase
public function testStreamStatistics(): void
{
$stream = CallbackStreamWrapper::open(
static function (string $chunk): void { /* no-op */ }
static function (string $chunk): void { // no-op
}
);
fwrite($stream, 'test data');
@@ -35,26 +35,26 @@ class CentralDirectoryFileHeaderTest extends TestCase
$this->assertSame(
bin2hex($header),
'504b0102' . // 4 bytes; central file header signature
'0306' . // 2 bytes; version made by
'2d00' . // 2 bytes; version needed to extract
'2222' . // 2 bytes; general purpose bit flag
'0800' . // 2 bytes; compression method
'2008' . // 2 bytes; last mod file time
'2154' . // 2 bytes; last mod file date
'11111111' . // 4 bytes; crc-32
'77777777' . // 4 bytes; compressed size
'99999999' . // 4 bytes; uncompressed size
'0800' . // 2 bytes; file name length (n)
'0c00' . // 2 bytes; extra field length (m)
'0c00' . // 2 bytes; file comment length (o)
'0000' . // 2 bytes; disk number start
'0000' . // 2 bytes; internal file attributes
'20000000' . // 4 bytes; external file attributes
'34120000' . // 4 bytes; relative offset of local header
'746573742e706e67' . // n bytes; file name
'736f6d6520636f6e74656e74' . // m bytes; extra field
'736f6d6520636f6d6d656e74' // o bytes; file comment
'504b0102' // 4 bytes; central file header signature
. '0306' // 2 bytes; version made by
. '2d00' // 2 bytes; version needed to extract
. '2222' // 2 bytes; general purpose bit flag
. '0800' // 2 bytes; compression method
. '2008' // 2 bytes; last mod file time
. '2154' // 2 bytes; last mod file date
. '11111111' // 4 bytes; crc-32
. '77777777' // 4 bytes; compressed size
. '99999999' // 4 bytes; uncompressed size
. '0800' // 2 bytes; file name length (n)
. '0c00' // 2 bytes; extra field length (m)
. '0c00' // 2 bytes; file comment length (o)
. '0000' // 2 bytes; disk number start
. '0000' // 2 bytes; internal file attributes
. '20000000' // 4 bytes; external file attributes
. '34120000' // 4 bytes; relative offset of local header
. '746573742e706e67' // n bytes; file name
. '736f6d6520636f6e74656e74' // m bytes; extra field
. '736f6d6520636f6d6d656e74' // o bytes; file comment
);
}
}
+4 -4
View File
@@ -17,10 +17,10 @@ class DataDescriptorTest extends TestCase
compressedSize: 0x77777777,
uncompressedSize: 0x99999999,
)),
'504b0708' . // 4 bytes; Optional data descriptor signature = 0x08074b50
'11111111' . // 4 bytes; CRC-32 of uncompressed data
'77777777' . // 4 bytes; Compressed size
'99999999' // 4 bytes; Uncompressed size
'504b0708' // 4 bytes; Optional data descriptor signature = 0x08074b50
. '11111111' // 4 bytes; CRC-32 of uncompressed data
. '77777777' // 4 bytes; Compressed size
. '99999999' // 4 bytes; Uncompressed size
);
}
}
@@ -21,15 +21,15 @@ class EndOfCentralDirectoryTest extends TestCase
centralDirectoryStartOffsetOnDisk: 0x33,
zipFileComment: 'foo',
)),
'504b0506' . // 4 bytes; end of central dir signature 0x06054b50
'0000' . // 2 bytes; number of this disk
'0000' . // 2 bytes; number of the disk with the start of the central directory
'1000' . // 2 bytes; total number of entries in the central directory on this disk
'1000' . // 2 bytes; total number of entries in the central directory
'22000000' . // 4 bytes; size of the central directory
'33000000' . // 4 bytes; offset of start of central directory with respect to the starting disk number
'0300' . // 2 bytes; .ZIP file comment length
bin2hex('foo')
'504b0506' // 4 bytes; end of central dir signature 0x06054b50
. '0000' // 2 bytes; number of this disk
. '0000' // 2 bytes; number of the disk with the start of the central directory
. '1000' // 2 bytes; total number of entries in the central directory on this disk
. '1000' // 2 bytes; total number of entries in the central directory
. '22000000' // 4 bytes; size of the central directory
. '33000000' // 4 bytes; offset of start of central directory with respect to the starting disk number
. '0300' // 2 bytes; .ZIP file comment length
. bin2hex('foo')
);
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ class EndlessCycleStream implements StreamInterface
throw new RuntimeException('Infinite Stream!');
}
public function getMetadata(?string $key = null): array|null
public function getMetadata(?string $key = null): ?array
{
return $key !== null ? null : [];
}
+13 -13
View File
@@ -29,19 +29,19 @@ class LocalFileHeaderTest extends TestCase
$this->assertSame(
bin2hex((string) $header),
'504b0304' . // 4 bytes; Local file header signature
'2d00' . // 2 bytes; Version needed to extract (minimum)
'2222' . // 2 bytes; General purpose bit flag
'0800' . // 2 bytes; Compression method; e.g. none = 0, DEFLATE = 8
'2008' . // 2 bytes; File last modification time
'2154' . // 2 bytes; File last modification date
'11111111' . // 4 bytes; CRC-32 of uncompressed data
'77777777' . // 4 bytes; Compressed size (or 0xffffffff for ZIP64)
'99999999' . // 4 bytes; Uncompressed size (or 0xffffffff for ZIP64)
'0800' . // 2 bytes; File name length (n)
'0c00' . // 2 bytes; Extra field length (m)
'746573742e706e67' . // n bytes; File name
'736f6d6520636f6e74656e74' // m bytes; Extra field
'504b0304' // 4 bytes; Local file header signature
. '2d00' // 2 bytes; Version needed to extract (minimum)
. '2222' // 2 bytes; General purpose bit flag
. '0800' // 2 bytes; Compression method; e.g. none = 0, DEFLATE = 8
. '2008' // 2 bytes; File last modification time
. '2154' // 2 bytes; File last modification date
. '11111111' // 4 bytes; CRC-32 of uncompressed data
. '77777777' // 4 bytes; Compressed size (or 0xffffffff for ZIP64)
. '99999999' // 4 bytes; Uncompressed size (or 0xffffffff for ZIP64)
. '0800' // 2 bytes; File name length (n)
. '0c00' // 2 bytes; Extra field length (m)
. '746573742e706e67' // n bytes; File name
. '736f6d6520636f6e74656e74' // m bytes; Extra field
);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace ZipStream\Test;
trait Tempfile
{
protected string|null $tempfile;
protected ?string $tempfile;
/**
* @var resource
@@ -19,10 +19,10 @@ class DataDescriptorTest extends TestCase
$this->assertSame(
bin2hex($descriptor),
'504b0708' . // 4 bytes; Optional data descriptor signature = 0x08074b50
'11111111' . // 4 bytes; CRC-32 of uncompressed data
'6666666677777777' . // 8 bytes; Compressed size
'8888888899999999' // 8 bytes; Uncompressed size
'504b0708' // 4 bytes; Optional data descriptor signature = 0x08074b50
. '11111111' // 4 bytes; CRC-32 of uncompressed data
. '6666666677777777' // 8 bytes; Compressed size
. '8888888899999999' // 8 bytes; Uncompressed size
);
}
}
@@ -19,10 +19,10 @@ class EndOfCentralDirectoryLocatorTest extends TestCase
$this->assertSame(
bin2hex($descriptor),
'504b0607' . // 4 bytes; zip64 end of central dir locator signature - 0x07064b50
'11111111' . // 4 bytes; number of the disk with the start of the zip64 end of central directory
'3333333322222222' . // 28 bytes; relative offset of the zip64 end of central directory record
'44444444' // 4 bytes;total number of disks
'504b0607' // 4 bytes; zip64 end of central dir locator signature - 0x07064b50
. '11111111' // 4 bytes; number of the disk with the start of the zip64 end of central directory
. '3333333322222222' // 28 bytes; relative offset of the zip64 end of central directory record
. '44444444' // 4 bytes;total number of disks
);
}
}
@@ -25,17 +25,17 @@ class EndOfCentralDirectoryTest extends TestCase
$this->assertSame(
bin2hex($descriptor),
'504b0606' . // 4 bytes;zip64 end of central dir signature - 0x06064b50
'2f00000000000000' . // 8 bytes; size of zip64 end of central directory record
'3333' . // 2 bytes; version made by
'4444' . // 2 bytes; version needed to extract
'55555555' . // 4 bytes; number of this disk
'66666666' . // 4 bytes; number of the disk with the start of the central directory
'8888888877777777' . // 8 bytes; total number of entries in the central directory on this disk
'aaaaaaaa99999999' . // 8 bytes; total number of entries in the central directory
'ccccccccbbbbbbbb' . // 8 bytes; size of the central directory
'eeeeeeeedddddddd' . // 8 bytes; offset of start of central directory with respect to the starting disk number
bin2hex('foo')
'504b0606' // 4 bytes;zip64 end of central dir signature - 0x06064b50
. '2f00000000000000' // 8 bytes; size of zip64 end of central directory record
. '3333' // 2 bytes; version made by
. '4444' // 2 bytes; version needed to extract
. '55555555' // 4 bytes; number of this disk
. '66666666' // 4 bytes; number of the disk with the start of the central directory
. '8888888877777777' // 8 bytes; total number of entries in the central directory on this disk
. 'aaaaaaaa99999999' // 8 bytes; total number of entries in the central directory
. 'ccccccccbbbbbbbb' // 8 bytes; size of the central directory
. 'eeeeeeeedddddddd' // 8 bytes; offset of start of central directory with respect to the starting disk number
. bin2hex('foo')
);
}
}
@@ -20,12 +20,12 @@ class ExtendedInformationExtraFieldTest extends TestCase
$this->assertSame(
bin2hex($extraField),
'0100' . // 2 bytes; Tag for this "extra" block type
'1c00' . // 2 bytes; Size of this "extra" block
'6666666677777777' . // 8 bytes; Original uncompressed file size
'8888888899999999' . // 8 bytes; Size of compressed data
'1111111122222222' . // 8 bytes; Offset of local header record
'33333333' // 4 bytes; Number of the disk on which this file starts
'0100' // 2 bytes; Tag for this "extra" block type
. '1c00' // 2 bytes; Size of this "extra" block
. '6666666677777777' // 8 bytes; Original uncompressed file size
. '8888888899999999' // 8 bytes; Size of compressed data
. '1111111122222222' // 8 bytes; Offset of local header record
. '33333333' // 4 bytes; Number of the disk on which this file starts
);
}
@@ -35,8 +35,8 @@ class ExtendedInformationExtraFieldTest extends TestCase
$this->assertSame(
bin2hex($extraField),
'0100' . // 2 bytes; Tag for this "extra" block type
'0000' // 2 bytes; Size of this "extra" block
'0100' // 2 bytes; Tag for this "extra" block type
. '0000' // 2 bytes; Size of this "extra" block
);
}
}
+24 -9
View File
@@ -62,10 +62,10 @@ class ZipStreamTest extends TestCase
$name = 'árvíztűrő tükörfúrógép.txt';
$content = 'Sample String Data';
$comment =
'Filename has every special characters ' .
'from Hungarian language in lowercase. ' .
'In uppercase: ÁÍŰŐÜÖÚÓÉ';
$comment
= 'Filename has every special characters '
. 'from Hungarian language in lowercase. '
. 'In uppercase: ÁÍŰŐÜÖÚÓÉ';
$zip->addFile(fileName: $name, data: $content, comment: $comment);
$zip->finish();
@@ -327,11 +327,14 @@ class ZipStreamTest extends TestCase
defaultEnableZeroHeader: false,
);
if (file_exists('/dev/null')) {
$streamUnseekable = fopen('/dev/null', 'w+');
} elseif (file_exists('NUL')) {
$streamUnseekable = fopen('NUL', 'w+');
} else {
foreach (['cat', 'more'] as $cmd) {
$streamUnseekable = @popen($cmd, 'w');
if ($streamUnseekable !== false) {
break;
}
}
if ($streamUnseekable === false) {
$this->markTestSkipped('Needs file /dev/null');
}
@@ -546,12 +549,24 @@ class ZipStreamTest extends TestCase
);
}
$zip->addDirectory('mydir/');
$zip->finish();
$tmpDir = $this->validateAndExtractZip($this->tempfile);
$files = $this->getRecursiveFileList($tmpDir);
$this->assertSame(['sample0', 'sample1', 'sample2', 'sample3'], $files);
$this->assertFileDoesNotContain(
$this->tempfile,
PackField::pack(
new PackField(format: 'V', value: 0x00000000), // CRC-32 = 0 (directory)
new PackField(format: 'V', value: 0xFFFFFFFF), // compressedSize — bug sentinel
new PackField(format: 'V', value: 0xFFFFFFFF), // uncompressedSize — bug sentinel
new PackField(format: 'v', value: 6), // filename length = strlen('mydir/')
)
);
}
#[Group('slow')]
@@ -15,8 +15,8 @@ class ExtendedInformationExtraFieldTest extends TestCase
$this->assertSame(
bin2hex((string) $extraField),
'5356' . // 2 bytes; Tag for this "extra" block type
'0000' // 2 bytes; TODO: Document
'5356' // 2 bytes; Tag for this "extra" block type
. '0000' // 2 bytes; TODO: Document
);
}
}
+210 -218
View File
@@ -3,9 +3,13 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com)
and this project adheres to [Semantic Versioning](https://semver.org). Thia is always true of the master branch. Some earlier branches remain supported and security fixes are applied to them; if the security fix represents a breaking change, it may have to be applied as a minor or patch version.
and this project adheres to [Semantic Versioning](https://semver.org). This is always true of the master branch.
Dropping support for EOL versions of PHP will not be considered
a breaking change.
## TBD - 5.3.0
Some earlier branches remain supported and security fixes are applied to them; if the security fix represents a breaking change, it may have to be applied as a minor or patch version.
## TBD - 5.11.0
### Added
@@ -31,6 +35,210 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Nothing yet.
## 2026-09-15 - 5.10.0
### Added
- XLSX password-to-open encryption and decryption using Office Agile encryption. [Issue #3878](https://github.com/PHPOffice/PhpSpreadsheet/issues/3878) [PR #4975](https://github.com/PHPOffice/PhpSpreadsheet/pull/4975) [PR #4988](https://github.com/PHPOffice/PhpSpreadsheet/pull/4988)
- Support for Excel sparklines (line, column, and win/loss) in Xlsx reader and writer. [Issue #4941](https://github.com/PHPOffice/PhpSpreadsheet/issues/4941)
- Read-only object model for Pivot Tables. Existing pivot tables in an Xlsx file are now parsed into `Worksheet\PivotTable\PivotTable` objects (name, location, source cache definition, and row/column/page/data field layout), accessible via `Worksheet::getPivotTableCollection()` / `getPivotTableByName()`. Pivot tables (their tables, caches and records) are now also preserved through an Xlsx load/save round-trip instead of being silently dropped. [Issue #4534](https://github.com/PHPOffice/PhpSpreadsheet/issues/4534)
### Changed
- Performance: avoid `Worksheet::getStyle()` on every `Cell::setValueExplicit()` unless quote-prefix must change. On a dense 40k-cell populate+save microbenchmark this cut wall time by ~5%. [PR #4958](https://github.com/PHPOffice/PhpSpreadsheet/pull/4958)
- Infrastructure changes for documentation. [PR #4973](https://github.com/PHPOffice/PhpSpreadsheet/pull/4973)
### Fixed
- Security patches.
- Xls Writer now creates conformant CFB/DIFAT metadata at FAT-sector boundaries. [Issue #4811](https://github.com/PHPOffice/PhpSpreadsheet/issues/4811) [PR #4983](https://github.com/PHPOffice/PhpSpreadsheet/pull/4983)
- Ods Reader/Writer Drawings. [Issue #4809](https://github.com/PHPOffice/PhpSpreadsheet/issues/4809) [PR #4956](https://github.com/PHPOffice/PhpSpreadsheet/pull/4956)
- Ods Writer no longer converts cell references or commas inside string literals, so `="THIS IS E1"` is written unchanged instead of as `="THIS IS [.E1]"`. [Issue #4454](https://github.com/PHPOffice/PhpSpreadsheet/issues/4454) [PR #4962](https://github.com/PHPOffice/PhpSpreadsheet/pull/4962)
- Ods Reader/Writer slight improvement for Date styles. [PR #4960](https://github.com/PHPOffice/PhpSpreadsheet/pull/4960)
- BETAINV/BETA.INV no longer abandons its search when the Beta CDF underflows to zero (wrong results for alpha above about 1080). [PR #4954](https://github.com/PHPOffice/PhpSpreadsheet/pull/4954)
- GAMMA.INV, the GAMMA.DIST/CHISQ.DIST/F.DIST densities, and GAMMALN no longer fail or return wrong results for large shape parameters / degrees of freedom. [PR #4953](https://github.com/PHPOffice/PhpSpreadsheet/pull/4953)
- GAMMAINV/GAMMA.INV no longer clamps upper-tail quantiles beyond alpha*beta*5. [PR #4946](https://github.com/PHPOffice/PhpSpreadsheet/pull/4946)
- Correct incomplete gamma convergence for GAMMA.DIST/CHISQ.DIST family (wrong once the series argument reached ~32). [PR #4945](https://github.com/PHPOffice/PhpSpreadsheet/pull/4945)
- Fix problem with VLOOKUP and whole-column ranges. [Issue #4969](https://github.com/PHPOffice/PhpSpreadsheet/issues/4969) [PR #4967](https://github.com/PHPOffice/PhpSpreadsheet/pull/4967)
- Problem with Xlsx Writer and header-row table. [PR #4968](https://github.com/PHPOffice/PhpSpreadsheet/pull/4968)
- Throw when Ods Reader encounters invalid Xml. [PR #4986](https://github.com/PHPOffice/PhpSpreadsheet/pull/4986)
- Update Sheetname in Charts when Sheetname changes. [Issue #744](https://github.com/PHPOffice/PhpSpreadsheet/issues/744) [PR #4984](https://github.com/PHPOffice/PhpSpreadsheet/pull/4984)
## 2026-07-12 - 5.9.0
### Removed
- **Drop support for PHP 8.1**, according to [our published guidelines](https://phpspreadsheet.readthedocs.io/en/latest/#php-version-support). [PR #4907](https://github.com/PHPOffice/PhpSpreadsheet/pull/4907)
### Added
- Navigate Cell as Cursor. [Issue #863](https://github.com/PHPOffice/PhpSpreadsheet/issues/863) [PR #4909](https://github.com/PHPOffice/PhpSpreadsheet/pull/4909)
- Chart DataTables. [Issue #413](https://github.com/PHPOffice/PhpSpreadsheet/issues/413) [PR #4911](https://github.com/PHPOffice/PhpSpreadsheet/pull/4911)
- Permit Ignoring "Misleading Format" Tag. [PR #4914](https://github.com/PHPOffice/PhpSpreadsheet/pull/4914)
### Changed
- Restructure parsing logic in Reader/Xlsx. [PR #4830](https://github.com/PHPOffice/PhpSpreadsheet/pull/4830)
### Fixed
- Small improvement for Radar Charts. [Issue #661](https://github.com/PHPOffice/PhpSpreadsheet/issues/661) [PR #4908](https://github.com/PHPOffice/PhpSpreadsheet/pull/4908)
- Allow Use of Multiple Ranges When Setting Styles. [Issue #411](https://github.com/PHPOffice/PhpSpreadsheet/issues/411) [PR #4910](https://github.com/PHPOffice/PhpSpreadsheet/pull/4910)
- Small change to Writer Html. [Issue #434](https://github.com/PHPOffice/PhpSpreadsheet/issues/434) [PR #4912](https://github.com/PHPOffice/PhpSpreadsheet/pull/4912)
- Avoid hard-coding some chart Xml attributes. [PR #4915](https://github.com/PHPOffice/PhpSpreadsheet/pull/4915)
- Readers should directly access readFilter. [PR #4919](https://github.com/PHPOffice/PhpSpreadsheet/pull/4919)
- Very minor changes to Worksheet, Reader/Xlsx, and Worksheet/AutoFilter. [Issue #4917](https://github.com/PHPOffice/PhpSpreadsheet/issues/4917) [PR #4926](https://github.com/PHPOffice/PhpSpreadsheet/pull/4926)
- Consistent HighestRow/Column after row/columnDelete. [Issue #943](https://github.com/PHPOffice/PhpSpreadsheet/issues/943) [PR #4925](https://github.com/PHPOffice/PhpSpreadsheet/pull/4925)
## 2026-06-06 - 5.8.0
### Security Note
- File::prohibitWrappers and Drawing::setPath now reject phar paths with extra leading slashes (e.g. phar:///…) that escaped the prior parse_url-based filter. No security exploit was possible even with the extra slashes. [PR #4876](https://github.com/PHPOffice/PhpSpreadsheet/pull/4876)
### Added
- Optional method to increase Calculation Engine's parsing speed. [PR #4829](https://github.com/PHPOffice/PhpSpreadsheet/pull/4829)
- Html Writer/Reader new data attributes. [PR #4858](https://github.com/PHPOffice/PhpSpreadsheet/pull/4858)
- IReader2 interface extending IReader with listWorksheetInfo/Names. [Issue #4883](https://github.com/PHPOffice/PhpSpreadsheet/issues/4883) [PR #4886](https://github.com/PHPOffice/PhpSpreadsheet/pull/4886)
### Changed
- Helper/Handler is now internal and final. [PR #4896](https://github.com/PHPOffice/PhpSpreadsheet/pull/4896)
### Fixed
- Html Writer handle text colors a bit better. [PR #4855](https://github.com/PHPOffice/PhpSpreadsheet/pull/4855)
- Html Writer apply rotation to images. [Issue #4875](https://github.com/PHPOffice/PhpSpreadsheet/issues/4875) [PR #4877](https://github.com/PHPOffice/PhpSpreadsheet/pull/4877)
- Work around Php8.6 deprecation in mpdf/mpdf. [PR #4878](https://github.com/PHPOffice/PhpSpreadsheet/pull/4878)
- Ability to reuse disconnected spreadsheet. [PR #4880](https://github.com/PHPOffice/PhpSpreadsheet/pull/4880)
- Allow int parameter for getChartIndex. [PR #4896](https://github.com/PHPOffice/PhpSpreadsheet/pull/4896)
## 2026-04-19 - 5.7.0
### Fixed
- Security patches.
## 2026-04-09 - 5.6.0
### Added
- Make Reader/Csv Extendable, add new preferred Reader/CsvNoEscape class. [Issue #4836](https://github.com/PHPOffice/PhpSpreadsheet/issues/4836) [PR #4837](https://github.com/PHPOffice/PhpSpreadsheet/pull/4837) [PR #4845](https://github.com/PHPOffice/PhpSpreadsheet/pull/4845)
- XLOOKUP function. [Issue #1453](https://github.com/PHPOffice/PhpSpreadsheet/issues/1453) [PR #4844](https://github.com/PHPOffice/PhpSpreadsheet/pull/4844)
- Introduction of a benchmark test suite, independent of the default unit test suite. Users can use it as a template for experimenting and making decisions concerning performance. [PR #4824](https://github.com/PHPOffice/PhpSpreadsheet/pull/4824)
### Deprecated
- Collection/Cells::MAX_COLUMN_ID - use Cell/AddressRange::MAX_COLUMN_INT.
- Writer/Xls/Worksheet constants MAX_XLS_COLUMN, MAX_XLS_COLUMN_STRING, MAX_XLS_ROW - use Cell/AddressRange MAX_COLUMN_INT_XLS, MAX_COLUMN_XLS, MAX_ROW_XLS
### Fixed
- Consistent handling of row and column limits. [PR #4820](https://github.com/PHPOffice/PhpSpreadsheet/pull/4820)
- Problems with Html Conditional Formatting Colorscale. [Issue #4838](https://github.com/PHPOffice/PhpSpreadsheet/issues/4838) [PR #4839](https://github.com/PHPOffice/PhpSpreadsheet/pull/4839)
- Fixed an issue where Date detection could misclassify invalid numeric values as dates. [PR #4841](https://github.com/PHPOffice/PhpSpreadsheet/pull/4841)
- Ods Reader/Writer Integer Styles with Leading Zeros. [Issue #1606](https://github.com/PHPOffice/PhpSpreadsheet/issues/1606) [PR #4822](https://github.com/PHPOffice/PhpSpreadsheet/pull/4822)
- Xlsx Writer Data URI for Images. [Issue #4823](https://github.com/PHPOffice/PhpSpreadsheet/issues/4823) [PR #4831](https://github.com/PHPOffice/PhpSpreadsheet/pull/4831)
- Confusion Checking for Union Arguments. [Issue #4832](https://github.com/PHPOffice/PhpSpreadsheet/issues/4832) [PR #4835](https://github.com/PHPOffice/PhpSpreadsheet/pull/4835)
- Reader Xlsx Hyperlink with Anchor. [Issue #4842](https://github.com/PHPOffice/PhpSpreadsheet/issues/4842) [PR #4843](https://github.com/PHPOffice/PhpSpreadsheet/pull/4843)
## 2026-02-28 - 5.5.0
### Added
- Much improved handling of Styles by Ods Reader. The relevant changes are listed at the end of the "Fixed" section below.
- Option to use OldCalculatedValue in ToArray and Relatives. [Issue #1810](https://github.com/PHPOffice/PhpSpreadsheet/issues/1810) [PR #4787](https://github.com/PHPOffice/PhpSpreadsheet/pull/4787)
- Add checkbox style (Xlsx and Html). [PR #4781](https://github.com/PHPOffice/PhpSpreadsheet/pull/4781)
- Option to whitelist external images. [PR #4793](https://github.com/PHPOffice/PhpSpreadsheet/pull/4793)
- Writer/Html add ability to set line ending. [PR #4779](https://github.com/PHPOffice/PhpSpreadsheet/pull/4779)
- Writer/Html optionally save formulas as data attributes. [PR #4783](https://github.com/PHPOffice/PhpSpreadsheet/pull/4783)
- User-supplied headers and footers in PDF. [Issue #3159](https://github.com/PHPOffice/PhpSpreadsheet/issues/3159) [PR #4789](https://github.com/PHPOffice/PhpSpreadsheet/pull/4789)
### Deprecated
- Writer/Html constant BODY_LINE no longer makes sense with a configurable line ending. No replacement.
- Calculation classes FormulaParser and FormulaToken are unused. No replacement.
- Writer/Xls/Worksheet methods insertBitMap, positionImage, writeObjPicture, processBitmapGd, and processBitmap are unused. No replacement.
### Fixed
- Improve performance in value binders. [PR #4780](https://github.com/PHPOffice/PhpSpreadsheet/pull/4780)
- Handle Unions as Function Arguments. [Issue #4656](https://github.com/PHPOffice/PhpSpreadsheet/issues/4656) [Issue #316](https://github.com/PHPOffice/PhpSpreadsheet/issues/316) [Issue #503](https://github.com/PHPOffice/PhpSpreadsheet/issues/503) [PR #4657](https://github.com/PHPOffice/PhpSpreadsheet/pull/4657)
- Unexpected Behavior of CONCATENATE. [Issue #4061](https://github.com/PHPOffice/PhpSpreadsheet/issues/4061) [PR #4797](https://github.com/PHPOffice/PhpSpreadsheet/pull/4797)
- Image Css size in millimeters. [Issue #4800](https://github.com/PHPOffice/PhpSpreadsheet/issues/4800) [PR #4801](https://github.com/PHPOffice/PhpSpreadsheet/pull/4801)
- Ods Reader column misalignment. [Issue #4802](https://github.com/PHPOffice/PhpSpreadsheet/issues/4802) [PR #4803](https://github.com/PHPOffice/PhpSpreadsheet/pull/4803)
- Ods improved handling of number formats. [Issue #3961](https://github.com/PHPOffice/PhpSpreadsheet/issues/3961) [Issue #4798](https://github.com/PHPOffice/PhpSpreadsheet/issues/4798) [PR #4806](https://github.com/PHPOffice/PhpSpreadsheet/pull/4806)
- Ods Reader fonts and fills. [Issue #2622](https://github.com/PHPOffice/PhpSpreadsheet/issues/2622) [Issue #1191](https://github.com/PHPOffice/PhpSpreadsheet/issues/1191) [PR #4810](https://github.com/PHPOffice/PhpSpreadsheet/pull/4810)
- Ods Reader alignment and cell protection. [PR #4813](https://github.com/PHPOffice/PhpSpreadsheet/pull/4813)
- Ods Reader borders. [PR #4814](https://github.com/PHPOffice/PhpSpreadsheet/pull/4814)
- Ods Reader column styles (partial). [PR #4815](https://github.com/PHPOffice/PhpSpreadsheet/pull/4815)
## 2026-01-10 - 5.4.0
### Added
- Store image in cell (Xlsx only). [Issue #4014](https://github.com/PHPOffice/PhpSpreadsheet/issues/4014) [Issue #4034](https://github.com/PHPOffice/PhpSpreadsheet/issues/4034) [Issue #913](https://github.com/PHPOffice/PhpSpreadsheet/issues/913) [PR #4677](https://github.com/PHPOffice/PhpSpreadsheet/pull/4677)
- Passthrough support (with some restrictions) for otherwise unsupported drawing elements. [Issue #4037](https://github.com/PHPOffice/PhpSpreadsheet/issues/4037) [Issue #4704](https://github.com/PHPOffice/PhpSpreadsheet/issues/4704) [PR #4712](https://github.com/PHPOffice/PhpSpreadsheet/pull/4712)
- Set all locale variables at once in a threadsafe manner. [Issue #954](https://github.com/PHPOffice/PhpSpreadsheet/issues/954) [PR #4760](https://github.com/PHPOffice/PhpSpreadsheet/pull/4760)
- Reader/Html add ability to suppress warning messages from loadhtml. [Issue #647](https://github.com/PHPOffice/PhpSpreadsheet/issues/647) [Issue #849](https://github.com/PHPOffice/PhpSpreadsheet/issues/849) [PR #4761](https://github.com/PHPOffice/PhpSpreadsheet/pull/4761)
### Changed
- Evaluation of WEBSERVICE no longer requires external client, but will use oldCalculatedValue unless the request is for a domain in a user-supplied whitelist. [PR #4751](https://github.com/PHPOffice/PhpSpreadsheet/pull/4751)
### Moved
- Code to merge cell base style with table and conditional styles moved from Html Writer to its own class. [Issue #1058](https://github.com/PHPOffice/PhpSpreadsheet/issues/1058) [PR #4763](https://github.com/PHPOffice/PhpSpreadsheet/pull/4763)
### Deprecated
- Settings methods setHttpClient, unsetHttpClient, getHttpClient, and getRequestFactory are no longer used. No replacement.
- Reader/Html protected property dataArray, described as used only for testing, is not used for testing. No replacement.
### Fixed
- Slightly better support for escaped characters in Xlsx Reader/Writer. [Discussion #4724](https://github.com/PHPOffice/PhpSpreadsheet/discussions/4724) [PR #4726](https://github.com/PHPOffice/PhpSpreadsheet/pull/4726)
- CODE/UNICODE and CHAR/UNICHAR. [PR #4727](https://github.com/PHPOffice/PhpSpreadsheet/pull/4727)
- Minor changes to TextGrid. [PR #4735](https://github.com/PHPOffice/PhpSpreadsheet/pull/4735) [PR #4743](https://github.com/PHPOffice/PhpSpreadsheet/pull/4743)
- Single-character table names. [Issue #4739](https://github.com/PHPOffice/PhpSpreadsheet/issues/4739) [PR #4740](https://github.com/PHPOffice/PhpSpreadsheet/pull/4740)
- Improvements to SORT and SORTBY. [PR #4743](https://github.com/PHPOffice/PhpSpreadsheet/pull/4743)
- Coverage-related changes in Shared. [PR #4745](https://github.com/PHPOffice/PhpSpreadsheet/pull/4745)
- ListWorksheetInfo improvements for Xlsx and Ods. [Issue #3255](https://github.com/PHPOffice/PhpSpreadsheet/issues/3255) [PR #4746](https://github.com/PHPOffice/PhpSpreadsheet/pull/4746)
- Fix functions related to Student-T distribution. [Issue #4167](https://github.com/PHPOffice/PhpSpreadsheet/issues/4167) [PR #4748](https://github.com/PHPOffice/PhpSpreadsheet/pull/4748)
- Fix drawing hyperlinks. [Issue #993](https://github.com/PHPOffice/PhpSpreadsheet/issues/993) [PR #4764](https://github.com/PHPOffice/PhpSpreadsheet/pull/4764)
- Fix clone spreadsheet with defined names. [PR #4753](https://github.com/PHPOffice/PhpSpreadsheet/pull/4753)
- Changes to WEBSERVICE. [PR #4751](https://github.com/PHPOffice/PhpSpreadsheet/pull/4751)
- More consistent handling of unsupported functions. [Issue #606](https://github.com/PHPOffice/PhpSpreadsheet/issues/606) [PR #4772](https://github.com/PHPOffice/PhpSpreadsheet/pull/4772)
- Chart shadow `kx` and `ky`. [PR #4770](https://github.com/PHPOffice/PhpSpreadsheet/pull/4770)
- SUBTOTAL and hidden rows. [Issue #820](https://github.com/PHPOffice/PhpSpreadsheet/issues/820) [PR #4765](https://github.com/PHPOffice/PhpSpreadsheet/pull/4765)
- Fix some hyperlink problems. [Issue #3889](https://github.com/PHPOffice/PhpSpreadsheet/issues/3889) [Issue #2464](https://github.com/PHPOffice/PhpSpreadsheet/issues/2464) [PR #4771](https://github.com/PHPOffice/PhpSpreadsheet/pull/4771)
- Xls Writer and empty RichText. [Issue #918](https://github.com/PHPOffice/PhpSpreadsheet/issues/918) [PR #4769](https://github.com/PHPOffice/PhpSpreadsheet/pull/4769)
- Strings that look like huge floating-point numbers. [Issue #4766](https://github.com/PHPOffice/PhpSpreadsheet/issues/4766) [PR #4768](https://github.com/PHPOffice/PhpSpreadsheet/pull/4768)
- Rowspan in Html. [Issue #1319](https://github.com/PHPOffice/PhpSpreadsheet/issues/1319) [PR #4767](https://github.com/PHPOffice/PhpSpreadsheet/pull/4767)
- Mpdf styling of multi-line strings. [Issue #4773](https://github.com/PHPOffice/PhpSpreadsheet/issues/4773) [PR #4775](https://github.com/PHPOffice/PhpSpreadsheet/pull/4775)
## 2025-11-24 - 5.3.0
### Added
- Formal support for Php8.5.
- Limited Printarea support for Html/Pdf. [Issue #3941](https://github.com/PHPOffice/PhpSpreadsheet/issues/3941) [PR #4711](https://github.com/PHPOffice/PhpSpreadsheet/pull/4711)
- Implement missing `INFO` function. [PR #4709](https://github.com/PHPOffice/PhpSpreadsheet/pull/4709)
- Implement missing `BAHTTEXT` function. [PR #4715](https://github.com/PHPOffice/PhpSpreadsheet/pull/4715)
### Deprecated
- $dataType, the second parameter of Cell::setValueExplicit, is currently optional. Omitting it is deprecated, and it will be required in a future release.
### Fixed
- Protected ranges and insert/delete rows/columns. [Issue #4695](https://github.com/PHPOffice/PhpSpreadsheet/issues/4695) [PR #4702](https://github.com/PHPOffice/PhpSpreadsheet/pull/4702)
- Unexpected Exception in Php DateTime. [Issue #4696](https://github.com/PHPOffice/PhpSpreadsheet/issues/4696) [Issue #917](https://github.com/PHPOffice/PhpSpreadsheet/issues/917) [PR #4697](https://github.com/PHPOffice/PhpSpreadsheet/pull/4697)
- Add missing Dutch translation to translation file. [PR #4707](https://github.com/PHPOffice/PhpSpreadsheet/pull/4707)
- Fix lots of typos throughout codebase. [PR #4705](https://github.com/PHPOffice/PhpSpreadsheet/pull/4705)
- Apply small code style improvements. [PR #4708](https://github.com/PHPOffice/PhpSpreadsheet/pull/4708)
## 2025-10-25 - 5.2.0
### Added
@@ -250,18 +458,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Worksheet::getProtectedCells - use getProtectedCellRanges instead.
- Writer/Html::isMpdf property - use instanceof Mpdf instead.
### Changed
- Nothing yet.
### Moved
- Nothing yet.
### Deprecated
- Nothing yet.
### Fixed
- Xls writer Parser Mishandling True/False Argument. [Issue #4331](https://github.com/PHPOffice/PhpSpreadsheet/issues/4331) [PR #4333](https://github.com/PHPOffice/PhpSpreadsheet/pull/4333)
@@ -314,22 +510,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
## 2024-12-08 - 3.6.0
### Added
- Nothing yet.
### Changed
- Nothing yet.
### Moved
- Nothing yet.
### Deprecated
- Nothing yet.
### Fixed
- Html Reader/Writer Better Handling of Booleans. [PR #4257](https://github.com/PHPOffice/PhpSpreadsheet/pull/4257)
@@ -342,18 +522,10 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
## 2024-11-22 - 3.5.0
### Added
- Nothing yet.
### Changed
- Settings::libXmlLoaderOptions is ignored. [PR #4233](https://github.com/PHPOffice/PhpSpreadsheet/pull/4233)
### Moved
- Nothing yet.
### Deprecated
- Settings::setLibXmlLoaderOptions() and Settings::getLibXmlLoaderOptions() are no longer needed - no replacement.
@@ -386,10 +558,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Worksheet::getProtectedCells was deprecated in release 2, but was not properly documented, and not removed in release 3. Use getProtectedCellRanges instead.
- Writer/Html::isMpdf property was deprecated in release 2, but was not properly documented, and not removed in release 3. Use instanceof Mpdf instead.
### Moved
- Nothing yet.
### Fixed
- Xls Writer Condtional Rules Applied to Whole Rows or Columns. [Issue #3185](https://github.com/PHPOffice/PhpSpreadsheet/issues/3185) [PR #4152](https://github.com/PHPOffice/PhpSpreadsheet/pull/4152)
@@ -421,10 +589,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Currency and Accounting Wizards are changed to act like Excel, and a new CurrencyBase Wizard is added for for non-Excel formats. [Issue #4125](https://github.com/PHPOffice/PhpSpreadsheet/issues/4125) [Issue #4124](https://github.com/PHPOffice/PhpSpreadsheet/issues/4124) [PR #4127](https://github.com/PHPOffice/PhpSpreadsheet/pull/4127)
- Images will not be added to spreadsheet if they cannot be validated as images.
### Deprecated
- Nothing yet.
### Removed
- The following items were deprecated in release 2 and are now removed.
@@ -433,10 +597,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Calculation\Calculation::_translateFormulaToLocale (use method name translateFormulaToLocale without leading underscore).
- Calculation\Calculation::_translateFormulaToEnglish (use method name translateFormulaToEnglish without leading underscore).
### Moved
- Nothing yet.
### Fixed
- Several security patches.
@@ -446,22 +606,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
## 2024-08-07 - 2.2.2
### Added
- Nothing yet.
### Changed
- Nothing yet.
### Deprecated
- Nothing yet.
### Moved
- Nothing yet.
### Fixed
- Html Reader Preserve Unicode Whitespace. [Issue #1284](https://github.com/PHPOffice/PhpSpreadsheet/issues/1284) [PR #4106](https://github.com/PHPOffice/PhpSpreadsheet/pull/4106)
@@ -500,10 +644,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Writer\Xls\Style\ColorMap is no longer needed.
### Moved
- Nothing
### Fixed
- Incorrect Reader CSV with BOM. [Issue #4028](https://github.com/PHPOffice/PhpSpreadsheet/issues/4028) [PR #4029](https://github.com/PHPOffice/PhpSpreadsheet/pull/4029)
@@ -538,18 +678,10 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Default Style Alignment Property (workaround for bug in non-Excel spreadsheet apps) [Issue #3918](https://github.com/PHPOffice/PhpSpreadsheet/issues/3918) [PR #3924](https://github.com/PHPOffice/PhpSpreadsheet/pull/3924)
- Additional Support for Date/Time Styles [PR #3939](https://github.com/PHPOffice/PhpSpreadsheet/pull/3939)
### Changed
- Nothing
### Deprecated
- Reader/Xml trySimpleXMLLoadString should not have had public visibility, and will be removed.
### Removed
- Nothing
### Fixed
- IF Empty Arguments. [Issue #3875](https://github.com/PHPOffice/PhpSpreadsheet/issues/3875) [Issue #2146](https://github.com/PHPOffice/PhpSpreadsheet/issues/2146) [PR #3879](https://github.com/PHPOffice/PhpSpreadsheet/pull/3879)
@@ -611,10 +743,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Functions `_translateFormulaToLocale` and `_translateFormulaEnglish` are replaced by versions without leading underscore. [PR #3828](https://github.com/PHPOffice/PhpSpreadsheet/pull/3828)
### Removed
- Nothing
### Fixed
- Take advantage of mitoteam/jpgraph Extended mode to enable rendering of more graphs. [PR #3603](https://github.com/PHPOffice/PhpSpreadsheet/pull/3603)
@@ -682,14 +810,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Allow `CellRange` and `CellAddress` objects for the `range` argument in the `rangeToArray()` method. [PR #3494](https://github.com/PHPOffice/PhpSpreadsheet/pull/3494)
- Stock charts will now read and reproduce `upDownBars` and subsidiary tags; these were previously ignored on read and hard-coded on write. [PR #3515](https://github.com/PHPOffice/PhpSpreadsheet/pull/3515)
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- Updates Cell formula absolute ranges/references, and Defined Name absolute ranges/references when inserting/deleting rows/columns. [Issue #3368](https://github.com/PHPOffice/PhpSpreadsheet/issues/3368) [PR #3402](https://github.com/PHPOffice/PhpSpreadsheet/pull/3402)
@@ -747,10 +867,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Rationalisation of Pre-defined Currency Format Masks [PR #3377](https://github.com/PHPOffice/PhpSpreadsheet/pull/3377)
### Removed
- Nothing
### Fixed
- Calculation Engine doesn't evaluate Defined Name when default cell A1 is quote-prefixed [Issue #3335](https://github.com/PHPOffice/PhpSpreadsheet/issues/3335) [PR #3336](https://github.com/PHPOffice/PhpSpreadsheet/pull/3336)
@@ -764,22 +880,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
## 1.27.1 - 2023-02-08
### Added
- Nothing
### Changed
- Nothing
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- Fix Composer --dev dependency issue with dealerdirect/phpcodesniffer-composer-installer renaming their `master` branch to `main`
@@ -794,14 +894,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Support for Structured References in the Calculation Engine [PR #3261](https://github.com/PHPOffice/PhpSpreadsheet/pull/3261)
- Limited Support for Form Controls [PR #3130](https://github.com/PHPOffice/PhpSpreadsheet/pull/3130) [Issue #2396](https://github.com/PHPOffice/PhpSpreadsheet/issues/2396) [Issue #1770](https://github.com/PHPOffice/PhpSpreadsheet/issues/1770) [Issue #2388](https://github.com/PHPOffice/PhpSpreadsheet/issues/2388) [Issue #2904](https://github.com/PHPOffice/PhpSpreadsheet/issues/2904) [Issue #2661](https://github.com/PHPOffice/PhpSpreadsheet/issues/2661)
### Changed
- Nothing
### Deprecated
- Nothing
### Removed
- Shared/JAMA is removed. [PR #3260](https://github.com/PHPOffice/PhpSpreadsheet/pull/3260)
@@ -827,10 +919,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Xlsx Reader support for Pivot Tables [PR #2829](https://github.com/PHPOffice/PhpSpreadsheet/pull/2829)
- Permit Date/Time Entered on Spreadsheet to be calculated as Float [Issue #1416](https://github.com/PHPOffice/PhpSpreadsheet/issues/1416) [PR #3121](https://github.com/PHPOffice/PhpSpreadsheet/pull/3121)
### Changed
- Nothing
### Deprecated
- Direct update of Calculation::suppressFormulaErrors is replaced with setter.
@@ -838,10 +926,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- ExcelError public static variable errorCodes replaced with constant ERROR_CODES.
- NumberFormat constant FORMAT_DATE_YYYYMMDD2 replaced with existing identical FORMAT_DATE_YYYYMMDD.
### Removed
- Nothing
### Fixed
- Fixed handling for `_xlws` prefixed functions from Office365 [Issue #3245](https://github.com/PHPOffice/PhpSpreadsheet/issues/3245) [PR #3247](https://github.com/PHPOffice/PhpSpreadsheet/pull/3247)
@@ -870,22 +954,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
## 1.25.2 - 2022-09-25
### Added
- Nothing
### Changed
- Nothing
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- Composer dependency clash with ezyang/htmlpurifier
@@ -918,10 +986,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Misspelled Properties::LINE_STYLE_DASH_SQUERE_DOT deprecated in favor of LINE_STYLE_DASH_SQUARE_DOT.
- Clone not permitted for Spreadsheet. Spreadsheet->copy() can be used instead.
### Removed
- Nothing
### Fixed
- Fix update to defined names when inserting/deleting rows/columns [Issue #3076](https://github.com/PHPOffice/PhpSpreadsheet/issues/3076) [PR #3077](https://github.com/PHPOffice/PhpSpreadsheet/pull/3077)
@@ -950,18 +1014,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Support for SimpleCache Interface versions 1.0, 2.0 and 3.0
- Add Chart Axis Option textRotation [Issue #2705](https://github.com/PHPOffice/PhpSpreadsheet/issues/2705) [PR #2940](https://github.com/PHPOffice/PhpSpreadsheet/pull/2940)
### Changed
- Nothing
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- Fix Encoding issue with Html reader (PHP 8.2 deprecation for mb_convert_encoding) [Issue #2942](https://github.com/PHPOffice/PhpSpreadsheet/issues/2942) [PR #2943](https://github.com/PHPOffice/PhpSpreadsheet/pull/2943)
@@ -1002,14 +1054,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
See [the Discussion section on github](https://github.com/PHPOffice/PhpSpreadsheet/discussions/2821) for details of performance across versions
- Improved performance for removing rows/columns from a worksheet
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- Xls Reader resolving absolute named ranges to relative ranges [Issue #2826](https://github.com/PHPOffice/PhpSpreadsheet/issues/2826) [PR #2827](https://github.com/PHPOffice/PhpSpreadsheet/pull/2827)
@@ -1089,10 +1133,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
This change provides more consistency in the methods (not every "by cell address" method has an equivalent "byColumnAndRow" method);
and the "by cell address" methods often provide more flexibility, such as allowing a range of cells, or referencing them by passing the defined name of a named range as the argument.
### Removed
- Nothing
### Fixed
- Make allowance for the AutoFilter dropdown icon in the first row of an Autofilter range when using Autosize columns. [Issue #2413](https://github.com/PHPOffice/PhpSpreadsheet/issues/2413) [PR #2754](https://github.com/PHPOffice/PhpSpreadsheet/pull/2754)
@@ -1160,14 +1200,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
- Extract some methods from the Calculation Engine into dedicated classes [#2537](https://github.com/PHPOffice/PhpSpreadsheet/issues/2537)
- Eliminate calls to `flattenSingleValue()` that are no longer required when we're checking for array values as arguments [#2590](https://github.com/PHPOffice/PhpSpreadsheet/issues/2590)
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- Fixed `ReferenceHelper@insertNewBefore` behavior when removing column before last column with null value [PR #2541](https://github.com/PHPOffice/PhpSpreadsheet/pull/2541)
@@ -1202,14 +1234,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
- Xlsx Writer will evaluate AutoFilter only if it is as yet unevaluated, or has changed since it was last evaluated [PR #2414](https://github.com/PHPOffice/PhpSpreadsheet/pull/2414)
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- Rounding in `NumberFormatter` [Issue #2385](https://github.com/PHPOffice/PhpSpreadsheet/issues/2385) [PR #2399](https://github.com/PHPOffice/PhpSpreadsheet/pull/2399)
@@ -1240,14 +1264,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
- Drop support for PHP 7.2, according to https://phpspreadsheet.readthedocs.io/en/latest/#php-version-support
- Use native typing for objects that were already documented as such
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- Fixed null conversation for strToUpper [#2292](https://github.com/PHPOffice/PhpSpreadsheet/issues/2292)
@@ -1281,18 +1297,10 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
- Ability to stream to an Amazon S3 bucket [Issue #2249](https://github.com/PHPOffice/PhpSpreadsheet/issues/2249)
- Provided a Size Helper class to validate size values (pt, px, em) [PR #1694](https://github.com/PHPOffice/PhpSpreadsheet/pull/1694)
### Changed
- Nothing.
### Deprecated
- PHP 8.1 will deprecate auto_detect_line_endings. As a result of this change, Csv Reader using some release after PHP8.1 will no longer be able to handle a Csv with Mac line endings.
### Removed
- Nothing.
### Fixed
- Unexpected format in Xlsx Timestamp [Issue #2331](https://github.com/PHPOffice/PhpSpreadsheet/issues/2331) [PR #2332](https://github.com/PHPOffice/PhpSpreadsheet/pull/2332)
@@ -1415,10 +1423,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
This makes the logic in these classes easier to maintain; and will reduce the memory footprint required to execute formulae when calling these functions.
### Removed
- Nothing.
### Fixed
- Avoid Duplicate Titles When Reading Multiple HTML Files.[Issue #1823](https://github.com/PHPOffice/PhpSpreadsheet/issues/1823) [PR #1829](https://github.com/PHPOffice/PhpSpreadsheet/pull/1829)
@@ -1448,10 +1452,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
- All Excel Function implementations in `Calculation\Database`, `Calculation\DateTime`, `Calculation\Engineering`, `Calculation\Financial`, `Calculation\Logical`, `Calculation\LookupRef`, `Calculation\MathTrig`, `Calculation\Statistical`, `Calculation\TextData` and `Calculation\Web` have been moved to dedicated classes for individual functions or groups of related functions. See the docblocks against all the deprecated methods for details of the new methods to call instead. At some point, these old classes will be deleted.
### Removed
- Nothing.
### Fixed
- Fixed issue with absolute path in worksheets' Target [PR #1769](https://github.com/PHPOffice/PhpSpreadsheet/pull/1769)
@@ -1511,10 +1511,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
- **IMPORTANT NOTE:** This Introduces a **BC break** in the handling of named ranges. Previously, a named range cell reference of `B2` would be treated identically to a named range cell reference of `$B2` or `B$2` or `$B$2` because the calculation engine treated then all as absolute references. These changes "fix" that, so the calculation engine now handles relative references in named ranges correctly.
This change that resolves previously incorrect behaviour in the calculation may affect users who have dynamically defined named ranges using relative references when they should have used absolute references.
### Removed
- Nothing.
### Fixed
- PrintArea causes exception [#1544](https://github.com/phpoffice/phpspreadsheet/pull/1544)
@@ -1525,10 +1521,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
## 1.14.1 - 2020-07-19
### Added
- nothing
### Fixed
- WEBSERVICE is HTTP client agnostic and must be configured via `Settings::setHttpClient()` [#1562](https://github.com/PHPOffice/PhpSpreadsheet/issues/1562)
+4 -4
View File
@@ -43,7 +43,7 @@ This makes it easier to see exactly what is being tested when reviewing the PR.
4. By default, Github removes markdown headings in the Release Notes. You can either edit to restore these, or, probably preferably, change the default comment character on your system - `git config core.commentChar ";"`.
> **Note:** Tagged releases are made from the `master` branch. Only in an emergency should a tagged release be made from the `release` branch. (i.e. cherry-picked hot-fixes.) However, there are 4 branches which have been updated to apply security patches, and those may be tagged if future security updates are needed.
- release1291 (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
- release210 (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
- release222
- release390
- 1.30.x (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
- 2.1.x (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
- 2.4.x (no further updates aside from security patches)
- 3.10.x
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019-2025 PhpSpreadsheet Authors
Copyright (c) 2019-2026 PhpSpreadsheet Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+3 -2
View File
@@ -4,7 +4,7 @@
[![Code Coverage](https://coveralls.io/repos/github/PHPOffice/PhpSpreadsheet/badge.svg?branch=master)](https://coveralls.io/github/PHPOffice/PhpSpreadsheet?branch=master)
[![Total Downloads](https://img.shields.io/packagist/dt/PHPOffice/PhpSpreadsheet)](https://packagist.org/packages/phpoffice/phpspreadsheet)
[![Latest Stable Version](https://img.shields.io/github/v/release/PHPOffice/PhpSpreadsheet)](https://packagist.org/packages/phpoffice/phpspreadsheet)
[![License](https://img.shields.io/github/license/PHPOffice/PhpSpreadsheet)](https://packagist.org/packages/phpoffice/phpspreadsheet)
[![License](https://poser.pugx.org/phpoffice/phpspreadsheet/license)](https://packagist.org/packages/phpoffice/phpspreadsheet)
[![Join the chat at https://gitter.im/PHPOffice/PhpSpreadsheet](https://img.shields.io/badge/GITTER-join%20chat-green.svg)](https://gitter.im/PHPOffice/PhpSpreadsheet)
PhpSpreadsheet is a library written in pure PHP and offers a set of classes that
@@ -16,8 +16,9 @@ This is the master branch, and is maintained for security and bug fixes.
LTS: For maintained branches, support for PHP versions will only be maintained for a period of six months beyond the
[end of life](https://www.php.net/supported-versions) of that PHP version.
Dropping support for versions after that date will not be considered a breaking change.
Currently the required PHP minimum version is PHP __8.1__, and we [will support that version](https://www.php.net/supported-versions.php) until 30th June 2026.
Currently the required PHP minimum version is PHP __8.2__, and we [will support that version](https://www.php.net/supported-versions.php) until 30th June 2027.
See the `composer.json` for other requirements.
+14 -8
View File
@@ -13,7 +13,7 @@
],
"config": {
"platform": {
"php" : "8.1.99"
"php": "8.2.99"
},
"process-timeout": 600,
"sort-packages": true,
@@ -42,6 +42,9 @@
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"scripts": {
@@ -66,10 +69,11 @@
]
},
"require": {
"php": "^8.1",
"php": "^8.2",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
@@ -84,11 +88,11 @@
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"ext-intl": "*",
"ext-openssl": "*",
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
@@ -98,12 +102,13 @@
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"phpunit/phpunit": "^10.5 || ^11.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
"tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
},
"suggest": {
"ext-intl": "PHP Internationalization Functions, regquired for NumberFormat Wizard",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer",
@@ -117,7 +122,8 @@
"autoload-dev": {
"psr-4": {
"PhpOffice\\PhpSpreadsheetTests\\": "tests/PhpSpreadsheetTests",
"PhpOffice\\PhpSpreadsheetInfra\\": "infra"
"PhpOffice\\PhpSpreadsheetInfra\\": "infra",
"PhpOffice\\PhpSpreadsheetBenchmarks\\": "tests/Benchmark"
}
}
}
@@ -2,6 +2,7 @@
namespace PhpOffice\PhpSpreadsheet\Calculation;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
class BinaryComparison
@@ -41,17 +42,25 @@ class BinaryComparison
return strcmp($str1, $str2);
}
public static function compare(mixed $operand1, mixed $operand2, string $operator): bool
public static function compare(mixed $operand1, mixed $operand2, string $operator): bool|string
{
// Simple validate the two operands if they are string values
if (is_string($operand1) && $operand1 > '' && $operand1[0] == Calculation::FORMULA_STRING_QUOTE) {
$operand1 = Calculation::unwrapResult($operand1);
}
if (ErrorValue::isError($operand1, true)) {
/** @var string $operand1 */
return $operand1;
}
if (is_string($operand2) && $operand2 > '' && $operand2[0] == Calculation::FORMULA_STRING_QUOTE) {
$operand2 = Calculation::unwrapResult($operand2);
}
if (ErrorValue::isError($operand2, true)) {
/** @var string $operand2 */
return $operand2;
}
// Use case insensitive comparaison if not OpenOffice mode
// Use case-insensitive comparison if not OpenOffice mode
if (Functions::getCompatibilityMode() != Functions::COMPATIBILITY_OPENOFFICE) {
if (is_string($operand1)) {
$operand1 = StringHelper::strToUpper($operand1);
@@ -2,6 +2,7 @@
namespace PhpOffice\PhpSpreadsheet\Calculation;
use Composer\Pcre\Preg; // many pregs in this program use u modifier, which has side-effects which make it unsuitable for this
use PhpOffice\PhpSpreadsheet\Calculation\Engine\BranchPruner;
use PhpOffice\PhpSpreadsheet\Calculation\Engine\CyclicReferenceStack;
use PhpOffice\PhpSpreadsheet\Calculation\Engine\Logger;
@@ -13,6 +14,7 @@ use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\DefinedName;
use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException;
use PhpOffice\PhpSpreadsheet\NamedRange;
use PhpOffice\PhpSpreadsheet\ReferenceHelper;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
@@ -36,7 +38,7 @@ class Calculation extends CalculationLocale
const CALCULATION_REGEXP_OPENBRACE = '\(';
// Function (allow for the old @ symbol that could be used to prefix a function, but we'll ignore it)
const CALCULATION_REGEXP_FUNCTION = '@?(?:_xlfn\.)?(?:_xlws\.)?((?:__xludf\.)?[\p{L}][\p{L}\p{N}\.]*)[\s]*\(';
// Cell reference (cell or range of cells, with or without a sheet reference)
// Cell reference, with or without a sheet reference)
const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.])';
// Used only to detect spill operator #
const CALCULATION_REGEXP_CELLREF_SPILL = '/' . self::CALCULATION_REGEXP_CELLREF . '#/i';
@@ -88,9 +90,22 @@ class Calculation extends CalculationLocale
*/
private bool $calculationCacheEnabled = true;
/**
* Maximum number of entries in the formula token cache.
* Default 0 (disabled). Set via setFormulaTokenCacheMaxSize() to enable.
*/
private int $formulaTokenCacheMaxSize = 0;
/**
* Cache of parsed formula tokens, keyed by the raw formula string.
*
* @var array<string, array<mixed>|bool>
*/
private array $formulaTokenCache = [];
private BranchPruner $branchPruner;
private bool $branchPruningEnabled = true;
protected bool $branchPruningEnabled = true;
/**
* List of operators that can be used within formulae
@@ -239,6 +254,7 @@ class Calculation extends CalculationLocale
{
$this->clearCalculationCache();
$this->branchPruner->clearBranchStore();
$this->formulaTokenCache = [];
}
/**
@@ -364,6 +380,44 @@ class Calculation extends CalculationLocale
$this->calculationCache = [];
}
/**
* Clear the formula token cache.
*/
public function clearFormulaTokenCache(): void
{
$this->formulaTokenCache = [];
}
/**
* Get the current number of entries in the formula token cache.
*/
public function getFormulaTokenCacheSize(): int
{
return count($this->formulaTokenCache);
}
/**
* Set the maximum number of entries in the formula token cache.
* Set to 0 to disable caching (default), or a positive integer to enable.
*/
public function setFormulaTokenCacheMaxSize(int $size): self
{
$this->formulaTokenCacheMaxSize = max(0, $size);
if ($this->formulaTokenCacheMaxSize === 0) {
$this->formulaTokenCache = [];
}
return $this;
}
/**
* Get the maximum number of entries allowed in the formula token cache.
*/
public function getFormulaTokenCacheMaxSize(): int
{
return $this->formulaTokenCacheMaxSize;
}
/**
* Clear calculation cache for a specified worksheet.
*/
@@ -415,7 +469,7 @@ class Calculation extends CalculationLocale
{
if (is_string($value)) {
// Error values cannot be "wrapped"
if (preg_match('/^' . self::CALCULATION_REGEXP_ERROR . '$/i', $value, $match)) {
if (Preg::isMatch('/^' . self::CALCULATION_REGEXP_ERROR . '$/i', $value, $match)) {
// Return Excel errors "as is"
return $value;
}
@@ -494,13 +548,13 @@ class Calculation extends CalculationLocale
try {
$value = $cell->getValue();
if (is_string($value) && $cell->getDataType() === DataType::TYPE_FORMULA) {
$value = preg_replace_callback(
$value = Preg::replaceCallback(
self::CALCULATION_REGEXP_CELLREF_SPILL,
fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
$value
);
}
$result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); //* @phpstan-ignore-line
$result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); //* @phpstan-ignore argument.type ($value can be mixed not string)
if ($this->spreadsheet === null) {
throw new Exception('null spreadsheet in calculateCellValue');
}
@@ -520,7 +574,7 @@ class Calculation extends CalculationLocale
$cellAddress = array_pop($this->cellStack);
}
if ($this->spreadsheet !== null && is_array($cellAddress) && array_key_exists('sheet', $cellAddress)) {
$sheetName = $cellAddress['sheet'] ?? null;
$sheetName = $cellAddress['sheet'];
$testSheet = is_string($sheetName) ? $this->spreadsheet->getSheetByName($sheetName) : null;
if ($testSheet !== null && array_key_exists('cell', $cellAddress)) {
/** @var array{cell: string} $cellAddress */
@@ -557,11 +611,17 @@ class Calculation extends CalculationLocale
*/
public function parseFormula(string $formula): array|bool
{
$formula = preg_replace_callback(
// Check the formula token cache first (only when caching is enabled)
if ($this->formulaTokenCacheMaxSize > 0 && isset($this->formulaTokenCache[$formula])) {
return $this->formulaTokenCache[$formula];
}
$originalFormula = $formula;
$formula = Preg::replaceCallback(
self::CALCULATION_REGEXP_CELLREF_SPILL,
fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
$formula
) ?? $formula;
);
// Basic validation that this is indeed a formula
// We return an empty array if not
$formula = trim($formula);
@@ -574,7 +634,21 @@ class Calculation extends CalculationLocale
}
// Parse the formula and return the token stack
return $this->internalParseFormula($formula);
$result = $this->internalParseFormula($formula);
// Cache the result when caching is enabled (clear cache if it exceeds the maximum size)
if ($this->formulaTokenCacheMaxSize > 0) {
// Phpstan says if condition is always false,
// but coverage report says next statement is covered.
if (count($this->formulaTokenCache) >= $this->formulaTokenCacheMaxSize) {
$this->formulaTokenCache = [];
}
// Cache key is the original formula string (before ANCHORARRAY transformation)
// to ensure consistent lookup regardless of internal transformations.
$this->formulaTokenCache[$originalFormula] = $result;
}
return $result;
}
/**
@@ -657,8 +731,9 @@ class Calculation extends CalculationLocale
return self::wrapResult((string) $formula);
}
// https://www.reddit.com/r/excel/comments/chr41y/cmd_formula_stopped_working_since_last_update/
if (preg_match('/^=\s*cmd\s*\|/miu', $formula) !== 0) {
return self::wrapResult($formula);
return ExcelError::REF(); // returns #BLOCKED in newer versions
}
// Basic validation that this is indeed a formula
@@ -914,13 +989,14 @@ class Calculation extends CalculationLocale
$pad = $rpad = ', ';
foreach ($value as $row) {
if (is_array($row)) {
$returnMatrix[] = implode($pad, array_map([$this, 'showValue'], $row));
$returnMatrix[] = implode($pad, array_map([$this, 'showValue'], $row)); // @phpstan-ignore argument.type (array_map can theoretically return array<mixed> not array<string>)
$rpad = '; ';
} else {
$returnMatrix[] = $this->showValue($row);
}
}
/** @var string[] $returnMatrix */
return '{ ' . implode($rpad, $returnMatrix) . ' }';
} elseif (is_string($value) && (trim($value, self::FORMULA_STRING_QUOTE) == $value)) {
return self::FORMULA_STRING_QUOTE . $value . self::FORMULA_STRING_QUOTE;
@@ -1052,6 +1128,50 @@ class Calculation extends CalculationLocale
'>' => 0, '<' => 0, '=' => 0, '>=' => 0, '<=' => 0, '<>' => 0, // Comparison
];
/** @param array<?string> $matches */
private function unionForComma(array $matches): string
{
$matches5 = (string) $matches[5];
// Weirdly, the regexp which get us here for issue 4832
// only gets us here for Php8.4+. 8.4 introduced
// major changes for PCRE, but I cannot identify
// the exact change which caused this discrepancy.
// I do plan to update coverage to 8.4 at some point,
// and I can remove the coverage annotations after that.
// @codeCoverageIgnoreStart
if (str_contains($matches5, '(') && !str_contains($matches5, ')')) {
if ($this->spreadsheet !== null) {
if ($this->spreadsheet->getSheetByName($matches5) === null) {
$matches0 = (string) $matches[0];
$this->debugLog->writeDebugLog('Not Reformulating %s', $matches0);
return $matches0;
}
}
}
// @codeCoverageIgnoreEnd
$matches1 = (string) $matches[1];
$matches2 = (string) $matches[2];
return $matches1 . str_replace(',', '∪', $matches2);
}
private const CELL_OR_CELLRANGE_OR_DEFINED_NAME
= '(?:'
. self::CALCULATION_REGEXP_CELLREF // cell address
. '(?::' . self::CALCULATION_REGEXP_CELLREF . ')?' // optional range address, non-capturing
. '|' . self::CALCULATION_REGEXP_DEFINEDNAME
. ')'
;
public const UNIONABLE_COMMAS = '/((?:[,(]|^)\s*)' // comma or open paren or start of string, followed by optional whitespace
. '([(]' // open paren
. self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
. '(?:\s*,\s*' // optioonal whitespace, comma, optional whitespace, non-capturing
. self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
. ')+' // one or more occurrences
. '\s*[)])/i'; // optional whitespace, end paren
/**
* @return array<int, mixed>|false
*/
@@ -1060,6 +1180,12 @@ class Calculation extends CalculationLocale
if (($formula = $this->convertMatrixReferences(trim($formula))) === false) {
return false;
}
$oldFormula = $formula;
$formula = Preg::replaceCallback(self::UNIONABLE_COMMAS, $this->unionForComma(...), $formula);
if ($oldFormula !== $formula) {
$this->debugLog->writeDebugLog('Reformulated as %s', $formula);
}
$phpSpreadsheetFunctions = &self::getFunctionsAddress();
// If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent worksheet),
@@ -1179,7 +1305,7 @@ class Calculation extends CalculationLocale
}
}
} elseif (is_string($expectedArgumentCount) && $expectedArgumentCount !== '*') {
if (1 !== preg_match('/(\d*)([-+,])(\d*)/', $expectedArgumentCount, $argMatch)) {
if (!Preg::isMatch('/(\d*)([-+,])(\d*)/', $expectedArgumentCount, $argMatch)) {
$argMatch = ['', '', '', ''];
}
switch ($argMatch[2]) {
@@ -1236,16 +1362,14 @@ class Calculation extends CalculationLocale
// because at least the braces are paired up (at this stage in the formula)
// MS Excel allows this if the content is cell references; but doesn't allow actual values,
// but at this point, we can't differentiate (so allow both)
return $this->raiseFormulaError('Formula Error: Unexpected ,');
/* The following code may be a better choice, but, with
the other changes for this PR, I can no longer come up
with a test case that gets here
//return $this->raiseFormulaError('Formula Error: Unexpected ,');
$stack->push('Binary Operator', '∪');
++$index;
$expectingOperator = false;
continue;*/
continue;
}
/** @var array<string, int> $d */
@@ -1267,11 +1391,12 @@ class Calculation extends CalculationLocale
// do we now have a function/variable/number?
$expectingOperator = true;
$expectingOperand = false;
$val = $match[1] ?? ''; //* @phpstan-ignore-line
$val = $match[1] ?? '';
$length = strlen($val);
if (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $val, $matches)) {
$val = (string) preg_replace('/\s/u', '', $val);
// $val is known to be valid unicode from statement above, so Preg::replace is okay even with u modifier
$val = Preg::replace('/\s/u', '', $val);
if (isset($phpSpreadsheetFunctions[strtoupper($matches[1])]) || isset(self::$controlFunctions[strtoupper($matches[1])])) { // it's a function
$valToUpper = strtoupper($val);
} else {
@@ -1410,7 +1535,7 @@ class Calculation extends CalculationLocale
$refSheet = $pCellParent->getParentOrThrow()->getSheetByName($rangeSheetRef);
}
if (ctype_digit($val) && $val <= 1048576) {
if (ctype_digit($val) && $val <= AddressRange::MAX_ROW) {
// Row range
$stackItemType = 'Row Reference';
$valx = $val;
@@ -1419,7 +1544,7 @@ class Calculation extends CalculationLocale
} elseif (ctype_alpha($val) && strlen($val) <= 3) {
// Column range
$stackItemType = 'Column Reference';
$endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow($val) : AddressRange::MAX_ROW; // Max 1,048,576 rows for Excel2007
$endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow() : AddressRange::MAX_ROW; // Max 1,048,576 rows for Excel2007
$val = "{$rangeWS2}{$val}{$endRowColRef}";
}
$stackItemReference = $val;
@@ -1445,7 +1570,7 @@ class Calculation extends CalculationLocale
// unescape any apostrophes or double quotes in worksheet name
$val = str_replace(["''", '""'], ["'", '"'], $val);
$column = 'A';
if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) {
if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { // @phpstan-ignore booleanAnd.alwaysFalse (testPrevop must be non-null), booleanAnd.alwaysFalse (ditto), identical.alwaysFalse (ditto)
$column = $pCellParent->getHighestDataColumn($val);
}
$val = "{$rowRangeReference[2]}{$column}{$rowRangeReference[7]}";
@@ -1459,7 +1584,7 @@ class Calculation extends CalculationLocale
// unescape any apostrophes or double quotes in worksheet name
$val = str_replace(["''", '""'], ["'", '"'], $val);
$row = '1';
if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) {
if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { // @phpstan-ignore booleanAnd.alwaysFalse (testPrevOp must be non-null?), booleanAnd.alwaysFalse (ditto), identical.alwaysFalse (ditto)
$row = $pCellParent->getHighestDataRow($val);
}
$val = "{$val}{$row}";
@@ -1610,7 +1735,7 @@ class Calculation extends CalculationLocale
foreach ($tokens as $tokenIdx => $tokenData) {
/** @var mixed[] $tokenData */
$this->processingAnchorArray = false;
if ($tokenData['type'] === 'Cell Reference' && isset($tokens[$tokenIdx + 1]) && $tokens[$tokenIdx + 1]['type'] === 'Operand Count for Function ANCHORARRAY()') { //* @phpstan-ignore-line
if ($tokenData['type'] === 'Cell Reference' && isset($tokens[$tokenIdx + 1]) && $tokens[$tokenIdx + 1]['type'] === 'Operand Count for Function ANCHORARRAY()') { //* @phpstan-ignore offsetAccess.nonOffsetAccessible ($tokens might be mixed not array)
$this->processingAnchorArray = true;
}
$token = $tokenData['value'];
@@ -1712,7 +1837,7 @@ class Calculation extends CalculationLocale
return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
}
}
} elseif (!is_numeric($token) && !is_object($token) && isset($token, self::BINARY_OPERATORS[$token])) { //* @phpstan-ignore-line
} elseif (!is_numeric($token) && !is_object($token) && isset($token, self::BINARY_OPERATORS[$token])) { //* @phpstan-ignore offsetAccess.invalidOffset ($token is mixed)
// if the token is a binary operator, pop the top two values off the stack, do the operation, and push the result back on the stack
// We must have two operands, error if we don't
$operand2Data = $stack->pop();
@@ -1830,7 +1955,7 @@ class Calculation extends CalculationLocale
if ($breakNeeded) {
break;
}
$cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line
$cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow);
if ($pCellParent !== null && $this->spreadsheet !== null) {
$cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($sheet1), false);
} else {
@@ -1901,7 +2026,7 @@ class Calculation extends CalculationLocale
} elseif (Information\ErrorValue::isError($operand2)) {
$result = $operand2;
} else {
$result = str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)); //* @phpstan-ignore-line
$result = str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)); //* @phpstan-ignore binaryOp.invalid (unwrapresult can return mixed rather than string)
$result = StringHelper::substring(
$result,
0,
@@ -1934,8 +2059,8 @@ class Calculation extends CalculationLocale
$this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
$stack->push('Error', ExcelError::null(), null);
} else {
$cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore-line
. Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line
$cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore argument.type ($oCol or $oRow might be empty), argument.type (ditto)
. Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore argument.type ($oCol or $oRow might be empty), argument.type (ditto)
$this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
$stack->push('Value', $cellIntersect, $cellRef);
}
@@ -1950,13 +2075,13 @@ class Calculation extends CalculationLocale
break;
}
} elseif (($token === '~') || ($token === '%')) {
} elseif (($token === '~') || ($token === '%')) { // @phpstan-ignore booleanOr.alwaysFalse (phpstan says token can't be plain string), identical.alwaysFalse (ditto), identical.alwaysFalse (ditto)
// if the token is a unary operator, pop one value off the stack, do the operation, and push it back on
if (($arg = $stack->pop()) === null) {
return $this->raiseFormulaError('Internal error - Operand value missing from stack');
}
$arg = $arg['value'];
if ($token === '~') {
if ($token === '~') { // @phpstan-ignore identical.alwaysFalse (phpstan says token can't be plain string)
$this->debugLog->writeDebugLog('Evaluating Negation of %s', $this->showValue($arg));
$multiplier = -1;
} else {
@@ -1988,20 +2113,27 @@ class Calculation extends CalculationLocale
} else {
$this->executeNumericBinaryOperation($multiplier, $arg, '*', $stack);
}
} elseif (preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', StringHelper::convertToString($token ?? ''), $matches)) {
} elseif (Preg::isMatch('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', StringHelper::convertToString($token ?? ''), $matches)) {
$cellRef = null;
/* Phpstan says matches[8/9/10] is never set,
and code coverage report seems to confirm.
Appease PhpStan for now;
probably delete this block later.
regex101.com confirms - only 7 capturing groups.
My theory is that this code expected regexp to
match cell *or* cellRange, but it does not
match the latter. Retain the code for now in case
we do want to add the range match later.
Probably delete this block later.
Until delete happens, turn code coverage off.
*/
if (isset($matches[self::$matchIndex8])) {
// @codeCoverageIgnoreStart
if ($cell === null) {
// We can't access the range, so return a REF error
$cellValue = ExcelError::REF();
} else {
$cellRef = $matches[6] . $matches[7] . ':' . $matches[self::$matchIndex9] . $matches[self::$matchIndex10];
$matches[2] = (string) $matches[2];
if ($matches[2] > '') {
$matches[2] = trim($matches[2], "\"'");
if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
@@ -2026,12 +2158,14 @@ class Calculation extends CalculationLocale
$this->debugLog->writeDebugLog('Evaluation Result for cells %s is %s', $cellRef, $this->showTypeDetails($cellValue));
}
}
// @codeCoverageIgnoreEnd
} else {
if ($cell === null) {
// We can't access the cell, so return a REF error
$cellValue = ExcelError::REF();
} else {
$cellRef = $matches[6] . $matches[7];
$matches[2] = (string) $matches[2];
if ($matches[2] > '') {
$matches[2] = trim($matches[2], "\"'");
if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
@@ -2042,7 +2176,11 @@ class Calculation extends CalculationLocale
if ($pCellParent !== null && $this->spreadsheet !== null) {
$cellSheet = $this->spreadsheet->getSheetByName($matches[2]);
if ($cellSheet && !$cellSheet->cellExists($cellRef)) {
$cellSheet->setCellValue($cellRef, null);
try {
$cellSheet->setCellValue($cellRef, null);
} catch (SpreadsheetException) {
// do nothing
}
}
if ($cellSheet && $cellSheet->cellExists($cellRef)) {
$cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($matches[2]), false);
@@ -2073,7 +2211,7 @@ class Calculation extends CalculationLocale
$cellValue = array_shift($cellValue);
}
if (is_string($cellValue)) {
$cellValue = preg_replace('/"/', '""', $cellValue);
$cellValue = Preg::replace('/"/', '""', $cellValue);
}
$this->debugLog->writeDebugLog('Scalar Result for cell %s is %s', $cellRef, $this->showTypeDetails($cellValue));
}
@@ -2117,7 +2255,7 @@ class Calculation extends CalculationLocale
$a = $argCount - $i - 1;
if (
($passByReference)
&& (isset($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])) //* @phpstan-ignore-line
&& (isset($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])) //* @phpstan-ignore offsetAccess.nonOffsetAccessible (possibly need to pass 2 arguments to isset)
&& ($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])
) {
/** @var mixed[] $arg */
@@ -2184,7 +2322,7 @@ class Calculation extends CalculationLocale
if ($functionName !== 'MKMATRIX') {
if ($this->debugLog->getWriteDebugLog()) {
krsort($argArrayVals);
$this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals)));
$this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals))); // @phpstan-ignore argument.type (flattenArray returns array<mixed> rather than array<string>)
}
}
@@ -2221,7 +2359,7 @@ class Calculation extends CalculationLocale
}
} else {
// if the token is a number, boolean, string or an Excel error, push it onto the stack
/** @var ?string $token */
/** @var null|numeric-string $token */
if (isset(self::EXCEL_CONSTANTS[strtoupper($token ?? '')])) {
$excelConstant = strtoupper("$token");
$stack->push('Constant Value', self::EXCEL_CONSTANTS[$excelConstant]);
@@ -2229,7 +2367,7 @@ class Calculation extends CalculationLocale
$branchStore[$storeKey] = self::EXCEL_CONSTANTS[$excelConstant];
}
$this->debugLog->writeDebugLog('Evaluating Constant %s as %s', $excelConstant, $this->showTypeDetails(self::EXCEL_CONSTANTS[$excelConstant]));
} elseif ((is_numeric($token)) || ($token === null) || (is_bool($token)) || ($token == '') || ($token[0] == self::FORMULA_STRING_QUOTE) || ($token[0] == '#')) { //* @phpstan-ignore-line
} elseif ((is_numeric($token)) || ($token === null) || (is_bool($token)) || ($token == '') || ($token[0] == self::FORMULA_STRING_QUOTE) || ($token[0] == '#')) { //* @phpstan-ignore function.alreadyNarrowedType (phpstan says is_bool has argument of *NEVER*?), equal.alwaysFalse (ditto)
/** @var array{type: string, reference: ?string} $tokenData */
$stack->push($tokenData['type'], $token, $tokenData['reference']);
if (isset($storeKey)) {
@@ -2355,7 +2493,7 @@ class Calculation extends CalculationLocale
$r = $stack->pop();
$result[$x] = $r['value'];
}
} elseif (is_array($operand2) && is_array($operand1)) {
} elseif (is_array($operand2) /*&& is_array($operand1)*/) {
// Operand 1 and Operand 2 are both arrays
if (!$recursingArrays) {
self::checkMatrixOperands($operand1, $operand2, 2);
@@ -2368,7 +2506,7 @@ class Calculation extends CalculationLocale
$result[$x] = $r['value'];
}
} else {
throw new Exception('Neither operand is an arra');
throw new Exception('Neither operand is an array');
}
// Log the result details
$this->debugLog->writeDebugLog('Comparison Evaluation Result is %s', $this->showTypeDetails($result));
@@ -2378,8 +2516,8 @@ class Calculation extends CalculationLocale
return $result;
}
/** @return bool|mixed[] */
private function executeBinaryComparisonOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays = false): array|bool
/** @return array<mixed>|bool|string */
private function executeBinaryComparisonOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays = false): array|bool|string
{
// If we're dealing with matrix operations, we want a matrix result
if ((is_array($operand1)) || (is_array($operand2))) {
@@ -2581,6 +2719,8 @@ class Calculation extends CalculationLocale
if (!isset($aReferences[1])) {
// Single cell in range
sscanf($aReferences[0], '%[A-Z]%d', $currentCol, $currentRow);
/** @var string $currentCol */
/** @var int $currentRow */
if ($createCell && $worksheet !== null && !$worksheet->cellExists($aReferences[0])) {
$worksheet->setCellValue($aReferences[0], null);
}
@@ -2600,6 +2740,8 @@ class Calculation extends CalculationLocale
foreach ($aReferences as $reference) {
// Extract range
sscanf($reference, '%[A-Z]%d', $currentCol, $currentRow);
/** @var string $currentCol */
/** @var int $currentRow */
if ($createCell && $worksheet !== null && !$worksheet->cellExists($reference)) {
$worksheet->setCellValue($reference, null);
}
@@ -2760,6 +2902,7 @@ class Calculation extends CalculationLocale
// read constant value
if (str_contains($constantName, '::')) {
[$className, $constantName] = explode('::', $constantName);
/** @var class-string $className */
$constantReflector = new ReflectionClassConstant($className, $constantName);
return $constantReflector->getValue();
@@ -2814,10 +2957,12 @@ class Calculation extends CalculationLocale
$definedNameValue = $namedRange->getValue();
$definedNameType = $namedRange->isFormula() ? 'Formula' : 'Range';
if ($definedNameType === 'Range') {
if (preg_match('/^(.*!)?(.*)$/', $definedNameValue, $matches) === 1) {
$matches2 = trim($matches[2]);
$matches2 = preg_replace('/ +/', ' ∩ ', $matches2) ?? $matches2;
$matches2 = preg_replace('/,/', ' ∪ ', $matches2) ?? $matches2;
if (Preg::isMatch('/^(.*!)?(.*)$/', $definedNameValue, $matches)) {
$matches2 = Preg::replace(
['/ +/', '/,/'],
[' ∩ ', ' ∪ '],
trim($matches[2])
);
$definedNameValue = $matches[1] . $matches2;
}
}
@@ -0,0 +1,43 @@
<?php
namespace PhpOffice\PhpSpreadsheet\Calculation;
/**
* A dedicated Calculation singleton for formula parsing only.
*
* This class provides an isolated instance specifically for parsing formulas
* without branch pruning, avoiding state conflicts with the main Calculation
* singleton used for cell value calculations.
*
* @internal
*/
final class CalculationParserOnly extends Calculation
{
/**
* Instance of this class.
*/
private static ?CalculationParserOnly $parserInstance = null;
/**
* Branch pruning is disabled by default for parsing-only operations.
*/
protected bool $branchPruningEnabled = false;
/**
* Get the singleton instance of this parser-only calculator.
*/
public static function getParserInstance(): self
{
if (!self::$parserInstance) {
self::$parserInstance = new self();
}
return self::$parserInstance;
}
/** @param mixed $enabled Unused, property will always be false in this class */
public function setBranchPruningEnabled(mixed $enabled): self
{
return $this;
}
}
@@ -53,7 +53,7 @@ abstract class DatabaseAbstract
/** @var callable */
$callable = 'strtoupper';
$fieldNames = array_map($callable, array_shift($database)); //* @phpstan-ignore-line
$fieldNames = array_map($callable, array_shift($database)); //* @phpstan-ignore argument.type (array_shift can return mixed not array?)
if (is_numeric($field)) {
$field = (int) $field - 1;
if ($field < 0 || $field >= count($fieldNames)) {
@@ -143,7 +143,7 @@ abstract class DatabaseAbstract
}
$rowQuery = array_map(
fn ($rowValue): string => (count($rowValue) > 1) ? 'AND(' . implode(',', $rowValue) . ')' : ($rowValue[0] ?? ''), // @phpstan-ignore-line
fn ($rowValue): string => (count($rowValue) > 1) ? 'AND(' . implode(',', $rowValue) . ')' : ($rowValue[0] ?? ''), // @phpstan-ignore nullCoalesce.offset ($rowValue[0] always exists?)
$baseQuery
);
@@ -24,7 +24,7 @@ class Date
* Excel Function:
* DATE(year,month,day)
*
* PhpSpreadsheet is a lot more forgiving than MS Excel when passing non numeric values to this function.
* PhpSpreadsheet is a lot more forgiving than MS Excel when passing non-numeric values to this function.
* A Month name or abbreviation (English only at this point) such as 'January' or 'Jan' will still be accepted,
* as will a day value with a suffix (e.g. '21st' rather than simply 21); again only English language.
*
@@ -46,13 +46,22 @@ class DateValue
return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $dateValue);
}
return self::fromString2($dateValue, null);
}
/**
* @return array<mixed>|DateTime|float|int|string Excel date/time serial value, PHP date/time serial value or PHP date/time object,
* depending on the value of the ReturnDateType flag
*/
public static function fromString2(null|string|int|bool|float $dateValue, ?int $calendar = null): array|string|float|int|DateTime
{
// try to parse as date iff there is at least one digit
if (is_string($dateValue) && preg_match('/\d/', $dateValue) !== 1) {
return ExcelError::VALUE();
}
$dti = new DateTimeImmutable();
$baseYear = SharedDateHelper::getExcelCalendar();
$baseYear = $calendar ?? SharedDateHelper::getExcelCalendar();
$dateValue = trim((string) $dateValue, '"');
// Strip any ordinals because they're allowed in Excel (English only)
$dateValue = (string) preg_replace('/(\d)(st|nd|rd|th)([ -\/])/Ui', '$1$3', $dateValue);
@@ -86,7 +95,7 @@ class DateValue
return self::finalResults($PHPDateArray, $dti, $baseYear);
}
/** @param mixed[] $t1 */
/** @param list<float|int|string> $t1 */
private static function t1ToString(array $t1, DateTimeImmutable $dti, bool $yearFound): string
{
if (count($t1) == 2) {
@@ -118,7 +127,7 @@ class DateValue
if (!Helpers::dateParseSucceeded($PHPDateArray)) {
// If original count was 1, we've already returned.
// If it was 2, we added another.
// Therefore, neither of the first 2 stroks below can fail.
// Therefore, neither of the first 2 strtoks below can fail.
$testVal1 = strtok($dateValue, '- ');
$testVal2 = strtok('- ');
$testVal3 = strtok('- ') ?: $dti->format('Y');
@@ -160,9 +169,9 @@ class DateValue
$day = self::getInt($PHPDateArray, 'day');
$year = self::getInt($PHPDateArray, 'year');
if (!checkdate($month, $day, $year)) {
return ($year === 1900 && $month === 2 && $day === 29) ? Helpers::returnIn3FormatsFloat(60.0) : ExcelError::VALUE();
return ($year === 1900 && $month === 2 && $day === 29) ? Helpers::returnIn3FormatsFloat(60.0, calendar: $baseYear) : ExcelError::VALUE();
}
$retValue = Helpers::returnIn3FormatsArray($PHPDateArray, true);
$retValue = Helpers::returnIn3FormatsArray($PHPDateArray, true, calendar: $baseYear);
}
return $retValue;
@@ -50,11 +50,9 @@ class Days
$days = ExcelError::VALUE();
$diff = $PHPStartDateObject->diff($PHPEndDateObject);
if (!is_bool($diff->days)) {
$days = $diff->days;
if ($diff->invert) {
$days = -$days;
}
$days = $diff->days;
if ($diff->invert) {
$days = -$days;
}
return $days;
@@ -76,7 +76,7 @@ class Days360
}
/**
* Return the number of days between two dates based on a 360 day calendar.
* Return the number of days between two dates based on a 360-day calendar.
*/
private static function dateDiff360(int $startDay, int $startMonth, int $startYear, int $endDay, int $endMonth, int $endYear, bool $methodUS): int
{
@@ -7,6 +7,7 @@ use PhpOffice\PhpSpreadsheet\Calculation\Exception;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDateHelper;
use Throwable;
class Helpers
{
@@ -29,10 +30,10 @@ class Helpers
*
* @return float Excel date/time serial value
*/
public static function getDateValue(mixed $dateValue, bool $allowBool = true): float
public static function getDateValue(mixed $dateValue, bool $allowBool = true, ?int $calendar = null): float
{
if (is_object($dateValue)) {
$retval = SharedDateHelper::PHPToExcel($dateValue);
$retval = SharedDateHelper::PHPToExcel($dateValue, calendar: $calendar);
if (is_bool($retval)) {
throw new Exception(ExcelError::VALUE());
}
@@ -40,7 +41,7 @@ class Helpers
return $retval;
}
self::nullFalseTrueToNumber($dateValue, $allowBool);
self::nullFalseTrueToNumber($dateValue, $allowBool, $calendar);
if (!is_numeric($dateValue)) {
$saveReturnDateType = Functions::getReturnDateType();
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
@@ -56,6 +57,12 @@ class Helpers
throw new Exception(ExcelError::NAN());
}
try {
SharedDateHelper::excelToDateTimeObject((float) $dateValue, calendar: $calendar);
} catch (Throwable) {
throw new Exception(ExcelError::NAN());
}
return (float) $dateValue;
}
@@ -80,10 +87,10 @@ class Helpers
*
* @param float|int $dateValue date to be adjusted
*/
public static function adjustDateByMonths($dateValue = 0, float $adjustmentMonths = 0): DateTime
public static function adjustDateByMonths($dateValue = 0, float $adjustmentMonths = 0, ?int $calendar = null): DateTime
{
// Execute function
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue, calendar: $calendar);
$oMonth = (int) $PHPDateObject->format('m');
$oYear = (int) $PHPDateObject->format('Y');
@@ -134,7 +141,7 @@ class Helpers
*
* @param array{year: int, month: int, day: int, hour: int, minute: int, second: int} $dateArray
*/
public static function returnIn3FormatsArray(array $dateArray, bool $noFrac = false): DateTime|float|int
public static function returnIn3FormatsArray(array $dateArray, bool $noFrac = false, ?int $calendar = null): DateTime|float|int
{
$retType = Functions::getReturnDateType();
if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
@@ -154,58 +161,60 @@ class Helpers
$dateArray['day'],
$dateArray['hour'],
$dateArray['minute'],
$dateArray['second']
$dateArray['second'],
calendar: $calendar
);
if ($retType === Functions::RETURNDATE_EXCEL) {
return $noFrac ? floor($excelDateValue) : $excelDateValue;
}
// RETURNDATE_UNIX_TIMESTAMP)
return SharedDateHelper::excelToTimestamp($excelDateValue);
return SharedDateHelper::excelToTimestamp($excelDateValue, calendar: $calendar);
}
/**
* Return result in one of three formats.
*/
public static function returnIn3FormatsFloat(float $excelDateValue): float|int|DateTime
public static function returnIn3FormatsFloat(float $excelDateValue, ?int $calendar = null): float|int|DateTime
{
$retType = Functions::getReturnDateType();
if ($retType === Functions::RETURNDATE_EXCEL) {
return $excelDateValue;
}
if ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
return SharedDateHelper::excelToTimestamp($excelDateValue);
return SharedDateHelper::excelToTimestamp($excelDateValue, calendar: $calendar);
}
// RETURNDATE_PHP_DATETIME_OBJECT
return SharedDateHelper::excelToDateTimeObject($excelDateValue);
return SharedDateHelper::excelToDateTimeObject($excelDateValue, calendar: $calendar);
}
/**
* Return result in one of three formats.
*/
public static function returnIn3FormatsObject(DateTime $PHPDateObject): DateTime|float|int
public static function returnIn3FormatsObject(DateTime $PHPDateObject, ?int $calendar = null): DateTime|float|int
{
$retType = Functions::getReturnDateType();
if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
return $PHPDateObject;
}
if ($retType === Functions::RETURNDATE_EXCEL) {
return (float) SharedDateHelper::PHPToExcel($PHPDateObject);
return (float) SharedDateHelper::PHPToExcel($PHPDateObject, calendar: $calendar);
}
// RETURNDATE_UNIX_TIMESTAMP
$stamp = SharedDateHelper::PHPToExcel($PHPDateObject);
$stamp = SharedDateHelper::PHPToExcel($PHPDateObject, calendar: $calendar);
$stamp = is_bool($stamp) ? ((int) $stamp) : $stamp;
return SharedDateHelper::excelToTimestamp($stamp);
return SharedDateHelper::excelToTimestamp($stamp, calendar: $calendar);
}
private static function baseDate(): int
private static function baseDate(?int $calendar): int
{
if (Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE) {
return 0;
}
if (SharedDateHelper::getExcelCalendar() === SharedDateHelper::CALENDAR_MAC_1904) {
$calendar ??= SharedDateHelper::getExcelCalendar();
if ($calendar === SharedDateHelper::CALENDAR_MAC_1904) {
return 0;
}
@@ -215,10 +224,10 @@ class Helpers
/**
* Many functions accept null/false/true argument treated as 0/0/1.
*/
public static function nullFalseTrueToNumber(mixed &$number, bool $allowBool = true): void
public static function nullFalseTrueToNumber(mixed &$number, bool $allowBool = true, ?int $calendar = null): void
{
$number = Functions::flattenSingleValue($number);
$nullVal = self::baseDate();
$nullVal = self::baseDate($calendar);
if ($number === null) {
$number = $nullVal;
} elseif ($allowBool && is_bool($number)) {
@@ -4,7 +4,9 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel;
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
use PhpOffice\PhpSpreadsheet\Calculation\Exception;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDateHelper;
use Throwable;
class TimeParts
{
@@ -44,6 +46,11 @@ class TimeParts
}
// Execute function
try {
SharedDateHelper::excelToDateTimeObject($timeValue);
} catch (Throwable) {
return ExcelError::NAN();
}
$timeValue = fmod($timeValue, 1);
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
SharedDateHelper::roundMicroseconds($timeValue);
@@ -85,6 +92,11 @@ class TimeParts
}
// Execute function
try {
SharedDateHelper::excelToDateTimeObject($timeValue);
} catch (Throwable) {
return ExcelError::NAN();
}
$timeValue = fmod($timeValue, 1);
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
SharedDateHelper::roundMicroseconds($timeValue);
@@ -126,6 +138,11 @@ class TimeParts
}
// Execute function
try {
SharedDateHelper::excelToDateTimeObject($timeValue);
} catch (Throwable) {
return ExcelError::NAN();
}
$timeValue = fmod($timeValue, 1);
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
SharedDateHelper::roundMicroseconds($timeValue);
@@ -3,7 +3,7 @@
namespace PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel;
use Composer\Pcre\Preg;
use Datetime;
use DateTime;
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
@@ -45,12 +45,12 @@ class TimeValue
* Date information in time_text is ignored.
* Or can be an array of date/time values
*
* @return array<mixed>|Datetime|float|int|string Excel date/time serial value, PHP date/time serial value or PHP date/time object,
* @return array<mixed>|DateTime|float|int|string Excel date/time serial value, PHP date/time serial value or PHP date/time object,
* depending on the value of the ReturnDateType flag
* If an array of numbers is passed as the argument, then the returned result will also be an array
* with the same dimensions
*/
public static function fromString(null|array|string|int|bool|float $timeValue): array|string|Datetime|int|float
public static function fromString(null|array|string|int|bool|float $timeValue): array|string|DateTime|int|float
{
if (is_array($timeValue)) {
return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $timeValue);
@@ -88,7 +88,7 @@ class TimeValue
} elseif ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
$retValue = (int) SharedDateHelper::excelToTimestamp($excelDateValue + 25569) - 3600;
} else {
$retValue = new Datetime('1900-01-01 ' . $PHPDateArray['hour'] . ':' . $PHPDateArray['minute'] . ':' . $PHPDateArray['second']);
$retValue = new DateTime('1900-01-01 ' . $PHPDateArray['hour'] . ':' . $PHPDateArray['minute'] . ':' . $PHPDateArray['second']);
}
}
@@ -78,8 +78,8 @@ class Logger
{
// Only write the debug log if logging is enabled
if ($this->writeDebugLog) {
$message = sprintf($message, ...$args); //* @phpstan-ignore-line
$cellReference = implode(' -> ', $this->cellStack->showStack());
$message = sprintf($message, ...$args); //* @phpstan-ignore argument.type (...$args could contain items that sprintf can't handle)
$cellReference = implode(' -> ', $this->cellStack->showStack()); //* @phpstan-ignore argument.type (showStack can return array<mixed> not array<string>)
if ($this->echoDebugLog) {
echo $cellReference,
($this->cellStack->count() > 0 ? ' => ' : ''),
@@ -204,8 +204,9 @@ final class StructuredReference implements Operand, Stringable
/** @var string $reference */
$reference = str_replace('[' . self::ITEM_SPECIFIER_THIS_ROW . '],', '', $reference);
foreach ($this->columns as $columnId => $columnName) {
$columnName = str_replace("\u{a0}", ' ', $columnName); //* @phpstan-ignore-line
foreach ($this->columns as $columnId => $columnNamex) {
/** @var string $columnNamex */
$columnName = str_replace("\u{a0}", ' ', $columnNamex);
$reference = $this->adjustRowReference($columnName, $reference, $cell, $columnId);
}
@@ -338,8 +339,9 @@ final class StructuredReference implements Operand, Stringable
private function getColumnsForColumnReference(string $reference, int $startRow, int $endRow): string
{
$columnsSelected = false;
foreach ($this->columns as $columnId => $columnName) {
$columnName = str_replace("\u{a0}", ' ', $columnName ?? ''); //* @phpstan-ignore-line
foreach ($this->columns as $columnId => $columnNamex) {
/** @var ?string $columnNamex */
$columnName = str_replace("\u{a0}", ' ', $columnNamex ?? '');
$cellFrom = "{$columnId}{$startRow}";
$cellTo = "{$columnId}{$endRow}";
$cellReference = ($cellFrom === $cellTo) ? $cellFrom : "{$cellFrom}:{$cellTo}";
@@ -150,7 +150,7 @@ class ConvertDecimal extends ConvertBase
/**
* toOctal.
*
* Return an decimal value as octal.
* Return a decimal value as octal.
*
* Excel Function:
* DEC2OCT(x[,places])
@@ -18,7 +18,7 @@ class ErfC
* Note: In Excel 2007 or earlier, if you input a negative value for the lower bound argument,
* the function would return a #NUM! error. However, in Excel 2010, the function algorithm was
* improved, so that it can now calculate the function for both positive and negative x values.
* PhpSpreadsheet follows Excel 2010 behaviour, and accepts nagative arguments.
* PhpSpreadsheet follows Excel 2010 behaviour, and accepts negative arguments.
*
* Excel Function:
* ERFC(x)
@@ -83,7 +83,7 @@ class Interest
* =ISPMT(interest_rate, period, number_payments, pv)
*
* @param mixed $interestRate is the interest rate for the investment
* @param mixed $period is the period to calculate the interest rate. It must be betweeen 1 and number_payments.
* @param mixed $period is the period to calculate the interest rate. It must be between 1 and number_payments.
* @param mixed $numberOfPeriods is the number of payments for the annuity
* @param mixed $principleRemaining is the loan amount or present value of the payments
*/
@@ -1,44 +0,0 @@
<?php
namespace PhpOffice\PhpSpreadsheet\Calculation\Financial\CashFlow\Constant\Periodic;
use PhpOffice\PhpSpreadsheet\Calculation\Financial\Constants as FinancialConstants;
class InterestAndPrincipal
{
protected float $interest;
protected float $principal;
public function __construct(
float $rate = 0.0,
int $period = 0,
int $numberOfPeriods = 0,
float $presentValue = 0,
float $futureValue = 0,
int $type = FinancialConstants::PAYMENT_END_OF_PERIOD
) {
$payment = Payments::annuity($rate, $numberOfPeriods, $presentValue, $futureValue, $type);
$capital = $presentValue;
$interest = 0.0;
$principal = 0.0;
for ($i = 1; $i <= $period; ++$i) {
$interest = ($type === FinancialConstants::PAYMENT_BEGINNING_OF_PERIOD && $i == 1) ? 0 : -$capital * $rate;
$principal = (float) $payment - $interest;
$capital += $principal;
}
$this->interest = $interest;
$this->principal = $principal;
}
public function interest(): float
{
return $this->interest;
}
public function principal(): float
{
return $this->principal;
}
}
@@ -166,7 +166,7 @@ class NonPeriodic
$foundneg = false;
for ($i = 0; $i < $valCount; ++$i) {
$fld = $values[$i];
if (!is_numeric($fld)) { //* @phpstan-ignore-line
if (!is_numeric($fld)) {
return ExcelError::VALUE();
} elseif ($fld > 0) {
$foundpos = true;
@@ -21,8 +21,11 @@ namespace PhpOffice\PhpSpreadsheet\Calculation;
* whether in an action of contract, tort or otherwise, arising from, out of or in connection with the
* software or the use or other dealings in the software.
*
* The following links are no longer valid.
* https://ewbi.blogs.com/develops/2007/03/excel_formula_p.html
* https://ewbi.blogs.com/develops/2004/12/excel_formula_p.html
*
* @deprecated 5.5.0 No replacement.
*/
class FormulaParser
{
@@ -463,16 +466,8 @@ class FormulaParser
$tokenCount = count($tokens1);
for ($i = 0; $i < $tokenCount; ++$i) {
$token = $tokens1[$i];
if (isset($tokens1[$i - 1])) {
$previousToken = $tokens1[$i - 1];
} else {
$previousToken = null;
}
if (isset($tokens1[$i + 1])) {
$nextToken = $tokens1[$i + 1];
} else {
$nextToken = null;
}
$previousToken = $tokens1[$i - 1] ?? null;
$nextToken = $tokens1[$i + 1] ?? null;
if ($token->getTokenType() != FormulaToken::TOKEN_TYPE_WHITESPACE) {
$tokens2[] = $token;
@@ -518,11 +513,7 @@ class FormulaParser
$tokenCount = count($tokens2);
for ($i = 0; $i < $tokenCount; ++$i) {
$token = $tokens2[$i];
if (isset($tokens2[$i - 1])) {
$previousToken = $tokens2[$i - 1];
} else {
$previousToken = null;
}
$previousToken = $tokens2[$i - 1] ?? null;
if ($token->getTokenType() == FormulaToken::TOKEN_TYPE_OPERATORINFIX && $token->getValue() == '-') {
if ($i == 0) {
@@ -21,8 +21,11 @@ namespace PhpOffice\PhpSpreadsheet\Calculation;
* whether in an action of contract, tort or otherwise, arising from, out of or in connection with the
* software or the use or other dealings in the software.
*
* The following links are no longer valid.
* https://ewbi.blogs.com/develops/2007/03/excel_formula_p.html
* https://ewbi.blogs.com/develops/2004/12/excel_formula_p.html
*
* @deprecated 5.5.0 No replacement.
*/
class FormulaToken
{
@@ -9,7 +9,7 @@ class FunctionArray extends CalculationBase
*
* @var array<string, array{category: string, functionCall: string|string[], argumentCount: string, passCellReference?: bool, passByReference?: bool[], custom?: bool}>
*/
protected static array $phpSpreadsheetFunctions = [
protected static array $phpSpreadsheetFunctions = [ // @phpstan-ignore property.defaultValue (Phpstan is wrong)
'ABS' => [
'category' => Category::CATEGORY_MATH_AND_TRIG,
'functionCall' => [MathTrig\Absolute::class, 'evaluate'],
@@ -149,7 +149,7 @@ class FunctionArray extends CalculationBase
],
'BAHTTEXT' => [
'category' => Category::CATEGORY_TEXT_AND_DATA,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [TextData\Thai::class, 'getBahtText'],
'argumentCount' => '1',
],
'BASE' => [
@@ -405,6 +405,7 @@ class FunctionArray extends CalculationBase
'category' => Category::CATEGORY_TEXT_AND_DATA,
'functionCall' => [TextData\Concatenate::class, 'actualCONCATENATE'],
'argumentCount' => '1+',
'passCellReference' => true,
],
'CONFIDENCE' => [
'category' => Category::CATEGORY_STATISTICAL,
@@ -919,7 +920,7 @@ class FunctionArray extends CalculationBase
'FLOOR' => [
'category' => Category::CATEGORY_MATH_AND_TRIG,
'functionCall' => [MathTrig\Floor::class, 'floor'],
'argumentCount' => '1-2', // Excel requries 2, Ods/Gnumeric 1-2
'argumentCount' => '1-2', // Excel requires 2, Ods/Gnumeric 1-2
],
'FLOOR.MATH' => [
'category' => Category::CATEGORY_MATH_AND_TRIG,
@@ -1284,8 +1285,9 @@ class FunctionArray extends CalculationBase
],
'INFO' => [
'category' => Category::CATEGORY_INFORMATION,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [Information\Info::class, 'getInfo'],
'argumentCount' => '1',
'passCellReference' => true,
],
'INT' => [
'category' => Category::CATEGORY_MATH_AND_TRIG,
@@ -2335,17 +2337,17 @@ class FunctionArray extends CalculationBase
],
'T.DIST' => [
'category' => Category::CATEGORY_STATISTICAL,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDist'],
'argumentCount' => '3',
],
'T.DIST.2T' => [
'category' => Category::CATEGORY_STATISTICAL,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDistDot2T'],
'argumentCount' => '2',
],
'T.DIST.RT' => [
'category' => Category::CATEGORY_STATISTICAL,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDistDotRT'],
'argumentCount' => '2',
],
'TEXT' => [
@@ -2425,12 +2427,12 @@ class FunctionArray extends CalculationBase
],
'T.INV' => [
'category' => Category::CATEGORY_STATISTICAL,
'functionCall' => [Statistical\Distributions\StudentT::class, 'inverse'],
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotInv'],
'argumentCount' => '2',
],
'T.INV.2T' => [
'category' => Category::CATEGORY_STATISTICAL,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [Statistical\Distributions\StudentT::class, 'inverse'],
'argumentCount' => '2',
],
'TODAY' => [
@@ -2495,12 +2497,12 @@ class FunctionArray extends CalculationBase
],
'UNICHAR' => [
'category' => Category::CATEGORY_TEXT_AND_DATA,
'functionCall' => [TextData\CharacterConvert::class, 'character'],
'functionCall' => [TextData\CharacterConvert::class, 'characterUnicode'],
'argumentCount' => '1',
],
'UNICODE' => [
'category' => Category::CATEGORY_TEXT_AND_DATA,
'functionCall' => [TextData\CharacterConvert::class, 'code'],
'functionCall' => [TextData\CharacterConvert::class, 'codeUnicode'],
'argumentCount' => '1',
],
'UNIQUE' => [
@@ -2577,6 +2579,7 @@ class FunctionArray extends CalculationBase
'category' => Category::CATEGORY_WEB,
'functionCall' => [Web\Service::class, 'webService'],
'argumentCount' => '1',
'passCellReference' => true,
],
'WEEKDAY' => [
'category' => Category::CATEGORY_DATE_AND_TIME,
@@ -2625,7 +2628,7 @@ class FunctionArray extends CalculationBase
],
'XLOOKUP' => [
'category' => Category::CATEGORY_LOOKUP_AND_REFERENCE,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [LookupRef\XLookup::class, 'lookup'],
'argumentCount' => '3-6',
],
'XNPV' => [
@@ -3,6 +3,7 @@
namespace PhpOffice\PhpSpreadsheet\Calculation;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
@@ -360,4 +361,47 @@ class Functions
return $coordinate;
}
/** @param mixed[] $array */
public static function convertArrayToCellRange(array $array): string
{
$retVal = '';
$lastRow = $lastColumn = $firstRow = $firstColumn = 0;
foreach ($array as $rowkey => $row) {
if (!is_array($row) || !is_int($rowkey) || $rowkey < 1) {
$firstRow = 0;
break;
}
if ($firstRow > $rowkey || $firstRow === 0) {
$firstRow = $rowkey;
}
if ($lastRow < $rowkey) {
$lastRow = $rowkey;
}
foreach ($row as $colkey => $cellValue) {
if (!preg_match('/^[A-Z]{1,3}$/', $colkey)) {
$firstRow = 0;
break 2;
}
$column = Coordinate::columnIndexFromString($colkey);
if ($firstColumn > $column || $firstColumn === 0) {
$firstColumn = $column;
}
if ($lastColumn < $column) {
$lastColumn = $column;
}
}
}
if ($firstRow > 0 && $firstColumn > 0 && ($firstRow !== $lastRow || $firstColumn !== $lastColumn)) {
$retVal = Coordinate::stringFromColumnIndex($firstColumn)
. $firstRow
. ':'
. Coordinate::stringFromColumnIndex($lastColumn)
. $lastRow;
}
return $retVal;
}
}
@@ -0,0 +1,44 @@
<?php
namespace PhpOffice\PhpSpreadsheet\Calculation\Information;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
class Info
{
/**
* @internal
*/
public static bool $infoSupported = true;
/**
* INFO.
*
* Excel Function:
* =INFO(type_text)
*
* @param mixed $typeText String specifying the type of information to be returned
* @param ?Cell $cell Cell from which spreadsheet information is retrieved
*
* @return int|string The requested information about the current operating environment
*/
public static function getInfo(mixed $typeText = '', ?Cell $cell = null): int|string
{
if (!self::$infoSupported) {
return Functions::DUMMY();
}
return match (is_string($typeText) ? strtolower($typeText) : $typeText) {
'directory' => '/',
'numfile' => $cell?->getWorksheetOrNull()?->getParent()?->getSheetCount() ?? 1,
'origin' => '$A:$A$1',
'osversion' => 'PHP ' . PHP_VERSION,
'recalc' => 'Automatic',
'release' => PHP_VERSION,
'system' => 'PHP',
'memavail', 'memused', 'totmem' => ExcelError::NA(),
default => ExcelError::VALUE(),
};
}
}
@@ -52,8 +52,10 @@ class Value
if (!empty($worksheet) && $cell->getWorksheet()->getParentOrThrow()->getSheetByName($worksheet) === null) {
return false;
}
[$column, $row] = Coordinate::indexesFromString($cellValue ?? '');
if ($column > 16384 || $row > 1048576) {
try {
[$column, $row] = Coordinate::indexesFromString($cellValue ?? ''); //* @phpstan-ignore nullCoalesce.variable (Not sure whether Phpstan is correct)
} catch (SpreadsheetException) {
return false;
}
@@ -265,7 +267,7 @@ class Value
if (is_bool($value)) {
return (int) $value;
}
if (is_string($value) && substr($value, 0, 1) === '#') {
if (is_string($value) && str_starts_with($value, '#')) {
return $value;
}
@@ -50,7 +50,7 @@ class Conditional
{
$condition = ($condition === null) ? true : Functions::flattenSingleValue($condition);
if (ErrorValue::isError($condition)) {
if (ErrorValue::isError($condition, true)) {
return $condition;
}
@@ -18,7 +18,7 @@ class ChooseRowsEtc
*/
public static function transpose(array $array): array
{
return empty($array) ? [] : (array_map((count($array) === 1) ? (fn ($x) => [$x]) : null, ...$array)); // @phpstan-ignore-line
return empty($array) ? [] : (array_map((count($array) === 1) ? (fn ($x) => [$x]) : null, ...$array)); // @phpstan-ignore argument.type (phstan is wrong), argument.type (phpstan is wrong)
}
/** @return mixed[] */
@@ -58,7 +58,7 @@ class ExcelMatch
return $e->getMessage();
}
// MATCH() is not case sensitive, so we convert lookup value to be lower cased if it's a string type.
// MATCH() is not case-sensitive, so we convert lookup value to be lower cased if it's a string type.
if (is_string($lookupValue)) {
$lookupValue = StringHelper::strToLower($lookupValue);
}
@@ -70,7 +70,7 @@ class ExcelMatch
};
if ($valueKey !== null) {
return ++$valueKey; //* @phpstan-ignore-line
return ++$valueKey; //* @phpstan-ignore preInc.type ($valueKey can be mixed not numeric)
}
// Unsuccessful in finding a match, return #N/A error value
@@ -58,7 +58,7 @@ class Filter
*/
private static function filterByRow(array $lookupArray, array $matchArray): array
{
$matchArray = array_values(array_column($matchArray, 0)); // @phpstan-ignore-line
$matchArray = array_values(array_column($matchArray, 0));
return array_filter(
array_values($lookupArray),
@@ -37,7 +37,7 @@ class Hstack
}
}
$transpose = array_map(null, ...$inputData); //* @phpstan-ignore-line
$transpose = array_map(null, ...$inputData); //* @phpstan-ignore argument.type (inputData is mixed not array)
$returnMatrix = [];
foreach ($transpose as $array) {
$returnMatrix[] = Functions::flattenArray($array);
@@ -23,6 +23,13 @@ class Hyperlink
*/
public static function set(mixed $linkURL = '', mixed $displayName = null, ?Cell $cell = null): string
{
$worksheet = null;
$coordinate = '';
if ($cell !== null) {
$coordinate = $cell->getCoordinate();
$worksheet = $cell->getWorksheetOrNull();
}
$linkURL = ($linkURL === null) ? '' : StringHelper::convertToString(Functions::flattenSingleValue($linkURL));
$displayName = ($displayName === null) ? '' : Functions::flattenSingleValue($displayName);
@@ -30,17 +37,16 @@ class Hyperlink
return ExcelError::REF();
}
if (is_object($displayName)) {
$displayName = $linkURL;
}
$displayName = StringHelper::convertToString($displayName);
$displayName = StringHelper::convertToString($displayName, false);
if (trim($displayName) === '') {
$displayName = $linkURL;
}
$cell->getHyperlink()
->setUrl($linkURL);
$cell->getHyperlink()->setTooltip($displayName);
$worksheet?->getCell($coordinate)
->getHyperlink()
->setUrl($linkURL)
->setTooltip($displayName)
->setDisplay('');
return $displayName;
}
@@ -106,14 +106,14 @@ class Indirect
*/
private static function extractRequiredCells(?Worksheet $worksheet, string $cellAddress): array
{
return Calculation::getInstance($worksheet !== null ? $worksheet->getParent() : null)
return Calculation::getInstance($worksheet?->getParent())
->extractCellRange($cellAddress, $worksheet, false, createCell: true);
}
private static function handleRowColumnRanges(?Worksheet $worksheet, string $start, string $end): string
{
// Being lazy, we're only checking a single row/column to get the max
if (ctype_digit($start) && $start <= 1048576) {
if (ctype_digit($start) && $start <= AddressRange::MAX_ROW) {
// Max 16,384 columns for Excel2007
$endColRef = ($worksheet !== null) ? $worksheet->getHighestDataColumn((int) $start) : AddressRange::MAX_COLUMN;
@@ -40,15 +40,15 @@ class Lookup
$lookupColumns = self::columnCount($lookupVector);
}
$resultVector = self::verifyResultVector($resultVector ?? $lookupVector); //* @phpstan-ignore-line
$resultVector = self::verifyResultVector($resultVector ?? $lookupVector); //* @phpstan-ignore argument.type (resultVector can be mixed not array<array<mixed>>)
if ($lookupRows === 2 && !$hasResultVector) {
$resultVector = array_pop($lookupVector);
$lookupVector = array_shift($lookupVector);
}
/** @var mixed[] $lookupVector */
/** @var mixed[] $resultVector */
/** @var array<int, mixed> $lookupVector */
/** @var array<int, mixed> $resultVector */
if ($lookupColumns !== 2) {
$lookupVector = self::verifyLookupValues($lookupVector, $resultVector);
}
@@ -57,8 +57,8 @@ class Lookup
}
/**
* @param mixed[] $lookupVector
* @param mixed[] $resultVector
* @param array<int, mixed> $lookupVector
* @param array<int, mixed> $resultVector
*
* @return mixed[]
*/
@@ -80,6 +80,7 @@ class Lookup
if (is_array($dataValue2)) {
$dataValue2 = array_shift($dataValue2);
}
/** @var int $key2 */
$value = [$key1 => $dataValue1, $key2 => $dataValue2];
}
unset($value);
@@ -11,7 +11,7 @@ class LookupRefValidations
public static function validateInt(mixed $value): int
{
if (!is_numeric($value)) {
if (is_string($value) && ErrorValue::isError($value)) {
if (is_string($value) && ErrorValue::isError($value, true)) {
throw new Exception($value);
}
@@ -125,7 +125,7 @@ class Matrix
}
$rowKeys = array_keys($matrix);
$columnKeys = @array_keys($matrix[$rowKeys[0]]); //* @phpstan-ignore-line
$columnKeys = @array_keys($matrix[$rowKeys[0]]); //* @phpstan-ignore argument.type (argument 1 of array_keys may be mixed not array)
if ($columnNum > count($columnKeys)) {
return ExcelError::REF();
@@ -135,14 +135,14 @@ class Matrix
return self::extractRowValue($matrix, $rowKeys, $rowNum);
}
$columnNum = $columnKeys[--$columnNum]; //* @phpstan-ignore-line
$columnNum = $columnKeys[--$columnNum]; //* @phpstan-ignore offsetAccess.notFound (perhaps index --$columnNum does not exist)
if ($rowNum === 0) {
return array_map(
fn ($value): array => [$value],
array_column($matrix, $columnNum)
);
}
$rowNum = $rowKeys[--$rowNum]; //* @phpstan-ignore-line
$rowNum = $rowKeys[--$rowNum]; //* @phpstan-ignore offsetAccess.notFound (perhaps index --$rowNum does not exist)
/** @var mixed[][] $matrix */
return $matrix[$rowNum][$columnNum];
@@ -150,7 +150,7 @@ class Matrix
/**
* @param mixed[] $matrix
* @param mixed[] $rowKeys
* @param array<int, int> $rowKeys
*/
private static function extractRowValue(array $matrix, array $rowKeys, int $rowNum): mixed
{
@@ -159,7 +159,7 @@ class Matrix
}
$rowNum = $rowKeys[--$rowNum];
$row = $matrix[$rowNum]; //* @phpstan-ignore-line
$row = $matrix[$rowNum];
if (is_array($row)) {
return [$rowNum => $row];
}
@@ -84,7 +84,7 @@ class Offset
$endCellColumn = self::adjustEndCellColumnForWidth($endCellColumn, $width, $startCellColumn, $columns);
$startCellColumn = Coordinate::stringFromColumnIndex($startCellColumn + 1);
$endCellRow = self::adustEndCellRowForHeight($height, $startCellRow, $rows, $endCellRow);
$endCellRow = self::adjustEndCellRowForHeight($height, $startCellRow, $rows, $endCellRow);
if (($endCellRow <= 0) || ($endCellColumn < 0)) {
return ExcelError::REF();
@@ -102,7 +102,7 @@ class Offset
/** @return mixed[] */
private static function extractRequiredCells(?Worksheet $worksheet, string $cellAddress): array
{
return Calculation::getInstance($worksheet !== null ? $worksheet->getParent() : null)
return Calculation::getInstance($worksheet?->getParent())
->extractCellRange($cellAddress, $worksheet, false);
}
@@ -152,7 +152,7 @@ class Offset
* @param null|object|scalar $height
* @param scalar $rows
*/
private static function adustEndCellRowForHeight($height, int $startCellRow, $rows, int $endCellRow): int
private static function adjustEndCellRowForHeight($height, int $startCellRow, $rows, int $endCellRow): int
{
if (($height !== null) && (!is_object($height))) {
$endCellRow = $startCellRow + (int) $height - 1;
@@ -5,6 +5,7 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
use PhpOffice\PhpSpreadsheet\Cell\AddressRange;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException;
@@ -106,7 +107,7 @@ class RowColumnInformation
if (self::cellAddressNullOrWhitespace($cellAddress)) {
return 1;
}
if (is_string($cellAddress) && ErrorValue::isError($cellAddress)) {
if (is_string($cellAddress) && ErrorValue::isError($cellAddress, true)) {
return $cellAddress;
}
if (!is_array($cellAddress)) {
@@ -131,7 +132,7 @@ class RowColumnInformation
private static function convert0ToName(int|string $result): int|string
{
if (is_int($result) && ($result <= 0 || $result > 1048576)) {
if (is_int($result) && ($result <= 0 || $result > AddressRange::MAX_ROW)) {
return ExcelError::NAME();
}
@@ -211,7 +212,7 @@ class RowColumnInformation
if (self::cellAddressNullOrWhitespace($cellAddress)) {
return 1;
}
if (is_string($cellAddress) && ErrorValue::isError($cellAddress)) {
if (is_string($cellAddress) && ErrorValue::isError($cellAddress, true)) {
return $cellAddress;
}
if (!is_array($cellAddress)) {
@@ -37,7 +37,7 @@ class Selection
} else {
return ExcelError::VALUE();
}
$chosenEntry = floor($chosenEntry);
$chosenEntry = (int) floor($chosenEntry);
if (($chosenEntry < 0) || ($chosenEntry > $entryCount)) {
return ExcelError::VALUE();
}
@@ -2,7 +2,6 @@
namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
use PhpOffice\PhpSpreadsheet\Calculation\Exception;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
@@ -20,6 +19,12 @@ class Sort extends LookupRefValidations
* The returned array is the same shape as the provided array argument.
* Both $sortIndex and $sortOrder can be arrays, to provide multi-level sorting.
*
* NOTE: If $sortArray contains a mixture of data types
* (string/int/bool), the results may be unexpected.
* This is also true if the array consists of string
* representations of numbers, especially if there are
* both positive and negative numbers in the mix.
*
* @param mixed $sortArray The range of cells being sorted
* @param mixed $sortIndex The column or row number within the sortArray to sort on
* @param mixed $sortOrder Flag indicating whether to sort ascending or descending
@@ -32,8 +37,7 @@ class Sort extends LookupRefValidations
public static function sort(mixed $sortArray, mixed $sortIndex = 1, mixed $sortOrder = self::ORDER_ASCENDING, mixed $byColumn = false): mixed
{
if (!is_array($sortArray)) {
// Scalars are always returned "as is"
return $sortArray;
$sortArray = [[$sortArray]];
}
/** @var mixed[][] */
@@ -55,7 +59,7 @@ class Sort extends LookupRefValidations
return $e->getMessage();
}
// We want a simple, enumrated array of arrays where we can reference column by its index number.
// We want a simple, enumerated array of arrays where we can reference column by its index number.
/** @var callable(mixed): mixed */
$temp = 'array_values';
/** @var array<int> $sortOrder */
@@ -72,6 +76,18 @@ class Sort extends LookupRefValidations
* The SORTBY function sorts the contents of a range or array based on the values in a corresponding range or array.
* The returned array is the same shape as the provided array argument.
* Both $sortIndex and $sortOrder can be arrays, to provide multi-level sorting.
* Microsoft doesn't even bother documenting that a column sort
* is possible. However, it is. According to:
* https://exceljet.net/functions/sortby-function
* When by_array is a horizontal range, SORTBY sorts horizontally by columns.
* My interpretation of this is that by_array must be an
* array which contains exactly one row.
*
* NOTE: If the "byArray" contains a mixture of data types
* (string/int/bool), the results may be unexpected.
* This is also true if the array consists of string
* representations of numbers, especially if there are
* both positive and negative numbers in the mix.
*
* @param mixed $sortArray The range of cells being sorted
* @param mixed $args
@@ -87,8 +103,16 @@ class Sort extends LookupRefValidations
public static function sortBy(mixed $sortArray, mixed ...$args): mixed
{
if (!is_array($sortArray)) {
// Scalars are always returned "as is"
return $sortArray;
$sortArray = [[$sortArray]];
}
$transpose = false;
$args0 = $args[0] ?? null;
if (is_array($args0) && count($args0) === 1) {
$args0 = reset($args0);
if (is_array($args0) && count($args0) > 1) {
$transpose = true;
$sortArray = Matrix::transpose($sortArray);
}
}
$sortArray = self::enumerateArrayKeys($sortArray);
@@ -99,14 +123,23 @@ class Sort extends LookupRefValidations
try {
$sortBy = $sortOrder = [];
for ($i = 0; $i < $argumentCount; $i += 2) {
$sortBy[] = self::validateSortVector($args[$i], $lookupArraySize);
$argsI = $args[$i];
if (!is_array($argsI)) {
$argsI = [[$argsI]];
}
$sortBy[] = self::validateSortVector($argsI, $lookupArraySize);
$sortOrder[] = self::validateSortOrder($args[$i + 1] ?? self::ORDER_ASCENDING);
}
} catch (Exception $e) {
return $e->getMessage();
}
return self::processSortBy($sortArray, $sortBy, $sortOrder);
$temp = self::processSortBy($sortArray, $sortBy, $sortOrder);
if ($transpose) {
$temp = Matrix::transpose($temp);
}
return $temp;
}
/**
@@ -130,10 +163,6 @@ class Sort extends LookupRefValidations
private static function validateScalarArgumentsForSort(mixed &$sortIndex, mixed &$sortOrder, int $sortArraySize): void
{
if (is_array($sortIndex) || is_array($sortOrder)) {
throw new Exception(ExcelError::VALUE());
}
$sortIndex = self::validatePositiveInt($sortIndex, false);
if ($sortIndex > $sortArraySize) {
@@ -143,13 +172,13 @@ class Sort extends LookupRefValidations
$sortOrder = self::validateSortOrder($sortOrder);
}
/** @return mixed[] */
private static function validateSortVector(mixed $sortVector, int $sortArraySize): array
/**
* @param mixed[] $sortVector
*
* @return mixed[]
*/
private static function validateSortVector(array $sortVector, int $sortArraySize): array
{
if (!is_array($sortVector)) {
throw new Exception(ExcelError::VALUE());
}
// It doesn't matter if it's a row or a column vectors, it works either way
$sortVector = Functions::flattenArray($sortVector);
if (count($sortVector) !== $sortArraySize) {
@@ -203,12 +232,19 @@ class Sort extends LookupRefValidations
*/
private static function prepareSortVectorValues(array $sortVector): array
{
// Strings should be sorted case-insensitive; with booleans converted to locale-strings
// Strings should be sorted case-insensitive.
// Booleans are a complete mess. Excel always seems to sort
// booleans in a mixed vector at either the top or the bottom,
// so converting them to string or int doesn't really work.
// Best advice is to use them in a boolean-only vector.
// Code below chooses int conversion, which is sensible,
// and, as a bonus, compatible with LibreOffice.
return array_map(
function ($value) {
if (is_bool($value)) {
return ($value) ? Calculation::getTRUE() : Calculation::getFALSE();
} elseif (is_string($value)) {
return (int) $value;
}
if (is_string($value)) {
return StringHelper::strToLower($value);
}
@@ -80,7 +80,7 @@ class TorowTocol
continue;
}
$cell = 0;
} elseif (ErrorValue::isError($cell)) {
} elseif (ErrorValue::isError($cell, true)) {
if ($ignore === 2 || $ignore === 3) {
continue;
}
@@ -40,7 +40,7 @@ class Unique
// so implode each row into a single string value
array_walk(
$lookupVector,
//* @phpstan-ignore-next-line
// @phpstan-ignore argument.type (not sure what is needed)
function (array &$value): void {
$valuex = '';
$separator = '';
@@ -79,7 +79,7 @@ class Unique
if (str_ends_with($stringValue, "\x01")) {
// x01 should only end a string which is otherwise a float or int,
// so phpstan is technically correct but what it fears should not happen.
$stringValue = 0 + substr($stringValue, 0, -1); //@phpstan-ignore-line
$stringValue = 0 + substr($stringValue, 0, -1); //@phpstan-ignore binaryOp.invalid (stringValue might not be numeric?)
}
}
}
@@ -20,7 +20,7 @@ class Vstack
if (!is_array($matrix)) {
$count = 1;
} else {
$count = count(reset($matrix)); //* @phpstan-ignore-line
$count = count(reset($matrix)); //* @phpstan-ignore argument.type (reset can theoretically return mixed not array)
}
$columns = max($columns, $count);
}
@@ -0,0 +1,275 @@
<?php
namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
use PhpOffice\PhpSpreadsheet\Calculation\Exception;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
use UnhandledMatchError;
class XLookup extends LookupBase
{
//use ArrayEnabled; // not yet supported
/**
* XLOOKUP — PHP emulation of Excel's XLOOKUP function.
*
* @param mixed $lookupValue Value to search for
* @param mixed $lookupArray Expect array, Array to search in
* @param mixed $returnArray Expect array, Array to return from (must match lookupArray size)
* @param mixed $ifNotFound Value to return if no match found (default: #N/A!)
* @param mixed $matchMode expect int 0 = exact match (default)
* -1 = exact or next smaller
* 1 = exact or next larger
* 2 = wildcard match (* ? ~)
* @param mixed $searchMode expect int 1 = first to last (default)
* -1 = last to first
* 2 = binary search ascending
* -2 = binary search descending
*/
public static function lookup(
mixed $lookupValue,
mixed $lookupArray,
mixed $returnArray,
mixed $ifNotFound = '#N/A!',
mixed $matchMode = 0,
mixed $searchMode = 1
): mixed {
if (is_array($lookupValue)) {
$lookupValue = Functions::flattenArray($lookupValue);
if (count($lookupValue) === 1) {
$lookupValue = reset($lookupValue);
}
}
if (is_array($lookupValue)) {
$result = [];
foreach ($lookupValue as $value) {
$result[] = self::lookup($value, $lookupArray, $returnArray, $ifNotFound, $matchMode, $searchMode);
}
return $result;
}
if (!is_array($lookupArray)) {
$lookupArray = [$lookupArray];
}
if (!is_array($returnArray)) {
$returnArray = [$returnArray];
}
$lookupArray = Functions::flattenArray($lookupArray);
if (count($returnArray) === 1) {
$returnArray = Functions::flattenArray($returnArray);
} else {
$oldArray = $returnArray;
$returnArray = [];
foreach ($oldArray as $row) {
$newRow = Functions::flattenArray($row);
if (count($newRow) === 1) {
$newRow = reset($newRow);
}
$returnArray[] = $newRow;
}
}
/*if (is_array($lookupValue)) { // not yet supported by ArrayEnabled
return self::evaluateArrayArgumentsIgnore([self::class, __FUNCTION__], 1, $lookupValue, $lookupArray, $returnArray, $ifNotFound, $matchMode, $searchMode);
}*/
try {
self::validateLookupArray($lookupArray);
self::validateLookupArray($returnArray);
$matchMode = LookupRefValidations::validateInt($matchMode);
$searchMode = LookupRefValidations::validateInt($searchMode);
} catch (Exception $e) {
return $e->getMessage();
}
if (!in_array($matchMode, [0, -1, 1, 2], true)) {
return ExcelError::VALUE();
}
if (count($lookupArray) !== count($returnArray)) {
return ExcelError::VALUE();
}
try {
$index = match ($searchMode) {
1 => self::searchLinear($lookupValue, $lookupArray, $matchMode, false),
-1 => self::searchLinear($lookupValue, $lookupArray, $matchMode, true),
2 => self::searchBinary($lookupValue, $lookupArray, $matchMode, true),
-2 => self::searchBinary($lookupValue, $lookupArray, $matchMode, false),
};
} catch (UnhandledMatchError) {
return ExcelError::VALUE();
}
return ($index === null) ? $ifNotFound : $returnArray[$index];
}
// ---------------------------------------------------------------------------
// Search strategies
// ---------------------------------------------------------------------------
/**
* Linear search (searchMode 1 and -1).
*
* @param mixed[] $lookupArray
*/
private static function searchLinear(
mixed $lookupValue,
array $lookupArray,
int $matchMode,
bool $reverse
): ?int {
$keys = array_keys($lookupArray);
if ($reverse) {
$keys = array_reverse($keys);
}
$bestIdx = null;
$bestVal = null;
foreach ($keys as $i) {
/** @var scalar */
$candidate = $lookupArray[$i];
if ($matchMode === 2) {
// Wildcard: convert Excel wildcards to PHP regex
/** @var scalar $lookupValue */
if (self::wildcardMatch((string) $lookupValue, (string) $candidate)) {
return $i;
}
continue;
}
$cmp = self::compareValues($candidate, $lookupValue);
if ($cmp === 0) {
return $i; // Exact match — return immediately
}
if ($matchMode === -1 && $cmp < 0) {
// Next smaller: track largest value still below lookupValue
if ($bestVal === null || self::compareValues($candidate, $bestVal) > 0) {
$bestVal = $candidate;
$bestIdx = $i;
}
}
if ($matchMode === 1 && $cmp > 0) {
// Next larger: track smallest value still above lookupValue
if ($bestVal === null || self::compareValues($candidate, $bestVal) < 0) {
$bestVal = $candidate;
$bestIdx = $i;
}
}
}
/** @var ?int $bestIdx */
return $bestIdx;
}
/**
* Binary search (searchMode 2 and -2)
* Assumes array is sorted ascending (searchMode 2) or descending (searchMode -2).
*
* @param mixed[] $lookupArray
*/
private static function searchBinary(
mixed $lookupValue,
array $lookupArray,
int $matchMode,
bool $ascending
): ?int {
$values = array_values($lookupArray);
$keys = array_keys($lookupArray);
$lo = 0;
$hi = count($values) - 1;
$bestIdx = null;
while ($lo <= $hi) {
$mid = intdiv($lo + $hi, 2);
$cmp = self::compareValues($values[$mid], $lookupValue);
if (!$ascending) {
$cmp = -$cmp; // Flip for descending
}
if ($cmp === 0) {
return $keys[$mid]; // Exact match
}
if ($cmp < 0) {
if ($matchMode === -1) {
$bestIdx = $keys[$mid]; // Candidate for next smaller
}
$lo = $mid + 1;
} else {
if ($matchMode === 1) {
$bestIdx = $keys[$mid]; // Candidate for next larger
}
$hi = $mid - 1;
}
}
/** @var int $bestIdx */
return ($matchMode !== 0) ? $bestIdx : null;
}
/**
* Compare two values with type coercion matching Excel's behaviour:
* numbers < strings < booleans
*/
private static function compareValues(mixed $a, mixed $b): int
{
// Numeric comparison
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
// String comparison (case-insensitive, like Excel)
if (is_string($a) && is_string($b)) {
return strcasecmp($a, $b);
}
// Bool comparison
if (is_bool($a) && is_bool($b)) {
return $a <=> $b;
}
// Cross-type: number < string < bool
$typeOrder = fn ($v) => match (true) {
is_numeric($v) => 0,
is_string($v) => 1,
is_bool($v) => 2,
default => 3,
};
return $typeOrder($a) <=> $typeOrder($b);
}
/**
* Wildcard match (matchMode 2)
* Supports Excel wildcards: * (any sequence), ? (any single char), ~ (escape).
*/
private static function wildcardMatch(string $pattern, string $subject): bool
{
// Handle ~* and ~? escapes first
$regex = '';
$len = strlen($pattern);
for ($i = 0; $i < $len; ++$i) {
$ch = $pattern[$i];
if ($ch === '~' && $i + 1 < $len) {
$next = $pattern[++$i];
$regex .= preg_quote($next, '/');
} elseif ($ch === '*') {
$regex .= '.*';
} elseif ($ch === '?') {
$regex .= '.';
} else {
$regex .= preg_quote($ch, '/');
}
}
return (bool) preg_match('/^' . $regex . '$/i', $subject);
}
}
@@ -57,7 +57,7 @@ class Arabic
*
* @param string|string[] $roman Should be a string, or can be an array of strings
*
* @return array<mixed>|int|string the arabic numberal contrived from the roman numeral
* @return array<mixed>|int|string the arabic numeral contrived from the roman numeral
* If an array of numbers is passed as the argument, then the returned result will also be an array
* with the same dimensions
*/
@@ -73,7 +73,7 @@ class Round
return round(
(float) (string) $number,
$digits,
RoundingMode::AwayFromZero //* @phpstan-ignore-line
RoundingMode::AwayFromZero //* @phpstan-ignore argument.type (phpstan error till we no longer support Php < 8.4)
);
}
@@ -119,7 +119,7 @@ class Round
return round(
(float) (string) $number,
$digits,
RoundingMode::TowardsZero //* @phpstan-ignore-line
RoundingMode::TowardsZero
);
}
@@ -32,6 +32,25 @@ class Subtotal
);
}
/**
* @param mixed[] $args
*
* @return mixed[]
*/
protected static function filterFilteredArgs(Cell $cellReference, array $args): array
{
return array_filter(
$args,
function ($index) use ($cellReference) {
$explodeArray = explode('.', $index);
$row = $explodeArray[1] ?? '';
return is_numeric($row) ? ($cellReference->getWorksheet()->getRowDimension((int) $row)->getVisibleAfterFilter()) : true;
},
ARRAY_FILTER_USE_KEY
);
}
/**
* @param mixed[] $args
*
@@ -125,13 +144,18 @@ class Subtotal
if ($subtotal > 100) {
$aArgs = self::filterHiddenArgs($cellReference, $aArgs);
$subtotal -= 100;
} else {
$aArgs = self::filterFilteredArgs($cellReference, $aArgs);
}
$aArgs = self::filterFormulaArgs($cellReference, $aArgs);
if (array_key_exists($subtotal, self::CALL_FUNCTIONS)) {
$call = self::CALL_FUNCTIONS[$subtotal];
return call_user_func_array($call, $aArgs); //* @phpstan-ignore-line
/** @var float|int|string */
$temp = call_user_func_array($call, $aArgs);
return $temp;
}
return ExcelError::VALUE();
@@ -59,7 +59,7 @@ class Sum
$returnValue += $arg;
} elseif (is_bool($arg)) {
$returnValue += (int) $arg;
} elseif (ErrorValue::isError($arg)) {
} elseif (ErrorValue::isError($arg, true)) {
/** @var string $arg */
return $arg;
} elseif ($arg !== null && !Functions::isCellValue($k)) {
@@ -110,6 +110,7 @@ class Sum
}
}
/** @var array<float|int> $wrkArray */
return array_sum($wrkArray);
}
}
@@ -163,7 +163,8 @@ class Averages extends AggregateBase
sort($aArgs, SORT_NUMERIC);
$valueCount = $valueCount / 2;
if ($valueCount == floor($valueCount)) {
$returnValue = ($aArgs[$valueCount--] + $aArgs[$valueCount]) / 2; //* @phpstan-ignore-line
$valueCount = (int) $valueCount;
$returnValue = ($aArgs[$valueCount--] + $aArgs[$valueCount]) / 2;
} else {
$valueCount = (int) floor($valueCount);
$returnValue = $aArgs[$valueCount];
@@ -243,7 +244,7 @@ class Averages extends AggregateBase
$maxfreqkey = $key;
$maxfreqdatum = $datum;
} elseif ($freq == $maxfreq) {
if ($frequencyArray[$key]['index'] < $frequencyArray[$maxfreqkey]['index']) { //* @phpstan-ignore-line
if ($frequencyArray[$key]['index'] < $frequencyArray[$maxfreqkey]['index']) { //* @phpstan-ignore offsetAccess.notFound (not sure what phpstan wants)
$maxfreqkey = $key;
$maxfreqdatum = $datum;
}
@@ -10,6 +10,7 @@ use PhpOffice\PhpSpreadsheet\Calculation\Database\DSum;
use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalcException;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
class Conditional
{
@@ -26,11 +27,14 @@ class Conditional
* AVERAGEIF(range,condition[, average_range])
*
* @param mixed $range Data values, expect array
* @param null|mixed[]|string $condition the criteria that defines which cells will be checked
* @param mixed $condition the criteria that defines which cells will be checked, expect null|mixed[]|string
* @param mixed $averageRange Data values
*/
public static function AVERAGEIF(mixed $range, null|array|string $condition, mixed $averageRange = []): null|int|float|string
public static function AVERAGEIF(mixed $range, mixed $condition, mixed $averageRange = []): null|int|float|string
{
if ($condition !== null && !is_array($condition)) {
$condition = StringHelper::convertToString($condition);
}
if (!is_array($range) || !is_array($averageRange) || array_key_exists(0, $range) || array_key_exists(0, $averageRange)) {
$refError = ExcelError::REF();
if (in_array($refError, [$range, $averageRange], true)) {
@@ -62,7 +66,7 @@ class Conditional
return 0.0;
}
if (count($args) === 3) {
return self::AVERAGEIF($args[1], $args[2], $args[0]); //* @phpstan-ignore-line
return self::AVERAGEIF($args[1], $args[2], $args[0]);
}
foreach ($args as $arg) {
if (is_array($arg) && array_key_exists(0, $arg)) {
@@ -28,9 +28,9 @@ class Beta
* Or can be an array of values
* @param mixed $beta Parameter to the distribution as a float
* Or can be an array of values
* @param mixed $rMin as an float
* @param mixed $rMin as a float
* Or can be an array of values
* @param mixed $rMax as an float
* @param mixed $rMax as a float
* Or can be an array of values
*
* @return array<mixed>|float|string If an array of numbers is passed as an argument, then the returned result will also be an array
@@ -116,6 +116,11 @@ class Beta
if (($alpha <= 0) || ($beta <= 0) || ($rMin == $rMax) || ($probability <= 0.0)) {
return ExcelError::NAN();
}
if (($alpha + $beta) > self::LOG_GAMMA_X_MAX_VALUE) {
// incompleteBeta declines to evaluate here and returns 0 for every x,
// so there is no quantile to search for.
return ExcelError::NAN();
}
return self::calculateInverse($probability, $alpha, $beta, $rMin, $rMax);
}
@@ -130,7 +135,7 @@ class Beta
while ((($b - $a) > Functions::PRECISION) && (++$i <= self::MAX_ITERATIONS)) {
$guess = ($a + $b) / 2;
$result = self::distribution($guess, $alpha, $beta);
if (($result === $probability) || ($result === 0.0)) {
if ($result === $probability) {
$b = $a;
} elseif ($result > $probability) {
$b = $guess;
@@ -65,7 +65,7 @@ class Binomial
/**
* BINOM.DIST.RANGE.
*
* Returns returns the Binomial Distribution probability for the number of successes from a specified number
* Returns the Binomial Distribution probability for the number of successes from a specified number
* of trials falling into a specified range.
*
* @param mixed $trials Integer number of trials
@@ -50,7 +50,7 @@ class ChiSquared
return ExcelError::NAN();
}
return 1 - (Gamma::incompleteGamma($degrees / 2, $value / 2) / Gamma::gammaValue($degrees / 2));
return Gamma::regularizedGammaQ($degrees / 2, $value / 2);
}
/**
@@ -99,8 +99,16 @@ class ChiSquared
return 1 - (is_numeric($temp) ? $temp : 0);
}
return ($value ** (($degrees / 2) - 1) * exp(-$value / 2))
/ ((2 ** ($degrees / 2)) * Gamma::gammaValue($degrees / 2));
if ($value == 0.0) {
if ($degrees === 2) {
return 0.5;
}
return ($degrees === 1) ? INF : 0.0;
}
// Log domain, so large degrees of freedom cannot overflow Gamma(d/2).
return exp((($degrees / 2) - 1) * log($value) - $value / 2 - ($degrees / 2) * M_LN2 - Gamma::logGamma($degrees / 2));
}
/**
@@ -133,8 +141,7 @@ class ChiSquared
return ExcelError::NAN();
}
$callback = fn (float $value): float => 1 - (Gamma::incompleteGamma($degrees / 2, $value / 2)
/ Gamma::gammaValue($degrees / 2));
$callback = fn (float $value): float => Gamma::regularizedGammaQ($degrees / 2, $value / 2);
$newtonRaphson = new NewtonRaphson($callback);
@@ -258,75 +265,6 @@ class ChiSquared
private static function pchisq(float $chi2, int $degrees): float
{
return self::gammp($degrees, 0.5 * $chi2);
}
private static function gammp(int $n, float $x): float
{
if ($x < 0.5 * $n + 1) {
return self::gser($n, $x);
}
return 1 - self::gcf($n, $x);
}
// Return the incomplete gamma function P(n/2,x) evaluated by
// series representation. Algorithm from numerical recipe.
// Assume that n is a positive integer and x>0, won't check arguments.
// Relative error controlled by the eps parameter
private static function gser(int $n, float $x): float
{
/** @var float $gln */
$gln = Gamma::ln($n / 2);
$a = 0.5 * $n;
$ap = $a;
$sum = 1.0 / $a;
$del = $sum;
for ($i = 1; $i < 101; ++$i) {
++$ap;
$del = $del * $x / $ap;
$sum += $del;
if ($del < $sum * self::EPS) {
break;
}
}
return $sum * exp(-$x + $a * log($x) - $gln);
}
// Return the incomplete gamma function Q(n/2,x) evaluated by
// its continued fraction representation. Algorithm from numerical recipe.
// Assume that n is a postive integer and x>0, won't check arguments.
// Relative error controlled by the eps parameter
private static function gcf(int $n, float $x): float
{
/** @var float $gln */
$gln = Gamma::ln($n / 2);
$a = 0.5 * $n;
$b = $x + 1 - $a;
$fpmin = 1.e-300;
$c = 1 / $fpmin;
$d = 1 / $b;
$h = $d;
for ($i = 1; $i < 101; ++$i) {
$an = -$i * ($i - $a);
$b += 2;
$d = $an * $d + $b;
if (abs($d) < $fpmin) {
$d = $fpmin;
}
$c = $b + $an / $c;
if (abs($c) < $fpmin) {
$c = $fpmin;
}
$d = 1 / $d;
$del = $d * $c;
$h = $h * $del;
if (abs($del - 1) < self::EPS) {
break;
}
}
return $h * exp(-$x + $a * log($x) - $gln);
return Gamma::regularizedGammaP($degrees / 2, 0.5 * $chi2);
}
}
@@ -55,9 +55,20 @@ class F
return Beta::incompleteBeta($adjustedValue, $u / 2, $v / 2);
}
return (Gamma::gammaValue(($v + $u) / 2)
/ (Gamma::gammaValue($u / 2) * Gamma::gammaValue($v / 2)))
* (($u / $v) ** ($u / 2))
* (($value ** (($u - 2) / 2)) / ((1 + ($u / $v) * $value) ** (($u + $v) / 2)));
if ($value == 0.0) {
if ($u === 2) {
return 1.0;
}
return ($u === 1) ? INF : 0.0;
}
// Log domain, so large degrees of freedom cannot overflow the Gamma ratio.
return exp(
Gamma::logGamma(($v + $u) / 2) - Gamma::logGamma($u / 2) - Gamma::logGamma($v / 2)
+ ($u / 2) * log($u / $v)
+ (($u - 2) / 2) * log($value)
- (($u + $v) / 2) * log(1 + ($u / $v) * $value)
);
}
}
@@ -85,6 +85,10 @@ class Gamma extends GammaBase
*
* Returns the inverse of the Gamma distribution.
*
* For a probability so far into the upper tail that the forward-CDF series
* approximation plateaus below it, the root cannot be bracketed; the result
* is capped at the alpha*beta*5 search ceiling rather than diverging.
*
* @param mixed $probability Float probability at which you want to evaluate the distribution
* Or can be an array of values
* @param mixed $alpha Parameter to the distribution as a float
@@ -143,6 +147,9 @@ class Gamma extends GammaBase
return ExcelError::NAN();
}
return log(self::gammaValue($value));
$result = self::logGamma($value);
// logGamma returns its MAX_VALUE sentinel when the result would overflow.
return ($result >= self::MAX_VALUE) ? ExcelError::NAN() : $result;
}
}
@@ -11,7 +11,7 @@ abstract class GammaBase
private const EPS = 2.22e-16;
private const MAX_VALUE = 1.2e308;
protected const MAX_VALUE = 1.2e308;
private const SQRT2PI = 2.5066282746310005024157652848110452530069867406099;
@@ -20,10 +20,19 @@ abstract class GammaBase
protected static function calculateDistribution(float $value, float $a, float $b, bool $cumulative): float
{
if ($cumulative) {
return self::incompleteGamma($a, $value / $b) / self::gammaValue($a);
return self::regularizedGammaP($a, $value / $b);
}
return (1 / ($b ** $a * self::gammaValue($a))) * $value ** ($a - 1) * exp(0 - ($value / $b));
if ($value == 0.0) {
if ($a == 1.0) {
return 1.0 / $b;
}
return ($a < 1.0) ? INF : 0.0;
}
// Log domain, so large shape parameters cannot overflow Gamma(a).
return exp(($a - 1.0) * log($value) - $value / $b - $a * log($b) - self::logGamma($a));
}
/** @return float|string */
@@ -32,6 +41,24 @@ abstract class GammaBase
$xLo = 0;
$xHi = $alpha * $beta * 5;
// Extend the upper bound while it does not yet bracket the root, so a
// tail quantile beyond alpha*beta*5 is no longer clamped to it. Stop if
// the CDF stops increasing (series approximation past its usable range)
// and keep the original bound rather than expanding into that region.
$xHiBase = $xHi;
$cdfHi = self::calculateDistribution($xHi, $alpha, $beta, true);
while ($cdfHi < $probability) {
$xHiNext = $xHi * 2;
$cdfNext = self::calculateDistribution($xHiNext, $alpha, $beta, true);
if ($cdfNext <= $cdfHi) {
$xHi = $xHiBase;
break;
}
$xHi = $xHiNext;
$cdfHi = $cdfNext;
}
$dx = 1024;
$x = $xNew = 1;
$i = 0;
@@ -78,17 +105,98 @@ abstract class GammaBase
//
public static function incompleteGamma(float $a, float $x): float
{
static $max = 32;
$summer = 0;
for ($n = 0; $n <= $max; ++$n) {
$divisor = $a;
for ($i = 1; $i <= $n; ++$i) {
$divisor *= ($a + $i);
}
$summer += ($x ** $n / $divisor);
// Unregularized lower incomplete gamma; kept for backward compatibility.
return self::regularizedGammaP($a, $x) * self::gammaValue($a);
}
/**
* Regularized lower incomplete gamma P(a,x) = gamma(a,x) / Gamma(a).
* Series for x < a+1, else the complement of the continued fraction.
*/
public static function regularizedGammaP(float $a, float $x): float
{
if ($x <= 0.0 || $a <= 0.0) {
return 0.0;
}
if ($x < $a + 1.0) {
return self::gammaSeries($a, $x);
}
return $x ** $a * exp(0 - $x) * $summer;
return 1.0 - self::gammaContinuedFraction($a, $x);
}
/**
* Regularized upper incomplete gamma Q(a,x) = 1 - P(a,x).
* Continued fraction for x >= a+1 keeps the right tail free of cancellation.
*/
public static function regularizedGammaQ(float $a, float $x): float
{
if ($x <= 0.0 || $a <= 0.0) {
return 1.0;
}
if ($x < $a + 1.0) {
return 1.0 - self::gammaSeries($a, $x);
}
return self::gammaContinuedFraction($a, $x);
}
// Near x ~ a both expansions need O(sqrt(a)) terms to reach EPS.
private static function incompleteGammaIterations(float $a): int
{
return max(self::MAX_ITERATIONS, (int) ceil(10.0 * sqrt($a)));
}
// P(a,x) by its series representation (Numerical Recipes gser).
private static function gammaSeries(float $a, float $x): float
{
$maxIterations = self::incompleteGammaIterations($a);
$gln = self::logGamma($a);
$ap = $a;
$sum = 1.0 / $a;
$del = $sum;
for ($i = 1; $i <= $maxIterations; ++$i) {
++$ap;
$del *= $x / $ap;
$sum += $del;
if (abs($del) < abs($sum) * self::EPS) {
break;
}
}
return $sum * exp(-$x + $a * log($x) - $gln);
}
// Q(a,x) by its continued fraction representation (Numerical Recipes gcf).
private static function gammaContinuedFraction(float $a, float $x): float
{
$maxIterations = self::incompleteGammaIterations($a);
$fpMin = 1.0e-300;
$gln = self::logGamma($a);
$b = $x + 1.0 - $a;
$c = 1.0 / $fpMin;
$d = 1.0 / $b;
$h = $d;
for ($i = 1; $i <= $maxIterations; ++$i) {
$an = -$i * ($i - $a);
$b += 2.0;
$d = $an * $d + $b;
if (abs($d) < $fpMin) {
$d = $fpMin;
}
$c = $b + $an / $c;
if (abs($c) < $fpMin) {
$c = $fpMin;
}
$d = 1.0 / $d;
$del = $d * $c;
$h *= $del;
if (abs($del - 1.0) < self::EPS) {
break;
}
}
return $h * exp(-$x + $a * log($x) - $gln);
}
private const GAMMA_VALUE_P0 = 1.000000000190015;
@@ -116,7 +116,7 @@ class Normal
// whatever purpose you want, but please show common courtesy and give credit
// where credit is due.
// Input paramater is $p - probability - where 0 < p < 1.
// Input parameter is $p - probability - where 0 < p < 1.
// Coefficients in rational approximations
/** @var array<int, float> */
@@ -4,9 +4,15 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\Statistical\Distributions;
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
use PhpOffice\PhpSpreadsheet\Calculation\Exception;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
/**
* Some of this code is drived from Perl CPAN Statistical::Distributions.
* Its copyright statement is:
* Copyright 2003 Michael Kospach. All rights reserved.
*
* This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.
*/
class StudentT
{
use ArrayEnabled;
@@ -28,9 +34,39 @@ class StudentT
* with the same dimensions
*/
public static function distribution(mixed $value, mixed $degrees, mixed $tails)
{
return self::calcDistribution($value, $degrees, $tails, self::distribution(...));
}
/**
* T.DIST.2T.
* Returns the two-tailed Student's t distribution.
*
* @return array<mixed>|float|string The result, or a string containing an error
*/
public static function tDotDistDot2T(mixed $value, mixed $degrees)
{
return self::calcDistribution($value, $degrees, 2, self::distribution(...));
}
/**
* T.DIST.RT.
* Returns the right-tailed Student's t distribution.
*
* @return array<mixed>|float|string The result, or a string containing an error
*/
public static function tDotDistDotRT(mixed $value, mixed $degrees)
{
return self::calcDistribution($value, $degrees, 1, self::distribution(...));
}
/**
* @return array<mixed>|float|string The result, or a string containing an error
*/
private static function calcDistribution(mixed $value, mixed $degrees, mixed $tails, callable $callback)
{
if (is_array($value) || is_array($degrees) || is_array($tails)) {
return self::evaluateArrayArguments([self::class, __FUNCTION__], $value, $degrees, $tails);
return self::evaluateArrayArguments($callback, $value, $degrees, $tails);
}
try {
@@ -45,13 +81,105 @@ class StudentT
return ExcelError::NAN();
}
return self::calculateDistribution($value, $degrees, $tails);
return self::subTProb($value, $degrees, $tails);
}
/**
* TINV.
* Based on code from Perl CPAN Statistical::Distributions.
*/
private static function subTProb(float $x, int $n, int $tails): float
{
$w = atan2($x / sqrt($n), 1);
$z = cos($w) ** 2;
$y = 1;
for ($i = $n - 2; $i >= 2; $i -= 2) {
$y = 1 + ($i - 1) / $i * $z * $y;
}
if ($n % 2 == 0) {
$a = sin($w) / 2;
$b = 0.5;
} else {
$a = ($n == 1) ? 0 : (sin($w) * cos($w) / M_PI);
$b = 0.5 + $w / M_PI;
}
return $tails * max(0, 1 - $b - $a * $y);
}
/**
* T.DIST.
* Returns the Student's left-tailed t distribution,
* either as a cumulative distribution function (cdf) (TRUE)
* or as a probability density function (pdf) (FALSE),
* where TRUE/FALSE are the value of $cumulative parameter.
*
* Returns the one-tailed probability of the chi-squared distribution.
* "True" algoritm adapted from java.
* org.apache.commons.math3.distribution.TDistribution.
* "False" algorithm comes from:
* https://statproofbook.github.io/P/t-pdf.html
*
* @param mixed $cumulative Expecting bool. See above for explanation.
*
* @return array<mixed>|float|string The result, or a string containing an error
*/
public static function tDotDist(mixed $value, mixed $degrees, mixed $cumulative)
{
if (is_array($value) || is_array($degrees) || is_array($cumulative)) {
return self::evaluateArrayArguments(self::tDotDist(...), $value, $degrees, $cumulative);
}
try {
$value = DistributionValidations::validateFloat($value);
$degrees = DistributionValidations::validateInt($degrees);
$cumulative = DistributionValidations::validateBool($cumulative);
} catch (Exception $e) {
return $e->getMessage();
}
/** @var int $degrees */
if (($degrees < 1)) {
return ExcelError::NAN();
}
/** @var float $value */
if (!$cumulative) {
return self::tDotDistFalse($value, $degrees);
}
$f16 = $degrees / ($degrees + $value * $value);
$g16 = 0.5 * $degrees;
$h16 = 0.5;
$result = Beta::distribution($f16, $g16, $h16);
if (is_numeric($result)) {
$result = ($value < 0) ? (0.5 * $result) : (1 - 0.5 * $result);
}
return $result;
}
private static function tDotDistFalse(float $value, int $degrees): float|string
{
$result = $k15 = Gamma::gamma(($degrees + 1) / 2);
if (is_numeric($k15)) {
$result = $k16 = Gamma::gamma($degrees / 2);
if (is_numeric($k16)) {
$k17 = sqrt(M_PI * $degrees);
$k18 = $k15 / ($k16 * $k17);
$k19 = $value * $value / $degrees + 1;
$k20 = -($degrees + 1) / 2;
$k21 = $k19 ** $k20;
$result = $k18 * $k21;
}
}
/** @var float|string $result */
return $result;
}
/**
* TINV and T.INV.2T.
* Returns the two-tailed inverse of the Student t distribution.
*
* @param mixed $probability Float probability for the function
* Or can be an array of values
@@ -63,9 +191,17 @@ class StudentT
* with the same dimensions
*/
public static function inverse(mixed $probability, mixed $degrees)
{
return self::calcInverse($probability, $degrees, 2, self::inverse(...));
}
/**
* @return array<mixed>|float|string The result, or a string containing an error
*/
private static function calcInverse(mixed $probability, mixed $degrees, int $tails, callable $callback2)
{
if (is_array($probability) || is_array($degrees)) {
return self::evaluateArrayArguments([self::class, __FUNCTION__], $probability, $degrees);
return self::evaluateArrayArguments($callback2, $probability, $degrees, $tails);
}
try {
@@ -79,54 +215,102 @@ class StudentT
return ExcelError::NAN();
}
$callback = fn ($value) => self::distribution($value, $degrees, 2);
$callback = fn ($value) => self::distribution($value, $degrees, $tails);
$newtonRaphson = new NewtonRaphson($callback);
return $newtonRaphson->execute($probability);
$result = $newtonRaphson->execute($probability);
if (is_numeric($result) && $tails === 1) {
$result = -$result; // @codeCoverageIgnore
}
return $result;
}
private static function calculateDistribution(float $value, int $degrees, int $tails): float
/**
* T.INV.
* Returns the left-tailed inverse of the Student's t distribution.
*
* Based on code from Perl CPAN Statistical::Distributions.
*
* @return array<mixed>|float|string The result, or a string containing an error
*/
public static function tDotInv(mixed $probability, mixed $degrees)
{
// tdist, which finds the probability that corresponds to a given value
// of t with k degrees of freedom. This algorithm is translated from a
// pascal function on p81 of "Statistical Computing in Pascal" by D
// Cooke, A H Craven & G M Clark (1985: Edward Arnold (Pubs.) Ltd:
// London). The above Pascal algorithm is itself a translation of the
// fortran algoritm "AS 3" by B E Cooper of the Atlas Computer
// Laboratory as reported in (among other places) "Applied Statistics
// Algorithms", editied by P Griffiths and I D Hill (1985; Ellis
// Horwood Ltd.; W. Sussex, England).
$tterm = $degrees;
$ttheta = atan2($value, sqrt($tterm));
$tc = cos($ttheta);
$ts = sin($ttheta);
if (($degrees % 2) === 1) {
$ti = 3;
$tterm = $tc;
} else {
$ti = 2;
$tterm = 1;
if (is_array($probability) || is_array($degrees)) {
return self::evaluateArrayArguments(self::tDotInv(...), $probability, $degrees);
}
$tsum = $tterm;
while ($ti < $degrees) {
$tterm *= $tc * $tc * ($ti - 1) / $ti;
$tsum += $tterm;
$ti += 2;
try {
$probability = DistributionValidations::validateProbability($probability);
$degrees = DistributionValidations::validateInt($degrees);
} catch (Exception $e) {
return $e->getMessage();
}
$tsum *= $ts;
if (($degrees % 2) == 1) {
$tsum = Functions::M_2DIVPI * ($tsum + $ttheta);
if ($degrees < 1) {
return ExcelError::NAN();
}
if ($probability == 0.5) {
return 0.0;
}
if ($probability < 0.5) {
$result = self::tDotInv(1.0 - $probability, $degrees);
return is_numeric($result) ? -$result : $result;
}
$p = $probability;
$n = $degrees;
$u = self::subU($p);
$u2 = $u ** 2;
$a = ($u2 + 1) / 4;
$b = ((5 * $u2 + 16) * $u2 + 3) / 96;
$c = (((3 * $u2 + 19) * $u2 + 17) * $u2 - 15) / 384;
$d = ((((79 * $u2 + 776) * $u2 + 1482) * $u2 - 1920) * $u2 - 945) / 92160;
$e = (((((27 * $u2 + 339) * $u2 + 930) * $u2 - 1782) * $u2 - 765) * $u2 + 17955) / 368640;
$x = $u * (1 + ($a + ($b + ($c + ($d + $e / $n) / $n) / $n) / $n) / $n);
if ($n <= log10($p) ** 2 + 3) {
do {
$p1 = self::subTProb($x, $n, 1);
$n1 = $n + 1;
$delta = ($p1 - $p)
/ exp(($n1 * log($n1 / ($n + $x * $x))
+ log($n / $n1 / 2 / M_PI) - 1
+ (1 / $n1 - 1 / $n) / 6) / 2);
$x += $delta;
$round = sprintf('%.' . abs((int) (log10(abs($x)) - 4)) . 'F', $delta);
} while (($x) && ($round != 0));
}
$tValue = 0.5 * (1 + $tsum);
if ($tails == 1) {
return 1 - abs($tValue);
return -$x;
}
/**
* Based on code from Perl CPAN Statistical::Distributions.
*/
private static function subU(float $p): float
{
$y = -log(4 * $p * (1 - $p));
$x = sqrt(
$y * (1.570796288
+ $y * (.03706987906
+ $y * (-.8364353589E-3
+ $y * (-.2250947176E-3
+ $y * (.6841218299E-5
+ $y * (0.5824238515E-5
+ $y * (-.104527497E-5
+ $y * (.8360937017E-7
+ $y * (-.3231081277E-8
+ $y * (.3657763036E-10
+ $y * .6936233982E-12))))))))))
);
if ($p > 0.5) {
$x = -$x;
}
return 1 - abs((1 - $tValue) - $tValue);
return $x;
}
}
@@ -25,7 +25,7 @@ class Maximum extends MaxMinBase
// Loop through arguments
$aArgs = Functions::flattenArray($args);
foreach ($aArgs as $arg) {
if (ErrorValue::isError($arg)) {
if (ErrorValue::isError($arg, true)) {
$returnValue = $arg;
break;
@@ -63,7 +63,7 @@ class Maximum extends MaxMinBase
// Loop through arguments
$aArgs = Functions::flattenArray($args);
foreach ($aArgs as $arg) {
if (ErrorValue::isError($arg)) {
if (ErrorValue::isError($arg, true)) {
$returnValue = $arg;
break;
@@ -25,7 +25,7 @@ class Minimum extends MaxMinBase
// Loop through arguments
$aArgs = Functions::flattenArray($args);
foreach ($aArgs as $arg) {
if (ErrorValue::isError($arg)) {
if (ErrorValue::isError($arg, true)) {
$returnValue = $arg;
break;
@@ -63,7 +63,7 @@ class Minimum extends MaxMinBase
// Loop through arguments
$aArgs = Functions::flattenArray($args);
foreach ($aArgs as $arg) {
if (ErrorValue::isError($arg)) {
if (ErrorValue::isError($arg, true)) {
$returnValue = $arg;
break;
@@ -89,6 +89,7 @@ class Percentiles
return $e->getMessage();
}
/** @var array<float|int|numeric-string> */
$valueSet = self::rankFilterValues($valueSet);
$valueCount = count($valueSet);
if ($valueCount == 0) {
@@ -172,6 +173,7 @@ class Percentiles
return $e->getMessage();
}
/** @var array<float|int|numeric-string> */
$valueSet = self::rankFilterValues($valueSet);
if ($order === self::RANK_SORT_DESCENDING) {
rsort($valueSet, SORT_NUMERIC);
@@ -6,11 +6,14 @@ use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalcExp;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
class CharacterConvert
{
use ArrayEnabled;
private static string $oneByteCharacterSet = 'Windows-1252';
/**
* CHAR.
*
@@ -27,19 +30,45 @@ class CharacterConvert
return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $character);
}
return self::characterBoth($character, true);
}
/** @return array<mixed>|string */
public static function characterUnicode(mixed $character): array|string
{
if (is_array($character)) {
return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $character);
}
return self::characterBoth($character, false);
}
private static function characterBoth(mixed $character, bool $ansi = true): string
{
try {
$character = Helpers::validateInt($character, true);
} catch (CalcExp $e) {
return $e->getMessage();
}
if ($ansi && $character === 219 && self::$oneByteCharacterSet[0] === 'M') {
return '€';
}
$min = Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE ? 0 : 1;
if ($character < $min || $character > 255) {
if ($character < $min || ($ansi && $character > 255) || $character > 0x10FFFF) {
return ExcelError::VALUE();
}
$result = iconv('UCS-4LE', 'UTF-8', pack('V', $character));
if ($character > 0x10FFFD) { // last assigned
return ExcelError::NA();
}
if ($ansi) {
$result = chr($character);
return ($result === false) ? '' : $result;
return (string) iconv(self::$oneByteCharacterSet, 'UTF-8//IGNORE', $result);
}
return mb_chr($character, 'UTF-8');
}
/**
@@ -57,7 +86,28 @@ class CharacterConvert
if (is_array($characters)) {
return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $characters);
}
if (is_bool($characters) && Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE) {
$characters = $characters ? '1' : '0';
}
return self::codeBoth(StringHelper::convertToString($characters, convertBool: true), true);
}
/** @return array<mixed>|int|string */
public static function codeUnicode(mixed $characters): array|string|int
{
if (is_array($characters)) {
return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $characters);
}
if (is_bool($characters) && Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE) {
$characters = $characters ? '1' : '0';
}
return self::codeBoth(StringHelper::convertToString($characters, convertBool: true), false);
}
private static function codeBoth(string $characters, bool $ansi = true): int|string
{
try {
$characters = Helpers::extractString($characters, true);
} catch (CalcExp $e) {
@@ -72,22 +122,27 @@ class CharacterConvert
if (mb_strlen($characters, 'UTF-8') > 1) {
$character = mb_substr($characters, 0, 1, 'UTF-8');
}
return self::unicodeToOrd($character);
}
private static function unicodeToOrd(string $character): int
{
$retVal = 0;
$iconv = iconv('UTF-8', 'UCS-4LE', $character);
if ($iconv !== false) {
/** @var false|int[] */
$result = unpack('V', $iconv);
if (is_array($result) && isset($result[1])) {
$retVal = $result[1];
}
if ($ansi && $character === '€' && self::$oneByteCharacterSet[0] === 'M') {
return 219;
}
return $retVal;
$result = mb_ord($character, 'UTF-8');
if ($ansi) {
$result = iconv('UTF-8', self::$oneByteCharacterSet . '//IGNORE', $character);
return ($result !== '') ? ord("$result") : 63; // question mark
}
return $result;
}
public static function setWindowsCharacterSet(): void
{
self::$oneByteCharacterSet = 'Windows-1252';
}
public static function setMacCharacterSet(): void
{
self::$oneByteCharacterSet = 'MAC';
}
}
@@ -7,6 +7,8 @@ use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
use PhpOffice\PhpSpreadsheet\Calculation\Internal\ExcelArrayPseudoFunctions;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
@@ -17,7 +19,7 @@ class Concatenate
/**
* This implements the CONCAT function, *not* CONCATENATE.
*
* @param mixed[] $args
* @param mixed $args data to be concatenated
*/
public static function CONCATENATE(...$args): string
{
@@ -47,17 +49,33 @@ class Concatenate
/**
* This implements the CONCATENATE function.
*
* @param mixed[] $args data to be concatenated
* @param mixed $args data to be concatenated
*
* @return array<string>|string
*/
public static function actualCONCATENATE(...$args): array|string
{
$useSingle = false;
$cell = null;
$count = count($args);
if ($args[$count - 1] instanceof Cell) {
/** @var Cell */
$cell = array_pop($args);
$type = $cell->getWorksheet()->getParent()?->getCalculationEngine()->getInstanceArrayReturnType() ?? Calculation::getArrayReturnType();
$useSingle = $type === Calculation::RETURN_ARRAY_AS_VALUE;
}
if (Functions::getCompatibilityMode() === Functions::COMPATIBILITY_GNUMERIC) {
return self::CONCATENATE(...$args);
}
$result = '';
foreach ($args as $operand2) {
if ($useSingle && $cell instanceof Cell && is_array($operand2)) {
$temp = Functions::convertArrayToCellRange($operand2);
if ($temp !== '') {
$operand2 = ExcelArrayPseudoFunctions::single($temp, $cell);
}
}
/** @var null|array<mixed>|bool|float|int|string $operand2 */
$result = self::concatenate2Args($result, $operand2);
if (ErrorValue::isError($result, true) === true) {
break;
@@ -142,6 +160,7 @@ class Concatenate
$aArgs = Functions::flattenArray($args);
$returnValue = self::evaluateTextJoinArray($ignoreEmpty, $aArgs);
/** @var string[] $aArgs */
$returnValue ??= implode($delimiter, $aArgs);
if (StringHelper::countCharacters($returnValue) > DataType::MAX_STRING_LENGTH) {
$returnValue = ExcelError::CALC();
@@ -96,16 +96,13 @@ class Format
if ($decimals < 0) {
$decimals = 0;
}
if ($noCommas === false) {
$valueResult = number_format(
$valueResult,
$decimals,
StringHelper::getDecimalSeparator(),
StringHelper::getThousandsSeparator()
);
}
return (string) $valueResult;
return number_format(
$valueResult,
$decimals,
StringHelper::getDecimalSeparator(),
$noCommas ? '' : StringHelper::getThousandsSeparator()
);
}
/**
@@ -209,18 +206,20 @@ class Format
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
if (str_contains($value, ':')) {
/** @var DateTimeInterface|float|int|string */
$timeValue = Functions::scalar(DateTimeExcel\TimeValue::fromString($value));
if ($timeValue !== ExcelError::VALUE()) {
Functions::setReturnDateType($dateSetting);
return $timeValue; //* @phpstan-ignore-line
return $timeValue;
}
}
/** @var DateTimeInterface|float|int|string */
$dateValue = Functions::scalar(DateTimeExcel\DateValue::fromString($value));
if ($dateValue !== ExcelError::VALUE()) {
Functions::setReturnDateType($dateSetting);
return $dateValue; //* @phpstan-ignore-line
return $dateValue;
}
Functions::setReturnDateType($dateSetting);
@@ -12,7 +12,7 @@ class Search
use ArrayEnabled;
/**
* FIND (case sensitive search).
* FIND (case-sensitive search).
*
* @param mixed $needle The string to look for
* Or can be an array of values
@@ -54,7 +54,7 @@ class Search
}
/**
* SEARCH (case insensitive search).
* SEARCH (case-insensitive search).
*
* @param mixed $needle The string to look for
* Or can be an array of values
@@ -171,12 +171,12 @@ class Text
{
$columnCount = array_reduce(
$rows,
fn (int $counter, array $row): int => max($counter, count($row)), //* @phpstan-ignore-line
fn (int $counter, array $row): int => max($counter, count($row)), //* @phpstan-ignore argument.type (I don't know)
0
);
return array_map(
fn (array $row): array => (count($row) < $columnCount) //* @phpstan-ignore-line
fn (array $row): array => (count($row) < $columnCount) //* @phpstan-ignore argument.type (I don't know)
? array_merge($row, array_fill(0, $columnCount - count($row), $padding))
: $row,
$rows

Some files were not shown because too many files have changed in this diff Show More