Passed
Push — develop ( 4bc3ee...ed2185 )
by Adrien
33:25
created

Coordinate::splitRange()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3.0175

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 4
nop 1
dl 0
loc 14
ccs 7
cts 8
cp 0.875
crap 3.0175
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
namespace PhpOffice\PhpSpreadsheet\Cell;
4
5
use PhpOffice\PhpSpreadsheet\Exception;
6
7
/**
8
 * Helper class to manipulate cell coordinates.
9
 *
10
 * Columns indexes and rows are always based on 1, **not** on 0. This match the behavior
11
 * that Excel users are used to, and also match the Excel functions `COLUMN()` and `ROW()`.
12
 */
13
abstract class Coordinate
14
{
15
    /**
16
     * Default range variable constant.
17
     *
18
     * @var string
19
     */
20
    const DEFAULT_RANGE = 'A1:A1';
21
22
    /**
23
     * Coordinate from string.
24
     *
25
     * @param string $pCoordinateString eg: 'A1'
26
     *
27
     * @throws Exception
28
     *
29
     * @return string[] Array containing column and row (indexes 0 and 1)
30
     */
31 220
    public static function coordinateFromString($pCoordinateString)
32
    {
33 220
        if (preg_match('/^([$]?[A-Z]{1,3})([$]?\\d{1,7})$/', $pCoordinateString, $matches)) {
34 217
            return [$matches[1], $matches[2]];
35 4
        } elseif (self::coordinateIsRange($pCoordinateString)) {
36 1
            throw new Exception('Cell coordinate string can not be a range of cells');
37 3
        } elseif ($pCoordinateString == '') {
38 1
            throw new Exception('Cell coordinate can not be zero-length string');
39
        }
40
41 2
        throw new Exception('Invalid cell coordinate ' . $pCoordinateString);
42
    }
43
44
    /**
45
     * Checks if a coordinate represents a range of cells.
46
     *
47
     * @param string $coord eg: 'A1' or 'A1:A2' or 'A1:A2,C1:C2'
48
     *
49
     * @return bool Whether the coordinate represents a range of cells
50
     */
51 217
    public static function coordinateIsRange($coord)
52
    {
53 217
        return (strpos($coord, ':') !== false) || (strpos($coord, ',') !== false);
54
    }
55
56
    /**
57
     * Make string row, column or cell coordinate absolute.
58
     *
59
     * @param string $pCoordinateString e.g. 'A' or '1' or 'A1'
60
     *                    Note that this value can be a row or column reference as well as a cell reference
61
     *
62
     * @throws Exception
63
     *
64
     * @return string Absolute coordinate        e.g. '$A' or '$1' or '$A$1'
65
     */
66 22
    public static function absoluteReference($pCoordinateString)
67
    {
68 22
        if (self::coordinateIsRange($pCoordinateString)) {
69 1
            throw new Exception('Cell coordinate string can not be a range of cells');
70
        }
71
72
        // Split out any worksheet name from the reference
73 21
        $worksheet = '';
74 21
        $cellAddress = explode('!', $pCoordinateString);
75 21
        if (count($cellAddress) > 1) {
76 8
            list($worksheet, $pCoordinateString) = $cellAddress;
77
        }
78 21
        if ($worksheet > '') {
79 8
            $worksheet .= '!';
80
        }
81
82
        // Create absolute coordinate
83 21
        if (ctype_digit($pCoordinateString)) {
84 2
            return $worksheet . '$' . $pCoordinateString;
85 19
        } elseif (ctype_alpha($pCoordinateString)) {
86 2
            return $worksheet . '$' . strtoupper($pCoordinateString);
87
        }
88
89 17
        return $worksheet . self::absoluteCoordinate($pCoordinateString);
90
    }
91
92
    /**
93
     * Make string coordinate absolute.
94
     *
95
     * @param string $pCoordinateString e.g. 'A1'
96
     *
97
     * @throws Exception
98
     *
99
     * @return string Absolute coordinate        e.g. '$A$1'
100
     */
101 33
    public static function absoluteCoordinate($pCoordinateString)
102
    {
103 33
        if (self::coordinateIsRange($pCoordinateString)) {
104 1
            throw new Exception('Cell coordinate string can not be a range of cells');
105
        }
106
107
        // Split out any worksheet name from the coordinate
108 32
        $worksheet = '';
109 32
        $cellAddress = explode('!', $pCoordinateString);
110 32
        if (count($cellAddress) > 1) {
111 6
            list($worksheet, $pCoordinateString) = $cellAddress;
112
        }
113 32
        if ($worksheet > '') {
114 6
            $worksheet .= '!';
115
        }
116
117
        // Create absolute coordinate
118 32
        list($column, $row) = self::coordinateFromString($pCoordinateString);
119 32
        $column = ltrim($column, '$');
120 32
        $row = ltrim($row, '$');
121
122 32
        return $worksheet . '$' . $column . '$' . $row;
123
    }
124
125
    /**
126
     * Split range into coordinate strings.
127
     *
128
     * @param string $pRange e.g. 'B4:D9' or 'B4:D9,H2:O11' or 'B4'
129
     *
130
     * @return array Array containing one or more arrays containing one or two coordinate strings
131
     *                                e.g. ['B4','D9'] or [['B4','D9'], ['H2','O11']]
132
     *                                        or ['B4']
133
     */
134 122
    public static function splitRange($pRange)
135
    {
136
        // Ensure $pRange is a valid range
137 122
        if (empty($pRange)) {
138
            $pRange = self::DEFAULT_RANGE;
139
        }
140
141 122
        $exploded = explode(',', $pRange);
142 122
        $counter = count($exploded);
143 122
        for ($i = 0; $i < $counter; ++$i) {
144 122
            $exploded[$i] = explode(':', $exploded[$i]);
145
        }
146
147 122
        return $exploded;
148
    }
149
150
    /**
151
     * Build range from coordinate strings.
152
     *
153
     * @param array $pRange Array containg one or more arrays containing one or two coordinate strings
154
     *
155
     * @throws Exception
156
     *
157
     * @return string String representation of $pRange
158
     */
159 19
    public static function buildRange(array $pRange)
160
    {
161
        // Verify range
162 19
        if (empty($pRange) || !is_array($pRange[0])) {
163
            throw new Exception('Range does not contain any information');
164
        }
165
166
        // Build range
167 19
        $imploded = [];
0 ignored issues
show
Unused Code introduced by
The assignment to $imploded is dead and can be removed.
Loading history...
168 19
        $counter = count($pRange);
169 19
        for ($i = 0; $i < $counter; ++$i) {
170 19
            $pRange[$i] = implode(':', $pRange[$i]);
171
        }
172 19
        $imploded = implode(',', $pRange);
173
174 19
        return $imploded;
175
    }
176
177
    /**
178
     * Calculate range boundaries.
179
     *
180
     * @param string $pRange Cell range (e.g. A1:A1)
181
     *
182
     * @return array Range coordinates [Start Cell, End Cell]
183
     *                    where Start Cell and End Cell are arrays (Column Number, Row Number)
184
     */
185 67
    public static function rangeBoundaries($pRange)
186
    {
187
        // Ensure $pRange is a valid range
188 67
        if (empty($pRange)) {
189
            $pRange = self::DEFAULT_RANGE;
190
        }
191
192
        // Uppercase coordinate
193 67
        $pRange = strtoupper($pRange);
194
195
        // Extract range
196 67
        if (strpos($pRange, ':') === false) {
197 2
            $rangeA = $rangeB = $pRange;
198
        } else {
199 65
            list($rangeA, $rangeB) = explode(':', $pRange);
200
        }
201
202
        // Calculate range outer borders
203 67
        $rangeStart = self::coordinateFromString($rangeA);
204 67
        $rangeEnd = self::coordinateFromString($rangeB);
205
206
        // Translate column into index
207 67
        $rangeStart[0] = self::columnIndexFromString($rangeStart[0]);
208 67
        $rangeEnd[0] = self::columnIndexFromString($rangeEnd[0]);
209
210 67
        return [$rangeStart, $rangeEnd];
211
    }
212
213
    /**
214
     * Calculate range dimension.
215
     *
216
     * @param string $pRange Cell range (e.g. A1:A1)
217
     *
218
     * @return array Range dimension (width, height)
219
     */
220 16
    public static function rangeDimension($pRange)
221
    {
222
        // Calculate range outer borders
223 16
        list($rangeStart, $rangeEnd) = self::rangeBoundaries($pRange);
224
225 16
        return [($rangeEnd[0] - $rangeStart[0] + 1), ($rangeEnd[1] - $rangeStart[1] + 1)];
226
    }
227
228
    /**
229
     * Calculate range boundaries.
230
     *
231
     * @param string $pRange Cell range (e.g. A1:A1)
232
     *
233
     * @return array Range coordinates [Start Cell, End Cell]
234
     *                    where Start Cell and End Cell are arrays [Column ID, Row Number]
235
     */
236 14
    public static function getRangeBoundaries($pRange)
237
    {
238
        // Ensure $pRange is a valid range
239 14
        if (empty($pRange)) {
240
            $pRange = self::DEFAULT_RANGE;
241
        }
242
243
        // Uppercase coordinate
244 14
        $pRange = strtoupper($pRange);
245
246
        // Extract range
247 14
        if (strpos($pRange, ':') === false) {
248 1
            $rangeA = $rangeB = $pRange;
249
        } else {
250 13
            list($rangeA, $rangeB) = explode(':', $pRange);
251
        }
252
253 14
        return [self::coordinateFromString($rangeA), self::coordinateFromString($rangeB)];
254
    }
255
256
    /**
257
     * Column index from string.
258
     *
259
     * @param string $pString eg 'A'
260
     *
261
     * @return int Column index (A = 1)
262
     */
263 222
    public static function columnIndexFromString($pString)
264
    {
265
        //    Using a lookup cache adds a slight memory overhead, but boosts speed
266
        //    caching using a static within the method is faster than a class static,
267
        //        though it's additional memory overhead
268 222
        static $indexCache = [];
269
270 222
        if (isset($indexCache[$pString])) {
271 207
            return $indexCache[$pString];
272
        }
273
        //    It's surprising how costly the strtoupper() and ord() calls actually are, so we use a lookup array rather than use ord()
274
        //        and make it case insensitive to get rid of the strtoupper() as well. Because it's a static, there's no significant
275
        //        memory overhead either
276 119
        static $columnLookup = [
277
            'A' => 1, 'B' => 2, 'C' => 3, 'D' => 4, 'E' => 5, 'F' => 6, 'G' => 7, 'H' => 8, 'I' => 9, 'J' => 10, 'K' => 11, 'L' => 12, 'M' => 13,
278
            'N' => 14, 'O' => 15, 'P' => 16, 'Q' => 17, 'R' => 18, 'S' => 19, 'T' => 20, 'U' => 21, 'V' => 22, 'W' => 23, 'X' => 24, 'Y' => 25, 'Z' => 26,
279
            'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5, 'f' => 6, 'g' => 7, 'h' => 8, 'i' => 9, 'j' => 10, 'k' => 11, 'l' => 12, 'm' => 13,
280
            'n' => 14, 'o' => 15, 'p' => 16, 'q' => 17, 'r' => 18, 's' => 19, 't' => 20, 'u' => 21, 'v' => 22, 'w' => 23, 'x' => 24, 'y' => 25, 'z' => 26,
281
        ];
282
283
        //    We also use the language construct isset() rather than the more costly strlen() function to match the length of $pString
284
        //        for improved performance
285 119
        if (isset($pString[0])) {
286 117
            if (!isset($pString[1])) {
287 106
                $indexCache[$pString] = $columnLookup[$pString];
288
289 106
                return $indexCache[$pString];
290 12
            } elseif (!isset($pString[2])) {
291 9
                $indexCache[$pString] = $columnLookup[$pString[0]] * 26 + $columnLookup[$pString[1]];
292
293 9
                return $indexCache[$pString];
294 3
            } elseif (!isset($pString[3])) {
295 2
                $indexCache[$pString] = $columnLookup[$pString[0]] * 676 + $columnLookup[$pString[1]] * 26 + $columnLookup[$pString[2]];
296
297 2
                return $indexCache[$pString];
298
            }
299
        }
300
301 3
        throw new Exception('Column string index can not be ' . ((isset($pString[0])) ? 'longer than 3 characters' : 'empty'));
302
    }
303
304
    /**
305
     * String from column index.
306
     *
307
     * @param int $columnIndex Column index (A = 1)
308
     *
309
     * @return string
310
     */
311 185
    public static function stringFromColumnIndex($columnIndex)
312
    {
313 185
        static $indexCache = [];
314
315 185
        if (!isset($indexCache[$columnIndex])) {
316 116
            $indexValue = $columnIndex;
317 116
            $base26 = null;
318
            do {
319 116
                $characterValue = ($indexValue % 26) ?: 26;
320 116
                $indexValue = ($indexValue - $characterValue) / 26;
321 116
                $base26 = chr($characterValue + 64) . ($base26 ?: '');
322 116
            } while ($indexValue > 0);
323 116
            $indexCache[$columnIndex] = $base26;
324
        }
325
326 185
        return $indexCache[$columnIndex];
327
    }
328
329
    /**
330
     * Extract all cell references in range, which may be comprised of multiple cell ranges.
331
     *
332
     * @param string $pRange Range (e.g. A1 or A1:C10 or A1:E10 A20:E25)
333
     *
334
     * @return array Array containing single cell references
335
     */
336 89
    public static function extractAllCellReferencesInRange($pRange)
337
    {
338 89
        $returnValue = [];
339
340
        // Explode spaces
341 89
        $cellBlocks = self::getCellBlocksFromRangeString($pRange);
342 89
        foreach ($cellBlocks as $cellBlock) {
343 89
            $returnValue = array_merge($returnValue, static::getReferencesForCellBlock($cellBlock));
344
        }
345
346
        //    Sort the result by column and row
347 85
        $sortKeys = [];
348 85
        foreach (array_unique($returnValue) as $coord) {
349 85
            sscanf($coord, '%[A-Z]%d', $column, $row);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $row seems to be never defined.
Loading history...
350 85
            $sortKeys[sprintf('%3s%09d', $column, $row)] = $coord;
351
        }
352 85
        ksort($sortKeys);
353
354
        // Return value
355 85
        return array_values($sortKeys);
356
    }
357
358
    /**
359
     * Get all cell references for an individual cell block.
360
     *
361
     * @param string $cellBlock A cell range e.g. A4:B5
362
     *
363
     * @throws Exception
364
     *
365
     * @return array All individual cells in that range
366
     */
367 89
    private static function getReferencesForCellBlock($cellBlock)
368
    {
369 89
        $returnValue = [];
370
371
        // Single cell?
372 89
        if (!self::coordinateIsRange($cellBlock)) {
373 62
            return (array) $cellBlock;
374
        }
375
376
        // Range...
377 80
        $ranges = self::splitRange($cellBlock);
378 80
        foreach ($ranges as $range) {
379
            // Single cell?
380 80
            if (!isset($range[1])) {
381
                $returnValue[] = $range[0];
382
383
                continue;
384
            }
385
386
            // Range...
387 80
            list($rangeStart, $rangeEnd) = $range;
388 80
            list($startCol, $startRow) = static::extractColumnAndRow($rangeStart);
389 80
            list($endCol, $endRow) = static::extractColumnAndRow($rangeEnd);
390 80
            ++$endCol;
391
392
            // Current data
393 80
            $currentCol = $startCol;
394 80
            $currentRow = $startRow;
395
396 80
            static::validateRange($cellBlock, $startCol, $endCol, $currentRow, $endRow);
397
398
            // Loop cells
399 76
            while ($currentCol < $endCol) {
400 76
                while ($currentRow <= $endRow) {
401 76
                    $returnValue[] = $currentCol . $currentRow;
402 76
                    ++$currentRow;
403
                }
404 76
                ++$currentCol;
405 76
                $currentRow = $startRow;
406
            }
407
        }
408
409 76
        return $returnValue;
410
    }
411
412
    /**
413
     * Extract the column and row from a cell reference in the format [$column, $row].
414
     *
415
     * @param string $cell
416
     *
417
     * @return array
418
     */
419 80
    private static function extractColumnAndRow($cell)
420
    {
421 80
        return sscanf($cell, '%[A-Z]%d');
422
    }
423
424
    /**
425
     * Convert an associative array of single cell coordinates to values to an associative array
426
     * of cell ranges to values.  Only adjacent cell coordinates with the same
427
     * value will be merged.  If the value is an object, it must implement the method getHashCode().
428
     *
429
     * For example, this function converts:
430
     *
431
     *    [ 'A1' => 'x', 'A2' => 'x', 'A3' => 'x', 'A4' => 'y' ]
432
     *
433
     * to:
434
     *
435
     *    [ 'A1:A3' => 'x', 'A4' => 'y' ]
436
     *
437
     * @param array $pCoordCollection associative array mapping coordinates to values
438
     *
439
     * @return array associative array mapping coordinate ranges to valuea
440
     */
441 7
    public static function mergeRangesInCollection(array $pCoordCollection)
442
    {
443 7
        $hashedValues = [];
444 7
        $mergedCoordCollection = [];
445
446 7
        foreach ($pCoordCollection as $coord => $value) {
447 7
            if (self::coordinateIsRange($coord)) {
448 1
                $mergedCoordCollection[$coord] = $value;
449
450 1
                continue;
451
            }
452
453 7
            list($column, $row) = self::coordinateFromString($coord);
454 7
            $row = (int) (ltrim($row, '$'));
455 7
            $hashCode = $column . '-' . (is_object($value) ? $value->getHashCode() : $value);
456
457 7
            if (!isset($hashedValues[$hashCode])) {
458 7
                $hashedValues[$hashCode] = (object) [
459 7
                    'value' => $value,
460 7
                    'col' => $column,
461 7
                    'rows' => [$row],
462
                ];
463
            } else {
464 7
                $hashedValues[$hashCode]->rows[] = $row;
465
            }
466
        }
467
468 7
        ksort($hashedValues);
469
470 7
        foreach ($hashedValues as $hashedValue) {
471 7
            sort($hashedValue->rows);
472 7
            $rowStart = null;
473 7
            $rowEnd = null;
474 7
            $ranges = [];
475
476 7
            foreach ($hashedValue->rows as $row) {
477 7
                if ($rowStart === null) {
478 7
                    $rowStart = $row;
479 7
                    $rowEnd = $row;
480 3
                } elseif ($rowEnd === $row - 1) {
481 3
                    $rowEnd = $row;
482
                } else {
483 1
                    if ($rowStart == $rowEnd) {
484
                        $ranges[] = $hashedValue->col . $rowStart;
485
                    } else {
486 1
                        $ranges[] = $hashedValue->col . $rowStart . ':' . $hashedValue->col . $rowEnd;
487
                    }
488
489 1
                    $rowStart = $row;
490 7
                    $rowEnd = $row;
491
                }
492
            }
493
494 7
            if ($rowStart !== null) {
495 7
                if ($rowStart == $rowEnd) {
496 6
                    $ranges[] = $hashedValue->col . $rowStart;
497
                } else {
498 2
                    $ranges[] = $hashedValue->col . $rowStart . ':' . $hashedValue->col . $rowEnd;
499
                }
500
            }
501
502 7
            foreach ($ranges as $range) {
503 7
                $mergedCoordCollection[$range] = $hashedValue->value;
504
            }
505
        }
506
507 7
        return $mergedCoordCollection;
508
    }
509
510
    /**
511
     * Get the individual cell blocks from a range string, splitting by space and removing any $ characters.
512
     *
513
     * @param string $pRange
514
     *
515
     * @return string[]
516
     */
517 89
    private static function getCellBlocksFromRangeString($pRange)
518
    {
519 89
        return explode(' ', str_replace('$', '', strtoupper($pRange)));
520
    }
521
522
    /**
523
     * Check that the given range is valid, i.e. that the start column and row are not greater than the end column and
524
     * row.
525
     *
526
     * @param string $cellBlock The original range, for displaying a meaningful error message
527
     * @param string $startCol
528
     * @param string $endCol
529
     * @param int $currentRow
530
     * @param int $endRow
531
     *
532
     * @throws Exception
533
     */
534 80
    private static function validateRange($cellBlock, $startCol, $endCol, $currentRow, $endRow)
535
    {
536 80
        if ($startCol >= $endCol || $currentRow > $endRow) {
537 4
            throw new Exception('Invalid range: "' . $cellBlock . '"');
538
        }
539 76
    }
540
}
541