Passed
Push — develop ( 2eb342...371c80 )
by Adrien
41:20
created

Coordinate::getCellBlocksFromRangeString()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
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 235
    public static function coordinateFromString($pCoordinateString)
32
    {
33 235
        if (preg_match('/^([$]?[A-Z]{1,3})([$]?\\d{1,7})$/', $pCoordinateString, $matches)) {
34 232
            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 219
    public static function coordinateIsRange($coord)
52
    {
53 219
        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 123
    public static function splitRange($pRange)
135
    {
136
        // Ensure $pRange is a valid range
137 123
        if (empty($pRange)) {
138
            $pRange = self::DEFAULT_RANGE;
139
        }
140
141 123
        $exploded = explode(',', $pRange);
142 123
        $counter = count($exploded);
143 123
        for ($i = 0; $i < $counter; ++$i) {
144 123
            $exploded[$i] = explode(':', $exploded[$i]);
145
        }
146
147 123
        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 237
    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 237
        static $indexCache = [];
269
270 237
        if (isset($indexCache[$pString])) {
271 222
            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 120
        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 120
        if (isset($pString[0])) {
286 118
            if (!isset($pString[1])) {
287 107
                $indexCache[$pString] = $columnLookup[$pString];
288
289 107
                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 196
    public static function stringFromColumnIndex($columnIndex)
312
    {
313 196
        static $indexCache = [];
314
315 196
        if (!isset($indexCache[$columnIndex])) {
316 119
            $indexValue = $columnIndex;
317 119
            $base26 = null;
318
            do {
319 119
                $characterValue = ($indexValue % 26) ?: 26;
320 119
                $indexValue = ($indexValue - $characterValue) / 26;
321 119
                $base26 = chr($characterValue + 64) . ($base26 ?: '');
322 119
            } while ($indexValue > 0);
323 119
            $indexCache[$columnIndex] = $base26;
324
        }
325
326 196
        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 90
    public static function extractAllCellReferencesInRange($pRange)
337
    {
338 90
        $returnValue = [];
339
340
        // Explode spaces
341 90
        $cellBlocks = self::getCellBlocksFromRangeString($pRange);
342 90
        foreach ($cellBlocks as $cellBlock) {
343 90
            $returnValue = array_merge($returnValue, self::getReferencesForCellBlock($cellBlock));
344
        }
345
346
        //    Sort the result by column and row
347 86
        $sortKeys = [];
348 86
        foreach (array_unique($returnValue) as $coord) {
349 86
            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 86
            $sortKeys[sprintf('%3s%09d', $column, $row)] = $coord;
351
        }
352 86
        ksort($sortKeys);
353
354
        // Return value
355 86
        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
     * @return array All individual cells in that range
364
     */
365 90
    private static function getReferencesForCellBlock($cellBlock)
366
    {
367 90
        $returnValue = [];
368
369
        // Single cell?
370 90
        if (!self::coordinateIsRange($cellBlock)) {
371 62
            return (array) $cellBlock;
372
        }
373
374
        // Range...
375 81
        $ranges = self::splitRange($cellBlock);
376 81
        foreach ($ranges as $range) {
377
            // Single cell?
378 81
            if (!isset($range[1])) {
379
                $returnValue[] = $range[0];
380
381
                continue;
382
            }
383
384
            // Range...
385 81
            list($rangeStart, $rangeEnd) = $range;
386 81
            list($startColumn, $startRow) = self::coordinateFromString($rangeStart);
387 81
            list($endColumn, $endRow) = self::coordinateFromString($rangeEnd);
388 81
            $startColumnIndex = self::columnIndexFromString($startColumn);
389 81
            $endColumnIndex = self::columnIndexFromString($endColumn);
390 81
            ++$endColumnIndex;
391
392
            // Current data
393 81
            $currentColumnIndex = $startColumnIndex;
394 81
            $currentRow = $startRow;
395
396 81
            self::validateRange($cellBlock, $startColumnIndex, $endColumnIndex, $currentRow, $endRow);
0 ignored issues
show
Bug introduced by
$endRow of type string is incompatible with the type integer expected by parameter $endRow of PhpOffice\PhpSpreadsheet...dinate::validateRange(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

396
            self::validateRange($cellBlock, $startColumnIndex, $endColumnIndex, $currentRow, /** @scrutinizer ignore-type */ $endRow);
Loading history...
Bug introduced by
$currentRow of type string is incompatible with the type integer expected by parameter $currentRow of PhpOffice\PhpSpreadsheet...dinate::validateRange(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

396
            self::validateRange($cellBlock, $startColumnIndex, $endColumnIndex, /** @scrutinizer ignore-type */ $currentRow, $endRow);
Loading history...
397
398
            // Loop cells
399 77
            while ($currentColumnIndex < $endColumnIndex) {
400 77
                while ($currentRow <= $endRow) {
401 77
                    $returnValue[] = self::stringFromColumnIndex($currentColumnIndex) . $currentRow;
402 77
                    ++$currentRow;
403
                }
404 77
                ++$currentColumnIndex;
405 77
                $currentRow = $startRow;
406
            }
407
        }
408
409 77
        return $returnValue;
410
    }
411
412
    /**
413
     * Convert an associative array of single cell coordinates to values to an associative array
414
     * of cell ranges to values.  Only adjacent cell coordinates with the same
415
     * value will be merged.  If the value is an object, it must implement the method getHashCode().
416
     *
417
     * For example, this function converts:
418
     *
419
     *    [ 'A1' => 'x', 'A2' => 'x', 'A3' => 'x', 'A4' => 'y' ]
420
     *
421
     * to:
422
     *
423
     *    [ 'A1:A3' => 'x', 'A4' => 'y' ]
424
     *
425
     * @param array $pCoordCollection associative array mapping coordinates to values
426
     *
427
     * @return array associative array mapping coordinate ranges to valuea
428
     */
429 7
    public static function mergeRangesInCollection(array $pCoordCollection)
430
    {
431 7
        $hashedValues = [];
432 7
        $mergedCoordCollection = [];
433
434 7
        foreach ($pCoordCollection as $coord => $value) {
435 7
            if (self::coordinateIsRange($coord)) {
436 1
                $mergedCoordCollection[$coord] = $value;
437
438 1
                continue;
439
            }
440
441 7
            list($column, $row) = self::coordinateFromString($coord);
442 7
            $row = (int) (ltrim($row, '$'));
443 7
            $hashCode = $column . '-' . (is_object($value) ? $value->getHashCode() : $value);
444
445 7
            if (!isset($hashedValues[$hashCode])) {
446 7
                $hashedValues[$hashCode] = (object) [
447 7
                    'value' => $value,
448 7
                    'col' => $column,
449 7
                    'rows' => [$row],
450
                ];
451
            } else {
452 7
                $hashedValues[$hashCode]->rows[] = $row;
453
            }
454
        }
455
456 7
        ksort($hashedValues);
457
458 7
        foreach ($hashedValues as $hashedValue) {
459 7
            sort($hashedValue->rows);
460 7
            $rowStart = null;
461 7
            $rowEnd = null;
462 7
            $ranges = [];
463
464 7
            foreach ($hashedValue->rows as $row) {
465 7
                if ($rowStart === null) {
466 7
                    $rowStart = $row;
467 7
                    $rowEnd = $row;
468 3
                } elseif ($rowEnd === $row - 1) {
469 3
                    $rowEnd = $row;
470
                } else {
471 1
                    if ($rowStart == $rowEnd) {
472
                        $ranges[] = $hashedValue->col . $rowStart;
473
                    } else {
474 1
                        $ranges[] = $hashedValue->col . $rowStart . ':' . $hashedValue->col . $rowEnd;
475
                    }
476
477 1
                    $rowStart = $row;
478 7
                    $rowEnd = $row;
479
                }
480
            }
481
482 7
            if ($rowStart !== null) {
483 7
                if ($rowStart == $rowEnd) {
484 6
                    $ranges[] = $hashedValue->col . $rowStart;
485
                } else {
486 2
                    $ranges[] = $hashedValue->col . $rowStart . ':' . $hashedValue->col . $rowEnd;
487
                }
488
            }
489
490 7
            foreach ($ranges as $range) {
491 7
                $mergedCoordCollection[$range] = $hashedValue->value;
492
            }
493
        }
494
495 7
        return $mergedCoordCollection;
496
    }
497
498
    /**
499
     * Get the individual cell blocks from a range string, splitting by space and removing any $ characters.
500
     *
501
     * @param string $pRange
502
     *
503
     * @return string[]
504
     */
505 90
    private static function getCellBlocksFromRangeString($pRange)
506
    {
507 90
        return explode(' ', str_replace('$', '', strtoupper($pRange)));
508
    }
509
510
    /**
511
     * Check that the given range is valid, i.e. that the start column and row are not greater than the end column and
512
     * row.
513
     *
514
     * @param string $cellBlock The original range, for displaying a meaningful error message
515
     * @param int $startColumnIndex
516
     * @param int $endColumnIndex
517
     * @param int $currentRow
518
     * @param int $endRow
519
     */
520 81
    private static function validateRange($cellBlock, $startColumnIndex, $endColumnIndex, $currentRow, $endRow)
521
    {
522 81
        if ($startColumnIndex >= $endColumnIndex || $currentRow > $endRow) {
523 4
            throw new Exception('Invalid range: "' . $cellBlock . '"');
524
        }
525 77
    }
526
}
527