Total Complexity | 107 |
Total Lines | 732 |
Duplicated Lines | 0 % |
Coverage | 97.38% |
Changes | 0 |
Complex classes like Coordinate often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use Coordinate, and based on these observations, apply Extract Interface, too.
1 | <?php |
||
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 |
||
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] |
||
1 ignored issue
–
show
|
|||
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) |
||
1 ignored issue
–
show
|
|||
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] |
|
1 ignored issue
–
show
|
|||
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 |
||
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 |
||
748 | } |
||
749 | } |
||
750 | } |
||
751 |