Failed Conditions
Pull Request — master (#4452)
by Owen
12:39
created

Coordinate::extractAllCellReferencesInRange()   B

Complexity

Conditions 9
Paths 21

Size

Total Lines 40
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 24
CRAP Score 9.0051

Importance

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