From 88bfa9829109e4b848d8ff71f0afe4161413ae05 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Mon, 18 Jul 2022 12:26:11 +0200 Subject: [PATCH] Initial Implementation of the new Excel TEXTBEFORE() and TEXTAFTER() functions --- CHANGELOG.md | 2 +- .../Calculation/Calculation.php | 8 +- .../Calculation/TextData/Extract.php | 156 +++++++++++++ src/PhpSpreadsheet/Writer/Xlsx/Xlfn.php | 2 + .../Functions/TextData/TextAfterTest.php | 49 ++++ .../Functions/TextData/TextBeforeTest.php | 33 +++ tests/data/Calculation/TextData/TEXTAFTER.php | 215 ++++++++++++++++++ .../data/Calculation/TextData/TEXTBEFORE.php | 207 +++++++++++++++++ 8 files changed, 667 insertions(+), 5 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Calculation/Functions/TextData/TextAfterTest.php create mode 100644 tests/PhpSpreadsheetTests/Calculation/Functions/TextData/TextBeforeTest.php create mode 100644 tests/data/Calculation/TextData/TEXTAFTER.php create mode 100644 tests/data/Calculation/TextData/TEXTBEFORE.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f53aeda2..732badb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 5b1c5520..b73c7eaa 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -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, diff --git a/src/PhpSpreadsheet/Calculation/TextData/Extract.php b/src/PhpSpreadsheet/Calculation/TextData/Extract.php index d29f80ca..1a9e84db 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/Extract.php +++ b/src/PhpSpreadsheet/Calculation/TextData/Extract.php @@ -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'; + } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Xlfn.php b/src/PhpSpreadsheet/Writer/Xlsx/Xlfn.php index 6fc0c66a..b623c573 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Xlfn.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Xlfn.php @@ -144,6 +144,8 @@ class Xlfn . '|call' . '|let' . '|register[.]id' + . '|textafter' + . '|textbefore' . '|valuetotext' . ')(?=\\s*[(])/i'; diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/TextAfterTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/TextAfterTest.php new file mode 100644 index 00000000..b3b01d24 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/TextAfterTest.php @@ -0,0 +1,49 @@ +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); + } +} diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/TextBeforeTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/TextBeforeTest.php new file mode 100644 index 00000000..17938b5e --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/TextBeforeTest.php @@ -0,0 +1,33 @@ +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'; + } +} diff --git a/tests/data/Calculation/TextData/TEXTAFTER.php b/tests/data/Calculation/TextData/TEXTAFTER.php new file mode 100644 index 00000000..c594ecdc --- /dev/null +++ b/tests/data/Calculation/TextData/TEXTAFTER.php @@ -0,0 +1,215 @@ + [ + "'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, + ], + ], +]; diff --git a/tests/data/Calculation/TextData/TEXTBEFORE.php b/tests/data/Calculation/TextData/TEXTBEFORE.php new file mode 100644 index 00000000..f94d5f28 --- /dev/null +++ b/tests/data/Calculation/TextData/TEXTBEFORE.php @@ -0,0 +1,207 @@ + [ + '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, + ], + ], +];