Skip to content

Commit e748ac7

Browse files
authored
Merge pull request #2947 from PHPOffice/TextFunctions-New
Initial Implementation of the new Excel TEXTBEFORE() and TEXTAFTER() functions
2 parents c0809b0 + 345c0eb commit e748ac7

File tree

8 files changed

+667
-5
lines changed

8 files changed

+667
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
99

1010
### Added
1111

12-
- Nothing
12+
- Implementation of the `TEXTBEFORE()` and `TEXTAFTER()` Excel Functions
1313

1414
### Changed
1515

src/PhpSpreadsheet/Calculation/Calculation.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2490,13 +2490,13 @@ class Calculation
24902490
],
24912491
'TEXTAFTER' => [
24922492
'category' => Category::CATEGORY_TEXT_AND_DATA,
2493-
'functionCall' => [Functions::class, 'DUMMY'],
2494-
'argumentCount' => '2-4',
2493+
'functionCall' => [TextData\Extract::class, 'after'],
2494+
'argumentCount' => '2-6',
24952495
],
24962496
'TEXTBEFORE' => [
24972497
'category' => Category::CATEGORY_TEXT_AND_DATA,
2498-
'functionCall' => [Functions::class, 'DUMMY'],
2499-
'argumentCount' => '2-4',
2498+
'functionCall' => [TextData\Extract::class, 'before'],
2499+
'argumentCount' => '2-6',
25002500
],
25012501
'TEXTJOIN' => [
25022502
'category' => Category::CATEGORY_TEXT_AND_DATA,

src/PhpSpreadsheet/Calculation/TextData/Extract.php

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
66
use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalcExp;
7+
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
8+
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
9+
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
710

811
class Extract
912
{
@@ -95,4 +98,157 @@ public static function right($value, $chars = 1)
9598

9699
return mb_substr($value ?? '', mb_strlen($value ?? '', 'UTF-8') - $chars, $chars, 'UTF-8');
97100
}
101+
102+
/**
103+
* TEXTBEFORE.
104+
*
105+
* @param mixed $text the text that you're searching
106+
* Or can be an array of values
107+
* @param ?string $delimiter the text that marks the point before which you want to extract
108+
* @param mixed $instance The instance of the delimiter after which you want to extract the text.
109+
* By default, this is the first instance (1).
110+
* A negative value means start searching from the end of the text string.
111+
* Or can be an array of values
112+
* @param mixed $matchMode Determines whether the match is case-sensitive or not.
113+
* 0 - Case-sensitive
114+
* 1 - Case-insensitive
115+
* Or can be an array of values
116+
* @param mixed $matchEnd Treats the end of text as a delimiter.
117+
* 0 - Don't match the delimiter against the end of the text.
118+
* 1 - Match the delimiter against the end of the text.
119+
* Or can be an array of values
120+
* @param mixed $ifNotFound value to return if no match is found
121+
* The default is a #N/A Error
122+
* Or can be an array of values
123+
*
124+
* @return mixed|mixed[] the string extracted from text before the delimiter; or the $ifNotFound value
125+
* If an array of values is passed for any of the arguments, then the returned result
126+
* will also be an array with matching dimensions
127+
*/
128+
public static function before($text, $delimiter, $instance = 1, $matchMode = 0, $matchEnd = 0, $ifNotFound = '#N/A')
129+
{
130+
if (is_array($text) || is_array($instance) || is_array($matchMode) || is_array($matchEnd) || is_array($ifNotFound)) {
131+
return self::evaluateArrayArgumentsIgnore([self::class, __FUNCTION__], 1, $text, $delimiter, $instance, $matchMode, $matchEnd, $ifNotFound);
132+
}
133+
134+
$text = Helpers::extractString($text ?? '');
135+
$delimiter = Helpers::extractString(Functions::flattenSingleValue($delimiter ?? ''));
136+
$instance = (int) $instance;
137+
$matchMode = (int) $matchMode;
138+
$matchEnd = (int) $matchEnd;
139+
140+
$split = self::validateTextBeforeAfter($text, $delimiter, $instance, $matchMode, $matchEnd, $ifNotFound);
141+
if (is_array($split) === false) {
142+
return $split;
143+
}
144+
if ($delimiter === '') {
145+
return ($instance > 0) ? '' : $text;
146+
}
147+
148+
// Adjustment for a match as the first element of the split
149+
$flags = self::matchFlags($matchMode);
150+
$adjust = preg_match('/^' . preg_quote($delimiter) . "\$/{$flags}", $split[0]);
151+
$oddReverseAdjustment = count($split) % 2;
152+
153+
$split = ($instance < 0)
154+
? array_slice($split, 0, max(count($split) - (abs($instance) * 2 - 1) - $adjust - $oddReverseAdjustment, 0))
155+
: array_slice($split, 0, $instance * 2 - 1 - $adjust);
156+
157+
return implode('', $split);
158+
}
159+
160+
/**
161+
* TEXTAFTER.
162+
*
163+
* @param mixed $text the text that you're searching
164+
* @param ?string $delimiter the text that marks the point before which you want to extract
165+
* @param mixed $instance The instance of the delimiter after which you want to extract the text.
166+
* By default, this is the first instance (1).
167+
* A negative value means start searching from the end of the text string.
168+
* Or can be an array of values
169+
* @param mixed $matchMode Determines whether the match is case-sensitive or not.
170+
* 0 - Case-sensitive
171+
* 1 - Case-insensitive
172+
* Or can be an array of values
173+
* @param mixed $matchEnd Treats the end of text as a delimiter.
174+
* 0 - Don't match the delimiter against the end of the text.
175+
* 1 - Match the delimiter against the end of the text.
176+
* Or can be an array of values
177+
* @param mixed $ifNotFound value to return if no match is found
178+
* The default is a #N/A Error
179+
* Or can be an array of values
180+
*
181+
* @return mixed|mixed[] the string extracted from text before the delimiter; or the $ifNotFound value
182+
* If an array of values is passed for any of the arguments, then the returned result
183+
* will also be an array with matching dimensions
184+
*/
185+
public static function after($text, $delimiter, $instance = 1, $matchMode = 0, $matchEnd = 0, $ifNotFound = '#N/A')
186+
{
187+
if (is_array($text) || is_array($instance) || is_array($matchMode) || is_array($matchEnd) || is_array($ifNotFound)) {
188+
return self::evaluateArrayArgumentsIgnore([self::class, __FUNCTION__], 1, $text, $delimiter, $instance, $matchMode, $matchEnd, $ifNotFound);
189+
}
190+
191+
$text = Helpers::extractString($text ?? '');
192+
$delimiter = Helpers::extractString(Functions::flattenSingleValue($delimiter ?? ''));
193+
$instance = (int) $instance;
194+
$matchMode = (int) $matchMode;
195+
$matchEnd = (int) $matchEnd;
196+
197+
$split = self::validateTextBeforeAfter($text, $delimiter, $instance, $matchMode, $matchEnd, $ifNotFound);
198+
if (is_array($split) === false) {
199+
return $split;
200+
}
201+
if ($delimiter === '') {
202+
return ($instance < 0) ? '' : $text;
203+
}
204+
205+
// Adjustment for a match as the first element of the split
206+
$flags = self::matchFlags($matchMode);
207+
$adjust = preg_match('/^' . preg_quote($delimiter) . "\$/{$flags}", $split[0]);
208+
$oddReverseAdjustment = count($split) % 2;
209+
210+
$split = ($instance < 0)
211+
? array_slice($split, count($split) - (abs($instance + 1) * 2) - $adjust - $oddReverseAdjustment)
212+
: array_slice($split, $instance * 2 - $adjust);
213+
214+
return implode('', $split);
215+
}
216+
217+
/**
218+
* @param int $matchMode
219+
* @param int $matchEnd
220+
* @param mixed $ifNotFound
221+
*
222+
* @return string|string[]
223+
*/
224+
private static function validateTextBeforeAfter(string $text, string $delimiter, int $instance, $matchMode, $matchEnd, $ifNotFound)
225+
{
226+
$flags = self::matchFlags($matchMode);
227+
228+
if (preg_match('/' . preg_quote($delimiter) . "/{$flags}", $text) === 0 && $matchEnd === 0) {
229+
return $ifNotFound;
230+
}
231+
232+
$split = preg_split('/(' . preg_quote($delimiter) . ")/{$flags}", $text, 0, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
233+
if ($split === false) {
234+
return ExcelError::NA();
235+
}
236+
237+
if ($instance === 0 || abs($instance) > StringHelper::countCharacters($text)) {
238+
return ExcelError::VALUE();
239+
}
240+
241+
if ($matchEnd === 0 && (abs($instance) > floor(count($split) / 2))) {
242+
return ExcelError::NA();
243+
} elseif ($matchEnd !== 0 && (abs($instance) - 1 > ceil(count($split) / 2))) {
244+
return ExcelError::NA();
245+
}
246+
247+
return $split;
248+
}
249+
250+
private static function matchFlags(int $matchMode): string
251+
{
252+
return ($matchMode === 0) ? 'mu' : 'miu';
253+
}
98254
}

src/PhpSpreadsheet/Writer/Xlsx/Xlfn.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ class Xlfn
144144
. '|call'
145145
. '|let'
146146
. '|register[.]id'
147+
. '|textafter'
148+
. '|textbefore'
147149
. '|valuetotext'
148150
. ')(?=\\s*[(])/i';
149151

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\TextData;
4+
5+
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
6+
7+
class TextAfterTest extends AllSetupTeardown
8+
{
9+
/**
10+
* @dataProvider providerTEXTAFTER
11+
*/
12+
public function testTextAfter(string $expectedResult, array $arguments): void
13+
{
14+
$text = $arguments[0];
15+
$delimiter = $arguments[1];
16+
17+
$args = 'A1, A2';
18+
$args .= (isset($arguments[2])) ? ", {$arguments[2]}" : ',';
19+
$args .= (isset($arguments[3])) ? ", {$arguments[3]}" : ',';
20+
$args .= (isset($arguments[4])) ? ", {$arguments[4]}" : ',';
21+
22+
$worksheet = $this->getSheet();
23+
$worksheet->getCell('A1')->setValue($text);
24+
$worksheet->getCell('A2')->setValue($delimiter);
25+
$worksheet->getCell('B1')->setValue("=TEXTAFTER({$args})");
26+
27+
$result = $worksheet->getCell('B1')->getCalculatedValue();
28+
self::assertEquals($expectedResult, $result);
29+
}
30+
31+
public function providerTEXTAFTER(): array
32+
{
33+
return require 'tests/data/Calculation/TextData/TEXTAFTER.php';
34+
}
35+
36+
public function testTextAfterWithArray(): void
37+
{
38+
$calculation = Calculation::getInstance();
39+
40+
$text = "Red Riding Hood's red riding hood";
41+
$delimiter = 'red';
42+
43+
$args = "\"{$text}\", \"{$delimiter}\", 1, {0;1}";
44+
45+
$formula = "=TEXTAFTER({$args})";
46+
$result = $calculation->_calculateFormulaValue($formula);
47+
self::assertEquals([[' riding hood'], [" Riding Hood's red riding hood"]], $result);
48+
}
49+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\TextData;
4+
5+
class TextBeforeTest extends AllSetupTeardown
6+
{
7+
/**
8+
* @dataProvider providerTEXTBEFORE
9+
*/
10+
public function testTextBefore(string $expectedResult, array $arguments): void
11+
{
12+
$text = $arguments[0];
13+
$delimiter = $arguments[1];
14+
15+
$args = 'A1, A2';
16+
$args .= (isset($arguments[2])) ? ", {$arguments[2]}" : ',';
17+
$args .= (isset($arguments[3])) ? ", {$arguments[3]}" : ',';
18+
$args .= (isset($arguments[4])) ? ", {$arguments[4]}" : ',';
19+
20+
$worksheet = $this->getSheet();
21+
$worksheet->getCell('A1')->setValue($text);
22+
$worksheet->getCell('A2')->setValue($delimiter);
23+
$worksheet->getCell('B1')->setValue("=TEXTBEFORE({$args})");
24+
25+
$result = $worksheet->getCell('B1')->getCalculatedValue();
26+
self::assertEquals($expectedResult, $result);
27+
}
28+
29+
public function providerTEXTBEFORE(): array
30+
{
31+
return require 'tests/data/Calculation/TextData/TEXTBEFORE.php';
32+
}
33+
}

0 commit comments

Comments
 (0)