Passed
Push — develop ( 57404f...50a9bc )
by Adrien
32:00
created

Coordinate::rangeDimension()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 6
ccs 3
cts 3
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
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
7
8
/**
9
 * Helper class to manipulate cell coordinates.
10
 *
11
 * Columns indexes and rows are always based on 1, **not** on 0. This match the behavior
12
 * that Excel users are used to, and also match the Excel functions `COLUMN()` and `ROW()`.
13
 */
14
abstract class Coordinate
15
{
16
    /**
17
     * Default range variable constant.
18
     *
19
     * @var string
20
     */
21
    const DEFAULT_RANGE = 'A1:A1';
22
23
    /**
24
     * Coordinate from string.
25
     *
26
     * @param string $pCoordinateString eg: 'A1'
27
     *
28
     * @throws Exception
29
     *
30
     * @return string[] Array containing column and row (indexes 0 and 1)
31
     */
32 242
    public static function coordinateFromString($pCoordinateString)
33
    {
34 242
        if (preg_match('/^([$]?[A-Z]{1,3})([$]?\\d{1,7})$/', $pCoordinateString, $matches)) {
35 239
            return [$matches[1], $matches[2]];
36 4
        } elseif (self::coordinateIsRange($pCoordinateString)) {
37 1
            throw new Exception('Cell coordinate string can not be a range of cells');
38 3
        } elseif ($pCoordinateString == '') {
39 1
            throw new Exception('Cell coordinate can not be zero-length string');
40
        }
41
42 2
        throw new Exception('Invalid cell coordinate ' . $pCoordinateString);
43
    }
44
45
    /**
46
     * Checks if a coordinate represents a range of cells.
47
     *
48
     * @param string $coord eg: 'A1' or 'A1:A2' or 'A1:A2,C1:C2'
49
     *
50
     * @return bool Whether the coordinate represents a range of cells
51
     */
52 226
    public static function coordinateIsRange($coord)
53
    {
54 226
        return (strpos($coord, ':') !== false) || (strpos($coord, ',') !== false);
55
    }
56
57
    /**
58
     * Make string row, column or cell coordinate absolute.
59
     *
60
     * @param string $pCoordinateString e.g. 'A' or '1' or 'A1'
61
     *                    Note that this value can be a row or column reference as well as a cell reference
62
     *
63
     * @throws Exception
64
     *
65
     * @return string Absolute coordinate        e.g. '$A' or '$1' or '$A$1'
66
     */
67 22
    public static function absoluteReference($pCoordinateString)
68
    {
69 22
        if (self::coordinateIsRange($pCoordinateString)) {
70 1
            throw new Exception('Cell coordinate string can not be a range of cells');
71
        }
72
73
        // Split out any worksheet name from the reference
74 21
        list($worksheet, $pCoordinateString) = Worksheet::extractSheetTitle($pCoordinateString, true);
75 21
        if ($worksheet > '') {
76 8
            $worksheet .= '!';
77
        }
78
79
        // Create absolute coordinate
80 21
        if (ctype_digit($pCoordinateString)) {
81 2
            return $worksheet . '$' . $pCoordinateString;
82 19
        } elseif (ctype_alpha($pCoordinateString)) {
83 2
            return $worksheet . '$' . strtoupper($pCoordinateString);
84
        }
85
86 17
        return $worksheet . self::absoluteCoordinate($pCoordinateString);
87
    }
88
89
    /**
90
     * Make string coordinate absolute.
91
     *
92
     * @param string $pCoordinateString e.g. 'A1'
93
     *
94
     * @throws Exception
95
     *
96
     * @return string Absolute coordinate        e.g. '$A$1'
97
     */
98 33
    public static function absoluteCoordinate($pCoordinateString)
99
    {
100 33
        if (self::coordinateIsRange($pCoordinateString)) {
101 1
            throw new Exception('Cell coordinate string can not be a range of cells');
102
        }
103
104
        // Split out any worksheet name from the coordinate
105 32
        list($worksheet, $pCoordinateString) = Worksheet::extractSheetTitle($pCoordinateString, true);
106 32
        if ($worksheet > '') {
107 6
            $worksheet .= '!';
108
        }
109
110
        // Create absolute coordinate
111 32
        list($column, $row) = self::coordinateFromString($pCoordinateString);
112 32
        $column = ltrim($column, '$');
113 32
        $row = ltrim($row, '$');
114
115 32
        return $worksheet . '$' . $column . '$' . $row;
116
    }
117
118
    /**
119
     * Split range into coordinate strings.
120
     *
121
     * @param string $pRange e.g. 'B4:D9' or 'B4:D9,H2:O11' or 'B4'
122
     *
123
     * @return array Array containing one or more arrays containing one or two coordinate strings
124
     *                                e.g. ['B4','D9'] or [['B4','D9'], ['H2','O11']]
125
     *                                        or ['B4']
126
     */
127 125
    public static function splitRange($pRange)
128
    {
129
        // Ensure $pRange is a valid range
130 125
        if (empty($pRange)) {
131
            $pRange = self::DEFAULT_RANGE;
132
        }
133
134 125
        $exploded = explode(',', $pRange);
135 125
        $counter = count($exploded);
136 125
        for ($i = 0; $i < $counter; ++$i) {
137 125
            $exploded[$i] = explode(':', $exploded[$i]);
138
        }
139
140 125
        return $exploded;
141
    }
142
143
    /**
144
     * Build range from coordinate strings.
145
     *
146
     * @param array $pRange Array containg one or more arrays containing one or two coordinate strings
147
     *
148
     * @throws Exception
149
     *
150
     * @return string String representation of $pRange
151
     */
152 19
    public static function buildRange(array $pRange)
153
    {
154
        // Verify range
155 19
        if (empty($pRange) || !is_array($pRange[0])) {
156
            throw new Exception('Range does not contain any information');
157
        }
158
159
        // Build range
160 19
        $imploded = [];
0 ignored issues
show
Unused Code introduced by
The assignment to $imploded is dead and can be removed.
Loading history...
161 19
        $counter = count($pRange);
162 19
        for ($i = 0; $i < $counter; ++$i) {
163 19
            $pRange[$i] = implode(':', $pRange[$i]);
164
        }
165 19
        $imploded = implode(',', $pRange);
166
167 19
        return $imploded;
168
    }
169
170
    /**
171
     * Calculate range boundaries.
172
     *
173
     * @param string $pRange Cell range (e.g. A1:A1)
174
     *
175
     * @return array Range coordinates [Start Cell, End Cell]
176
     *                    where Start Cell and End Cell are arrays (Column Number, Row Number)
177
     */
178 67
    public static function rangeBoundaries($pRange)
179
    {
180
        // Ensure $pRange is a valid range
181 67
        if (empty($pRange)) {
182
            $pRange = self::DEFAULT_RANGE;
183
        }
184
185
        // Uppercase coordinate
186 67
        $pRange = strtoupper($pRange);
187
188
        // Extract range
189 67
        if (strpos($pRange, ':') === false) {
190 2
            $rangeA = $rangeB = $pRange;
191
        } else {
192 65
            list($rangeA, $rangeB) = explode(':', $pRange);
193
        }
194
195
        // Calculate range outer borders
196 67
        $rangeStart = self::coordinateFromString($rangeA);
197 67
        $rangeEnd = self::coordinateFromString($rangeB);
198
199
        // Translate column into index
200 67
        $rangeStart[0] = self::columnIndexFromString($rangeStart[0]);
201 67
        $rangeEnd[0] = self::columnIndexFromString($rangeEnd[0]);
202
203 67
        return [$rangeStart, $rangeEnd];
204
    }
205
206
    /**
207
     * Calculate range dimension.
208
     *
209
     * @param string $pRange Cell range (e.g. A1:A1)
210
     *
211
     * @return array Range dimension (width, height)
212
     */
213 16
    public static function rangeDimension($pRange)
214
    {
215
        // Calculate range outer borders
216 16
        list($rangeStart, $rangeEnd) = self::rangeBoundaries($pRange);
217
218 16
        return [($rangeEnd[0] - $rangeStart[0] + 1), ($rangeEnd[1] - $rangeStart[1] + 1)];
219
    }
220
221
    /**
222
     * Calculate range boundaries.
223
     *
224
     * @param string $pRange Cell range (e.g. A1:A1)
225
     *
226
     * @return array Range coordinates [Start Cell, End Cell]
227
     *                    where Start Cell and End Cell are arrays [Column ID, Row Number]
228
     */
229 14
    public static function getRangeBoundaries($pRange)
230
    {
231
        // Ensure $pRange is a valid range
232 14
        if (empty($pRange)) {
233
            $pRange = self::DEFAULT_RANGE;
234
        }
235
236
        // Uppercase coordinate
237 14
        $pRange = strtoupper($pRange);
238
239
        // Extract range
240 14
        if (strpos($pRange, ':') === false) {
241 1
            $rangeA = $rangeB = $pRange;
242
        } else {
243 13
            list($rangeA, $rangeB) = explode(':', $pRange);
244
        }
245
246 14
        return [self::coordinateFromString($rangeA), self::coordinateFromString($rangeB)];
247
    }
248
249
    /**
250
     * Column index from string.
251
     *
252
     * @param string $pString eg 'A'
253
     *
254
     * @return int Column index (A = 1)
255
     */
256 244
    public static function columnIndexFromString($pString)
257
    {
258
        //    Using a lookup cache adds a slight memory overhead, but boosts speed
259
        //    caching using a static within the method is faster than a class static,
260
        //        though it's additional memory overhead
261 244
        static $indexCache = [];
262
263 244
        if (isset($indexCache[$pString])) {
264 229
            return $indexCache[$pString];
265
        }
266
        //    It's surprising how costly the strtoupper() and ord() calls actually are, so we use a lookup array rather than use ord()
267
        //        and make it case insensitive to get rid of the strtoupper() as well. Because it's a static, there's no significant
268
        //        memory overhead either
269 121
        static $columnLookup = [
270
            '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,
271
            '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,
272
            '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,
273
            '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,
274
        ];
275
276
        //    We also use the language construct isset() rather than the more costly strlen() function to match the length of $pString
277
        //        for improved performance
278 121
        if (isset($pString[0])) {
279 119
            if (!isset($pString[1])) {
280 108
                $indexCache[$pString] = $columnLookup[$pString];
281
282 108
                return $indexCache[$pString];
283 12
            } elseif (!isset($pString[2])) {
284 9
                $indexCache[$pString] = $columnLookup[$pString[0]] * 26 + $columnLookup[$pString[1]];
285
286 9
                return $indexCache[$pString];
287 3
            } elseif (!isset($pString[3])) {
288 2
                $indexCache[$pString] = $columnLookup[$pString[0]] * 676 + $columnLookup[$pString[1]] * 26 + $columnLookup[$pString[2]];
289
290 2
                return $indexCache[$pString];
291
            }
292
        }
293
294 3
        throw new Exception('Column string index can not be ' . ((isset($pString[0])) ? 'longer than 3 characters' : 'empty'));
295
    }
296
297
    /**
298
     * String from column index.
299
     *
300
     * @param int $columnIndex Column index (A = 1)
301
     *
302
     * @return string
303
     */
304 201
    public static function stringFromColumnIndex($columnIndex)
305
    {
306 201
        static $indexCache = [];
307
308 201
        if (!isset($indexCache[$columnIndex])) {
309 120
            $indexValue = $columnIndex;
310 120
            $base26 = null;
311
            do {
312 120
                $characterValue = ($indexValue % 26) ?: 26;
313 120
                $indexValue = ($indexValue - $characterValue) / 26;
314 120
                $base26 = chr($characterValue + 64) . ($base26 ?: '');
315 120
            } while ($indexValue > 0);
316 120
            $indexCache[$columnIndex] = $base26;
317
        }
318
319 201
        return $indexCache[$columnIndex];
320
    }
321
322
    /**
323
     * Extract all cell references in range, which may be comprised of multiple cell ranges.
324
     *
325
     * @param string $pRange Range (e.g. A1 or A1:C10 or A1:E10 A20:E25)
326
     *
327
     * @return array Array containing single cell references
328
     */
329 90
    public static function extractAllCellReferencesInRange($pRange)
330
    {
331 90
        $returnValue = [];
332
333
        // Explode spaces
334 90
        $cellBlocks = self::getCellBlocksFromRangeString($pRange);
335 90
        foreach ($cellBlocks as $cellBlock) {
336 90
            $returnValue = array_merge($returnValue, self::getReferencesForCellBlock($cellBlock));
337
        }
338
339
        //    Sort the result by column and row
340 86
        $sortKeys = [];
341 86
        foreach (array_unique($returnValue) as $coord) {
342 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...
343 86
            $sortKeys[sprintf('%3s%09d', $column, $row)] = $coord;
344
        }
345 86
        ksort($sortKeys);
346
347
        // Return value
348 86
        return array_values($sortKeys);
349
    }
350
351
    /**
352
     * Get all cell references for an individual cell block.
353
     *
354
     * @param string $cellBlock A cell range e.g. A4:B5
355
     *
356
     * @return array All individual cells in that range
357
     */
358 90
    private static function getReferencesForCellBlock($cellBlock)
359
    {
360 90
        $returnValue = [];
361
362
        // Single cell?
363 90
        if (!self::coordinateIsRange($cellBlock)) {
364 62
            return (array) $cellBlock;
365
        }
366
367
        // Range...
368 81
        $ranges = self::splitRange($cellBlock);
369 81
        foreach ($ranges as $range) {
370
            // Single cell?
371 81
            if (!isset($range[1])) {
372
                $returnValue[] = $range[0];
373
374
                continue;
375
            }
376
377
            // Range...
378 81
            list($rangeStart, $rangeEnd) = $range;
379 81
            list($startColumn, $startRow) = self::coordinateFromString($rangeStart);
380 81
            list($endColumn, $endRow) = self::coordinateFromString($rangeEnd);
381 81
            $startColumnIndex = self::columnIndexFromString($startColumn);
382 81
            $endColumnIndex = self::columnIndexFromString($endColumn);
383 81
            ++$endColumnIndex;
384
385
            // Current data
386 81
            $currentColumnIndex = $startColumnIndex;
387 81
            $currentRow = $startRow;
388
389 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

389
            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

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