Coordinate.php 17 KB

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