PluginProbe
TablePress – Tables in WordPress made easy / 3.4
TablePress – Tables in WordPress made easy v3.4
3.4 3.3.4 3.3.3 3.3.2 3.3.1 trunk 1.12 1.14 1.9.2 2.0.4 2.1.7 2.1.8 2.2 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3 2.3.1 2.3.2 2.4 2.4.1 2.4.2 2.4.3 All 45 releases
tablepress / libraries / vendor / PhpSpreadsheet / Cell / Coordinate.php

Coordinate.php in TablePress – Tables in WordPress made easy 3.4, at libraries/vendor/PhpSpreadsheet/Cell/Coordinate.php

791 lines 24.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace TablePress\PhpOffice\PhpSpreadsheet\Cell;
4
5 use TablePress\PhpOffice\PhpSpreadsheet\Exception;
6 use TablePress\PhpOffice\PhpSpreadsheet\Shared\StringHelper;
7 use TablePress\PhpOffice\PhpSpreadsheet\Worksheet\Validations;
8 use TablePress\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
9
10 /**
11 * Helper class to manipulate cell coordinates.
12 *
13 * Columns indexes and rows are always based on 1, **not** on 0. This match the behavior
14 * that Excel users are used to, and also match the Excel functions `COLUMN()` and `ROW()`.
15 */
16 abstract class Coordinate
17 {
18 public const A1_COORDINATE_REGEX = '/^(?<col>\$?[A-Z]{1,3})(?<row>\$?\d{1,7})$/i';
19 public const FULL_REFERENCE_REGEX = '/^(?:(?<worksheet>[^!]*)!)?(?<localReference>(?<firstCoordinate>[$]?[A-Z]{1,3}[$]?\d{1,7})(?:\:(?<secondCoordinate>[$]?[A-Z]{1,3}[$]?\d{1,7}))?)$/i';
20
21 /**
22 * Default range variable constant.
23 *
24 * @var string
25 */
26 const DEFAULT_RANGE = 'A1:A1';
27
28 /**
29 * Convert string coordinate to [0 => int column index, 1 => int row index].
30 *
31 * @param string $cellAddress eg: 'A1'
32 *
33 * @return array{0: string, 1: string} Array containing column and row (indexes 0 and 1)
34 */
35 public static function coordinateFromString(string $cellAddress): array
36 {
37 if (preg_match(self::A1_COORDINATE_REGEX, $cellAddress, $matches)) {
38 $row = (int) ltrim($matches['row'], '$');
39 // reluctantly allow row 0 due to regression problems
40 if (/*$row > 0 &&*/ $row <= AddressRange::MAX_ROW) {
41 return [$matches['col'], $matches['row']];
42 }
43 } elseif (self::coordinateIsRange($cellAddress)) {
44 throw new Exception('Cell coordinate string can not be a range of cells');
45 } elseif ($cellAddress == '') {
46 throw new Exception('Cell coordinate can not be zero-length string');
47 }
48
49 throw new Exception('Invalid cell coordinate ' . $cellAddress);
50 }
51
52 /**
53 * Convert string coordinate to [0 => int column index, 1 => int row index, 2 => string column string].
54 *
55 * @param string $coordinates eg: 'A1', '$B$12'
56 *
57 * @return array{0: int, 1: int, 2: string} Array containing column and row index, and column string
58 */
59 public static function indexesFromString(string $coordinates): array
60 {
61 [$column, $row] = self::coordinateFromString($coordinates);
62 $column = ltrim($column, '$');
63
64 return [
65 self::columnIndexFromString($column),
66 (int) ltrim($row, '$'),
67 $column,
68 ];
69 }
70
71 /**
72 * Checks if a Cell Address represents a range of cells.
73 *
74 * @param string $cellAddress eg: 'A1' or 'A1:A2' or 'A1:A2,C1:C2'
75 *
76 * @return bool Whether the coordinate represents a range of cells
77 */
78 public static function coordinateIsRange(string $cellAddress): bool
79 {
80 return str_contains($cellAddress, ':') || str_contains($cellAddress, ',');
81 }
82
83 /**
84 * Make string row, column or cell coordinate absolute.
85 *
86 * @param int|string $cellAddress e.g. 'A' or '1' or 'A1'
87 * Note that this value can be a row or column reference as well as a cell reference
88 *
89 * @return string Absolute coordinate e.g. '$A' or '$1' or '$A$1'
90 */
91 public static function absoluteReference($cellAddress): string
92 {
93 $cellAddress = (string) $cellAddress;
94 if (self::coordinateIsRange($cellAddress)) {
95 throw new Exception('Cell coordinate string can not be a range of cells');
96 }
97
98 // Split out any worksheet name from the reference
99 [$worksheet, $cellAddress] = Worksheet::extractSheetTitle($cellAddress, true);
100 if ($worksheet > '') {
101 $worksheet .= '!';
102 }
103
104 // Create absolute coordinate
105 $cellAddress = "$cellAddress";
106 if (ctype_digit($cellAddress)) {
107 return $worksheet . '$' . $cellAddress;
108 } elseif (ctype_alpha($cellAddress)) {
109 return $worksheet . '$' . strtoupper($cellAddress);
110 }
111
112 return $worksheet . self::absoluteCoordinate($cellAddress);
113 }
114
115 /**
116 * Make string coordinate absolute.
117 *
118 * @param string $cellAddress e.g. 'A1'
119 *
120 * @return string Absolute coordinate e.g. '$A$1'
121 */
122 public static function absoluteCoordinate(string $cellAddress): string
123 {
124 if (self::coordinateIsRange($cellAddress)) {
125 throw new Exception('Cell coordinate string can not be a range of cells');
126 }
127
128 // Split out any worksheet name from the coordinate
129 [$worksheet, $cellAddress] = Worksheet::extractSheetTitle($cellAddress, true);
130 if ($worksheet > '') {
131 $worksheet .= '!';
132 }
133
134 // Create absolute coordinate
135 [$column, $row] = self::coordinateFromString($cellAddress ?? 'A1');
136 $column = ltrim($column, '$');
137 $row = ltrim($row, '$');
138
139 return $worksheet . '$' . $column . '$' . $row;
140 }
141
142 /**
143 * Split range into coordinate strings, using comma for union
144 * and ignoring intersection (space).
145 *
146 * @param string $range e.g. 'B4:D9' or 'B4:D9,H2:O11' or 'B4'
147 *
148 * @return array<array<string>> Array containing one or more arrays containing one or two coordinate strings
149 * e.g. ['B4','D9'] or [['B4','D9'], ['H2','O11']]
150 * or ['B4']
151 */
152 public static function splitRange(string $range): array
153 {
154 // Ensure $pRange is a valid range
155 if (empty($range)) {
156 $range = self::DEFAULT_RANGE;
157 }
158
159 $exploded = explode(',', $range);
160 $outArray = [];
161 foreach ($exploded as $value) {
162 $outArray[] = explode(':', $value);
163 }
164
165 return $outArray;
166 }
167
168 /**
169 * Split range into coordinate strings, resolving unions and intersections.
170 *
171 * @param string $range e.g. 'B4:D9' or 'B4:D9,H2:O11' or 'B4'
172 * @param bool $unionIsComma true=comma is union, space is intersection
173 * false=space is union, comma is intersection
174 *
175 * @return array<array<string>> Array containing one or more arrays containing one or two coordinate strings
176 * e.g. ['B4','D9'] or [['B4','D9'], ['H2','O11']]
177 * or ['B4']
178 */
179 public static function allRanges(string $range, bool $unionIsComma = true): array
180 {
181 if (!$unionIsComma) {
182 $range = str_replace([',', ' ', "\0"], ["\0", ',', ' '], $range);
183 }
184
185 return self::splitRange(
186 self::resolveUnionAndIntersection($range)
187 );
188 }
189
190 /**
191 * Build range from coordinate strings.
192 *
193 * @param array<array<string>> $range Array containing one or more arrays containing one or two coordinate strings
194 *
195 * @return string String representation of $pRange
196 */
197 public static function buildRange(array $range): string
198 {
199 // Verify range
200 if (empty($range)) {
201 throw new Exception('Range does not contain any information');
202 }
203
204 // Build range
205 $counter = count($range);
206 for ($i = 0; $i < $counter; ++$i) {
207 if (!is_array($range[$i])) {
208 throw new Exception('Each array entry must be an array');
209 }
210 $range[$i] = implode(':', $range[$i]);
211 }
212
213 /** @var array<string> $range */
214 return implode(',', $range);
215 }
216
217 /**
218 * Calculate range boundaries.
219 *
220 * @param string $range Cell range, Single Cell, Row/Column Range (e.g. A1:A1, B2, B:C, 2:3)
221 *
222 * @return array{array{int, int}, array{int, int}} Range coordinates [Start Cell, End Cell]
223 * where Start Cell and End Cell are arrays (Column Number, Row Number)
224 */
225 public static function rangeBoundaries(string $range): array
226 {
227 // Ensure $pRange is a valid range
228 if (empty($range)) {
229 $range = self::DEFAULT_RANGE;
230 }
231
232 // Uppercase coordinate
233 $range = strtoupper($range);
234
235 // Extract range
236 if (!str_contains($range, ':')) {
237 $rangeA = $rangeB = $range;
238 } else {
239 [$rangeA, $rangeB] = explode(':', $range);
240 }
241
242 if (is_numeric($rangeA) && is_numeric($rangeB)) {
243 $rangeA = 'A' . $rangeA;
244 $rangeB = AddressRange::MAX_COLUMN . $rangeB;
245 }
246
247 if (ctype_alpha($rangeA) && ctype_alpha($rangeB)) {
248 $rangeA = $rangeA . '1';
249 $rangeB = $rangeB . AddressRange::MAX_ROW;
250 }
251
252 // Calculate range outer borders
253 $rangeStart = self::coordinateFromString($rangeA);
254 $rangeEnd = self::coordinateFromString($rangeB);
255
256 // Translate column into index
257 $rangeStart[0] = self::columnIndexFromString($rangeStart[0]);
258 $rangeEnd[0] = self::columnIndexFromString($rangeEnd[0]);
259 $rangeStart[1] = (int) $rangeStart[1];
260 $rangeEnd[1] = (int) $rangeEnd[1];
261
262 return [$rangeStart, $rangeEnd];
263 }
264
265 /**
266 * Calculate range dimension.
267 *
268 * @param string $range Cell range, Single Cell, Row/Column Range (e.g. A1:A1, B2, B:C, 2:3)
269 *
270 * @return array{int, int} Range dimension (width, height)
271 */
272 public static function rangeDimension(string $range): array
273 {
274 // Calculate range outer borders
275 [$rangeStart, $rangeEnd] = self::rangeBoundaries($range);
276
277 return [($rangeEnd[0] - $rangeStart[0] + 1), ($rangeEnd[1] - $rangeStart[1] + 1)];
278 }
279
280 /**
281 * Calculate range boundaries.
282 *
283 * @param string $range Cell range, Single Cell, Row/Column Range (e.g. A1:A1, B2, B:C, 2:3)
284 *
285 * @return array{array{string, int}, array{string, int}} Range coordinates [Start Cell, End Cell]
286 * where Start Cell and End Cell are arrays [Column ID, Row Number]
287 */
288 public static function getRangeBoundaries(string $range): array
289 {
290 [$rangeA, $rangeB] = self::rangeBoundaries($range);
291
292 return [
293 [self::stringFromColumnIndex($rangeA[0]), $rangeA[1]],
294 [self::stringFromColumnIndex($rangeB[0]), $rangeB[1]],
295 ];
296 }
297
298 /**
299 * Check if cell or range reference is valid and return an array with type of reference (cell or range), worksheet (if it was given)
300 * and the coordinate or the first coordinate and second coordinate if it is a range.
301 *
302 * @param string $reference Coordinate or Range (e.g. A1:A1, B2, B:C, 2:3)
303 *
304 * @return array{type: string, firstCoordinate?: string, secondCoordinate?: string, coordinate?: string, worksheet?: string, localReference?: string} reference data
305 */
306 private static function validateReferenceAndGetData($reference): array
307 {
308 $data = [];
309 if (1 !== preg_match(self::FULL_REFERENCE_REGEX, $reference, $matches)) {
310 return ['type' => 'invalid'];
311 }
312
313 if (isset($matches['secondCoordinate'])) {
314 $data['type'] = 'range';
315 $data['firstCoordinate'] = str_replace('$', '', $matches['firstCoordinate']);
316 $data['secondCoordinate'] = str_replace('$', '', $matches['secondCoordinate']);
317 } else {
318 $data['type'] = 'coordinate';
319 $data['coordinate'] = str_replace('$', '', $matches['firstCoordinate']);
320 }
321
322 $worksheet = $matches['worksheet'];
323 if ($worksheet !== '') {
324 if (str_starts_with($worksheet, "'") && str_ends_with($worksheet, "'")) {
325 $worksheet = (string) substr($worksheet, 1, -1);
326 }
327 $data['worksheet'] = strtolower($worksheet);
328 }
329 $data['localReference'] = str_replace('$', '', $matches['localReference']);
330
331 return $data;
332 }
333
334 /**
335 * Check if coordinate is inside a range.
336 *
337 * @param string $range Cell range, Single Cell, Row/Column Range (e.g. A1:A1, B2, B:C, 2:3)
338 * @param string $coordinate Cell coordinate (e.g. A1)
339 *
340 * @return bool true if coordinate is inside range
341 */
342 public static function coordinateIsInsideRange(string $range, string $coordinate): bool
343 {
344 $range = Validations::convertWholeRowColumn($range);
345 $rangeData = self::validateReferenceAndGetData($range);
346 if ($rangeData['type'] === 'invalid') {
347 throw new Exception('First argument needs to be a range');
348 }
349
350 $coordinateData = self::validateReferenceAndGetData($coordinate);
351 if ($coordinateData['type'] === 'invalid') {
352 throw new Exception('Second argument needs to be a single coordinate');
353 }
354
355 if (isset($coordinateData['worksheet']) && !isset($rangeData['worksheet'])) {
356 return false;
357 }
358 if (!isset($coordinateData['worksheet']) && isset($rangeData['worksheet'])) {
359 return false;
360 }
361
362 if (isset($coordinateData['worksheet'], $rangeData['worksheet'])) {
363 if ($coordinateData['worksheet'] !== $rangeData['worksheet']) {
364 return false;
365 }
366 }
367
368 if (!isset($rangeData['localReference'])) {
369 return false;
370 }
371 $boundaries = self::rangeBoundaries($rangeData['localReference']);
372 if (!isset($coordinateData['localReference'])) {
373 return false;
374 }
375 $coordinates = self::indexesFromString($coordinateData['localReference']);
376
377 $columnIsInside = $boundaries[0][0] <= $coordinates[0] && $coordinates[0] <= $boundaries[1][0];
378 if (!$columnIsInside) {
379 return false;
380 }
381 $rowIsInside = $boundaries[0][1] <= $coordinates[1] && $coordinates[1] <= $boundaries[1][1];
382 if (!$rowIsInside) {
383 return false;
384 }
385
386 return true;
387 }
388
389 /**
390 * Column index from string.
391 *
392 * @param ?string $columnAddress eg 'A'
393 *
394 * @return int Column index (A = 1)
395 */
396 public static function columnIndexFromString(?string $columnAddress): int
397 {
398 // Using a lookup cache adds a slight memory overhead, but boosts speed
399 // caching using a static within the method is faster than a class static,
400 // though it's additional memory overhead
401 /** @var int[] */
402 static $indexCache = [];
403 $columnAddress = $columnAddress ?? '';
404
405 if (isset($indexCache[$columnAddress])) {
406 return $indexCache[$columnAddress];
407 }
408 // It's surprising how costly the strtoupper() and ord() calls actually are, so we use a lookup array
409 // rather than use ord() and make it case-insensitive to get rid of the strtoupper() as well.
410 // Because it's a static, there's no significant memory overhead either.
411 /** @var array<string, int> */
412 static $columnLookup = [
413 'A' => 1, 'B' => 2, 'C' => 3, 'D' => 4, 'E' => 5, 'F' => 6, 'G' => 7, 'H' => 8, 'I' => 9, 'J' => 10,
414 'K' => 11, 'L' => 12, 'M' => 13, 'N' => 14, 'O' => 15, 'P' => 16, 'Q' => 17, 'R' => 18, 'S' => 19,
415 'T' => 20, 'U' => 21, 'V' => 22, 'W' => 23, 'X' => 24, 'Y' => 25, 'Z' => 26,
416 'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5, 'f' => 6, 'g' => 7, 'h' => 8, 'i' => 9, 'j' => 10,
417 'k' => 11, 'l' => 12, 'm' => 13, 'n' => 14, 'o' => 15, 'p' => 16, 'q' => 17, 'r' => 18, 's' => 19,
418 't' => 20, 'u' => 21, 'v' => 22, 'w' => 23, 'x' => 24, 'y' => 25, 'z' => 26,
419 ];
420
421 // We also use the language construct isset() rather than the more costly strlen() function to match the
422 // length of $columnAddress for improved performance
423 if (isset($columnAddress[0])) {
424 if (!isset($columnAddress[1])) {
425 $indexCache[$columnAddress] = $columnLookup[$columnAddress];
426
427 return $indexCache[$columnAddress];
428 }
429 if (!isset($columnAddress[2])) {
430 $indexCache[$columnAddress] = $columnLookup[$columnAddress[0]] * 26
431 + $columnLookup[$columnAddress[1]];
432
433 return $indexCache[$columnAddress];
434 }
435 if (!isset($columnAddress[3])) {
436 $temp = $columnLookup[$columnAddress[0]] * 676
437 + $columnLookup[$columnAddress[1]] * 26
438 + $columnLookup[$columnAddress[2]];
439
440 if ($temp <= AddressRange::MAX_COLUMN_INT) {
441 $indexCache[$columnAddress] = $temp;
442
443 return $temp;
444 }
445 }
446 }
447
448 throw new Exception(
449 'Column string index can not be ' . ((isset($columnAddress[0])) ? ('beyond ' . AddressRange::MAX_COLUMN) : 'empty')
450 );
451 }
452
453 private const LOOKUP_CACHE = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ';
454
455 /**
456 * String from column index.
457 *
458 * @param int|numeric-string $columnIndex Column index (A = 1)
459 */
460 public static function stringFromColumnIndex($columnIndex, bool $tolerateZero = false): string
461 {
462 /** @var string[] */
463 static $indexCache = [];
464 $columnIndex2 = (int) $columnIndex;
465 if ($columnIndex2 === 0 && $tolerateZero) {
466 return '';
467 }
468 if ($columnIndex2 < 1 || $columnIndex2 > AddressRange::MAX_COLUMN_INT) {
469 throw new Exception("Invalid column index $columnIndex");
470 }
471
472 $columnIndex = $columnIndex2;
473 if (!isset($indexCache[$columnIndex])) {
474 $indexValue = $columnIndex;
475 $base26 = '';
476 do {
477 $characterValue = ($indexValue % 26) ?: 26;
478 $indexValue = ($indexValue - $characterValue) / 26;
479 $base26 = self::LOOKUP_CACHE[$characterValue] . $base26;
480 } while ($indexValue > 0);
481 $indexCache[$columnIndex] = $base26;
482 }
483
484 return $indexCache[$columnIndex];
485 }
486
487 /**
488 * Extract all cell references in range, which may be comprised of multiple cell ranges.
489 *
490 * @param string $cellRange Range: e.g. 'A1' or 'A1:C10' or 'A1:E10,A20:E25' or 'A1:E5 C3:G7' or 'A1:C1,A3:C3 B1:C3'
491 *
492 * @return string[] Array containing single cell references
493 */
494 public static function extractAllCellReferencesInRange(string $cellRange): array
495 {
496 if (substr_count($cellRange, '!') > 1) {
497 throw new Exception('3-D Range References are not supported');
498 }
499
500 [$worksheet, $cellRange] = Worksheet::extractSheetTitle($cellRange, true);
501 $quoted = '';
502 if ($worksheet) {
503 $quoted = Worksheet::nameRequiresQuotes($worksheet) ? "'" : '';
504 if (str_starts_with($worksheet, "'") && str_ends_with($worksheet, "'")) {
505 $worksheet = (string) substr($worksheet, 1, -1);
506 }
507 $worksheet = str_replace("'", "''", $worksheet);
508 }
509 [$ranges, $operators] = self::getCellBlocksFromRangeString($cellRange ?? 'A1');
510
511 $cells = [];
512 foreach ($ranges as $range) {
513 /** @var string $range */
514 $cells[] = self::getReferencesForCellBlock($range);
515 }
516
517 /** @var mixed[] */
518 $cells = self::processRangeSetOperators($operators, $cells);
519
520 if (empty($cells)) {
521 return [];
522 }
523
524 /** @var string[] */
525 $cellList = array_merge(...$cells); //* @phpstan-ignore argument.type (Unsure how to satisfy phpstan)
526
527 $retVal = array_map(
528 fn (string $cellAddress) => ($worksheet !== '') ? "{$quoted}{$worksheet}{$quoted}!{$cellAddress}" : $cellAddress,
529 self::sortCellReferenceArray($cellList)
530 );
531
532 return $retVal;
533 }
534
535 /**
536 * @param mixed[] $operators
537 * @param string[][] $cells
538 *
539 * @return mixed[]
540 */
541 private static function processRangeSetOperators(array $operators, array $cells): array
542 {
543 $operatorCount = count($operators);
544 for ($offset = 0; $offset < $operatorCount; ++$offset) {
545 $operator = $operators[$offset];
546 if ($operator !== ' ') {
547 continue;
548 }
549
550 $cells[$offset] = array_intersect($cells[$offset], $cells[$offset + 1]);
551 unset($operators[$offset], $cells[$offset + 1]);
552 $operators = array_values($operators);
553 $cells = array_values($cells);
554 --$offset;
555 --$operatorCount;
556 }
557
558 return $cells;
559 }
560
561 /**
562 * @param string[] $cellList
563 *
564 * @return string[]
565 */
566 private static function sortCellReferenceArray(array $cellList): array
567 {
568 // Sort the result by column and row
569 $sortKeys = [];
570 foreach ($cellList as $coordinate) {
571 $column = '';
572 $row = 0;
573 sscanf($coordinate, '%[A-Z]%d', $column, $row);
574 /** @var int $row */
575 $key = (--$row * AddressRange::MAX_COLUMN_INT) + self::columnIndexFromString((string) $column);
576 $sortKeys[$key] = $coordinate;
577 }
578 ksort($sortKeys);
579
580 return array_values($sortKeys);
581 }
582
583 /**
584 * Get all cell references applying union and intersection.
585 *
586 * @param string $cellBlock A cell range e.g. A1:B5,D1:E5 B2:C4
587 *
588 * @return string A string without intersection operator.
589 * If there was no intersection to begin with, return original argument.
590 * Otherwise, return cells and/or cell ranges in that range separated by comma.
591 */
592 public static function resolveUnionAndIntersection(string $cellBlock, string $implodeCharacter = ','): string
593 {
594 $cellBlock = preg_replace('/ +/', ' ', trim($cellBlock)) ?? $cellBlock;
595 $cellBlock = preg_replace('/ ,/', ',', $cellBlock) ?? $cellBlock;
596 $cellBlock = preg_replace('/, /', ',', $cellBlock) ?? $cellBlock;
597 $array1 = [];
598 $blocks = explode(',', $cellBlock);
599 foreach ($blocks as $block) {
600 $block0 = explode(' ', $block);
601 if (count($block0) === 1) {
602 $array1 = array_merge($array1, $block0);
603 } else {
604 $blockIdx = -1;
605 $array2 = [];
606 foreach ($block0 as $block00) {
607 ++$blockIdx;
608 if ($blockIdx === 0) {
609 $array2 = self::getReferencesForCellBlock($block00);
610 } else {
611 $array2 = array_intersect($array2, self::getReferencesForCellBlock($block00));
612 }
613 }
614 $array1 = array_merge($array1, $array2);
615 }
616 }
617
618 return implode($implodeCharacter, $array1);
619 }
620
621 /**
622 * Get all cell references for an individual cell block.
623 *
624 * @param string $cellBlock A cell range e.g. A4:B5
625 *
626 * @return string[] All individual cells in that range
627 */
628 private static function getReferencesForCellBlock(string $cellBlock): array
629 {
630 $returnValue = [];
631
632 // Single cell?
633 if (!self::coordinateIsRange($cellBlock)) {
634 return (array) $cellBlock;
635 }
636
637 // Range...
638 $ranges = self::splitRange($cellBlock);
639 foreach ($ranges as $range) {
640 // Single cell?
641 if (!isset($range[1])) {
642 $returnValue[] = $range[0];
643
644 continue;
645 }
646
647 // Range...
648 [$rangeStart, $rangeEnd] = $range;
649 [$startColumnIndex, $startRow, $startColumn] = self::indexesFromString($rangeStart);
650 [$endColumnIndex, $endRow, $endColumn] = self::indexesFromString($rangeEnd);
651 ++$endColumnIndex;
652
653 // Current data
654 $currentColumnIndex = $startColumnIndex;
655 $currentRow = $startRow;
656
657 self::validateRange($cellBlock, $startColumnIndex, $endColumnIndex, (int) $currentRow, (int) $endRow);
658
659 // Loop cells
660 while ($currentColumnIndex < $endColumnIndex) {
661 while ($currentRow <= $endRow) {
662 $returnValue[] = self::stringFromColumnIndex($currentColumnIndex) . $currentRow;
663 ++$currentRow;
664 }
665 ++$currentColumnIndex;
666 $currentRow = $startRow;
667 }
668 }
669
670 return $returnValue;
671 }
672
673 /**
674 * Convert an associative array of single cell coordinates to values to an associative array
675 * of cell ranges to values. Only adjacent cell coordinates with the same
676 * value will be merged. If the value is an object, it must implement the method getHashCode().
677 *
678 * For example, this function converts:
679 *
680 * [ 'A1' => 'x', 'A2' => 'x', 'A3' => 'x', 'A4' => 'y' ]
681 *
682 * to:
683 *
684 * [ 'A1:A3' => 'x', 'A4' => 'y' ]
685 *
686 * @param array<string, mixed> $coordinateCollection associative array mapping coordinates to values
687 *
688 * @return array<string, mixed> associative array mapping coordinate ranges to values
689 */
690 public static function mergeRangesInCollection(array $coordinateCollection): array
691 {
692 $hashedValues = [];
693 $mergedCoordCollection = [];
694
695 foreach ($coordinateCollection as $coord => $value) {
696 if (self::coordinateIsRange($coord)) {
697 $mergedCoordCollection[$coord] = $value;
698
699 continue;
700 }
701
702 [$column, $row] = self::coordinateFromString($coord);
703 $row = (int) (ltrim($row, '$'));
704 $hashCode = $column . '-' . StringHelper::convertToString((is_object($value) && method_exists($value, 'getHashCode')) ? $value->getHashCode() : $value);
705
706 if (!isset($hashedValues[$hashCode])) {
707 $hashedValues[$hashCode] = (object) [
708 'value' => $value,
709 'col' => $column,
710 'rows' => [$row],
711 ];
712 } else {
713 $hashedValues[$hashCode]->rows[] = $row;
714 }
715 }
716
717 ksort($hashedValues);
718
719 foreach ($hashedValues as $hashedValue) {
720 sort($hashedValue->rows);
721 $rowStart = null;
722 $rowEnd = null;
723 $ranges = [];
724
725 foreach ($hashedValue->rows as $row) {
726 if ($rowStart === null) {
727 $rowStart = $row;
728 $rowEnd = $row;
729 } elseif ($rowEnd === $row - 1) {
730 $rowEnd = $row;
731 } else {
732 if ($rowStart == $rowEnd) {
733 $ranges[] = $hashedValue->col . $rowStart;
734 } else {
735 $ranges[] = $hashedValue->col . $rowStart . ':' . $hashedValue->col . $rowEnd;
736 }
737
738 $rowStart = $row;
739 $rowEnd = $row;
740 }
741 }
742
743 if ($rowStart !== null) {
744 if ($rowStart == $rowEnd) {
745 $ranges[] = $hashedValue->col . $rowStart;
746 } else {
747 $ranges[] = $hashedValue->col . $rowStart . ':' . $hashedValue->col . $rowEnd;
748 }
749 }
750
751 foreach ($ranges as $range) {
752 $mergedCoordCollection[$range] = $hashedValue->value;
753 }
754 }
755
756 return $mergedCoordCollection;
757 }
758
759 /**
760 * Get the individual cell blocks from a range string, removing any $ characters.
761 * then splitting by operators and returning an array with ranges and operators.
762 *
763 * @return mixed[][]
764 */
765 private static function getCellBlocksFromRangeString(string $rangeString): array
766 {
767 $rangeString = str_replace('$', '', strtoupper($rangeString));
768
769 // split range sets on intersection (space) or union (,) operators
770 $tokens = preg_split('/([ ,])/', $rangeString, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
771 $split = array_chunk($tokens, 2);
772 $ranges = array_column($split, 0);
773 $operators = array_column($split, 1);
774
775 return [$ranges, $operators];
776 }
777
778 /**
779 * Check that the given range is valid, i.e. that the start column and row are not greater than the end column and
780 * row.
781 *
782 * @param string $cellBlock The original range, for displaying a meaningful error message
783 */
784 private static function validateRange(string $cellBlock, int $startColumnIndex, int $endColumnIndex, int $currentRow, int $endRow): void
785 {
786 if ($startColumnIndex >= $endColumnIndex || $currentRow > $endRow) {
787 throw new Exception('Invalid range: "' . $cellBlock . '"');
788 }
789 }
790 }
791