Merge pull request #2947 from PHPOffice/TextFunctions-New

Initial Implementation of the new Excel TEXTBEFORE() and TEXTAFTER() functions
This commit is contained in:
Mark Baker 2022-07-29 13:04:18 +02:00 committed by GitHub
commit e748ac7c03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 667 additions and 5 deletions

View File

@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
### Added
- Nothing
- Implementation of the `TEXTBEFORE()` and `TEXTAFTER()` Excel Functions
### Changed

View File

@ -2490,13 +2490,13 @@ class Calculation
],
'TEXTAFTER' => [
'category' => Category::CATEGORY_TEXT_AND_DATA,
'functionCall' => [Functions::class, 'DUMMY'],
'argumentCount' => '2-4',
'functionCall' => [TextData\Extract::class, 'after'],
'argumentCount' => '2-6',
],
'TEXTBEFORE' => [
'category' => Category::CATEGORY_TEXT_AND_DATA,
'functionCall' => [Functions::class, 'DUMMY'],
'argumentCount' => '2-4',
'functionCall' => [TextData\Extract::class, 'before'],
'argumentCount' => '2-6',
],
'TEXTJOIN' => [
'category' => Category::CATEGORY_TEXT_AND_DATA,

View File

@ -4,6 +4,9 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\TextData;
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 Extract
{
@ -95,4 +98,157 @@ class Extract
return mb_substr($value ?? '', mb_strlen($value ?? '', 'UTF-8') - $chars, $chars, 'UTF-8');
}
/**
* TEXTBEFORE.
*
* @param mixed $text the text that you're searching
* Or can be an array of values
* @param ?string $delimiter the text that marks the point before which you want to extract
* @param mixed $instance The instance of the delimiter after which you want to extract the text.
* By default, this is the first instance (1).
* A negative value means start searching from the end of the text string.
* Or can be an array of values
* @param mixed $matchMode Determines whether the match is case-sensitive or not.
* 0 - Case-sensitive
* 1 - Case-insensitive
* Or can be an array of values
* @param mixed $matchEnd Treats the end of text as a delimiter.
* 0 - Don't match the delimiter against the end of the text.
* 1 - Match the delimiter against the end of the text.
* Or can be an array of values
* @param mixed $ifNotFound value to return if no match is found
* The default is a #N/A Error
* Or can be an array of values
*
* @return mixed|mixed[] the string extracted from text before the delimiter; or the $ifNotFound value
* If an array of values is passed for any of the arguments, then the returned result
* will also be an array with matching dimensions
*/
public static function before($text, $delimiter, $instance = 1, $matchMode = 0, $matchEnd = 0, $ifNotFound = '#N/A')
{
if (is_array($text) || is_array($instance) || is_array($matchMode) || is_array($matchEnd) || is_array($ifNotFound)) {
return self::evaluateArrayArgumentsIgnore([self::class, __FUNCTION__], 1, $text, $delimiter, $instance, $matchMode, $matchEnd, $ifNotFound);
}
$text = Helpers::extractString($text ?? '');
$delimiter = Helpers::extractString(Functions::flattenSingleValue($delimiter ?? ''));
$instance = (int) $instance;
$matchMode = (int) $matchMode;
$matchEnd = (int) $matchEnd;
$split = self::validateTextBeforeAfter($text, $delimiter, $instance, $matchMode, $matchEnd, $ifNotFound);
if (is_array($split) === false) {
return $split;
}
if ($delimiter === '') {
return ($instance > 0) ? '' : $text;
}
// Adjustment for a match as the first element of the split
$flags = self::matchFlags($matchMode);
$adjust = preg_match('/^' . preg_quote($delimiter) . "\$/{$flags}", $split[0]);
$oddReverseAdjustment = count($split) % 2;
$split = ($instance < 0)
? array_slice($split, 0, max(count($split) - (abs($instance) * 2 - 1) - $adjust - $oddReverseAdjustment, 0))
: array_slice($split, 0, $instance * 2 - 1 - $adjust);
return implode('', $split);
}
/**
* TEXTAFTER.
*
* @param mixed $text the text that you're searching
* @param ?string $delimiter the text that marks the point before which you want to extract
* @param mixed $instance The instance of the delimiter after which you want to extract the text.
* By default, this is the first instance (1).
* A negative value means start searching from the end of the text string.
* Or can be an array of values
* @param mixed $matchMode Determines whether the match is case-sensitive or not.
* 0 - Case-sensitive
* 1 - Case-insensitive
* Or can be an array of values
* @param mixed $matchEnd Treats the end of text as a delimiter.
* 0 - Don't match the delimiter against the end of the text.
* 1 - Match the delimiter against the end of the text.
* Or can be an array of values
* @param mixed $ifNotFound value to return if no match is found
* The default is a #N/A Error
* Or can be an array of values
*
* @return mixed|mixed[] the string extracted from text before the delimiter; or the $ifNotFound value
* If an array of values is passed for any of the arguments, then the returned result
* will also be an array with matching dimensions
*/
public static function after($text, $delimiter, $instance = 1, $matchMode = 0, $matchEnd = 0, $ifNotFound = '#N/A')
{
if (is_array($text) || is_array($instance) || is_array($matchMode) || is_array($matchEnd) || is_array($ifNotFound)) {
return self::evaluateArrayArgumentsIgnore([self::class, __FUNCTION__], 1, $text, $delimiter, $instance, $matchMode, $matchEnd, $ifNotFound);
}
$text = Helpers::extractString($text ?? '');
$delimiter = Helpers::extractString(Functions::flattenSingleValue($delimiter ?? ''));
$instance = (int) $instance;
$matchMode = (int) $matchMode;
$matchEnd = (int) $matchEnd;
$split = self::validateTextBeforeAfter($text, $delimiter, $instance, $matchMode, $matchEnd, $ifNotFound);
if (is_array($split) === false) {
return $split;
}
if ($delimiter === '') {
return ($instance < 0) ? '' : $text;
}
// Adjustment for a match as the first element of the split
$flags = self::matchFlags($matchMode);
$adjust = preg_match('/^' . preg_quote($delimiter) . "\$/{$flags}", $split[0]);
$oddReverseAdjustment = count($split) % 2;
$split = ($instance < 0)
? array_slice($split, count($split) - (abs($instance + 1) * 2) - $adjust - $oddReverseAdjustment)
: array_slice($split, $instance * 2 - $adjust);
return implode('', $split);
}
/**
* @param int $matchMode
* @param int $matchEnd
* @param mixed $ifNotFound
*
* @return string|string[]
*/
private static function validateTextBeforeAfter(string $text, string $delimiter, int $instance, $matchMode, $matchEnd, $ifNotFound)
{
$flags = self::matchFlags($matchMode);
if (preg_match('/' . preg_quote($delimiter) . "/{$flags}", $text) === 0 && $matchEnd === 0) {
return $ifNotFound;
}
$split = preg_split('/(' . preg_quote($delimiter) . ")/{$flags}", $text, 0, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
if ($split === false) {
return ExcelError::NA();
}
if ($instance === 0 || abs($instance) > StringHelper::countCharacters($text)) {
return ExcelError::VALUE();
}
if ($matchEnd === 0 && (abs($instance) > floor(count($split) / 2))) {
return ExcelError::NA();
} elseif ($matchEnd !== 0 && (abs($instance) - 1 > ceil(count($split) / 2))) {
return ExcelError::NA();
}
return $split;
}
private static function matchFlags(int $matchMode): string
{
return ($matchMode === 0) ? 'mu' : 'miu';
}
}

View File

@ -144,6 +144,8 @@ class Xlfn
. '|call'
. '|let'
. '|register[.]id'
. '|textafter'
. '|textbefore'
. '|valuetotext'
. ')(?=\\s*[(])/i';

View File

@ -0,0 +1,49 @@
<?php
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\TextData;
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
class TextAfterTest extends AllSetupTeardown
{
/**
* @dataProvider providerTEXTAFTER
*/
public function testTextAfter(string $expectedResult, array $arguments): void
{
$text = $arguments[0];
$delimiter = $arguments[1];
$args = 'A1, A2';
$args .= (isset($arguments[2])) ? ", {$arguments[2]}" : ',';
$args .= (isset($arguments[3])) ? ", {$arguments[3]}" : ',';
$args .= (isset($arguments[4])) ? ", {$arguments[4]}" : ',';
$worksheet = $this->getSheet();
$worksheet->getCell('A1')->setValue($text);
$worksheet->getCell('A2')->setValue($delimiter);
$worksheet->getCell('B1')->setValue("=TEXTAFTER({$args})");
$result = $worksheet->getCell('B1')->getCalculatedValue();
self::assertEquals($expectedResult, $result);
}
public function providerTEXTAFTER(): array
{
return require 'tests/data/Calculation/TextData/TEXTAFTER.php';
}
public function testTextAfterWithArray(): void
{
$calculation = Calculation::getInstance();
$text = "Red Riding Hood's red riding hood";
$delimiter = 'red';
$args = "\"{$text}\", \"{$delimiter}\", 1, {0;1}";
$formula = "=TEXTAFTER({$args})";
$result = $calculation->_calculateFormulaValue($formula);
self::assertEquals([[' riding hood'], [" Riding Hood's red riding hood"]], $result);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\TextData;
class TextBeforeTest extends AllSetupTeardown
{
/**
* @dataProvider providerTEXTBEFORE
*/
public function testTextBefore(string $expectedResult, array $arguments): void
{
$text = $arguments[0];
$delimiter = $arguments[1];
$args = 'A1, A2';
$args .= (isset($arguments[2])) ? ", {$arguments[2]}" : ',';
$args .= (isset($arguments[3])) ? ", {$arguments[3]}" : ',';
$args .= (isset($arguments[4])) ? ", {$arguments[4]}" : ',';
$worksheet = $this->getSheet();
$worksheet->getCell('A1')->setValue($text);
$worksheet->getCell('A2')->setValue($delimiter);
$worksheet->getCell('B1')->setValue("=TEXTBEFORE({$args})");
$result = $worksheet->getCell('B1')->getCalculatedValue();
self::assertEquals($expectedResult, $result);
}
public function providerTEXTBEFORE(): array
{
return require 'tests/data/Calculation/TextData/TEXTBEFORE.php';
}
}

View File

@ -0,0 +1,215 @@
<?php
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
return [
'END Case-sensitive Offset 1' => [
"'s red hood",
[
"Red riding hood's red hood",
'hood',
],
],
'END Case-sensitive Offset 2' => [
'',
[
"Red riding hood's red hood",
'hood',
2,
],
],
'END Case-sensitive Offset -1' => [
'',
[
"Red riding hood's red hood",
'hood',
-1,
],
],
'END Case-sensitive Offset -2' => [
"'s red hood",
[
"Red riding hood's red hood",
'hood',
-2,
],
],
'END Case-sensitive Offset 3' => [
ExcelError::NA(),
[
"Red riding hood's red hood",
'hood',
3,
],
],
'END Case-sensitive Offset -3' => [
ExcelError::NA(),
[
"Red riding hood's red hood",
'hood',
-3,
],
],
'END Case-sensitive Offset 3 with end' => [
'',
[
"Red riding hood's red hood",
'hood',
3,
0,
1,
],
],
'END Case-sensitive Offset -3 with end' => [
"Red riding hood's red hood",
[
"Red riding hood's red hood",
'hood',
-3,
0,
1,
],
],
'END Case-sensitive - No Match' => [
ExcelError::NA(),
[
"Red riding hood's red hood",
'HOOD',
],
],
'END Case-insensitive Offset 1' => [
"'s red hood",
[
"Red riding hood's red hood",
'HOOD',
1,
1,
],
],
'END Case-insensitive Offset 2' => [
'',
[
"Red riding hood's red hood",
'HOOD',
2,
1,
],
],
'END Offset 0' => [
ExcelError::VALUE(),
[
"Red riding hood's red hood",
'hood',
0,
],
],
'Empty match positive' => [
"Red riding hood's red hood",
[
"Red riding hood's red hood",
'',
],
],
'Empty match negative' => [
'',
[
"Red riding hood's red hood",
'',
-1,
],
],
'START Case-sensitive Offset 1' => [
' riding hood',
[
"Red Riding Hood's red riding hood",
'red',
],
],
'START Case-insensitive Offset 1' => [
" Riding Hood's red riding hood",
[
"Red Riding Hood's red riding hood",
'red',
1,
1,
],
],
'START Case-sensitive Offset -2' => [
"Red Riding Hood's red riding hood",
[
"Red Riding Hood's red riding hood",
'red',
-2,
0,
1,
],
],
'START Case-insensitive Offset -2' => [
" Riding Hood's red riding hood",
[
"Red Riding Hood's red riding hood",
'red',
-2,
1,
1,
],
],
[
' riding hood',
[
"Red Riding Hood's red riding hood",
'red',
1,
0,
],
],
[
" Riding Hood's red riding hood",
[
"Red Riding Hood's red riding hood",
'red',
1,
1,
],
],
[
"Red Riding Hood's red riding hood",
[
"Red Riding Hood's red riding hood",
'red',
-2,
0,
1,
],
],
[
" Riding Hood's red riding hood",
[
"Red Riding Hood's red riding hood",
'red',
-2,
1,
1,
],
],
[
ExcelError::NA(),
[
'Socrates',
' ',
1,
0,
0,
],
],
[
'',
[
'Socrates',
' ',
1,
0,
1,
],
],
];

View File

@ -0,0 +1,207 @@
<?php
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
return [
'END Case-sensitive Offset 1' => [
'Red riding ',
[
"Red riding hood's red hood",
'hood',
],
],
'END Case-sensitive Offset 2' => [
"Red riding hood's red ",
[
"Red riding hood's red hood",
'hood',
2,
],
],
'END Case-sensitive Offset -1' => [
"Red riding hood's red ",
[
"Red riding hood's red hood",
'hood',
-1,
],
],
'END Case-sensitive Offset -2' => [
'Red riding ',
[
"Red riding hood's red hood",
'hood',
-2,
],
],
'END Case-sensitive Offset 3' => [
ExcelError::NA(),
[
"Red riding hood's red hood",
'hood',
3,
],
],
'END Case-sensitive Offset -3' => [
ExcelError::NA(),
[
"Red riding hood's red hood",
'hood',
-3,
],
],
'END Case-sensitive Offset 3 with end' => [
"Red riding hood's red hood",
[
"Red riding hood's red hood",
'hood',
3,
0,
1,
],
],
'END Case-sensitive Offset -3 with end' => [
'',
[
"Red riding hood's red hood",
'hood',
-3,
0,
1,
],
],
'END Case-sensitive - No Match' => [
ExcelError::NA(),
[
"Red riding hood's red hood",
'HOOD',
],
],
'END Case-insensitive Offset 1' => [
'Red riding ',
[
"Red riding hood's red hood",
'HOOD',
1,
1,
],
],
'END Case-insensitive Offset 2' => [
"Red riding hood's red ",
[
"Red riding hood's red hood",
'HOOD',
2,
1,
],
],
'END Offset 0' => [
ExcelError::VALUE(),
[
"Red riding hood's red hood",
'hood',
0,
],
],
'Empty match positive' => [
'',
[
"Red riding hood's red hood",
'',
],
],
'Empty match negative' => [
"Red riding hood's red hood",
[
"Red riding hood's red hood",
'',
-1,
],
],
'START Case-sensitive Offset 1' => [
"Red Riding Hood's ",
[
"Red Riding Hood's red riding hood",
'red',
],
],
'START Case-insensitive Offset 1' => [
'',
[
"Red Riding Hood's red riding hood",
'red',
1,
1,
],
],
'START Case-sensitive Offset -2' => [
'',
[
"Red Riding Hood's red riding hood",
'red',
-2,
0,
1,
],
],
'START Case-insensitive Offset -2' => [
'',
[
"Red Riding Hood's red riding hood",
'red',
-2,
1,
1,
],
],
[
ExcelError::NA(),
[
'ABACADAEA',
'A',
6,
0,
0,
],
],
[
'ABACADAEA',
[
'ABACADAEA',
'A',
6,
0,
1,
],
],
[
ExcelError::NA(),
[
'Socrates',
' ',
1,
0,
0,
],
],
[
'Socrates',
[
'Socrates',
' ',
1,
0,
1,
],
],
[
'Immanuel',
[
'Immanuel Kant',
' ',
1,
0,
1,
],
],
];