• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

PHPOffice / PhpSpreadsheet / 20627326675

31 Dec 2025 09:15PM UTC coverage: 96.167% (+0.03%) from 96.141%
20627326675

Pull #4765

github

web-flow
Merge 6af5379b6 into 947cdeeee
Pull Request #4765: SUBTOTAL and Hidden Rows

20 of 20 new or added lines in 3 files covered. (100.0%)

93 existing lines in 4 files now uncovered.

45992 of 47825 relevant lines covered (96.17%)

385.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.75
/src/PhpSpreadsheet/Worksheet/Worksheet.php
1
<?php
2

3
namespace PhpOffice\PhpSpreadsheet\Worksheet;
4

5
use ArrayObject;
6
use Composer\Pcre\Preg;
7
use Generator;
8
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
9
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
10
use PhpOffice\PhpSpreadsheet\Cell\AddressRange;
11
use PhpOffice\PhpSpreadsheet\Cell\Cell;
12
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
13
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
14
use PhpOffice\PhpSpreadsheet\Cell\DataType;
15
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
16
use PhpOffice\PhpSpreadsheet\Cell\Hyperlink;
17
use PhpOffice\PhpSpreadsheet\Cell\IValueBinder;
18
use PhpOffice\PhpSpreadsheet\Chart\Chart;
19
use PhpOffice\PhpSpreadsheet\Collection\Cells;
20
use PhpOffice\PhpSpreadsheet\Collection\CellsFactory;
21
use PhpOffice\PhpSpreadsheet\Comment;
22
use PhpOffice\PhpSpreadsheet\DefinedName;
23
use PhpOffice\PhpSpreadsheet\Exception;
24
use PhpOffice\PhpSpreadsheet\ReferenceHelper;
25
use PhpOffice\PhpSpreadsheet\RichText\RichText;
26
use PhpOffice\PhpSpreadsheet\Shared;
27
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
28
use PhpOffice\PhpSpreadsheet\Spreadsheet;
29
use PhpOffice\PhpSpreadsheet\Style\Alignment;
30
use PhpOffice\PhpSpreadsheet\Style\Color;
31
use PhpOffice\PhpSpreadsheet\Style\Conditional;
32
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
33
use PhpOffice\PhpSpreadsheet\Style\Protection as StyleProtection;
34
use PhpOffice\PhpSpreadsheet\Style\Style;
35

36
class Worksheet
37
{
38
    // Break types
39
    public const BREAK_NONE = 0;
40
    public const BREAK_ROW = 1;
41
    public const BREAK_COLUMN = 2;
42
    // Maximum column for row break
43
    public const BREAK_ROW_MAX_COLUMN = 16383;
44

45
    // Sheet state
46
    public const SHEETSTATE_VISIBLE = 'visible';
47
    public const SHEETSTATE_HIDDEN = 'hidden';
48
    public const SHEETSTATE_VERYHIDDEN = 'veryHidden';
49

50
    public const MERGE_CELL_CONTENT_EMPTY = 'empty';
51
    public const MERGE_CELL_CONTENT_HIDE = 'hide';
52
    public const MERGE_CELL_CONTENT_MERGE = 'merge';
53

54
    public const FUNCTION_LIKE_GROUPBY = '/\b(groupby|_xleta)\b/i'; // weird new syntax
55

56
    protected const SHEET_NAME_REQUIRES_NO_QUOTES = '/^[_\p{L}][_\p{L}\p{N}]*$/mui';
57

58
    /**
59
     * Maximum 31 characters allowed for sheet title.
60
     *
61
     * @var int
62
     */
63
    const SHEET_TITLE_MAXIMUM_LENGTH = 31;
64

65
    /**
66
     * Invalid characters in sheet title.
67
     */
68
    private const INVALID_CHARACTERS = ['*', ':', '/', '\\', '?', '[', ']'];
69

70
    /**
71
     * Parent spreadsheet.
72
     */
73
    private ?Spreadsheet $parent = null;
74

75
    /**
76
     * Collection of cells.
77
     */
78
    private Cells $cellCollection;
79

80
    /**
81
     * Collection of row dimensions.
82
     *
83
     * @var RowDimension[]
84
     */
85
    private array $rowDimensions = [];
86

87
    /**
88
     * Default row dimension.
89
     */
90
    private RowDimension $defaultRowDimension;
91

92
    /**
93
     * Collection of column dimensions.
94
     *
95
     * @var ColumnDimension[]
96
     */
97
    private array $columnDimensions = [];
98

99
    /**
100
     * Default column dimension.
101
     */
102
    private ColumnDimension $defaultColumnDimension;
103

104
    /**
105
     * Collection of drawings.
106
     *
107
     * @var ArrayObject<int, BaseDrawing>
108
     */
109
    private ArrayObject $drawingCollection;
110

111
    /**
112
     * Collection of Chart objects.
113
     *
114
     * @var ArrayObject<int, Chart>
115
     */
116
    private ArrayObject $chartCollection;
117

118
    /**
119
     * Collection of Table objects.
120
     *
121
     * @var ArrayObject<int, Table>
122
     */
123
    private ArrayObject $tableCollection;
124

125
    /**
126
     * Worksheet title.
127
     */
128
    private string $title = '';
129

130
    /**
131
     * Sheet state.
132
     */
133
    private string $sheetState;
134

135
    /**
136
     * Page setup.
137
     */
138
    private PageSetup $pageSetup;
139

140
    /**
141
     * Page margins.
142
     */
143
    private PageMargins $pageMargins;
144

145
    /**
146
     * Page header/footer.
147
     */
148
    private HeaderFooter $headerFooter;
149

150
    /**
151
     * Sheet view.
152
     */
153
    private SheetView $sheetView;
154

155
    /**
156
     * Protection.
157
     */
158
    private Protection $protection;
159

160
    /**
161
     * Conditional styles. Indexed by cell coordinate, e.g. 'A1'.
162
     *
163
     * @var Conditional[][]
164
     */
165
    private array $conditionalStylesCollection = [];
166

167
    /**
168
     * Collection of row breaks.
169
     *
170
     * @var PageBreak[]
171
     */
172
    private array $rowBreaks = [];
173

174
    /**
175
     * Collection of column breaks.
176
     *
177
     * @var PageBreak[]
178
     */
179
    private array $columnBreaks = [];
180

181
    /**
182
     * Collection of merged cell ranges.
183
     *
184
     * @var string[]
185
     */
186
    private array $mergeCells = [];
187

188
    /**
189
     * Collection of protected cell ranges.
190
     *
191
     * @var ProtectedRange[]
192
     */
193
    private array $protectedCells = [];
194

195
    /**
196
     * Autofilter Range and selection.
197
     */
198
    private AutoFilter $autoFilter;
199

200
    /**
201
     * Freeze pane.
202
     */
203
    private ?string $freezePane = null;
204

205
    /**
206
     * Default position of the right bottom pane.
207
     */
208
    private ?string $topLeftCell = null;
209

210
    private string $paneTopLeftCell = '';
211

212
    private string $activePane = '';
213

214
    private int $xSplit = 0;
215

216
    private int $ySplit = 0;
217

218
    private string $paneState = '';
219

220
    /**
221
     * Properties of the 4 panes.
222
     *
223
     * @var (null|Pane)[]
224
     */
225
    private array $panes = [
226
        'bottomRight' => null,
227
        'bottomLeft' => null,
228
        'topRight' => null,
229
        'topLeft' => null,
230
    ];
231

232
    /**
233
     * Show gridlines?
234
     */
235
    private bool $showGridlines = true;
236

237
    /**
238
     * Print gridlines?
239
     */
240
    private bool $printGridlines = false;
241

242
    /**
243
     * Show row and column headers?
244
     */
245
    private bool $showRowColHeaders = true;
246

247
    /**
248
     * Show summary below? (Row/Column outline).
249
     */
250
    private bool $showSummaryBelow = true;
251

252
    /**
253
     * Show summary right? (Row/Column outline).
254
     */
255
    private bool $showSummaryRight = true;
256

257
    /**
258
     * Collection of comments.
259
     *
260
     * @var Comment[]
261
     */
262
    private array $comments = [];
263

264
    /**
265
     * Active cell. (Only one!).
266
     */
267
    private string $activeCell = 'A1';
268

269
    /**
270
     * Selected cells.
271
     */
272
    private string $selectedCells = 'A1';
273

274
    /**
275
     * Cached highest column.
276
     */
277
    private int $cachedHighestColumn = 1;
278

279
    /**
280
     * Cached highest row.
281
     */
282
    private int $cachedHighestRow = 1;
283

284
    /**
285
     * Right-to-left?
286
     */
287
    private bool $rightToLeft = false;
288

289
    /**
290
     * Hyperlinks. Indexed by cell coordinate, e.g. 'A1'.
291
     *
292
     * @var Hyperlink[]
293
     */
294
    private array $hyperlinkCollection = [];
295

296
    /**
297
     * Data validation objects. Indexed by cell coordinate, e.g. 'A1'.
298
     * Index can include ranges, and multiple cells/ranges.
299
     *
300
     * @var DataValidation[]
301
     */
302
    private array $dataValidationCollection = [];
303

304
    /**
305
     * Tab color.
306
     */
307
    private ?Color $tabColor = null;
308

309
    /**
310
     * CodeName.
311
     */
312
    private ?string $codeName = null;
313

314
    /**
315
     * Create a new worksheet.
316
     */
317
    public function __construct(?Spreadsheet $parent = null, string $title = 'Worksheet')
11,146✔
318
    {
319
        // Set parent and title
320
        $this->parent = $parent;
11,146✔
321
        $this->setTitle($title, false);
11,146✔
322
        // setTitle can change $pTitle
323
        $this->setCodeName($this->getTitle());
11,146✔
324
        $this->setSheetState(self::SHEETSTATE_VISIBLE);
11,146✔
325

326
        $this->cellCollection = CellsFactory::getInstance($this);
11,146✔
327
        // Set page setup
328
        $this->pageSetup = new PageSetup();
11,146✔
329
        // Set page margins
330
        $this->pageMargins = new PageMargins();
11,146✔
331
        // Set page header/footer
332
        $this->headerFooter = new HeaderFooter();
11,146✔
333
        // Set sheet view
334
        $this->sheetView = new SheetView();
11,146✔
335
        // Drawing collection
336
        $this->drawingCollection = new ArrayObject();
11,146✔
337
        // Chart collection
338
        $this->chartCollection = new ArrayObject();
11,146✔
339
        // Protection
340
        $this->protection = new Protection();
11,146✔
341
        // Default row dimension
342
        $this->defaultRowDimension = new RowDimension(null);
11,146✔
343
        // Default column dimension
344
        $this->defaultColumnDimension = new ColumnDimension(null);
11,146✔
345
        // AutoFilter
346
        $this->autoFilter = new AutoFilter('', $this);
11,146✔
347
        // Table collection
348
        $this->tableCollection = new ArrayObject();
11,146✔
349
    }
350

351
    /**
352
     * Disconnect all cells from this Worksheet object,
353
     * typically so that the worksheet object can be unset.
354
     */
355
    public function disconnectCells(): void
10,179✔
356
    {
357
        if (isset($this->cellCollection)) { //* @phpstan-ignore-line
10,179✔
358
            $this->cellCollection->unsetWorksheetCells();
10,179✔
359
            unset($this->cellCollection);
10,179✔
360
        }
361
        //    detach ourself from the workbook, so that it can then delete this worksheet successfully
362
        $this->parent = null;
10,179✔
363
    }
364

365
    /**
366
     * Code to execute when this worksheet is unset().
367
     */
368
    public function __destruct()
138✔
369
    {
370
        Calculation::getInstanceOrNull($this->parent)
138✔
371
            ?->clearCalculationCacheForWorksheet($this->title);
138✔
372

373
        $this->disconnectCells();
138✔
374
        unset($this->rowDimensions, $this->columnDimensions, $this->tableCollection, $this->drawingCollection, $this->chartCollection, $this->autoFilter);
138✔
375
    }
376

377
    /**
378
     * Return the cell collection.
379
     */
380
    public function getCellCollection(): Cells
10,735✔
381
    {
382
        return $this->cellCollection;
10,735✔
383
    }
384

385
    /**
386
     * Get array of invalid characters for sheet title.
387
     *
388
     * @return string[]
389
     */
390
    public static function getInvalidCharacters(): array
1✔
391
    {
392
        return self::INVALID_CHARACTERS;
1✔
393
    }
394

395
    /**
396
     * Check sheet code name for valid Excel syntax.
397
     *
398
     * @param string $sheetCodeName The string to check
399
     *
400
     * @return string The valid string
401
     */
402
    private static function checkSheetCodeName(string $sheetCodeName): string
11,146✔
403
    {
404
        $charCount = StringHelper::countCharacters($sheetCodeName);
11,146✔
405
        if ($charCount == 0) {
11,146✔
406
            throw new Exception('Sheet code name cannot be empty.');
1✔
407
        }
408
        // Some of the printable ASCII characters are invalid:  * : / \ ? [ ] and  first and last characters cannot be a "'"
409
        if (
410
            (str_replace(self::INVALID_CHARACTERS, '', $sheetCodeName) !== $sheetCodeName)
11,146✔
411
            || (StringHelper::substring($sheetCodeName, -1, 1) == '\'')
11,146✔
412
            || (StringHelper::substring($sheetCodeName, 0, 1) == '\'')
11,146✔
413
        ) {
414
            throw new Exception('Invalid character found in sheet code name');
1✔
415
        }
416

417
        // Enforce maximum characters allowed for sheet title
418
        if ($charCount > self::SHEET_TITLE_MAXIMUM_LENGTH) {
11,146✔
419
            throw new Exception('Maximum ' . self::SHEET_TITLE_MAXIMUM_LENGTH . ' characters allowed in sheet code name.');
1✔
420
        }
421

422
        return $sheetCodeName;
11,146✔
423
    }
424

425
    /**
426
     * Check sheet title for valid Excel syntax.
427
     *
428
     * @param string $sheetTitle The string to check
429
     *
430
     * @return string The valid string
431
     */
432
    private static function checkSheetTitle(string $sheetTitle): string
11,146✔
433
    {
434
        // Some of the printable ASCII characters are invalid:  * : / \ ? [ ]
435
        if (str_replace(self::INVALID_CHARACTERS, '', $sheetTitle) !== $sheetTitle) {
11,146✔
436
            throw new Exception('Invalid character found in sheet title');
2✔
437
        }
438

439
        // Enforce maximum characters allowed for sheet title
440
        if (StringHelper::countCharacters($sheetTitle) > self::SHEET_TITLE_MAXIMUM_LENGTH) {
11,146✔
441
            throw new Exception('Maximum ' . self::SHEET_TITLE_MAXIMUM_LENGTH . ' characters allowed in sheet title.');
4✔
442
        }
443

444
        return $sheetTitle;
11,146✔
445
    }
446

447
    /**
448
     * Get a sorted list of all cell coordinates currently held in the collection by row and column.
449
     *
450
     * @param bool $sorted Also sort the cell collection?
451
     *
452
     * @return string[]
453
     */
454
    public function getCoordinates(bool $sorted = true): array
1,562✔
455
    {
456
        if (!isset($this->cellCollection)) { //* @phpstan-ignore-line
1,562✔
457
            return [];
1✔
458
        }
459

460
        if ($sorted) {
1,562✔
461
            return $this->cellCollection->getSortedCoordinates();
607✔
462
        }
463

464
        return $this->cellCollection->getCoordinates();
1,416✔
465
    }
466

467
    /**
468
     * Get collection of row dimensions.
469
     *
470
     * @return RowDimension[]
471
     */
472
    public function getRowDimensions(): array
1,276✔
473
    {
474
        return $this->rowDimensions;
1,276✔
475
    }
476

477
    /**
478
     * Get default row dimension.
479
     */
480
    public function getDefaultRowDimension(): RowDimension
1,246✔
481
    {
482
        return $this->defaultRowDimension;
1,246✔
483
    }
484

485
    /**
486
     * Get collection of column dimensions.
487
     *
488
     * @return ColumnDimension[]
489
     */
490
    public function getColumnDimensions(): array
1,282✔
491
    {
492
        /** @var callable $callable */
493
        $callable = [self::class, 'columnDimensionCompare'];
1,282✔
494
        uasort($this->columnDimensions, $callable);
1,282✔
495

496
        return $this->columnDimensions;
1,282✔
497
    }
498

499
    private static function columnDimensionCompare(ColumnDimension $a, ColumnDimension $b): int
100✔
500
    {
501
        return $a->getColumnNumeric() - $b->getColumnNumeric();
100✔
502
    }
503

504
    /**
505
     * Get default column dimension.
506
     */
507
    public function getDefaultColumnDimension(): ColumnDimension
636✔
508
    {
509
        return $this->defaultColumnDimension;
636✔
510
    }
511

512
    /**
513
     * Get collection of drawings.
514
     *
515
     * @return ArrayObject<int, BaseDrawing>
516
     */
517
    public function getDrawingCollection(): ArrayObject
1,255✔
518
    {
519
        return $this->drawingCollection;
1,255✔
520
    }
521

522
    /**
523
     * Get collection of charts.
524
     *
525
     * @return ArrayObject<int, Chart>
526
     */
527
    public function getChartCollection(): ArrayObject
100✔
528
    {
529
        return $this->chartCollection;
100✔
530
    }
531

532
    public function addChart(Chart $chart): Chart
106✔
533
    {
534
        $chart->setWorksheet($this);
106✔
535
        $this->chartCollection[] = $chart;
106✔
536

537
        return $chart;
106✔
538
    }
539

540
    /**
541
     * Return the count of charts on this worksheet.
542
     *
543
     * @return int The number of charts
544
     */
545
    public function getChartCount(): int
83✔
546
    {
547
        return count($this->chartCollection);
83✔
548
    }
549

550
    /**
551
     * Get a chart by its index position.
552
     *
553
     * @param ?string $index Chart index position
554
     *
555
     * @return Chart|false
556
     */
557
    public function getChartByIndex(?string $index)
78✔
558
    {
559
        $chartCount = count($this->chartCollection);
78✔
560
        if ($chartCount == 0) {
78✔
561
            return false;
×
562
        }
563
        if ($index === null) {
78✔
564
            $index = --$chartCount;
×
565
        }
566
        if (!isset($this->chartCollection[$index])) {
78✔
567
            return false;
×
568
        }
569

570
        return $this->chartCollection[$index];
78✔
571
    }
572

573
    /**
574
     * Return an array of the names of charts on this worksheet.
575
     *
576
     * @return string[] The names of charts
577
     */
578
    public function getChartNames(): array
5✔
579
    {
580
        $chartNames = [];
5✔
581
        foreach ($this->chartCollection as $chart) {
5✔
582
            $chartNames[] = $chart->getName();
5✔
583
        }
584

585
        return $chartNames;
5✔
586
    }
587

588
    /**
589
     * Get a chart by name.
590
     *
591
     * @param string $chartName Chart name
592
     *
593
     * @return Chart|false
594
     */
595
    public function getChartByName(string $chartName)
6✔
596
    {
597
        foreach ($this->chartCollection as $index => $chart) {
6✔
598
            if ($chart->getName() == $chartName) {
6✔
599
                return $chart;
6✔
600
            }
601
        }
602

603
        return false;
1✔
604
    }
605

606
    public function getChartByNameOrThrow(string $chartName): Chart
6✔
607
    {
608
        $chart = $this->getChartByName($chartName);
6✔
609
        if ($chart !== false) {
6✔
610
            return $chart;
6✔
611
        }
612

613
        throw new Exception("Sheet does not have a chart named $chartName.");
1✔
614
    }
615

616
    /**
617
     * Refresh column dimensions.
618
     *
619
     * @return $this
620
     */
621
    public function refreshColumnDimensions(): static
25✔
622
    {
623
        $newColumnDimensions = [];
25✔
624
        foreach ($this->getColumnDimensions() as $objColumnDimension) {
25✔
625
            $newColumnDimensions[$objColumnDimension->getColumnIndex()] = $objColumnDimension;
25✔
626
        }
627

628
        $this->columnDimensions = $newColumnDimensions;
25✔
629

630
        return $this;
25✔
631
    }
632

633
    /**
634
     * Refresh row dimensions.
635
     *
636
     * @return $this
637
     */
638
    public function refreshRowDimensions(): static
9✔
639
    {
640
        $newRowDimensions = [];
9✔
641
        foreach ($this->getRowDimensions() as $objRowDimension) {
9✔
642
            $newRowDimensions[$objRowDimension->getRowIndex()] = $objRowDimension;
9✔
643
        }
644

645
        $this->rowDimensions = $newRowDimensions;
9✔
646

647
        return $this;
9✔
648
    }
649

650
    /**
651
     * Calculate worksheet dimension.
652
     *
653
     * @return string String containing the dimension of this worksheet
654
     */
655
    public function calculateWorksheetDimension(): string
513✔
656
    {
657
        // Return
658
        return 'A1:' . $this->getHighestColumn() . $this->getHighestRow();
513✔
659
    }
660

661
    /**
662
     * Calculate worksheet data dimension.
663
     *
664
     * @return string String containing the dimension of this worksheet that actually contain data
665
     */
666
    public function calculateWorksheetDataDimension(): string
572✔
667
    {
668
        // Return
669
        return 'A1:' . $this->getHighestDataColumn() . $this->getHighestDataRow();
572✔
670
    }
671

672
    /**
673
     * Calculate widths for auto-size columns.
674
     *
675
     * @return $this
676
     */
677
    public function calculateColumnWidths(): static
815✔
678
    {
679
        $activeSheet = $this->getParent()?->getActiveSheetIndex();
815✔
680
        $selectedCells = $this->selectedCells;
815✔
681
        // initialize $autoSizes array
682
        $autoSizes = [];
815✔
683
        foreach ($this->getColumnDimensions() as $colDimension) {
815✔
684
            if ($colDimension->getAutoSize()) {
165✔
685
                $autoSizes[$colDimension->getColumnIndex()] = -1;
65✔
686
            }
687
        }
688

689
        // There is only something to do if there are some auto-size columns
690
        if (!empty($autoSizes)) {
815✔
691
            $holdActivePane = $this->activePane;
65✔
692
            // build list of cells references that participate in a merge
693
            $isMergeCell = [];
65✔
694
            foreach ($this->getMergeCells() as $cells) {
65✔
695
                foreach (Coordinate::extractAllCellReferencesInRange($cells) as $cellReference) {
16✔
696
                    $isMergeCell[$cellReference] = true;
16✔
697
                }
698
            }
699

700
            $autoFilterIndentRanges = (new AutoFit($this))->getAutoFilterIndentRanges();
65✔
701

702
            // loop through all cells in the worksheet
703
            foreach ($this->getCoordinates(false) as $coordinate) {
65✔
704
                $cell = $this->getCellOrNull($coordinate);
65✔
705

706
                if ($cell !== null && isset($autoSizes[$this->cellCollection->getCurrentColumn()])) {
65✔
707
                    //Determine if cell is in merge range
708
                    $isMerged = isset($isMergeCell[$this->cellCollection->getCurrentCoordinate()]);
65✔
709

710
                    //By default merged cells should be ignored
711
                    $isMergedButProceed = false;
65✔
712

713
                    //The only exception is if it's a merge range value cell of a 'vertical' range (1 column wide)
714
                    if ($isMerged && $cell->isMergeRangeValueCell()) {
65✔
715
                        $range = (string) $cell->getMergeRange();
×
716
                        $rangeBoundaries = Coordinate::rangeDimension($range);
×
717
                        if ($rangeBoundaries[0] === 1) {
×
718
                            $isMergedButProceed = true;
×
719
                        }
720
                    }
721

722
                    // Determine width if cell is not part of a merge or does and is a value cell of 1-column wide range
723
                    if (!$isMerged || $isMergedButProceed) {
65✔
724
                        // Determine if we need to make an adjustment for the first row in an AutoFilter range that
725
                        //    has a column filter dropdown
726
                        $filterAdjustment = false;
65✔
727
                        if (!empty($autoFilterIndentRanges)) {
65✔
728
                            foreach ($autoFilterIndentRanges as $autoFilterFirstRowRange) {
4✔
729
                                /** @var string $autoFilterFirstRowRange */
730
                                if ($cell->isInRange($autoFilterFirstRowRange)) {
4✔
731
                                    $filterAdjustment = true;
4✔
732

733
                                    break;
4✔
734
                                }
735
                            }
736
                        }
737

738
                        $indentAdjustment = $cell->getStyle()->getAlignment()->getIndent();
65✔
739
                        $indentAdjustment += (int) ($cell->getStyle()->getAlignment()->getHorizontal() === Alignment::HORIZONTAL_CENTER);
65✔
740

741
                        // Calculated value
742
                        // To formatted string
743
                        $cellValue = NumberFormat::toFormattedString(
65✔
744
                            $cell->getCalculatedValueString(),
65✔
745
                            (string) $this->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())
65✔
746
                                ->getNumberFormat()->getFormatCode(true)
65✔
747
                        );
65✔
748

749
                        if ($cellValue !== '') {
65✔
750
                            $autoSizes[$this->cellCollection->getCurrentColumn()] = max(
65✔
751
                                $autoSizes[$this->cellCollection->getCurrentColumn()],
65✔
752
                                round(
65✔
753
                                    Shared\Font::calculateColumnWidth(
65✔
754
                                        $this->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())->getFont(),
65✔
755
                                        $cellValue,
65✔
756
                                        (int) $this->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())
65✔
757
                                            ->getAlignment()->getTextRotation(),
65✔
758
                                        $this->getParentOrThrow()->getDefaultStyle()->getFont(),
65✔
759
                                        $filterAdjustment,
65✔
760
                                        $indentAdjustment
65✔
761
                                    ),
65✔
762
                                    3
65✔
763
                                )
65✔
764
                            );
65✔
765
                        }
766
                    }
767
                }
768
            }
769

770
            // adjust column widths
771
            foreach ($autoSizes as $columnIndex => $width) {
65✔
772
                if ($width == -1) {
65✔
773
                    $width = $this->getDefaultColumnDimension()->getWidth();
×
774
                }
775
                $this->getColumnDimension($columnIndex)->setWidth($width);
65✔
776
            }
777
            $this->activePane = $holdActivePane;
65✔
778
        }
779
        if ($activeSheet !== null && $activeSheet >= 0) {
815✔
780
            $this->getParent()?->setActiveSheetIndex($activeSheet);
815✔
781
        }
782
        $this->setSelectedCells($selectedCells);
815✔
783

784
        return $this;
815✔
785
    }
786

787
    /**
788
     * Get parent or null.
789
     */
790
    public function getParent(): ?Spreadsheet
10,743✔
791
    {
792
        return $this->parent;
10,743✔
793
    }
794

795
    /**
796
     * Get parent, throw exception if null.
797
     */
798
    public function getParentOrThrow(): Spreadsheet
10,816✔
799
    {
800
        if ($this->parent !== null) {
10,816✔
801
            return $this->parent;
10,815✔
802
        }
803

804
        throw new Exception('Sheet does not have a parent.');
1✔
805
    }
806

807
    /**
808
     * Re-bind parent.
809
     *
810
     * @return $this
811
     */
812
    public function rebindParent(Spreadsheet $parent): static
54✔
813
    {
814
        if ($this->parent !== null) {
54✔
815
            $definedNames = $this->parent->getDefinedNames();
4✔
816
            foreach ($definedNames as $definedName) {
4✔
817
                $parent->addDefinedName($definedName);
×
818
            }
819

820
            $this->parent->removeSheetByIndex(
4✔
821
                $this->parent->getIndex($this)
4✔
822
            );
4✔
823
        }
824
        $this->parent = $parent;
54✔
825

826
        return $this;
54✔
827
    }
828

829
    public function setParent(Spreadsheet $parent): self
6✔
830
    {
831
        $this->parent = $parent;
6✔
832

833
        return $this;
6✔
834
    }
835

836
    /**
837
     * Get title.
838
     */
839
    public function getTitle(): string
11,146✔
840
    {
841
        return $this->title;
11,146✔
842
    }
843

844
    /**
845
     * Set title.
846
     *
847
     * @param string $title String containing the dimension of this worksheet
848
     * @param bool $updateFormulaCellReferences Flag indicating whether cell references in formulae should
849
     *            be updated to reflect the new sheet name.
850
     *          This should be left as the default true, unless you are
851
     *          certain that no formula cells on any worksheet contain
852
     *          references to this worksheet
853
     * @param bool $validate False to skip validation of new title. WARNING: This should only be set
854
     *                       at parse time (by Readers), where titles can be assumed to be valid.
855
     *
856
     * @return $this
857
     */
858
    public function setTitle(string $title, bool $updateFormulaCellReferences = true, bool $validate = true): static
11,146✔
859
    {
860
        // Is this a 'rename' or not?
861
        if ($this->getTitle() == $title) {
11,146✔
862
            return $this;
317✔
863
        }
864

865
        // Old title
866
        $oldTitle = $this->getTitle();
11,146✔
867

868
        if ($validate) {
11,146✔
869
            // Syntax check
870
            self::checkSheetTitle($title);
11,146✔
871

872
            if ($this->parent && $this->parent->getIndex($this, true) >= 0) {
11,146✔
873
                // Is there already such sheet name?
874
                if ($this->parent->sheetNameExists($title)) {
821✔
875
                    // Use name, but append with lowest possible integer
876

877
                    if (StringHelper::countCharacters($title) > 29) {
2✔
878
                        $title = StringHelper::substring($title, 0, 29);
×
879
                    }
880
                    $i = 1;
2✔
881
                    while ($this->parent->sheetNameExists($title . ' ' . $i)) {
2✔
882
                        ++$i;
1✔
883
                        if ($i == 10) {
1✔
884
                            if (StringHelper::countCharacters($title) > 28) {
×
885
                                $title = StringHelper::substring($title, 0, 28);
×
886
                            }
887
                        } elseif ($i == 100) {
1✔
888
                            if (StringHelper::countCharacters($title) > 27) {
×
889
                                $title = StringHelper::substring($title, 0, 27);
×
890
                            }
891
                        }
892
                    }
893

894
                    $title .= " $i";
2✔
895
                }
896
            }
897
        }
898

899
        // Set title
900
        $this->title = $title;
11,146✔
901

902
        if ($this->parent && $this->parent->getIndex($this, true) >= 0) {
11,146✔
903
            // New title
904
            $newTitle = $this->getTitle();
1,462✔
905
            $this->parent->getCalculationEngine()
1,462✔
906
                ->renameCalculationCacheForWorksheet($oldTitle, $newTitle);
1,462✔
907
            if ($updateFormulaCellReferences) {
1,462✔
908
                ReferenceHelper::getInstance()->updateNamedFormulae($this->parent, $oldTitle, $newTitle);
821✔
909
            }
910
        }
911

912
        return $this;
11,146✔
913
    }
914

915
    /**
916
     * Get sheet state.
917
     *
918
     * @return string Sheet state (visible, hidden, veryHidden)
919
     */
920
    public function getSheetState(): string
548✔
921
    {
922
        return $this->sheetState;
548✔
923
    }
924

925
    /**
926
     * Set sheet state.
927
     *
928
     * @param string $value Sheet state (visible, hidden, veryHidden)
929
     *
930
     * @return $this
931
     */
932
    public function setSheetState(string $value): static
11,146✔
933
    {
934
        $this->sheetState = $value;
11,146✔
935

936
        return $this;
11,146✔
937
    }
938

939
    /**
940
     * Get page setup.
941
     */
942
    public function getPageSetup(): PageSetup
1,676✔
943
    {
944
        return $this->pageSetup;
1,676✔
945
    }
946

947
    /**
948
     * Set page setup.
949
     *
950
     * @return $this
951
     */
952
    public function setPageSetup(PageSetup $pageSetup): static
1✔
953
    {
954
        $this->pageSetup = $pageSetup;
1✔
955

956
        return $this;
1✔
957
    }
958

959
    /**
960
     * Get page margins.
961
     */
962
    public function getPageMargins(): PageMargins
1,674✔
963
    {
964
        return $this->pageMargins;
1,674✔
965
    }
966

967
    /**
968
     * Set page margins.
969
     *
970
     * @return $this
971
     */
972
    public function setPageMargins(PageMargins $pageMargins): static
1✔
973
    {
974
        $this->pageMargins = $pageMargins;
1✔
975

976
        return $this;
1✔
977
    }
978

979
    /**
980
     * Get page header/footer.
981
     */
982
    public function getHeaderFooter(): HeaderFooter
626✔
983
    {
984
        return $this->headerFooter;
626✔
985
    }
986

987
    /**
988
     * Set page header/footer.
989
     *
990
     * @return $this
991
     */
992
    public function setHeaderFooter(HeaderFooter $headerFooter): static
1✔
993
    {
994
        $this->headerFooter = $headerFooter;
1✔
995

996
        return $this;
1✔
997
    }
998

999
    /**
1000
     * Get sheet view.
1001
     */
1002
    public function getSheetView(): SheetView
657✔
1003
    {
1004
        return $this->sheetView;
657✔
1005
    }
1006

1007
    /**
1008
     * Set sheet view.
1009
     *
1010
     * @return $this
1011
     */
1012
    public function setSheetView(SheetView $sheetView): static
1✔
1013
    {
1014
        $this->sheetView = $sheetView;
1✔
1015

1016
        return $this;
1✔
1017
    }
1018

1019
    /**
1020
     * Get Protection.
1021
     */
1022
    public function getProtection(): Protection
677✔
1023
    {
1024
        return $this->protection;
677✔
1025
    }
1026

1027
    /**
1028
     * Set Protection.
1029
     *
1030
     * @return $this
1031
     */
1032
    public function setProtection(Protection $protection): static
1✔
1033
    {
1034
        $this->protection = $protection;
1✔
1035

1036
        return $this;
1✔
1037
    }
1038

1039
    /**
1040
     * Get highest worksheet column.
1041
     *
1042
     * @param null|int|string $row Return the data highest column for the specified row,
1043
     *                                     or the highest column of any row if no row number is passed
1044
     *
1045
     * @return string Highest column name
1046
     */
1047
    public function getHighestColumn($row = null): string
1,579✔
1048
    {
1049
        if ($row === null) {
1,579✔
1050
            return Coordinate::stringFromColumnIndex($this->cachedHighestColumn);
1,578✔
1051
        }
1052

1053
        return $this->getHighestDataColumn($row);
1✔
1054
    }
1055

1056
    /**
1057
     * Get highest worksheet column that contains data.
1058
     *
1059
     * @param null|int|string $row Return the highest data column for the specified row,
1060
     *                                     or the highest data column of any row if no row number is passed
1061
     *
1062
     * @return string Highest column name that contains data
1063
     */
1064
    public function getHighestDataColumn($row = null): string
779✔
1065
    {
1066
        return $this->cellCollection->getHighestColumn($row);
779✔
1067
    }
1068

1069
    /**
1070
     * Get highest worksheet row.
1071
     *
1072
     * @param null|string $column Return the highest data row for the specified column,
1073
     *                                     or the highest row of any column if no column letter is passed
1074
     *
1075
     * @return int Highest row number
1076
     */
1077
    public function getHighestRow(?string $column = null): int
1,048✔
1078
    {
1079
        if ($column === null) {
1,048✔
1080
            return $this->cachedHighestRow;
1,047✔
1081
        }
1082

1083
        return $this->getHighestDataRow($column);
1✔
1084
    }
1085

1086
    /**
1087
     * Get highest worksheet row that contains data.
1088
     *
1089
     * @param null|string $column Return the highest data row for the specified column,
1090
     *                                     or the highest data row of any column if no column letter is passed
1091
     *
1092
     * @return int Highest row number that contains data
1093
     */
1094
    public function getHighestDataRow(?string $column = null): int
780✔
1095
    {
1096
        return $this->cellCollection->getHighestRow($column);
780✔
1097
    }
1098

1099
    /**
1100
     * Get highest worksheet column and highest row that have cell records.
1101
     *
1102
     * @return array{row: int, column: string} Highest column name and highest row number
1103
     */
1104
    public function getHighestRowAndColumn(): array
1✔
1105
    {
1106
        return $this->cellCollection->getHighestRowAndColumn();
1✔
1107
    }
1108

1109
    /**
1110
     * Set a cell value.
1111
     *
1112
     * @param array{0: int, 1: int}|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5';
1113
     *               or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object.
1114
     * @param mixed $value Value for the cell
1115
     * @param null|IValueBinder $binder Value Binder to override the currently set Value Binder
1116
     *
1117
     * @return $this
1118
     */
1119
    public function setCellValue(CellAddress|string|array $coordinate, mixed $value, ?IValueBinder $binder = null): static
5,047✔
1120
    {
1121
        $cellAddress = Functions::trimSheetFromCellReference(Validations::validateCellAddress($coordinate));
5,047✔
1122
        $this->getCell($cellAddress)->setValue($value, $binder);
5,047✔
1123

1124
        return $this;
5,047✔
1125
    }
1126

1127
    /**
1128
     * Set a cell value.
1129
     *
1130
     * @param array{0: int, 1: int}|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5';
1131
     *               or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object.
1132
     * @param mixed $value Value of the cell
1133
     * @param string $dataType Explicit data type, see DataType::TYPE_*
1134
     *        Note that PhpSpreadsheet does not validate that the value and datatype are consistent, in using this
1135
     *             method, then it is your responsibility as an end-user developer to validate that the value and
1136
     *             the datatype match.
1137
     *       If you do mismatch value and datatpe, then the value you enter may be changed to match the datatype
1138
     *          that you specify.
1139
     *
1140
     * @see DataType
1141
     *
1142
     * @return $this
1143
     */
1144
    public function setCellValueExplicit(CellAddress|string|array $coordinate, mixed $value, string $dataType): static
114✔
1145
    {
1146
        $cellAddress = Functions::trimSheetFromCellReference(Validations::validateCellAddress($coordinate));
114✔
1147
        $this->getCell($cellAddress)->setValueExplicit($value, $dataType);
114✔
1148

1149
        return $this;
114✔
1150
    }
1151

1152
    /**
1153
     * Get cell at a specific coordinate.
1154
     *
1155
     * @param array{0: int, 1: int}|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5';
1156
     *               or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object.
1157
     *
1158
     * @return Cell Cell that was found or created
1159
     *              WARNING: Because the cell collection can be cached to reduce memory, it only allows one
1160
     *              "active" cell at a time in memory. If you assign that cell to a variable, then select
1161
     *              another cell using getCell() or any of its variants, the newly selected cell becomes
1162
     *              the "active" cell, and any previous assignment becomes a disconnected reference because
1163
     *              the active cell has changed.
1164
     */
1165
    public function getCell(CellAddress|string|array $coordinate): Cell
10,675✔
1166
    {
1167
        $cellAddress = Functions::trimSheetFromCellReference(Validations::validateCellAddress($coordinate));
10,675✔
1168

1169
        // Shortcut for increased performance for the vast majority of simple cases
1170
        if ($this->cellCollection->has($cellAddress)) {
10,675✔
1171
            /** @var Cell $cell */
1172
            $cell = $this->cellCollection->get($cellAddress);
10,651✔
1173

1174
            return $cell;
10,651✔
1175
        }
1176

1177
        /** @var Worksheet $sheet */
1178
        [$sheet, $finalCoordinate] = $this->getWorksheetAndCoordinate($cellAddress);
10,675✔
1179
        $cell = $sheet->getCellCollection()->get($finalCoordinate);
10,675✔
1180

1181
        return $cell ?? $sheet->createNewCell($finalCoordinate);
10,675✔
1182
    }
1183

1184
    /**
1185
     * Get the correct Worksheet and coordinate from a coordinate that may
1186
     * contains reference to another sheet or a named range.
1187
     *
1188
     * @return array{0: Worksheet, 1: string}
1189
     */
1190
    private function getWorksheetAndCoordinate(string $coordinate): array
10,699✔
1191
    {
1192
        $sheet = null;
10,699✔
1193
        $finalCoordinate = null;
10,699✔
1194

1195
        // Worksheet reference?
1196
        if (str_contains($coordinate, '!')) {
10,699✔
1197
            $worksheetReference = self::extractSheetTitle($coordinate, true, true);
×
1198

1199
            $sheet = $this->getParentOrThrow()->getSheetByName($worksheetReference[0]);
×
1200
            $finalCoordinate = strtoupper($worksheetReference[1]);
×
1201

1202
            if ($sheet === null) {
×
1203
                throw new Exception('Sheet not found for name: ' . $worksheetReference[0]);
×
1204
            }
1205
        } elseif (
1206
            !Preg::isMatch('/^' . Calculation::CALCULATION_REGEXP_CELLREF . '$/i', $coordinate)
10,699✔
1207
            && Preg::isMatch('/^' . Calculation::CALCULATION_REGEXP_DEFINEDNAME . '$/iu', $coordinate)
10,699✔
1208
        ) {
1209
            // Named range?
1210
            $namedRange = $this->validateNamedRange($coordinate, true);
17✔
1211
            if ($namedRange !== null) {
17✔
1212
                $sheet = $namedRange->getWorksheet();
12✔
1213
                if ($sheet === null) {
12✔
1214
                    throw new Exception('Sheet not found for named range: ' . $namedRange->getName());
×
1215
                }
1216

1217
                /** @phpstan-ignore-next-line */
1218
                $cellCoordinate = ltrim(substr($namedRange->getValue(), strrpos($namedRange->getValue(), '!')), '!');
12✔
1219
                $finalCoordinate = str_replace('$', '', $cellCoordinate);
12✔
1220
            }
1221
        }
1222

1223
        if ($sheet === null || $finalCoordinate === null) {
10,699✔
1224
            $sheet = $this;
10,699✔
1225
            $finalCoordinate = strtoupper($coordinate);
10,699✔
1226
        }
1227

1228
        if (Coordinate::coordinateIsRange($finalCoordinate)) {
10,699✔
1229
            throw new Exception('Cell coordinate string can not be a range of cells.');
2✔
1230
        }
1231
        $finalCoordinate = str_replace('$', '', $finalCoordinate);
10,699✔
1232

1233
        return [$sheet, $finalCoordinate];
10,699✔
1234
    }
1235

1236
    /**
1237
     * Get an existing cell at a specific coordinate, or null.
1238
     *
1239
     * @param string $coordinate Coordinate of the cell, eg: 'A1'
1240
     *
1241
     * @return null|Cell Cell that was found or null
1242
     */
1243
    private function getCellOrNull(string $coordinate): ?Cell
65✔
1244
    {
1245
        // Check cell collection
1246
        if ($this->cellCollection->has($coordinate)) {
65✔
1247
            return $this->cellCollection->get($coordinate);
65✔
1248
        }
1249

1250
        return null;
×
1251
    }
1252

1253
    /**
1254
     * Create a new cell at the specified coordinate.
1255
     *
1256
     * @param string $coordinate Coordinate of the cell
1257
     *
1258
     * @return Cell Cell that was created
1259
     *              WARNING: Because the cell collection can be cached to reduce memory, it only allows one
1260
     *              "active" cell at a time in memory. If you assign that cell to a variable, then select
1261
     *              another cell using getCell() or any of its variants, the newly selected cell becomes
1262
     *              the "active" cell, and any previous assignment becomes a disconnected reference because
1263
     *              the active cell has changed.
1264
     */
1265
    public function createNewCell(string $coordinate): Cell
10,675✔
1266
    {
1267
        [$column, $row, $columnString] = Coordinate::indexesFromString($coordinate);
10,675✔
1268
        $cell = new Cell(null, DataType::TYPE_NULL, $this);
10,675✔
1269
        $this->cellCollection->add($coordinate, $cell);
10,675✔
1270

1271
        // Coordinates
1272
        if ($column > $this->cachedHighestColumn) {
10,675✔
1273
            $this->cachedHighestColumn = $column;
7,378✔
1274
        }
1275
        if ($row > $this->cachedHighestRow) {
10,675✔
1276
            $this->cachedHighestRow = $row;
8,814✔
1277
        }
1278

1279
        // Cell needs appropriate xfIndex from dimensions records
1280
        //    but don't create dimension records if they don't already exist
1281
        $rowDimension = $this->rowDimensions[$row] ?? null;
10,675✔
1282
        $columnDimension = $this->columnDimensions[$columnString] ?? null;
10,675✔
1283

1284
        $xfSet = false;
10,675✔
1285
        if ($rowDimension !== null) {
10,675✔
1286
            $rowXf = (int) $rowDimension->getXfIndex();
409✔
1287
            if ($rowXf > 0) {
409✔
1288
                // then there is a row dimension with explicit style, assign it to the cell
1289
                $cell->setXfIndex($rowXf);
203✔
1290
                $xfSet = true;
203✔
1291
            }
1292
        }
1293
        if (!$xfSet && $columnDimension !== null) {
10,675✔
1294
            $colXf = (int) $columnDimension->getXfIndex();
579✔
1295
            if ($colXf > 0) {
579✔
1296
                // then there is a column dimension, assign it to the cell
1297
                $cell->setXfIndex($colXf);
217✔
1298
            }
1299
        }
1300

1301
        return $cell;
10,675✔
1302
    }
1303

1304
    /**
1305
     * Does the cell at a specific coordinate exist?
1306
     *
1307
     * @param array{0: int, 1: int}|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5';
1308
     *               or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object.
1309
     */
1310
    public function cellExists(CellAddress|string|array $coordinate): bool
10,619✔
1311
    {
1312
        $cellAddress = Validations::validateCellAddress($coordinate);
10,619✔
1313
        [$sheet, $finalCoordinate] = $this->getWorksheetAndCoordinate($cellAddress);
10,619✔
1314

1315
        return $sheet->getCellCollection()->has($finalCoordinate);
10,619✔
1316
    }
1317

1318
    /**
1319
     * Get row dimension at a specific row.
1320
     *
1321
     * @param int $row Numeric index of the row
1322
     */
1323
    public function getRowDimension(int $row): RowDimension
615✔
1324
    {
1325
        // Get row dimension
1326
        if (!isset($this->rowDimensions[$row])) {
615✔
1327
            $this->rowDimensions[$row] = new RowDimension($row);
615✔
1328

1329
            $this->cachedHighestRow = max($this->cachedHighestRow, $row);
615✔
1330
        }
1331

1332
        return $this->rowDimensions[$row];
615✔
1333
    }
1334

1335
    public function getRowStyle(int $row): ?Style
1✔
1336
    {
1337
        return $this->parent?->getCellXfByIndexOrNull(
1✔
1338
            ($this->rowDimensions[$row] ?? null)?->getXfIndex()
1✔
1339
        );
1✔
1340
    }
1341

1342
    public function rowDimensionExists(int $row): bool
665✔
1343
    {
1344
        return isset($this->rowDimensions[$row]);
665✔
1345
    }
1346

1347
    public function columnDimensionExists(string $column): bool
41✔
1348
    {
1349
        return isset($this->columnDimensions[$column]);
41✔
1350
    }
1351

1352
    /**
1353
     * Get column dimension at a specific column.
1354
     *
1355
     * @param string $column String index of the column eg: 'A'
1356
     */
1357
    public function getColumnDimension(string $column): ColumnDimension
677✔
1358
    {
1359
        // Uppercase coordinate
1360
        $column = strtoupper($column);
677✔
1361

1362
        // Fetch dimensions
1363
        if (!isset($this->columnDimensions[$column])) {
677✔
1364
            $this->columnDimensions[$column] = new ColumnDimension($column);
677✔
1365

1366
            $columnIndex = Coordinate::columnIndexFromString($column);
677✔
1367
            if ($this->cachedHighestColumn < $columnIndex) {
677✔
1368
                $this->cachedHighestColumn = $columnIndex;
468✔
1369
            }
1370
        }
1371

1372
        return $this->columnDimensions[$column];
677✔
1373
    }
1374

1375
    /**
1376
     * Get column dimension at a specific column by using numeric cell coordinates.
1377
     *
1378
     * @param int $columnIndex Numeric column coordinate of the cell
1379
     */
1380
    public function getColumnDimensionByColumn(int $columnIndex): ColumnDimension
113✔
1381
    {
1382
        return $this->getColumnDimension(Coordinate::stringFromColumnIndex($columnIndex));
113✔
1383
    }
1384

1385
    public function getColumnStyle(string $column): ?Style
1✔
1386
    {
1387
        return $this->parent?->getCellXfByIndexOrNull(
1✔
1388
            ($this->columnDimensions[$column] ?? null)?->getXfIndex()
1✔
1389
        );
1✔
1390
    }
1391

1392
    /**
1393
     * Get style for cell.
1394
     *
1395
     * @param AddressRange<CellAddress>|AddressRange<int>|AddressRange<string>|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $cellCoordinate
1396
     *              A simple string containing a cell address like 'A1' or a cell range like 'A1:E10'
1397
     *              or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]),
1398
     *              or a CellAddress or AddressRange object.
1399
     */
1400
    public function getStyle(AddressRange|CellAddress|int|string|array $cellCoordinate): Style
10,644✔
1401
    {
1402
        if (is_string($cellCoordinate)) {
10,644✔
1403
            $cellCoordinate = Validations::definedNameToCoordinate($cellCoordinate, $this);
10,642✔
1404
        }
1405
        $cellCoordinate = Validations::validateCellOrCellRange($cellCoordinate);
10,644✔
1406
        $cellCoordinate = str_replace('$', '', $cellCoordinate);
10,644✔
1407

1408
        // set this sheet as active
1409
        $this->getParentOrThrow()->setActiveSheetIndex($this->getParentOrThrow()->getIndex($this));
10,644✔
1410

1411
        // set cell coordinate as active
1412
        $this->setSelectedCells($cellCoordinate);
10,644✔
1413

1414
        return $this->getParentOrThrow()->getCellXfSupervisor();
10,644✔
1415
    }
1416

1417
    /**
1418
     * Get table styles set for the for given cell.
1419
     *
1420
     * @param Cell $cell
1421
     *              The Cell for which the tables are retrieved
1422
     *
1423
     * @return Table[]
1424
     */
1425
    public function getTablesWithStylesForCell(Cell $cell): array
7✔
1426
    {
1427
        $retVal = [];
7✔
1428

1429
        foreach ($this->tableCollection as $table) {
7✔
1430
            $dxfsTableStyle = $table->getStyle()->getTableDxfsStyle();
7✔
1431
            if ($dxfsTableStyle !== null) {
7✔
1432
                if ($dxfsTableStyle->getHeaderRowStyle() !== null || $dxfsTableStyle->getFirstRowStripeStyle() !== null || $dxfsTableStyle->getSecondRowStripeStyle() !== null) {
4✔
1433
                    $range = $table->getRange();
4✔
1434
                    if ($cell->isInRange($range)) {
4✔
1435
                        $retVal[] = $table;
4✔
1436
                    }
1437
                }
1438
            }
1439
        }
1440

1441
        return $retVal;
7✔
1442
    }
1443

1444
    /**
1445
     * Get tables without styles set for the for given cell.
1446
     *
1447
     * @param Cell $cell
1448
     *              The Cell for which the tables are retrieved
1449
     *
1450
     * @return Table[]
1451
     */
1452
    public function getTablesWithoutStylesForCell(Cell $cell): array
6✔
1453
    {
1454
        $retVal = [];
6✔
1455

1456
        foreach ($this->tableCollection as $table) {
6✔
1457
            $range = $table->getRange();
6✔
1458
            if ($cell->isInRange($range)) {
6✔
1459
                $dxfsTableStyle = $table->getStyle()->getTableDxfsStyle();
6✔
1460
                if ($dxfsTableStyle === null || ($dxfsTableStyle->getHeaderRowStyle() === null && $dxfsTableStyle->getFirstRowStripeStyle() === null && $dxfsTableStyle->getSecondRowStripeStyle() === null)) {
6✔
1461
                    $retVal[] = $table;
2✔
1462
                }
1463
            }
1464
        }
1465

1466
        return $retVal;
6✔
1467
    }
1468

1469
    /**
1470
     * Get conditional styles for a cell.
1471
     *
1472
     * @param string $coordinate eg: 'A1' or 'A1:A3'.
1473
     *          If a single cell is referenced, then the array of conditional styles will be returned if the cell is
1474
     *               included in a conditional style range.
1475
     *          If a range of cells is specified, then the styles will only be returned if the range matches the entire
1476
     *               range of the conditional.
1477
     * @param bool $firstOnly default true, return all matching
1478
     *          conditionals ordered by priority if false, first only if true
1479
     *
1480
     * @return Conditional[]
1481
     */
1482
    public function getConditionalStyles(string $coordinate, bool $firstOnly = true): array
805✔
1483
    {
1484
        $coordinate = strtoupper($coordinate);
805✔
1485
        if (Preg::isMatch('/[: ,]/', $coordinate)) {
805✔
1486
            return $this->conditionalStylesCollection[$coordinate] ?? [];
49✔
1487
        }
1488

1489
        $conditionalStyles = [];
781✔
1490
        foreach ($this->conditionalStylesCollection as $keyStylesOrig => $conditionalRange) {
781✔
1491
            $keyStyles = Coordinate::resolveUnionAndIntersection($keyStylesOrig);
222✔
1492
            $keyParts = explode(',', $keyStyles);
222✔
1493
            foreach ($keyParts as $keyPart) {
222✔
1494
                if ($keyPart === $coordinate) {
222✔
1495
                    if ($firstOnly) {
14✔
1496
                        return $conditionalRange;
14✔
1497
                    }
UNCOV
1498
                    $conditionalStyles[$keyStylesOrig] = $conditionalRange;
×
1499

UNCOV
1500
                    break;
×
1501
                } elseif (str_contains($keyPart, ':')) {
217✔
1502
                    if (Coordinate::coordinateIsInsideRange($keyPart, $coordinate)) {
212✔
1503
                        if ($firstOnly) {
198✔
1504
                            return $conditionalRange;
197✔
1505
                        }
1506
                        $conditionalStyles[$keyStylesOrig] = $conditionalRange;
1✔
1507

1508
                        break;
1✔
1509
                    }
1510
                }
1511
            }
1512
        }
1513
        $outArray = [];
607✔
1514
        foreach ($conditionalStyles as $conditionalArray) {
607✔
1515
            foreach ($conditionalArray as $conditional) {
1✔
1516
                $outArray[] = $conditional;
1✔
1517
            }
1518
        }
1519
        usort($outArray, [self::class, 'comparePriority']);
607✔
1520

1521
        return $outArray;
607✔
1522
    }
1523

1524
    private static function comparePriority(Conditional $condA, Conditional $condB): int
1✔
1525
    {
1526
        $a = $condA->getPriority();
1✔
1527
        $b = $condB->getPriority();
1✔
1528
        if ($a === $b) {
1✔
UNCOV
1529
            return 0;
×
1530
        }
1531
        if ($a === 0) {
1✔
UNCOV
1532
            return 1;
×
1533
        }
1534
        if ($b === 0) {
1✔
UNCOV
1535
            return -1;
×
1536
        }
1537

1538
        return ($a < $b) ? -1 : 1;
1✔
1539
    }
1540

1541
    public function getConditionalRange(string $coordinate): ?string
189✔
1542
    {
1543
        $coordinate = strtoupper($coordinate);
189✔
1544
        $cell = $this->getCell($coordinate);
189✔
1545
        foreach (array_keys($this->conditionalStylesCollection) as $conditionalRange) {
189✔
1546
            $cellBlocks = explode(',', Coordinate::resolveUnionAndIntersection($conditionalRange));
189✔
1547
            foreach ($cellBlocks as $cellBlock) {
189✔
1548
                if ($cell->isInRange($cellBlock)) {
189✔
1549
                    return $conditionalRange;
188✔
1550
                }
1551
            }
1552
        }
1553

1554
        return null;
10✔
1555
    }
1556

1557
    /**
1558
     * Do conditional styles exist for this cell?
1559
     *
1560
     * @param string $coordinate eg: 'A1' or 'A1:A3'.
1561
     *          If a single cell is specified, then this method will return true if that cell is included in a
1562
     *               conditional style range.
1563
     *          If a range of cells is specified, then true will only be returned if the range matches the entire
1564
     *               range of the conditional.
1565
     */
1566
    public function conditionalStylesExists(string $coordinate): bool
22✔
1567
    {
1568
        return !empty($this->getConditionalStyles($coordinate));
22✔
1569
    }
1570

1571
    /**
1572
     * Removes conditional styles for a cell.
1573
     *
1574
     * @param string $coordinate eg: 'A1'
1575
     *
1576
     * @return $this
1577
     */
1578
    public function removeConditionalStyles(string $coordinate): static
55✔
1579
    {
1580
        unset($this->conditionalStylesCollection[strtoupper($coordinate)]);
55✔
1581

1582
        return $this;
55✔
1583
    }
1584

1585
    /**
1586
     * Get collection of conditional styles.
1587
     *
1588
     * @return Conditional[][]
1589
     */
1590
    public function getConditionalStylesCollection(): array
1,400✔
1591
    {
1592
        return $this->conditionalStylesCollection;
1,400✔
1593
    }
1594

1595
    /**
1596
     * Set conditional styles.
1597
     *
1598
     * @param string $coordinate eg: 'A1'
1599
     * @param Conditional[] $styles
1600
     *
1601
     * @return $this
1602
     */
1603
    public function setConditionalStyles(string $coordinate, array $styles): static
345✔
1604
    {
1605
        $this->conditionalStylesCollection[strtoupper($coordinate)] = $styles;
345✔
1606

1607
        return $this;
345✔
1608
    }
1609

1610
    /**
1611
     * Duplicate cell style to a range of cells.
1612
     *
1613
     * Please note that this will overwrite existing cell styles for cells in range!
1614
     *
1615
     * @param Style $style Cell style to duplicate
1616
     * @param string $range Range of cells (i.e. "A1:B10"), or just one cell (i.e. "A1")
1617
     *
1618
     * @return $this
1619
     */
1620
    public function duplicateStyle(Style $style, string $range): static
2✔
1621
    {
1622
        // Add the style to the workbook if necessary
1623
        $workbook = $this->getParentOrThrow();
2✔
1624
        if ($existingStyle = $workbook->getCellXfByHashCode($style->getHashCode())) {
2✔
1625
            // there is already such cell Xf in our collection
1626
            $xfIndex = $existingStyle->getIndex();
1✔
1627
        } else {
1628
            // we don't have such a cell Xf, need to add
1629
            $workbook->addCellXf($style);
2✔
1630
            $xfIndex = $style->getIndex();
2✔
1631
        }
1632

1633
        // Calculate range outer borders
1634
        [$rangeStart, $rangeEnd] = Coordinate::rangeBoundaries($range . ':' . $range);
2✔
1635

1636
        // Make sure we can loop upwards on rows and columns
1637
        if ($rangeStart[0] > $rangeEnd[0] && $rangeStart[1] > $rangeEnd[1]) {
2✔
UNCOV
1638
            $tmp = $rangeStart;
×
UNCOV
1639
            $rangeStart = $rangeEnd;
×
UNCOV
1640
            $rangeEnd = $tmp;
×
1641
        }
1642

1643
        // Loop through cells and apply styles
1644
        for ($col = $rangeStart[0]; $col <= $rangeEnd[0]; ++$col) {
2✔
1645
            for ($row = $rangeStart[1]; $row <= $rangeEnd[1]; ++$row) {
2✔
1646
                $this->getCell(Coordinate::stringFromColumnIndex($col) . $row)->setXfIndex($xfIndex);
2✔
1647
            }
1648
        }
1649

1650
        return $this;
2✔
1651
    }
1652

1653
    /**
1654
     * Duplicate conditional style to a range of cells.
1655
     *
1656
     * Please note that this will overwrite existing cell styles for cells in range!
1657
     *
1658
     * @param Conditional[] $styles Cell style to duplicate
1659
     * @param string $range Range of cells (i.e. "A1:B10"), or just one cell (i.e. "A1")
1660
     *
1661
     * @return $this
1662
     */
1663
    public function duplicateConditionalStyle(array $styles, string $range = ''): static
18✔
1664
    {
1665
        foreach ($styles as $cellStyle) {
18✔
1666
            if (!($cellStyle instanceof Conditional)) { // @phpstan-ignore-line
18✔
UNCOV
1667
                throw new Exception('Style is not a conditional style');
×
1668
            }
1669
        }
1670

1671
        // Calculate range outer borders
1672
        [$rangeStart, $rangeEnd] = Coordinate::rangeBoundaries($range . ':' . $range);
18✔
1673

1674
        // Make sure we can loop upwards on rows and columns
1675
        if ($rangeStart[0] > $rangeEnd[0] && $rangeStart[1] > $rangeEnd[1]) {
18✔
UNCOV
1676
            $tmp = $rangeStart;
×
UNCOV
1677
            $rangeStart = $rangeEnd;
×
UNCOV
1678
            $rangeEnd = $tmp;
×
1679
        }
1680

1681
        // Loop through cells and apply styles
1682
        for ($col = $rangeStart[0]; $col <= $rangeEnd[0]; ++$col) {
18✔
1683
            for ($row = $rangeStart[1]; $row <= $rangeEnd[1]; ++$row) {
18✔
1684
                $this->setConditionalStyles(Coordinate::stringFromColumnIndex($col) . $row, $styles);
18✔
1685
            }
1686
        }
1687

1688
        return $this;
18✔
1689
    }
1690

1691
    /**
1692
     * Set break on a cell.
1693
     *
1694
     * @param array{0: int, 1: int}|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5';
1695
     *               or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object.
1696
     * @param int $break Break type (type of Worksheet::BREAK_*)
1697
     *
1698
     * @return $this
1699
     */
1700
    public function setBreak(CellAddress|string|array $coordinate, int $break, int $max = -1): static
33✔
1701
    {
1702
        $cellAddress = Functions::trimSheetFromCellReference(Validations::validateCellAddress($coordinate));
33✔
1703

1704
        if ($break === self::BREAK_NONE) {
33✔
1705
            unset($this->rowBreaks[$cellAddress], $this->columnBreaks[$cellAddress]);
7✔
1706
        } elseif ($break === self::BREAK_ROW) {
33✔
1707
            $this->rowBreaks[$cellAddress] = new PageBreak($break, $cellAddress, $max);
23✔
1708
        } elseif ($break === self::BREAK_COLUMN) {
19✔
1709
            $this->columnBreaks[$cellAddress] = new PageBreak($break, $cellAddress, $max);
19✔
1710
        }
1711

1712
        return $this;
33✔
1713
    }
1714

1715
    /**
1716
     * Get breaks.
1717
     *
1718
     * @return int[]
1719
     */
1720
    public function getBreaks(): array
688✔
1721
    {
1722
        $breaks = [];
688✔
1723
        /** @var callable $compareFunction */
1724
        $compareFunction = [self::class, 'compareRowBreaks'];
688✔
1725
        uksort($this->rowBreaks, $compareFunction);
688✔
1726
        foreach ($this->rowBreaks as $break) {
688✔
1727
            $breaks[$break->getCoordinate()] = self::BREAK_ROW;
10✔
1728
        }
1729
        /** @var callable $compareFunction */
1730
        $compareFunction = [self::class, 'compareColumnBreaks'];
688✔
1731
        uksort($this->columnBreaks, $compareFunction);
688✔
1732
        foreach ($this->columnBreaks as $break) {
688✔
1733
            $breaks[$break->getCoordinate()] = self::BREAK_COLUMN;
8✔
1734
        }
1735

1736
        return $breaks;
688✔
1737
    }
1738

1739
    /**
1740
     * Get row breaks.
1741
     *
1742
     * @return PageBreak[]
1743
     */
1744
    public function getRowBreaks(): array
572✔
1745
    {
1746
        /** @var callable $compareFunction */
1747
        $compareFunction = [self::class, 'compareRowBreaks'];
572✔
1748
        uksort($this->rowBreaks, $compareFunction);
572✔
1749

1750
        return $this->rowBreaks;
572✔
1751
    }
1752

1753
    protected static function compareRowBreaks(string $coordinate1, string $coordinate2): int
9✔
1754
    {
1755
        $row1 = Coordinate::indexesFromString($coordinate1)[1];
9✔
1756
        $row2 = Coordinate::indexesFromString($coordinate2)[1];
9✔
1757

1758
        return $row1 - $row2;
9✔
1759
    }
1760

1761
    protected static function compareColumnBreaks(string $coordinate1, string $coordinate2): int
5✔
1762
    {
1763
        $column1 = Coordinate::indexesFromString($coordinate1)[0];
5✔
1764
        $column2 = Coordinate::indexesFromString($coordinate2)[0];
5✔
1765

1766
        return $column1 - $column2;
5✔
1767
    }
1768

1769
    /**
1770
     * Get column breaks.
1771
     *
1772
     * @return PageBreak[]
1773
     */
1774
    public function getColumnBreaks(): array
571✔
1775
    {
1776
        /** @var callable $compareFunction */
1777
        $compareFunction = [self::class, 'compareColumnBreaks'];
571✔
1778
        uksort($this->columnBreaks, $compareFunction);
571✔
1779

1780
        return $this->columnBreaks;
571✔
1781
    }
1782

1783
    /**
1784
     * Set merge on a cell range.
1785
     *
1786
     * @param AddressRange<CellAddress>|AddressRange<int>|AddressRange<string>|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range A simple string containing a Cell range like 'A1:E10'
1787
     *              or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]),
1788
     *              or an AddressRange.
1789
     * @param string $behaviour How the merged cells should behave.
1790
     *               Possible values are:
1791
     *                   MERGE_CELL_CONTENT_EMPTY - Empty the content of the hidden cells
1792
     *                   MERGE_CELL_CONTENT_HIDE - Keep the content of the hidden cells
1793
     *                   MERGE_CELL_CONTENT_MERGE - Move the content of the hidden cells into the first cell
1794
     *
1795
     * @return $this
1796
     */
1797
    public function mergeCells(AddressRange|string|array $range, string $behaviour = self::MERGE_CELL_CONTENT_EMPTY): static
176✔
1798
    {
1799
        $range = Functions::trimSheetFromCellReference(Validations::validateCellRange($range));
176✔
1800

1801
        if (!str_contains($range, ':')) {
175✔
1802
            $range .= ":{$range}";
1✔
1803
        }
1804

1805
        if (!Preg::isMatch('/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/', $range, $matches)) {
175✔
1806
            throw new Exception('Merge must be on a valid range of cells.');
1✔
1807
        }
1808

1809
        $this->mergeCells[$range] = $range;
174✔
1810
        $firstRow = (int) $matches[2];
174✔
1811
        $lastRow = (int) $matches[4];
174✔
1812
        $firstColumn = $matches[1];
174✔
1813
        $lastColumn = $matches[3];
174✔
1814
        $firstColumnIndex = Coordinate::columnIndexFromString($firstColumn);
174✔
1815
        $lastColumnIndex = Coordinate::columnIndexFromString($lastColumn);
174✔
1816
        $numberRows = $lastRow - $firstRow;
174✔
1817
        $numberColumns = $lastColumnIndex - $firstColumnIndex;
174✔
1818

1819
        if ($numberRows === 1 && $numberColumns === 1) {
174✔
1820
            return $this;
35✔
1821
        }
1822

1823
        // create upper left cell if it does not already exist
1824
        $upperLeft = "{$firstColumn}{$firstRow}";
167✔
1825
        if (!$this->cellExists($upperLeft)) {
167✔
1826
            $this->getCell($upperLeft)->setValueExplicit(null, DataType::TYPE_NULL);
36✔
1827
        }
1828

1829
        if ($behaviour !== self::MERGE_CELL_CONTENT_HIDE) {
167✔
1830
            // Blank out the rest of the cells in the range (if they exist)
1831
            if ($numberRows > $numberColumns) {
58✔
1832
                $this->clearMergeCellsByColumn($firstColumn, $lastColumn, $firstRow, $lastRow, $upperLeft, $behaviour);
18✔
1833
            } else {
1834
                $this->clearMergeCellsByRow($firstColumn, $lastColumnIndex, $firstRow, $lastRow, $upperLeft, $behaviour);
40✔
1835
            }
1836
        }
1837

1838
        return $this;
167✔
1839
    }
1840

1841
    private function clearMergeCellsByColumn(string $firstColumn, string $lastColumn, int $firstRow, int $lastRow, string $upperLeft, string $behaviour): void
18✔
1842
    {
1843
        $leftCellValue = ($behaviour === self::MERGE_CELL_CONTENT_MERGE)
18✔
UNCOV
1844
            ? [$this->getCell($upperLeft)->getFormattedValue()]
×
1845
            : [];
18✔
1846

1847
        foreach ($this->getColumnIterator($firstColumn, $lastColumn) as $column) {
18✔
1848
            $iterator = $column->getCellIterator($firstRow);
18✔
1849
            $iterator->setIterateOnlyExistingCells(true);
18✔
1850
            foreach ($iterator as $cell) {
18✔
1851
                $row = $cell->getRow();
18✔
1852
                if ($row > $lastRow) {
18✔
1853
                    break;
8✔
1854
                }
1855
                $leftCellValue = $this->mergeCellBehaviour($cell, $upperLeft, $behaviour, $leftCellValue);
18✔
1856
            }
1857
        }
1858

1859
        if ($behaviour === self::MERGE_CELL_CONTENT_MERGE) {
18✔
UNCOV
1860
            $this->getCell($upperLeft)->setValueExplicit(implode(' ', $leftCellValue), DataType::TYPE_STRING);
×
1861
        }
1862
    }
1863

1864
    private function clearMergeCellsByRow(string $firstColumn, int $lastColumnIndex, int $firstRow, int $lastRow, string $upperLeft, string $behaviour): void
40✔
1865
    {
1866
        $leftCellValue = ($behaviour === self::MERGE_CELL_CONTENT_MERGE)
40✔
1867
            ? [$this->getCell($upperLeft)->getFormattedValue()]
4✔
1868
            : [];
36✔
1869

1870
        foreach ($this->getRowIterator($firstRow, $lastRow) as $row) {
40✔
1871
            $iterator = $row->getCellIterator($firstColumn);
40✔
1872
            $iterator->setIterateOnlyExistingCells(true);
40✔
1873
            foreach ($iterator as $cell) {
40✔
1874
                $column = $cell->getColumn();
40✔
1875
                $columnIndex = Coordinate::columnIndexFromString($column);
40✔
1876
                if ($columnIndex > $lastColumnIndex) {
40✔
1877
                    break;
9✔
1878
                }
1879
                $leftCellValue = $this->mergeCellBehaviour($cell, $upperLeft, $behaviour, $leftCellValue);
40✔
1880
            }
1881
        }
1882

1883
        if ($behaviour === self::MERGE_CELL_CONTENT_MERGE) {
40✔
1884
            $this->getCell($upperLeft)->setValueExplicit(implode(' ', $leftCellValue), DataType::TYPE_STRING);
4✔
1885
        }
1886
    }
1887

1888
    /**
1889
     * @param mixed[] $leftCellValue
1890
     *
1891
     * @return mixed[]
1892
     */
1893
    public function mergeCellBehaviour(Cell $cell, string $upperLeft, string $behaviour, array $leftCellValue): array
58✔
1894
    {
1895
        if ($cell->getCoordinate() !== $upperLeft) {
58✔
1896
            Calculation::getInstance($cell->getWorksheet()->getParentOrThrow())->flushInstance();
24✔
1897
            if ($behaviour === self::MERGE_CELL_CONTENT_MERGE) {
24✔
1898
                $cellValue = $cell->getFormattedValue();
4✔
1899
                if ($cellValue !== '') {
4✔
1900
                    $leftCellValue[] = $cellValue;
4✔
1901
                }
1902
            }
1903
            $cell->setValueExplicit(null, DataType::TYPE_NULL);
24✔
1904
        }
1905

1906
        return $leftCellValue;
58✔
1907
    }
1908

1909
    /**
1910
     * Remove merge on a cell range.
1911
     *
1912
     * @param AddressRange<CellAddress>|AddressRange<int>|AddressRange<string>|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range A simple string containing a Cell range like 'A1:E10'
1913
     *              or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]),
1914
     *              or an AddressRange.
1915
     *
1916
     * @return $this
1917
     */
1918
    public function unmergeCells(AddressRange|string|array $range): static
23✔
1919
    {
1920
        $range = Functions::trimSheetFromCellReference(Validations::validateCellRange($range));
23✔
1921

1922
        if (str_contains($range, ':')) {
23✔
1923
            if (isset($this->mergeCells[$range])) {
22✔
1924
                unset($this->mergeCells[$range]);
22✔
1925
            } else {
UNCOV
1926
                throw new Exception('Cell range ' . $range . ' not known as merged.');
×
1927
            }
1928
        } else {
1929
            throw new Exception('Merge can only be removed from a range of cells.');
1✔
1930
        }
1931

1932
        return $this;
22✔
1933
    }
1934

1935
    /**
1936
     * Get merge cells array.
1937
     *
1938
     * @return string[]
1939
     */
1940
    public function getMergeCells(): array
1,286✔
1941
    {
1942
        return $this->mergeCells;
1,286✔
1943
    }
1944

1945
    /**
1946
     * Set merge cells array for the entire sheet. Use instead mergeCells() to merge
1947
     * a single cell range.
1948
     *
1949
     * @param string[] $mergeCells
1950
     *
1951
     * @return $this
1952
     */
1953
    public function setMergeCells(array $mergeCells): static
126✔
1954
    {
1955
        $this->mergeCells = $mergeCells;
126✔
1956

1957
        return $this;
126✔
1958
    }
1959

1960
    /**
1961
     * Set protection on a cell or cell range.
1962
     *
1963
     * @param AddressRange<CellAddress>|AddressRange<int>|AddressRange<string>|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $range A simple string containing a Cell range like 'A1:E10'
1964
     *              or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]),
1965
     *              or a CellAddress or AddressRange object.
1966
     * @param string $password Password to unlock the protection
1967
     * @param bool $alreadyHashed If the password has already been hashed, set this to true
1968
     *
1969
     * @return $this
1970
     */
1971
    public function protectCells(AddressRange|CellAddress|int|string|array $range, string $password = '', bool $alreadyHashed = false, string $name = '', string $securityDescriptor = ''): static
28✔
1972
    {
1973
        $range = Functions::trimSheetFromCellReference(Validations::validateCellOrCellRange($range));
28✔
1974

1975
        if (!$alreadyHashed && $password !== '') {
28✔
1976
            $password = Shared\PasswordHasher::hashPassword($password);
24✔
1977
        }
1978
        $this->protectedCells[$range] = new ProtectedRange($range, $password, $name, $securityDescriptor);
28✔
1979

1980
        return $this;
28✔
1981
    }
1982

1983
    /**
1984
     * Remove protection on a cell or cell range.
1985
     *
1986
     * @param AddressRange<CellAddress>|AddressRange<int>|AddressRange<string>|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $range A simple string containing a Cell range like 'A1:E10'
1987
     *              or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]),
1988
     *              or a CellAddress or AddressRange object.
1989
     *
1990
     * @return $this
1991
     */
1992
    public function unprotectCells(AddressRange|CellAddress|int|string|array $range): static
22✔
1993
    {
1994
        $range = Functions::trimSheetFromCellReference(Validations::validateCellOrCellRange($range));
22✔
1995

1996
        if (isset($this->protectedCells[$range])) {
22✔
1997
            unset($this->protectedCells[$range]);
21✔
1998
        } else {
1999
            throw new Exception('Cell range ' . $range . ' not known as protected.');
1✔
2000
        }
2001

2002
        return $this;
21✔
2003
    }
2004

2005
    /**
2006
     * Get protected cells.
2007
     *
2008
     * @return ProtectedRange[]
2009
     */
2010
    public function getProtectedCellRanges(): array
681✔
2011
    {
2012
        return $this->protectedCells;
681✔
2013
    }
2014

2015
    /**
2016
     * Get Autofilter.
2017
     */
2018
    public function getAutoFilter(): AutoFilter
882✔
2019
    {
2020
        return $this->autoFilter;
882✔
2021
    }
2022

2023
    /**
2024
     * Set AutoFilter.
2025
     *
2026
     * @param AddressRange<CellAddress>|AddressRange<int>|AddressRange<string>|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|AutoFilter|string $autoFilterOrRange
2027
     *            A simple string containing a Cell range like 'A1:E10' is permitted for backward compatibility
2028
     *              or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]),
2029
     *              or an AddressRange.
2030
     *
2031
     * @return $this
2032
     */
2033
    public function setAutoFilter(AddressRange|string|array|AutoFilter $autoFilterOrRange): static
21✔
2034
    {
2035
        if (is_object($autoFilterOrRange) && ($autoFilterOrRange instanceof AutoFilter)) {
21✔
UNCOV
2036
            $this->autoFilter = $autoFilterOrRange;
×
2037
        } else {
2038
            $cellRange = Functions::trimSheetFromCellReference(Validations::validateCellRange($autoFilterOrRange));
21✔
2039

2040
            $this->autoFilter->setRange($cellRange);
21✔
2041
        }
2042

2043
        return $this;
21✔
2044
    }
2045

2046
    /**
2047
     * Remove autofilter.
2048
     */
2049
    public function removeAutoFilter(): self
1✔
2050
    {
2051
        $this->autoFilter->setRange('');
1✔
2052

2053
        return $this;
1✔
2054
    }
2055

2056
    /**
2057
     * Get collection of Tables.
2058
     *
2059
     * @return ArrayObject<int, Table>
2060
     */
2061
    public function getTableCollection(): ArrayObject
10,682✔
2062
    {
2063
        return $this->tableCollection;
10,682✔
2064
    }
2065

2066
    /**
2067
     * Add Table.
2068
     *
2069
     * @return $this
2070
     */
2071
    public function addTable(Table $table): self
105✔
2072
    {
2073
        $table->setWorksheet($this);
105✔
2074
        $this->tableCollection[] = $table;
105✔
2075

2076
        return $this;
105✔
2077
    }
2078

2079
    /**
2080
     * @return string[] array of Table names
2081
     */
2082
    public function getTableNames(): array
1✔
2083
    {
2084
        $tableNames = [];
1✔
2085

2086
        foreach ($this->tableCollection as $table) {
1✔
2087
            /** @var Table $table */
2088
            $tableNames[] = $table->getName();
1✔
2089
        }
2090

2091
        return $tableNames;
1✔
2092
    }
2093

2094
    /**
2095
     * @param string $name the table name to search
2096
     *
2097
     * @return null|Table The table from the tables collection, or null if not found
2098
     */
2099
    public function getTableByName(string $name): ?Table
97✔
2100
    {
2101
        $tableIndex = $this->getTableIndexByName($name);
97✔
2102

2103
        return ($tableIndex === null) ? null : $this->tableCollection[$tableIndex];
97✔
2104
    }
2105

2106
    /**
2107
     * @param string $name the table name to search
2108
     *
2109
     * @return null|int The index of the located table in the tables collection, or null if not found
2110
     */
2111
    protected function getTableIndexByName(string $name): ?int
98✔
2112
    {
2113
        $name = StringHelper::strToUpper($name);
98✔
2114
        foreach ($this->tableCollection as $index => $table) {
98✔
2115
            /** @var Table $table */
2116
            if (StringHelper::strToUpper($table->getName()) === $name) {
63✔
2117
                return $index;
62✔
2118
            }
2119
        }
2120

2121
        return null;
41✔
2122
    }
2123

2124
    /**
2125
     * Remove Table by name.
2126
     *
2127
     * @param string $name Table name
2128
     *
2129
     * @return $this
2130
     */
2131
    public function removeTableByName(string $name): self
1✔
2132
    {
2133
        $tableIndex = $this->getTableIndexByName($name);
1✔
2134

2135
        if ($tableIndex !== null) {
1✔
2136
            unset($this->tableCollection[$tableIndex]);
1✔
2137
        }
2138

2139
        return $this;
1✔
2140
    }
2141

2142
    /**
2143
     * Remove collection of Tables.
2144
     */
2145
    public function removeTableCollection(): self
1✔
2146
    {
2147
        $this->tableCollection = new ArrayObject();
1✔
2148

2149
        return $this;
1✔
2150
    }
2151

2152
    /**
2153
     * Get Freeze Pane.
2154
     */
2155
    public function getFreezePane(): ?string
297✔
2156
    {
2157
        return $this->freezePane;
297✔
2158
    }
2159

2160
    /**
2161
     * Freeze Pane.
2162
     *
2163
     * Examples:
2164
     *
2165
     *     - A2 will freeze the rows above cell A2 (i.e row 1)
2166
     *     - B1 will freeze the columns to the left of cell B1 (i.e column A)
2167
     *     - B2 will freeze the rows above and to the left of cell B2 (i.e row 1 and column A)
2168
     *
2169
     * @param null|array{0: int, 1: int}|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5';
2170
     *            or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object.
2171
     *        Passing a null value for this argument will clear any existing freeze pane for this worksheet.
2172
     * @param null|array{0: int, 1: int}|CellAddress|string $topLeftCell default position of the right bottom pane
2173
     *            Coordinate of the cell as a string, eg: 'C5'; or as an array of [$columnIndex, $row] (e.g. [3, 5]),
2174
     *            or a CellAddress object.
2175
     *
2176
     * @return $this
2177
     */
2178
    public function freezePane(null|CellAddress|string|array $coordinate, null|CellAddress|string|array $topLeftCell = null, bool $frozenSplit = false): static
50✔
2179
    {
2180
        $this->panes = [
50✔
2181
            'bottomRight' => null,
50✔
2182
            'bottomLeft' => null,
50✔
2183
            'topRight' => null,
50✔
2184
            'topLeft' => null,
50✔
2185
        ];
50✔
2186
        $cellAddress = ($coordinate !== null)
50✔
2187
            ? Functions::trimSheetFromCellReference(Validations::validateCellAddress($coordinate))
50✔
2188
            : null;
1✔
2189
        if ($cellAddress !== null && Coordinate::coordinateIsRange($cellAddress)) {
50✔
2190
            throw new Exception('Freeze pane can not be set on a range of cells.');
1✔
2191
        }
2192
        $topLeftCell = ($topLeftCell !== null)
49✔
2193
            ? Functions::trimSheetFromCellReference(Validations::validateCellAddress($topLeftCell))
37✔
2194
            : null;
37✔
2195

2196
        if ($cellAddress !== null && $topLeftCell === null) {
49✔
2197
            $coordinate = Coordinate::coordinateFromString($cellAddress);
37✔
2198
            $topLeftCell = $coordinate[0] . $coordinate[1];
37✔
2199
        }
2200

2201
        $topLeftCell = "$topLeftCell";
49✔
2202
        $this->paneTopLeftCell = $topLeftCell;
49✔
2203

2204
        $this->freezePane = $cellAddress;
49✔
2205
        $this->topLeftCell = $topLeftCell;
49✔
2206
        if ($cellAddress === null) {
49✔
2207
            $this->paneState = '';
1✔
2208
            $this->xSplit = $this->ySplit = 0;
1✔
2209
            $this->activePane = '';
1✔
2210
        } else {
2211
            $coordinates = Coordinate::indexesFromString($cellAddress);
49✔
2212
            $this->xSplit = $coordinates[0] - 1;
49✔
2213
            $this->ySplit = $coordinates[1] - 1;
49✔
2214
            if ($this->xSplit > 0 || $this->ySplit > 0) {
49✔
2215
                $this->paneState = $frozenSplit ? self::PANE_FROZENSPLIT : self::PANE_FROZEN;
48✔
2216
                $this->setSelectedCellsActivePane();
48✔
2217
            } else {
2218
                $this->paneState = '';
1✔
2219
                $this->freezePane = null;
1✔
2220
                $this->activePane = '';
1✔
2221
            }
2222
        }
2223

2224
        return $this;
49✔
2225
    }
2226

2227
    public function setTopLeftCell(string $topLeftCell): self
59✔
2228
    {
2229
        $this->topLeftCell = $topLeftCell;
59✔
2230

2231
        return $this;
59✔
2232
    }
2233

2234
    /**
2235
     * Unfreeze Pane.
2236
     *
2237
     * @return $this
2238
     */
2239
    public function unfreezePane(): static
1✔
2240
    {
2241
        return $this->freezePane(null);
1✔
2242
    }
2243

2244
    /**
2245
     * Get the default position of the right bottom pane.
2246
     */
2247
    public function getTopLeftCell(): ?string
507✔
2248
    {
2249
        return $this->topLeftCell;
507✔
2250
    }
2251

2252
    public function getPaneTopLeftCell(): string
11✔
2253
    {
2254
        return $this->paneTopLeftCell;
11✔
2255
    }
2256

2257
    public function setPaneTopLeftCell(string $paneTopLeftCell): self
26✔
2258
    {
2259
        $this->paneTopLeftCell = $paneTopLeftCell;
26✔
2260

2261
        return $this;
26✔
2262
    }
2263

2264
    public function usesPanes(): bool
495✔
2265
    {
2266
        return $this->xSplit > 0 || $this->ySplit > 0;
495✔
2267
    }
2268

2269
    public function getPane(string $position): ?Pane
2✔
2270
    {
2271
        return $this->panes[$position] ?? null;
2✔
2272
    }
2273

2274
    public function setPane(string $position, ?Pane $pane): self
46✔
2275
    {
2276
        if (array_key_exists($position, $this->panes)) {
46✔
2277
            $this->panes[$position] = $pane;
46✔
2278
        }
2279

2280
        return $this;
46✔
2281
    }
2282

2283
    /** @return (null|Pane)[] */
2284
    public function getPanes(): array
3✔
2285
    {
2286
        return $this->panes;
3✔
2287
    }
2288

2289
    public function getActivePane(): string
14✔
2290
    {
2291
        return $this->activePane;
14✔
2292
    }
2293

2294
    public function setActivePane(string $activePane): self
49✔
2295
    {
2296
        $this->activePane = array_key_exists($activePane, $this->panes) ? $activePane : '';
49✔
2297

2298
        return $this;
49✔
2299
    }
2300

2301
    public function getXSplit(): int
11✔
2302
    {
2303
        return $this->xSplit;
11✔
2304
    }
2305

2306
    public function setXSplit(int $xSplit): self
11✔
2307
    {
2308
        $this->xSplit = $xSplit;
11✔
2309
        if (in_array($this->paneState, self::VALIDFROZENSTATE, true)) {
11✔
2310
            $this->freezePane([$this->xSplit + 1, $this->ySplit + 1], $this->topLeftCell, $this->paneState === self::PANE_FROZENSPLIT);
1✔
2311
        }
2312

2313
        return $this;
11✔
2314
    }
2315

2316
    public function getYSplit(): int
11✔
2317
    {
2318
        return $this->ySplit;
11✔
2319
    }
2320

2321
    public function setYSplit(int $ySplit): self
26✔
2322
    {
2323
        $this->ySplit = $ySplit;
26✔
2324
        if (in_array($this->paneState, self::VALIDFROZENSTATE, true)) {
26✔
2325
            $this->freezePane([$this->xSplit + 1, $this->ySplit + 1], $this->topLeftCell, $this->paneState === self::PANE_FROZENSPLIT);
1✔
2326
        }
2327

2328
        return $this;
26✔
2329
    }
2330

2331
    public function getPaneState(): string
30✔
2332
    {
2333
        return $this->paneState;
30✔
2334
    }
2335

2336
    public const PANE_FROZEN = 'frozen';
2337
    public const PANE_FROZENSPLIT = 'frozenSplit';
2338
    public const PANE_SPLIT = 'split';
2339
    private const VALIDPANESTATE = [self::PANE_FROZEN, self::PANE_SPLIT, self::PANE_FROZENSPLIT];
2340
    private const VALIDFROZENSTATE = [self::PANE_FROZEN, self::PANE_FROZENSPLIT];
2341

2342
    public function setPaneState(string $paneState): self
26✔
2343
    {
2344
        $this->paneState = in_array($paneState, self::VALIDPANESTATE, true) ? $paneState : '';
26✔
2345
        if (in_array($this->paneState, self::VALIDFROZENSTATE, true)) {
26✔
2346
            $this->freezePane([$this->xSplit + 1, $this->ySplit + 1], $this->topLeftCell, $this->paneState === self::PANE_FROZENSPLIT);
25✔
2347
        } else {
2348
            $this->freezePane = null;
3✔
2349
        }
2350

2351
        return $this;
26✔
2352
    }
2353

2354
    /**
2355
     * Insert a new row, updating all possible related data.
2356
     *
2357
     * @param int $before Insert before this row number
2358
     * @param int $numberOfRows Number of new rows to insert
2359
     *
2360
     * @return $this
2361
     */
2362
    public function insertNewRowBefore(int $before, int $numberOfRows = 1): static
43✔
2363
    {
2364
        if ($before >= 1) {
43✔
2365
            $objReferenceHelper = ReferenceHelper::getInstance();
42✔
2366
            $objReferenceHelper->insertNewBefore('A' . $before, 0, $numberOfRows, $this);
42✔
2367
        } else {
2368
            throw new Exception('Rows can only be inserted before at least row 1.');
1✔
2369
        }
2370

2371
        return $this;
42✔
2372
    }
2373

2374
    /**
2375
     * Insert a new column, updating all possible related data.
2376
     *
2377
     * @param string $before Insert before this column Name, eg: 'A'
2378
     * @param int $numberOfColumns Number of new columns to insert
2379
     *
2380
     * @return $this
2381
     */
2382
    public function insertNewColumnBefore(string $before, int $numberOfColumns = 1): static
50✔
2383
    {
2384
        if (!is_numeric($before)) {
50✔
2385
            $objReferenceHelper = ReferenceHelper::getInstance();
49✔
2386
            $objReferenceHelper->insertNewBefore($before . '1', $numberOfColumns, 0, $this);
49✔
2387
        } else {
2388
            throw new Exception('Column references should not be numeric.');
1✔
2389
        }
2390

2391
        return $this;
49✔
2392
    }
2393

2394
    /**
2395
     * Insert a new column, updating all possible related data.
2396
     *
2397
     * @param int $beforeColumnIndex Insert before this column ID (numeric column coordinate of the cell)
2398
     * @param int $numberOfColumns Number of new columns to insert
2399
     *
2400
     * @return $this
2401
     */
2402
    public function insertNewColumnBeforeByIndex(int $beforeColumnIndex, int $numberOfColumns = 1): static
2✔
2403
    {
2404
        if ($beforeColumnIndex >= 1) {
2✔
2405
            return $this->insertNewColumnBefore(Coordinate::stringFromColumnIndex($beforeColumnIndex), $numberOfColumns);
1✔
2406
        }
2407

2408
        throw new Exception('Columns can only be inserted before at least column A (1).');
1✔
2409
    }
2410

2411
    /**
2412
     * Delete a row, updating all possible related data.
2413
     *
2414
     * @param int $row Remove rows, starting with this row number
2415
     * @param int $numberOfRows Number of rows to remove
2416
     *
2417
     * @return $this
2418
     */
2419
    public function removeRow(int $row, int $numberOfRows = 1): static
53✔
2420
    {
2421
        if ($row < 1) {
53✔
2422
            throw new Exception('Rows to be deleted should at least start from row 1.');
1✔
2423
        }
2424
        $startRow = $row;
52✔
2425
        $endRow = $startRow + $numberOfRows - 1;
52✔
2426
        $removeKeys = [];
52✔
2427
        $addKeys = [];
52✔
2428
        foreach ($this->mergeCells as $key => $value) {
52✔
2429
            if (
2430
                Preg::isMatch(
21✔
2431
                    '/^([a-z]{1,3})(\d+):([a-z]{1,3})(\d+)/i',
21✔
2432
                    $key,
21✔
2433
                    $matches
21✔
2434
                )
21✔
2435
            ) {
2436
                $startMergeInt = (int) $matches[2];
21✔
2437
                $endMergeInt = (int) $matches[4];
21✔
2438
                if ($startMergeInt >= $startRow) {
21✔
2439
                    if ($startMergeInt <= $endRow) {
21✔
2440
                        $removeKeys[] = $key;
3✔
2441
                    }
2442
                } elseif ($endMergeInt >= $startRow) {
1✔
2443
                    if ($endMergeInt <= $endRow) {
1✔
2444
                        $temp = $endMergeInt - 1;
1✔
2445
                        $removeKeys[] = $key;
1✔
2446
                        if ($temp !== $startMergeInt) {
1✔
2447
                            $temp3 = $matches[1] . $matches[2] . ':' . $matches[3] . $temp;
1✔
2448
                            $addKeys[] = $temp3;
1✔
2449
                        }
2450
                    }
2451
                }
2452
            }
2453
        }
2454
        foreach ($removeKeys as $key) {
52✔
2455
            unset($this->mergeCells[$key]);
3✔
2456
        }
2457
        foreach ($addKeys as $key) {
52✔
2458
            $this->mergeCells[$key] = $key;
1✔
2459
        }
2460

2461
        $holdRowDimensions = $this->removeRowDimensions($row, $numberOfRows);
52✔
2462
        $highestRow = $this->getHighestDataRow();
52✔
2463
        $removedRowsCounter = 0;
52✔
2464

2465
        for ($r = 0; $r < $numberOfRows; ++$r) {
52✔
2466
            if ($row + $r <= $highestRow) {
52✔
2467
                $this->cellCollection->removeRow($row + $r);
40✔
2468
                ++$removedRowsCounter;
40✔
2469
            }
2470
        }
2471

2472
        $objReferenceHelper = ReferenceHelper::getInstance();
52✔
2473
        $objReferenceHelper->insertNewBefore('A' . ($row + $numberOfRows), 0, -$numberOfRows, $this);
52✔
2474
        for ($r = 0; $r < $removedRowsCounter; ++$r) {
52✔
2475
            $this->cellCollection->removeRow($highestRow);
40✔
2476
            --$highestRow;
40✔
2477
        }
2478

2479
        $this->rowDimensions = $holdRowDimensions;
52✔
2480

2481
        return $this;
52✔
2482
    }
2483

2484
    /** @return RowDimension[] */
2485
    private function removeRowDimensions(int $row, int $numberOfRows): array
52✔
2486
    {
2487
        $highRow = $row + $numberOfRows - 1;
52✔
2488
        $holdRowDimensions = [];
52✔
2489
        foreach ($this->rowDimensions as $rowDimension) {
52✔
2490
            $num = $rowDimension->getRowIndex();
5✔
2491
            if ($num < $row) {
5✔
2492
                $holdRowDimensions[$num] = $rowDimension;
3✔
2493
            } elseif ($num > $highRow) {
5✔
2494
                $num -= $numberOfRows;
4✔
2495
                $cloneDimension = clone $rowDimension;
4✔
2496
                $cloneDimension->setRowIndex($num);
4✔
2497
                $holdRowDimensions[$num] = $cloneDimension;
4✔
2498
            }
2499
        }
2500

2501
        return $holdRowDimensions;
52✔
2502
    }
2503

2504
    /**
2505
     * Remove a column, updating all possible related data.
2506
     *
2507
     * @param string $column Remove columns starting with this column name, eg: 'A'
2508
     * @param int $numberOfColumns Number of columns to remove
2509
     *
2510
     * @return $this
2511
     */
2512
    public function removeColumn(string $column, int $numberOfColumns = 1): static
43✔
2513
    {
2514
        if (is_numeric($column)) {
43✔
2515
            throw new Exception('Column references should not be numeric.');
1✔
2516
        }
2517
        $startColumnInt = Coordinate::columnIndexFromString($column);
42✔
2518
        $endColumnInt = $startColumnInt + $numberOfColumns - 1;
42✔
2519
        $removeKeys = [];
42✔
2520
        $addKeys = [];
42✔
2521
        foreach ($this->mergeCells as $key => $value) {
42✔
2522
            if (
2523
                Preg::isMatch(
19✔
2524
                    '/^([a-z]{1,3})(\d+):([a-z]{1,3})(\d+)/i',
19✔
2525
                    $key,
19✔
2526
                    $matches
19✔
2527
                )
19✔
2528
            ) {
2529
                $startMergeInt = Coordinate::columnIndexFromString($matches[1]);
19✔
2530
                $endMergeInt = Coordinate::columnIndexFromString($matches[3]);
19✔
2531
                if ($startMergeInt >= $startColumnInt) {
19✔
2532
                    if ($startMergeInt <= $endColumnInt) {
2✔
2533
                        $removeKeys[] = $key;
2✔
2534
                    }
2535
                } elseif ($endMergeInt >= $startColumnInt) {
18✔
2536
                    if ($endMergeInt <= $endColumnInt) {
18✔
2537
                        $temp = Coordinate::columnIndexFromString($matches[3]) - 1;
1✔
2538
                        $temp2 = Coordinate::stringFromColumnIndex($temp);
1✔
2539
                        $removeKeys[] = $key;
1✔
2540
                        if ($temp2 !== $matches[1]) {
1✔
2541
                            $temp3 = $matches[1] . $matches[2] . ':' . $temp2 . $matches[4];
1✔
2542
                            $addKeys[] = $temp3;
1✔
2543
                        }
2544
                    }
2545
                }
2546
            }
2547
        }
2548
        foreach ($removeKeys as $key) {
42✔
2549
            unset($this->mergeCells[$key]);
2✔
2550
        }
2551
        foreach ($addKeys as $key) {
42✔
2552
            $this->mergeCells[$key] = $key;
1✔
2553
        }
2554

2555
        $highestColumn = $this->getHighestDataColumn();
42✔
2556
        $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);
42✔
2557
        $pColumnIndex = Coordinate::columnIndexFromString($column);
42✔
2558

2559
        $holdColumnDimensions = $this->removeColumnDimensions($pColumnIndex, $numberOfColumns);
42✔
2560

2561
        $column = Coordinate::stringFromColumnIndex($pColumnIndex + $numberOfColumns);
42✔
2562
        $objReferenceHelper = ReferenceHelper::getInstance();
42✔
2563
        $objReferenceHelper->insertNewBefore($column . '1', -$numberOfColumns, 0, $this);
42✔
2564

2565
        $this->columnDimensions = $holdColumnDimensions;
42✔
2566

2567
        if ($pColumnIndex > $highestColumnIndex) {
42✔
2568
            return $this;
9✔
2569
        }
2570

2571
        $maxPossibleColumnsToBeRemoved = $highestColumnIndex - $pColumnIndex + 1;
33✔
2572

2573
        for ($c = 0, $n = min($maxPossibleColumnsToBeRemoved, $numberOfColumns); $c < $n; ++$c) {
33✔
2574
            $this->cellCollection->removeColumn($highestColumn);
33✔
2575
            $highestColumn = Coordinate::stringFromColumnIndex(Coordinate::columnIndexFromString($highestColumn) - 1);
33✔
2576
        }
2577

2578
        $this->garbageCollect();
33✔
2579

2580
        return $this;
33✔
2581
    }
2582

2583
    /** @return ColumnDimension[] */
2584
    private function removeColumnDimensions(int $pColumnIndex, int $numberOfColumns): array
42✔
2585
    {
2586
        $highCol = $pColumnIndex + $numberOfColumns - 1;
42✔
2587
        $holdColumnDimensions = [];
42✔
2588
        foreach ($this->columnDimensions as $columnDimension) {
42✔
2589
            $num = $columnDimension->getColumnNumeric();
18✔
2590
            if ($num < $pColumnIndex) {
18✔
2591
                $str = $columnDimension->getColumnIndex();
18✔
2592
                $holdColumnDimensions[$str] = $columnDimension;
18✔
2593
            } elseif ($num > $highCol) {
18✔
2594
                $cloneDimension = clone $columnDimension;
18✔
2595
                $cloneDimension->setColumnNumeric($num - $numberOfColumns);
18✔
2596
                $str = $cloneDimension->getColumnIndex();
18✔
2597
                $holdColumnDimensions[$str] = $cloneDimension;
18✔
2598
            }
2599
        }
2600

2601
        return $holdColumnDimensions;
42✔
2602
    }
2603

2604
    /**
2605
     * Remove a column, updating all possible related data.
2606
     *
2607
     * @param int $columnIndex Remove starting with this column Index (numeric column coordinate)
2608
     * @param int $numColumns Number of columns to remove
2609
     *
2610
     * @return $this
2611
     */
2612
    public function removeColumnByIndex(int $columnIndex, int $numColumns = 1): static
3✔
2613
    {
2614
        if ($columnIndex >= 1) {
3✔
2615
            return $this->removeColumn(Coordinate::stringFromColumnIndex($columnIndex), $numColumns);
2✔
2616
        }
2617

2618
        throw new Exception('Columns to be deleted should at least start from column A (1)');
1✔
2619
    }
2620

2621
    /**
2622
     * Show gridlines?
2623
     */
2624
    public function getShowGridlines(): bool
1,112✔
2625
    {
2626
        return $this->showGridlines;
1,112✔
2627
    }
2628

2629
    /**
2630
     * Set show gridlines.
2631
     *
2632
     * @param bool $showGridLines Show gridlines (true/false)
2633
     *
2634
     * @return $this
2635
     */
2636
    public function setShowGridlines(bool $showGridLines): self
895✔
2637
    {
2638
        $this->showGridlines = $showGridLines;
895✔
2639

2640
        return $this;
895✔
2641
    }
2642

2643
    /**
2644
     * Print gridlines?
2645
     */
2646
    public function getPrintGridlines(): bool
1,119✔
2647
    {
2648
        return $this->printGridlines;
1,119✔
2649
    }
2650

2651
    /**
2652
     * Set print gridlines.
2653
     *
2654
     * @param bool $printGridLines Print gridlines (true/false)
2655
     *
2656
     * @return $this
2657
     */
2658
    public function setPrintGridlines(bool $printGridLines): self
588✔
2659
    {
2660
        $this->printGridlines = $printGridLines;
588✔
2661

2662
        return $this;
588✔
2663
    }
2664

2665
    /**
2666
     * Show row and column headers?
2667
     */
2668
    public function getShowRowColHeaders(): bool
568✔
2669
    {
2670
        return $this->showRowColHeaders;
568✔
2671
    }
2672

2673
    /**
2674
     * Set show row and column headers.
2675
     *
2676
     * @param bool $showRowColHeaders Show row and column headers (true/false)
2677
     *
2678
     * @return $this
2679
     */
2680
    public function setShowRowColHeaders(bool $showRowColHeaders): self
427✔
2681
    {
2682
        $this->showRowColHeaders = $showRowColHeaders;
427✔
2683

2684
        return $this;
427✔
2685
    }
2686

2687
    /**
2688
     * Show summary below? (Row/Column outlining).
2689
     */
2690
    public function getShowSummaryBelow(): bool
569✔
2691
    {
2692
        return $this->showSummaryBelow;
569✔
2693
    }
2694

2695
    /**
2696
     * Set show summary below.
2697
     *
2698
     * @param bool $showSummaryBelow Show summary below (true/false)
2699
     *
2700
     * @return $this
2701
     */
2702
    public function setShowSummaryBelow(bool $showSummaryBelow): self
426✔
2703
    {
2704
        $this->showSummaryBelow = $showSummaryBelow;
426✔
2705

2706
        return $this;
426✔
2707
    }
2708

2709
    /**
2710
     * Show summary right? (Row/Column outlining).
2711
     */
2712
    public function getShowSummaryRight(): bool
569✔
2713
    {
2714
        return $this->showSummaryRight;
569✔
2715
    }
2716

2717
    /**
2718
     * Set show summary right.
2719
     *
2720
     * @param bool $showSummaryRight Show summary right (true/false)
2721
     *
2722
     * @return $this
2723
     */
2724
    public function setShowSummaryRight(bool $showSummaryRight): self
426✔
2725
    {
2726
        $this->showSummaryRight = $showSummaryRight;
426✔
2727

2728
        return $this;
426✔
2729
    }
2730

2731
    /**
2732
     * Get comments.
2733
     *
2734
     * @return Comment[]
2735
     */
2736
    public function getComments(): array
1,168✔
2737
    {
2738
        return $this->comments;
1,168✔
2739
    }
2740

2741
    /**
2742
     * Set comments array for the entire sheet.
2743
     *
2744
     * @param Comment[] $comments
2745
     *
2746
     * @return $this
2747
     */
2748
    public function setComments(array $comments): self
126✔
2749
    {
2750
        $this->comments = $comments;
126✔
2751

2752
        return $this;
126✔
2753
    }
2754

2755
    /**
2756
     * Remove comment from cell.
2757
     *
2758
     * @param array{0: int, 1: int}|CellAddress|string $cellCoordinate Coordinate of the cell as a string, eg: 'C5';
2759
     *               or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object.
2760
     *
2761
     * @return $this
2762
     */
2763
    public function removeComment(CellAddress|string|array $cellCoordinate): self
58✔
2764
    {
2765
        $cellAddress = Functions::trimSheetFromCellReference(Validations::validateCellAddress($cellCoordinate));
58✔
2766

2767
        if (Coordinate::coordinateIsRange($cellAddress)) {
58✔
2768
            throw new Exception('Cell coordinate string can not be a range of cells.');
1✔
2769
        } elseif (str_contains($cellAddress, '$')) {
57✔
2770
            throw new Exception('Cell coordinate string must not be absolute.');
1✔
2771
        } elseif ($cellAddress == '') {
56✔
2772
            throw new Exception('Cell coordinate can not be zero-length string.');
1✔
2773
        }
2774
        // Check if we have a comment for this cell and delete it
2775
        if (isset($this->comments[$cellAddress])) {
55✔
2776
            unset($this->comments[$cellAddress]);
3✔
2777
        }
2778

2779
        return $this;
55✔
2780
    }
2781

2782
    /**
2783
     * Get comment for cell.
2784
     *
2785
     * @param array{0: int, 1: int}|CellAddress|string $cellCoordinate Coordinate of the cell as a string, eg: 'C5';
2786
     *               or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object.
2787
     */
2788
    public function getComment(CellAddress|string|array $cellCoordinate, bool $attachNew = true): Comment
119✔
2789
    {
2790
        $cellAddress = Functions::trimSheetFromCellReference(Validations::validateCellAddress($cellCoordinate));
119✔
2791

2792
        if (Coordinate::coordinateIsRange($cellAddress)) {
119✔
2793
            throw new Exception('Cell coordinate string can not be a range of cells.');
1✔
2794
        } elseif (str_contains($cellAddress, '$')) {
118✔
2795
            throw new Exception('Cell coordinate string must not be absolute.');
1✔
2796
        } elseif ($cellAddress == '') {
117✔
2797
            throw new Exception('Cell coordinate can not be zero-length string.');
1✔
2798
        }
2799

2800
        // Check if we already have a comment for this cell.
2801
        if (isset($this->comments[$cellAddress])) {
116✔
2802
            return $this->comments[$cellAddress];
83✔
2803
        }
2804

2805
        // If not, create a new comment.
2806
        $newComment = new Comment();
116✔
2807
        if ($attachNew) {
116✔
2808
            $this->comments[$cellAddress] = $newComment;
116✔
2809
        }
2810

2811
        return $newComment;
116✔
2812
    }
2813

2814
    /**
2815
     * Get active cell.
2816
     *
2817
     * @return string Example: 'A1'
2818
     */
2819
    public function getActiveCell(): string
10,711✔
2820
    {
2821
        return $this->activeCell;
10,711✔
2822
    }
2823

2824
    /**
2825
     * Get selected cells.
2826
     */
2827
    public function getSelectedCells(): string
10,760✔
2828
    {
2829
        return $this->selectedCells;
10,760✔
2830
    }
2831

2832
    /**
2833
     * Selected cell.
2834
     *
2835
     * @param string $coordinate Cell (i.e. A1)
2836
     *
2837
     * @return $this
2838
     */
2839
    public function setSelectedCell(string $coordinate): static
38✔
2840
    {
2841
        return $this->setSelectedCells($coordinate);
38✔
2842
    }
2843

2844
    /**
2845
     * Select a range of cells.
2846
     *
2847
     * @param AddressRange<CellAddress>|AddressRange<int>|AddressRange<string>|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $coordinate A simple string containing a Cell range like 'A1:E10'
2848
     *              or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]),
2849
     *              or a CellAddress or AddressRange object.
2850
     *
2851
     * @return $this
2852
     */
2853
    public function setSelectedCells(AddressRange|CellAddress|int|string|array $coordinate): static
10,709✔
2854
    {
2855
        if (is_string($coordinate)) {
10,709✔
2856
            $coordinate = Validations::definedNameToCoordinate($coordinate, $this);
10,709✔
2857
        }
2858
        $coordinate = Validations::validateCellOrCellRange($coordinate);
10,709✔
2859

2860
        if (Coordinate::coordinateIsRange($coordinate)) {
10,709✔
2861
            [$first] = Coordinate::splitRange($coordinate);
518✔
2862
            $this->activeCell = $first[0];
518✔
2863
        } else {
2864
            $this->activeCell = $coordinate;
10,678✔
2865
        }
2866
        $this->selectedCells = $coordinate;
10,709✔
2867
        $this->setSelectedCellsActivePane();
10,709✔
2868

2869
        return $this;
10,709✔
2870
    }
2871

2872
    private function setSelectedCellsActivePane(): void
10,710✔
2873
    {
2874
        if (!empty($this->freezePane)) {
10,710✔
2875
            $coordinateC = Coordinate::indexesFromString($this->freezePane);
48✔
2876
            $coordinateT = Coordinate::indexesFromString($this->activeCell);
48✔
2877
            if ($coordinateC[0] === 1) {
48✔
2878
                $activePane = ($coordinateT[1] <= $coordinateC[1]) ? 'topLeft' : 'bottomLeft';
26✔
2879
            } elseif ($coordinateC[1] === 1) {
24✔
2880
                $activePane = ($coordinateT[0] <= $coordinateC[0]) ? 'topLeft' : 'topRight';
3✔
2881
            } elseif ($coordinateT[1] <= $coordinateC[1]) {
22✔
2882
                $activePane = ($coordinateT[0] <= $coordinateC[0]) ? 'topLeft' : 'topRight';
22✔
2883
            } else {
2884
                $activePane = ($coordinateT[0] <= $coordinateC[0]) ? 'bottomLeft' : 'bottomRight';
10✔
2885
            }
2886
            $this->setActivePane($activePane);
48✔
2887
            $this->panes[$activePane] = new Pane($activePane, $this->selectedCells, $this->activeCell);
48✔
2888
        }
2889
    }
2890

2891
    /**
2892
     * Get right-to-left.
2893
     */
2894
    public function getRightToLeft(): bool
1,122✔
2895
    {
2896
        return $this->rightToLeft;
1,122✔
2897
    }
2898

2899
    /**
2900
     * Set right-to-left.
2901
     *
2902
     * @param bool $value Right-to-left true/false
2903
     *
2904
     * @return $this
2905
     */
2906
    public function setRightToLeft(bool $value): static
162✔
2907
    {
2908
        $this->rightToLeft = $value;
162✔
2909

2910
        return $this;
162✔
2911
    }
2912

2913
    /**
2914
     * Fill worksheet from values in array.
2915
     *
2916
     * @param mixed[]|mixed[][] $source Source array
2917
     * @param mixed $nullValue Value in source array that stands for blank cell
2918
     * @param string $startCell Insert array starting from this cell address as the top left coordinate
2919
     * @param bool $strictNullComparison Apply strict comparison when testing for null values in the array
2920
     *
2921
     * @return $this
2922
     */
2923
    public function fromArray(array $source, mixed $nullValue = null, string $startCell = 'A1', bool $strictNullComparison = false): static
860✔
2924
    {
2925
        //    Convert a 1-D array to 2-D (for ease of looping)
2926
        if (!is_array(end($source))) {
860✔
2927
            $source = [$source];
49✔
2928
        }
2929
        /** @var mixed[][] $source */
2930

2931
        // start coordinate
2932
        [$startColumn, $startRow] = Coordinate::coordinateFromString($startCell);
860✔
2933
        $startRow = (int) $startRow;
860✔
2934

2935
        // Loop through $source
2936
        if ($strictNullComparison) {
860✔
2937
            foreach ($source as $rowData) {
408✔
2938
                /** @var string */
2939
                $currentColumn = $startColumn;
408✔
2940
                foreach ($rowData as $cellValue) {
408✔
2941
                    if ($cellValue !== $nullValue) {
408✔
2942
                        $this->getCell($currentColumn . $startRow)->setValue($cellValue);
408✔
2943
                    }
2944
                    StringHelper::stringIncrement($currentColumn);
408✔
2945
                }
2946
                ++$startRow;
408✔
2947
            }
2948
        } else {
2949
            foreach ($source as $rowData) {
461✔
2950
                $currentColumn = $startColumn;
461✔
2951
                foreach ($rowData as $cellValue) {
461✔
2952
                    if ($cellValue != $nullValue) {
460✔
2953
                        $this->getCell($currentColumn . $startRow)->setValue($cellValue);
454✔
2954
                    }
2955
                    StringHelper::stringIncrement($currentColumn);
460✔
2956
                }
2957
                ++$startRow;
461✔
2958
            }
2959
        }
2960

2961
        return $this;
860✔
2962
    }
2963

2964
    /**
2965
     * @param bool $calculateFormulas Whether to calculate cell's value if it is a formula.
2966
     * @param null|bool|float|int|RichText|string $nullValue value to use when null
2967
     * @param bool $formatData Whether to format data according to cell's style.
2968
     * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
2969
     *
2970
     * @throws Exception
2971
     * @throws \PhpOffice\PhpSpreadsheet\Calculation\Exception
2972
     */
2973
    protected function cellToArray(Cell $cell, bool $calculateFormulas, bool $formatData, mixed $nullValue, bool $lessFloatPrecision = false): mixed
186✔
2974
    {
2975
        $returnValue = $nullValue;
186✔
2976

2977
        if ($cell->getValue() !== null) {
186✔
2978
            if ($cell->getValue() instanceof RichText) {
186✔
2979
                $returnValue = $cell->getValue()->getPlainText();
4✔
2980
            } else {
2981
                $returnValue = ($calculateFormulas) ? $cell->getCalculatedValue() : $cell->getValue();
186✔
2982
            }
2983

2984
            if ($formatData) {
186✔
2985
                $style = $this->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex());
122✔
2986
                /** @var null|bool|float|int|RichText|string */
2987
                $returnValuex = $returnValue;
122✔
2988
                $returnValue = NumberFormat::toFormattedString(
122✔
2989
                    $returnValuex,
122✔
2990
                    $style->getNumberFormat()->getFormatCode() ?? NumberFormat::FORMAT_GENERAL,
122✔
2991
                    lessFloatPrecision: $lessFloatPrecision
122✔
2992
                );
122✔
2993
            }
2994
        }
2995

2996
        return $returnValue;
186✔
2997
    }
2998

2999
    /**
3000
     * Create array from a range of cells.
3001
     *
3002
     * @param null|bool|float|int|RichText|string $nullValue Value returned in the array entry if a cell doesn't exist
3003
     * @param bool $calculateFormulas Should formulas be calculated?
3004
     * @param bool $formatData Should formatting be applied to cell values?
3005
     * @param bool $returnCellRef False - Return a simple array of rows and columns indexed by number counting from zero
3006
     *                             True - Return rows and columns indexed by their actual row and column IDs
3007
     * @param bool $ignoreHidden False - Return values for rows/columns even if they are defined as hidden.
3008
     *                            True - Don't return values for rows/columns that are defined as hidden.
3009
     * @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value.
3010
     * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
3011
     *
3012
     * @return mixed[][]
3013
     */
3014
    public function rangeToArray(
154✔
3015
        string $range,
3016
        mixed $nullValue = null,
3017
        bool $calculateFormulas = true,
3018
        bool $formatData = true,
3019
        bool $returnCellRef = false,
3020
        bool $ignoreHidden = false,
3021
        bool $reduceArrays = false,
3022
        bool $lessFloatPrecision = false
3023
    ): array {
3024
        $returnValue = [];
154✔
3025

3026
        // Loop through rows
3027
        foreach ($this->rangeToArrayYieldRows($range, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision) as $rowRef => $rowArray) {
154✔
3028
            /** @var int $rowRef */
3029
            $returnValue[$rowRef] = $rowArray;
154✔
3030
        }
3031

3032
        // Return
3033
        return $returnValue;
154✔
3034
    }
3035

3036
    /**
3037
     * Create array from a multiple ranges of cells. (such as A1:A3,A15,B17:C17).
3038
     *
3039
     * @param null|bool|float|int|RichText|string $nullValue Value returned in the array entry if a cell doesn't exist
3040
     * @param bool $calculateFormulas Should formulas be calculated?
3041
     * @param bool $formatData Should formatting be applied to cell values?
3042
     * @param bool $returnCellRef False - Return a simple array of rows and columns indexed by number counting from zero
3043
     *                             True - Return rows and columns indexed by their actual row and column IDs
3044
     * @param bool $ignoreHidden False - Return values for rows/columns even if they are defined as hidden.
3045
     *                            True - Don't return values for rows/columns that are defined as hidden.
3046
     * @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value.
3047
     * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
3048
     *
3049
     * @return mixed[][]
3050
     */
3051
    public function rangesToArray(
3✔
3052
        string $ranges,
3053
        mixed $nullValue = null,
3054
        bool $calculateFormulas = true,
3055
        bool $formatData = true,
3056
        bool $returnCellRef = false,
3057
        bool $ignoreHidden = false,
3058
        bool $reduceArrays = false,
3059
        bool $lessFloatPrecision = false,
3060
    ): array {
3061
        $returnValue = [];
3✔
3062

3063
        $parts = explode(',', $ranges);
3✔
3064
        foreach ($parts as $part) {
3✔
3065
            // Loop through rows
3066
            foreach ($this->rangeToArrayYieldRows($part, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision) as $rowRef => $rowArray) {
3✔
3067
                /** @var int $rowRef */
3068
                $returnValue[$rowRef] = $rowArray;
3✔
3069
            }
3070
        }
3071

3072
        // Return
3073
        return $returnValue;
3✔
3074
    }
3075

3076
    /**
3077
     * Create array from a range of cells, yielding each row in turn.
3078
     *
3079
     * @param null|bool|float|int|RichText|string $nullValue Value returned in the array entry if a cell doesn't exist
3080
     * @param bool $calculateFormulas Should formulas be calculated?
3081
     * @param bool $formatData Should formatting be applied to cell values?
3082
     * @param bool $returnCellRef False - Return a simple array of rows and columns indexed by number counting from zero
3083
     *                             True - Return rows and columns indexed by their actual row and column IDs
3084
     * @param bool $ignoreHidden False - Return values for rows/columns even if they are defined as hidden.
3085
     *                            True - Don't return values for rows/columns that are defined as hidden.
3086
     * @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value.
3087
     * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
3088
     *
3089
     * @return Generator<array<mixed>>
3090
     */
3091
    public function rangeToArrayYieldRows(
186✔
3092
        string $range,
3093
        mixed $nullValue = null,
3094
        bool $calculateFormulas = true,
3095
        bool $formatData = true,
3096
        bool $returnCellRef = false,
3097
        bool $ignoreHidden = false,
3098
        bool $reduceArrays = false,
3099
        bool $lessFloatPrecision = false
3100
    ) {
3101
        $range = Validations::validateCellOrCellRange($range);
186✔
3102

3103
        //    Identify the range that we need to extract from the worksheet
3104
        [$rangeStart, $rangeEnd] = Coordinate::rangeBoundaries($range);
186✔
3105
        $minCol = Coordinate::stringFromColumnIndex($rangeStart[0]);
186✔
3106
        $minRow = $rangeStart[1];
186✔
3107
        $maxCol = Coordinate::stringFromColumnIndex($rangeEnd[0]);
186✔
3108
        $maxRow = $rangeEnd[1];
186✔
3109
        $minColInt = $rangeStart[0];
186✔
3110
        $maxColInt = $rangeEnd[0];
186✔
3111

3112
        StringHelper::stringIncrement($maxCol);
186✔
3113
        /** @var array<string, bool> */
3114
        $hiddenColumns = [];
186✔
3115
        $nullRow = $this->buildNullRow($nullValue, $minCol, $maxCol, $returnCellRef, $ignoreHidden, $hiddenColumns);
186✔
3116
        $hideColumns = !empty($hiddenColumns);
186✔
3117

3118
        $keys = $this->cellCollection->getSortedCoordinatesInt();
186✔
3119
        $keyIndex = 0;
186✔
3120
        $keysCount = count($keys);
186✔
3121
        // Loop through rows
3122
        for ($row = $minRow; $row <= $maxRow; ++$row) {
186✔
3123
            if (($ignoreHidden === true) && ($this->isRowVisible($row) === false)) {
186✔
3124
                continue;
4✔
3125
            }
3126
            $rowRef = $returnCellRef ? $row : ($row - $minRow);
186✔
3127
            $returnValue = $nullRow;
186✔
3128

3129
            $index = ($row - 1) * AddressRange::MAX_COLUMN_INT + 1;
186✔
3130
            $indexPlus = $index + AddressRange::MAX_COLUMN_INT - 1;
186✔
3131

3132
            // Binary search to quickly approach the correct index
3133
            $keyIndex = intdiv($keysCount, 2);
186✔
3134
            $boundLow = 0;
186✔
3135
            $boundHigh = $keysCount - 1;
186✔
3136
            while ($boundLow <= $boundHigh) {
186✔
3137
                $keyIndex = intdiv($boundLow + $boundHigh, 2);
186✔
3138
                if ($keys[$keyIndex] < $index) {
186✔
3139
                    $boundLow = $keyIndex + 1;
154✔
3140
                } elseif ($keys[$keyIndex] > $index) {
186✔
3141
                    $boundHigh = $keyIndex - 1;
168✔
3142
                } else {
3143
                    break;
178✔
3144
                }
3145
            }
3146

3147
            // Realign to the proper index value
3148
            while ($keyIndex > 0 && $keys[$keyIndex] > $index) {
186✔
3149
                --$keyIndex;
14✔
3150
            }
3151
            while ($keyIndex < $keysCount && $keys[$keyIndex] < $index) {
186✔
3152
                ++$keyIndex;
20✔
3153
            }
3154

3155
            while ($keyIndex < $keysCount && $keys[$keyIndex] <= $indexPlus) {
186✔
3156
                $key = $keys[$keyIndex];
186✔
3157
                $thisRow = intdiv($key - 1, AddressRange::MAX_COLUMN_INT) + 1;
186✔
3158
                $thisCol = ($key % AddressRange::MAX_COLUMN_INT) ?: AddressRange::MAX_COLUMN_INT;
186✔
3159
                if ($thisCol >= $minColInt && $thisCol <= $maxColInt) {
186✔
3160
                    $col = Coordinate::stringFromColumnIndex($thisCol);
186✔
3161
                    if ($hideColumns === false || !isset($hiddenColumns[$col])) {
186✔
3162
                        $columnRef = $returnCellRef ? $col : ($thisCol - $minColInt);
186✔
3163
                        $cell = $this->cellCollection->get("{$col}{$thisRow}");
186✔
3164
                        if ($cell !== null) {
186✔
3165
                            $value = $this->cellToArray($cell, $calculateFormulas, $formatData, $nullValue, lessFloatPrecision: $lessFloatPrecision);
186✔
3166
                            if ($reduceArrays) {
186✔
3167
                                while (is_array($value)) {
21✔
3168
                                    $value = array_shift($value);
19✔
3169
                                }
3170
                            }
3171
                            if ($value !== $nullValue) {
186✔
3172
                                $returnValue[$columnRef] = $value;
186✔
3173
                            }
3174
                        }
3175
                    }
3176
                }
3177
                ++$keyIndex;
186✔
3178
            }
3179

3180
            yield $rowRef => $returnValue;
186✔
3181
        }
3182
    }
3183

3184
    /**
3185
     * Prepare a row data filled with null values to deduplicate the memory areas for empty rows.
3186
     *
3187
     * @param mixed $nullValue Value returned in the array entry if a cell doesn't exist
3188
     * @param string $minCol Start column of the range
3189
     * @param string $maxCol End column of the range
3190
     * @param bool $returnCellRef False - Return a simple array of rows and columns indexed by number counting from zero
3191
     *                              True - Return rows and columns indexed by their actual row and column IDs
3192
     * @param bool $ignoreHidden False - Return values for rows/columns even if they are defined as hidden.
3193
     *                             True - Don't return values for rows/columns that are defined as hidden.
3194
     * @param array<string, bool> $hiddenColumns
3195
     *
3196
     * @return mixed[]
3197
     */
3198
    private function buildNullRow(
186✔
3199
        mixed $nullValue,
3200
        string $minCol,
3201
        string $maxCol,
3202
        bool $returnCellRef,
3203
        bool $ignoreHidden,
3204
        array &$hiddenColumns
3205
    ): array {
3206
        $nullRow = [];
186✔
3207
        $c = -1;
186✔
3208
        for ($col = $minCol; $col !== $maxCol; StringHelper::stringIncrement($col)) {
186✔
3209
            if ($ignoreHidden === true && $this->columnDimensionExists($col) && $this->getColumnDimension($col)->getVisible() === false) {
186✔
3210
                $hiddenColumns[$col] = true;
2✔
3211
            } else {
3212
                $columnRef = $returnCellRef ? $col : ++$c;
186✔
3213
                $nullRow[$columnRef] = $nullValue;
186✔
3214
            }
3215
        }
3216

3217
        return $nullRow;
186✔
3218
    }
3219

3220
    private function validateNamedRange(string $definedName, bool $returnNullIfInvalid = false): ?DefinedName
19✔
3221
    {
3222
        $namedRange = DefinedName::resolveName($definedName, $this);
19✔
3223
        if ($namedRange === null) {
19✔
3224
            if ($returnNullIfInvalid) {
6✔
3225
                return null;
5✔
3226
            }
3227

3228
            throw new Exception('Named Range ' . $definedName . ' does not exist.');
1✔
3229
        }
3230

3231
        if ($namedRange->isFormula()) {
13✔
UNCOV
3232
            if ($returnNullIfInvalid) {
×
UNCOV
3233
                return null;
×
3234
            }
3235

UNCOV
3236
            throw new Exception('Defined Named ' . $definedName . ' is a formula, not a range or cell.');
×
3237
        }
3238

3239
        if ($namedRange->getLocalOnly()) {
13✔
3240
            $worksheet = $namedRange->getWorksheet();
2✔
3241
            if ($worksheet === null || $this !== $worksheet) {
2✔
UNCOV
3242
                if ($returnNullIfInvalid) {
×
UNCOV
3243
                    return null;
×
3244
                }
3245

UNCOV
3246
                throw new Exception(
×
UNCOV
3247
                    'Named range ' . $definedName . ' is not accessible from within sheet ' . $this->getTitle()
×
UNCOV
3248
                );
×
3249
            }
3250
        }
3251

3252
        return $namedRange;
13✔
3253
    }
3254

3255
    /**
3256
     * Create array from a range of cells.
3257
     *
3258
     * @param string $definedName The Named Range that should be returned
3259
     * @param null|bool|float|int|RichText|string $nullValue Value returned in the array entry if a cell doesn't exist
3260
     * @param bool $calculateFormulas Should formulas be calculated?
3261
     * @param bool $formatData Should formatting be applied to cell values?
3262
     * @param bool $returnCellRef False - Return a simple array of rows and columns indexed by number counting from zero
3263
     *                             True - Return rows and columns indexed by their actual row and column IDs
3264
     * @param bool $ignoreHidden False - Return values for rows/columns even if they are defined as hidden.
3265
     *                            True - Don't return values for rows/columns that are defined as hidden.
3266
     * @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value.
3267
     * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
3268
     *
3269
     * @return mixed[][]
3270
     */
3271
    public function namedRangeToArray(
2✔
3272
        string $definedName,
3273
        mixed $nullValue = null,
3274
        bool $calculateFormulas = true,
3275
        bool $formatData = true,
3276
        bool $returnCellRef = false,
3277
        bool $ignoreHidden = false,
3278
        bool $reduceArrays = false,
3279
        bool $lessFloatPrecision = false
3280
    ): array {
3281
        $retVal = [];
2✔
3282
        $namedRange = $this->validateNamedRange($definedName);
2✔
3283
        if ($namedRange !== null) {
1✔
3284
            $cellRange = ltrim(substr($namedRange->getValue(), (int) strrpos($namedRange->getValue(), '!')), '!');
1✔
3285
            $cellRange = str_replace('$', '', $cellRange);
1✔
3286
            $workSheet = $namedRange->getWorksheet();
1✔
3287
            if ($workSheet !== null) {
1✔
3288
                $retVal = $workSheet->rangeToArray($cellRange, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision);
1✔
3289
            }
3290
        }
3291

3292
        return $retVal;
1✔
3293
    }
3294

3295
    /**
3296
     * Create array from worksheet.
3297
     *
3298
     * @param null|bool|float|int|RichText|string $nullValue Value returned in the array entry if a cell doesn't exist
3299
     * @param bool $calculateFormulas Should formulas be calculated?
3300
     * @param bool $formatData Should formatting be applied to cell values?
3301
     * @param bool $returnCellRef False - Return a simple array of rows and columns indexed by number counting from zero
3302
     *                             True - Return rows and columns indexed by their actual row and column IDs
3303
     * @param bool $ignoreHidden False - Return values for rows/columns even if they are defined as hidden.
3304
     *                            True - Don't return values for rows/columns that are defined as hidden.
3305
     * @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value.
3306
     * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
3307
     *
3308
     * @return mixed[][]
3309
     */
3310
    public function toArray(
85✔
3311
        mixed $nullValue = null,
3312
        bool $calculateFormulas = true,
3313
        bool $formatData = true,
3314
        bool $returnCellRef = false,
3315
        bool $ignoreHidden = false,
3316
        bool $reduceArrays = false,
3317
        bool $lessFloatPrecision = false
3318
    ): array {
3319
        // Garbage collect...
3320
        $this->garbageCollect();
85✔
3321
        $this->calculateArrays($calculateFormulas);
85✔
3322

3323
        //    Identify the range that we need to extract from the worksheet
3324
        $maxCol = $this->getHighestColumn();
85✔
3325
        $maxRow = $this->getHighestRow();
85✔
3326

3327
        // Return
3328
        return $this->rangeToArray("A1:{$maxCol}{$maxRow}", $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision);
85✔
3329
    }
3330

3331
    /**
3332
     * Get row iterator.
3333
     *
3334
     * @param int $startRow The row number at which to start iterating
3335
     * @param ?int $endRow The row number at which to stop iterating
3336
     */
3337
    public function getRowIterator(int $startRow = 1, ?int $endRow = null): RowIterator
97✔
3338
    {
3339
        return new RowIterator($this, $startRow, $endRow);
97✔
3340
    }
3341

3342
    /**
3343
     * Get column iterator.
3344
     *
3345
     * @param string $startColumn The column address at which to start iterating
3346
     * @param ?string $endColumn The column address at which to stop iterating
3347
     */
3348
    public function getColumnIterator(string $startColumn = 'A', ?string $endColumn = null): ColumnIterator
26✔
3349
    {
3350
        return new ColumnIterator($this, $startColumn, $endColumn);
26✔
3351
    }
3352

3353
    /**
3354
     * Run PhpSpreadsheet garbage collector.
3355
     *
3356
     * @return $this
3357
     */
3358
    public function garbageCollect(): static
1,243✔
3359
    {
3360
        // Flush cache
3361
        $this->cellCollection->get('A1');
1,243✔
3362

3363
        // Lookup highest column and highest row if cells are cleaned
3364
        $colRow = $this->cellCollection->getHighestRowAndColumn();
1,243✔
3365
        $highestRow = $colRow['row'];
1,243✔
3366
        $highestColumn = Coordinate::columnIndexFromString($colRow['column']);
1,243✔
3367

3368
        // Loop through column dimensions
3369
        foreach ($this->columnDimensions as $dimension) {
1,243✔
3370
            $highestColumn = max($highestColumn, Coordinate::columnIndexFromString($dimension->getColumnIndex()));
174✔
3371
        }
3372

3373
        // Loop through row dimensions
3374
        foreach ($this->rowDimensions as $dimension) {
1,243✔
3375
            $highestRow = max($highestRow, $dimension->getRowIndex());
117✔
3376
        }
3377

3378
        // Cache values
3379
        $this->cachedHighestColumn = max(1, $highestColumn);
1,243✔
3380
        /** @var int $highestRow */
3381
        $this->cachedHighestRow = $highestRow;
1,243✔
3382

3383
        // Return
3384
        return $this;
1,243✔
3385
    }
3386

3387
    /**
3388
     * @deprecated 5.2.0 Serves no useful purpose. No replacement.
3389
     *
3390
     * @codeCoverageIgnore
3391
     */
3392
    public function getHashInt(): int
3393
    {
3394
        return spl_object_id($this);
3395
    }
3396

3397
    /**
3398
     * Extract worksheet title from range.
3399
     *
3400
     * Example: extractSheetTitle("testSheet!A1") ==> 'A1'
3401
     * Example: extractSheetTitle("testSheet!A1:C3") ==> 'A1:C3'
3402
     * Example: extractSheetTitle("'testSheet 1'!A1", true) ==> ['testSheet 1', 'A1'];
3403
     * Example: extractSheetTitle("'testSheet 1'!A1:C3", true) ==> ['testSheet 1', 'A1:C3'];
3404
     * Example: extractSheetTitle("A1", true) ==> ['', 'A1'];
3405
     * Example: extractSheetTitle("A1:C3", true) ==> ['', 'A1:C3']
3406
     *
3407
     * @param ?string $range Range to extract title from
3408
     * @param bool $returnRange Return range? (see example)
3409
     *
3410
     * @return ($range is non-empty-string ? ($returnRange is true ? array{0: string, 1: string} : string) : ($returnRange is true ? array{0: null, 1: null} : null))
3411
     */
3412
    public static function extractSheetTitle(?string $range, bool $returnRange = false, bool $unapostrophize = false): array|null|string
10,937✔
3413
    {
3414
        if (empty($range)) {
10,937✔
3415
            return $returnRange ? [null, null] : null;
13✔
3416
        }
3417

3418
        // Sheet title included?
3419
        if (($sep = strrpos($range, '!')) === false) {
10,935✔
3420
            return $returnRange ? ['', $range] : '';
10,906✔
3421
        }
3422

3423
        if ($returnRange) {
1,451✔
3424
            $title = substr($range, 0, $sep);
1,451✔
3425
            if ($unapostrophize) {
1,451✔
3426
                $title = self::unApostrophizeTitle($title);
1,391✔
3427
            }
3428

3429
            return [$title, substr($range, $sep + 1)];
1,451✔
3430
        }
3431

3432
        return substr($range, $sep + 1);
7✔
3433
    }
3434

3435
    public static function unApostrophizeTitle(?string $title): string
1,405✔
3436
    {
3437
        $title ??= '';
1,405✔
3438
        if (str_starts_with($title, "'") && str_ends_with($title, "'")) {
1,405✔
3439
            $title = str_replace("''", "'", substr($title, 1, -1));
1,330✔
3440
        }
3441

3442
        return $title;
1,405✔
3443
    }
3444

3445
    /**
3446
     * Get hyperlink.
3447
     *
3448
     * @param string $cellCoordinate Cell coordinate to get hyperlink for, eg: 'A1'
3449
     */
3450
    public function getHyperlink(string $cellCoordinate): Hyperlink
100✔
3451
    {
3452
        // return hyperlink if we already have one
3453
        if (isset($this->hyperlinkCollection[$cellCoordinate])) {
100✔
3454
            return $this->hyperlinkCollection[$cellCoordinate];
47✔
3455
        }
3456

3457
        // else create hyperlink
3458
        $this->hyperlinkCollection[$cellCoordinate] = new Hyperlink();
100✔
3459

3460
        return $this->hyperlinkCollection[$cellCoordinate];
100✔
3461
    }
3462

3463
    /**
3464
     * Set hyperlink.
3465
     *
3466
     * @param string $cellCoordinate Cell coordinate to insert hyperlink, eg: 'A1'
3467
     *
3468
     * @return $this
3469
     */
3470
    public function setHyperlink(string $cellCoordinate, ?Hyperlink $hyperlink = null): static
55✔
3471
    {
3472
        if ($hyperlink === null) {
55✔
3473
            unset($this->hyperlinkCollection[$cellCoordinate]);
54✔
3474
        } else {
3475
            $this->hyperlinkCollection[$cellCoordinate] = $hyperlink;
21✔
3476
        }
3477

3478
        return $this;
55✔
3479
    }
3480

3481
    /**
3482
     * Hyperlink at a specific coordinate exists?
3483
     *
3484
     * @param string $coordinate eg: 'A1'
3485
     */
3486
    public function hyperlinkExists(string $coordinate): bool
563✔
3487
    {
3488
        return isset($this->hyperlinkCollection[$coordinate]);
563✔
3489
    }
3490

3491
    /**
3492
     * Get collection of hyperlinks.
3493
     *
3494
     * @return Hyperlink[]
3495
     */
3496
    public function getHyperlinkCollection(): array
676✔
3497
    {
3498
        return $this->hyperlinkCollection;
676✔
3499
    }
3500

3501
    /**
3502
     * Get data validation.
3503
     *
3504
     * @param string $cellCoordinate Cell coordinate to get data validation for, eg: 'A1'
3505
     */
3506
    public function getDataValidation(string $cellCoordinate): DataValidation
37✔
3507
    {
3508
        // return data validation if we already have one
3509
        if (isset($this->dataValidationCollection[$cellCoordinate])) {
37✔
3510
            return $this->dataValidationCollection[$cellCoordinate];
28✔
3511
        }
3512

3513
        // or if cell is part of a data validation range
3514
        foreach ($this->dataValidationCollection as $key => $dataValidation) {
28✔
3515
            $keyParts = explode(' ', $key);
12✔
3516
            foreach ($keyParts as $keyPart) {
12✔
3517
                if ($keyPart === $cellCoordinate) {
12✔
3518
                    return $dataValidation;
1✔
3519
                }
3520
                if (str_contains($keyPart, ':')) {
12✔
3521
                    if (Coordinate::coordinateIsInsideRange($keyPart, $cellCoordinate)) {
9✔
3522
                        return $dataValidation;
9✔
3523
                    }
3524
                }
3525
            }
3526
        }
3527

3528
        // else create data validation
3529
        $dataValidation = new DataValidation();
20✔
3530
        $dataValidation->setSqref($cellCoordinate);
20✔
3531
        $this->dataValidationCollection[$cellCoordinate] = $dataValidation;
20✔
3532

3533
        return $dataValidation;
20✔
3534
    }
3535

3536
    /**
3537
     * Set data validation.
3538
     *
3539
     * @param string $cellCoordinate Cell coordinate to insert data validation, eg: 'A1'
3540
     *
3541
     * @return $this
3542
     */
3543
    public function setDataValidation(string $cellCoordinate, ?DataValidation $dataValidation = null): static
92✔
3544
    {
3545
        if ($dataValidation === null) {
92✔
3546
            unset($this->dataValidationCollection[$cellCoordinate]);
59✔
3547
        } else {
3548
            $dataValidation->setSqref($cellCoordinate);
40✔
3549
            $this->dataValidationCollection[$cellCoordinate] = $dataValidation;
40✔
3550
        }
3551

3552
        return $this;
92✔
3553
    }
3554

3555
    /**
3556
     * Data validation at a specific coordinate exists?
3557
     *
3558
     * @param string $coordinate eg: 'A1'
3559
     */
3560
    public function dataValidationExists(string $coordinate): bool
25✔
3561
    {
3562
        if (isset($this->dataValidationCollection[$coordinate])) {
25✔
3563
            return true;
23✔
3564
        }
3565
        foreach ($this->dataValidationCollection as $key => $dataValidation) {
8✔
3566
            $keyParts = explode(' ', $key);
7✔
3567
            foreach ($keyParts as $keyPart) {
7✔
3568
                if ($keyPart === $coordinate) {
7✔
3569
                    return true;
1✔
3570
                }
3571
                if (str_contains($keyPart, ':')) {
7✔
3572
                    if (Coordinate::coordinateIsInsideRange($keyPart, $coordinate)) {
2✔
3573
                        return true;
2✔
3574
                    }
3575
                }
3576
            }
3577
        }
3578

3579
        return false;
6✔
3580
    }
3581

3582
    /**
3583
     * Get collection of data validations.
3584
     *
3585
     * @return DataValidation[]
3586
     */
3587
    public function getDataValidationCollection(): array
677✔
3588
    {
3589
        $collectionCells = [];
677✔
3590
        $collectionRanges = [];
677✔
3591
        foreach ($this->dataValidationCollection as $key => $dataValidation) {
677✔
3592
            if (Preg::isMatch('/[: ]/', $key)) {
27✔
3593
                $collectionRanges[$key] = $dataValidation;
15✔
3594
            } else {
3595
                $collectionCells[$key] = $dataValidation;
22✔
3596
            }
3597
        }
3598

3599
        return array_merge($collectionCells, $collectionRanges);
677✔
3600
    }
3601

3602
    /**
3603
     * Accepts a range, returning it as a range that falls within the current highest row and column of the worksheet.
3604
     *
3605
     * @return string Adjusted range value
3606
     */
3607
    public function shrinkRangeToFit(string $range): string
×
3608
    {
3609
        $maxCol = $this->getHighestColumn();
×
UNCOV
3610
        $maxRow = $this->getHighestRow();
×
UNCOV
3611
        $maxCol = Coordinate::columnIndexFromString($maxCol);
×
3612

UNCOV
3613
        $rangeBlocks = explode(' ', $range);
×
UNCOV
3614
        foreach ($rangeBlocks as &$rangeSet) {
×
UNCOV
3615
            $rangeBoundaries = Coordinate::getRangeBoundaries($rangeSet);
×
3616

UNCOV
3617
            if (Coordinate::columnIndexFromString($rangeBoundaries[0][0]) > $maxCol) {
×
UNCOV
3618
                $rangeBoundaries[0][0] = Coordinate::stringFromColumnIndex($maxCol);
×
3619
            }
UNCOV
3620
            if ($rangeBoundaries[0][1] > $maxRow) {
×
UNCOV
3621
                $rangeBoundaries[0][1] = $maxRow;
×
3622
            }
UNCOV
3623
            if (Coordinate::columnIndexFromString($rangeBoundaries[1][0]) > $maxCol) {
×
UNCOV
3624
                $rangeBoundaries[1][0] = Coordinate::stringFromColumnIndex($maxCol);
×
3625
            }
UNCOV
3626
            if ($rangeBoundaries[1][1] > $maxRow) {
×
UNCOV
3627
                $rangeBoundaries[1][1] = $maxRow;
×
3628
            }
UNCOV
3629
            $rangeSet = $rangeBoundaries[0][0] . $rangeBoundaries[0][1] . ':' . $rangeBoundaries[1][0] . $rangeBoundaries[1][1];
×
3630
        }
UNCOV
3631
        unset($rangeSet);
×
3632

UNCOV
3633
        return implode(' ', $rangeBlocks);
×
3634
    }
3635

3636
    /**
3637
     * Get tab color.
3638
     */
3639
    public function getTabColor(): Color
23✔
3640
    {
3641
        if ($this->tabColor === null) {
23✔
3642
            $this->tabColor = new Color();
23✔
3643
        }
3644

3645
        return $this->tabColor;
23✔
3646
    }
3647

3648
    /**
3649
     * Reset tab color.
3650
     *
3651
     * @return $this
3652
     */
3653
    public function resetTabColor(): static
1✔
3654
    {
3655
        $this->tabColor = null;
1✔
3656

3657
        return $this;
1✔
3658
    }
3659

3660
    /**
3661
     * Tab color set?
3662
     */
3663
    public function isTabColorSet(): bool
570✔
3664
    {
3665
        return $this->tabColor !== null;
570✔
3666
    }
3667

3668
    /**
3669
     * Copy worksheet (!= clone!).
3670
     */
UNCOV
3671
    public function copy(): static
×
3672
    {
UNCOV
3673
        return clone $this;
×
3674
    }
3675

3676
    /**
3677
     * Returns a boolean true if the specified row contains no cells. By default, this means that no cell records
3678
     *          exist in the collection for this row. false will be returned otherwise.
3679
     *     This rule can be modified by passing a $definitionOfEmptyFlags value:
3680
     *          1 - CellIterator::TREAT_NULL_VALUE_AS_EMPTY_CELL If the only cells in the collection are null value
3681
     *                  cells, then the row will be considered empty.
3682
     *          2 - CellIterator::TREAT_EMPTY_STRING_AS_EMPTY_CELL If the only cells in the collection are empty
3683
     *                  string value cells, then the row will be considered empty.
3684
     *          3 - CellIterator::TREAT_NULL_VALUE_AS_EMPTY_CELL | CellIterator::TREAT_EMPTY_STRING_AS_EMPTY_CELL
3685
     *                  If the only cells in the collection are null value or empty string value cells, then the row
3686
     *                  will be considered empty.
3687
     *
3688
     * @param int $definitionOfEmptyFlags
3689
     *              Possible Flag Values are:
3690
     *                  CellIterator::TREAT_NULL_VALUE_AS_EMPTY_CELL
3691
     *                  CellIterator::TREAT_EMPTY_STRING_AS_EMPTY_CELL
3692
     */
3693
    public function isEmptyRow(int $rowId, int $definitionOfEmptyFlags = 0): bool
9✔
3694
    {
3695
        try {
3696
            $iterator = new RowIterator($this, $rowId, $rowId);
9✔
3697
            $iterator->seek($rowId);
8✔
3698
            $row = $iterator->current();
8✔
3699
        } catch (Exception) {
1✔
3700
            return true;
1✔
3701
        }
3702

3703
        return $row->isEmpty($definitionOfEmptyFlags);
8✔
3704
    }
3705

3706
    /**
3707
     * Returns a boolean true if the specified column contains no cells. By default, this means that no cell records
3708
     *          exist in the collection for this column. false will be returned otherwise.
3709
     *     This rule can be modified by passing a $definitionOfEmptyFlags value:
3710
     *          1 - CellIterator::TREAT_NULL_VALUE_AS_EMPTY_CELL If the only cells in the collection are null value
3711
     *                  cells, then the column will be considered empty.
3712
     *          2 - CellIterator::TREAT_EMPTY_STRING_AS_EMPTY_CELL If the only cells in the collection are empty
3713
     *                  string value cells, then the column will be considered empty.
3714
     *          3 - CellIterator::TREAT_NULL_VALUE_AS_EMPTY_CELL | CellIterator::TREAT_EMPTY_STRING_AS_EMPTY_CELL
3715
     *                  If the only cells in the collection are null value or empty string value cells, then the column
3716
     *                  will be considered empty.
3717
     *
3718
     * @param int $definitionOfEmptyFlags
3719
     *              Possible Flag Values are:
3720
     *                  CellIterator::TREAT_NULL_VALUE_AS_EMPTY_CELL
3721
     *                  CellIterator::TREAT_EMPTY_STRING_AS_EMPTY_CELL
3722
     */
3723
    public function isEmptyColumn(string $columnId, int $definitionOfEmptyFlags = 0): bool
9✔
3724
    {
3725
        try {
3726
            $iterator = new ColumnIterator($this, $columnId, $columnId);
9✔
3727
            $iterator->seek($columnId);
8✔
3728
            $column = $iterator->current();
8✔
3729
        } catch (Exception) {
1✔
3730
            return true;
1✔
3731
        }
3732

3733
        return $column->isEmpty($definitionOfEmptyFlags);
8✔
3734
    }
3735

3736
    /**
3737
     * Implement PHP __clone to create a deep clone, not just a shallow copy.
3738
     */
3739
    public function __clone()
21✔
3740
    {
3741
        foreach (get_object_vars($this) as $key => $val) {
21✔
3742
            if ($key == 'parent') {
21✔
3743
                continue;
21✔
3744
            }
3745

3746
            if (is_object($val) || (is_array($val))) {
21✔
3747
                if ($key === 'cellCollection') {
21✔
3748
                    $newCollection = $this->cellCollection->cloneCellCollection($this);
21✔
3749
                    $this->cellCollection = $newCollection;
21✔
3750
                } elseif ($key === 'drawingCollection') {
21✔
3751
                    $currentCollection = $this->drawingCollection;
21✔
3752
                    $this->drawingCollection = new ArrayObject();
21✔
3753
                    foreach ($currentCollection as $item) {
21✔
3754
                        $newDrawing = clone $item;
4✔
3755
                        $newDrawing->setWorksheet($this);
4✔
3756
                    }
3757
                } elseif ($key === 'tableCollection') {
21✔
3758
                    $currentCollection = $this->tableCollection;
21✔
3759
                    $this->tableCollection = new ArrayObject();
21✔
3760
                    foreach ($currentCollection as $item) {
21✔
3761
                        $newTable = clone $item;
1✔
3762
                        $newTable->setName($item->getName() . 'clone');
1✔
3763
                        $this->addTable($newTable);
1✔
3764
                    }
3765
                } elseif ($key === 'chartCollection') {
21✔
3766
                    $currentCollection = $this->chartCollection;
21✔
3767
                    $this->chartCollection = new ArrayObject();
21✔
3768
                    foreach ($currentCollection as $item) {
21✔
3769
                        $newChart = clone $item;
5✔
3770
                        $this->addChart($newChart);
5✔
3771
                    }
3772
                } elseif ($key === 'autoFilter') {
21✔
3773
                    $newAutoFilter = clone $this->autoFilter;
21✔
3774
                    $this->autoFilter = $newAutoFilter;
21✔
3775
                    $this->autoFilter->setParent($this);
21✔
3776
                } else {
3777
                    $this->{$key} = unserialize(serialize($val));
21✔
3778
                }
3779
            }
3780
        }
3781
    }
3782

3783
    /**
3784
     * Define the code name of the sheet.
3785
     *
3786
     * @param string $codeName Same rule as Title minus space not allowed (but, like Excel, change
3787
     *                       silently space to underscore)
3788
     * @param bool $validate False to skip validation of new title. WARNING: This should only be set
3789
     *                       at parse time (by Readers), where titles can be assumed to be valid.
3790
     *
3791
     * @return $this
3792
     */
3793
    public function setCodeName(string $codeName, bool $validate = true): static
11,146✔
3794
    {
3795
        // Is this a 'rename' or not?
3796
        if ($this->getCodeName() == $codeName) {
11,146✔
UNCOV
3797
            return $this;
×
3798
        }
3799

3800
        if ($validate) {
11,146✔
3801
            $codeName = str_replace(' ', '_', $codeName); //Excel does this automatically without flinching, we are doing the same
11,146✔
3802

3803
            // Syntax check
3804
            // throw an exception if not valid
3805
            self::checkSheetCodeName($codeName);
11,146✔
3806

3807
            // We use the same code that setTitle to find a valid codeName else not using a space (Excel don't like) but a '_'
3808

3809
            if ($this->parent !== null) {
11,146✔
3810
                // Is there already such sheet name?
3811
                if ($this->parent->sheetCodeNameExists($codeName)) {
11,105✔
3812
                    // Use name, but append with lowest possible integer
3813

3814
                    if (StringHelper::countCharacters($codeName) > 29) {
701✔
UNCOV
3815
                        $codeName = StringHelper::substring($codeName, 0, 29);
×
3816
                    }
3817
                    $i = 1;
701✔
3818
                    while ($this->getParentOrThrow()->sheetCodeNameExists($codeName . '_' . $i)) {
701✔
3819
                        ++$i;
285✔
3820
                        if ($i == 10) {
285✔
3821
                            if (StringHelper::countCharacters($codeName) > 28) {
2✔
UNCOV
3822
                                $codeName = StringHelper::substring($codeName, 0, 28);
×
3823
                            }
3824
                        } elseif ($i == 100) {
285✔
UNCOV
3825
                            if (StringHelper::countCharacters($codeName) > 27) {
×
UNCOV
3826
                                $codeName = StringHelper::substring($codeName, 0, 27);
×
3827
                            }
3828
                        }
3829
                    }
3830

3831
                    $codeName .= '_' . $i; // ok, we have a valid name
701✔
3832
                }
3833
            }
3834
        }
3835

3836
        $this->codeName = $codeName;
11,146✔
3837

3838
        return $this;
11,146✔
3839
    }
3840

3841
    /**
3842
     * Return the code name of the sheet.
3843
     */
3844
    public function getCodeName(): ?string
11,146✔
3845
    {
3846
        return $this->codeName;
11,146✔
3847
    }
3848

3849
    /**
3850
     * Sheet has a code name ?
3851
     */
3852
    public function hasCodeName(): bool
2✔
3853
    {
3854
        return $this->codeName !== null;
2✔
3855
    }
3856

3857
    public static function nameRequiresQuotes(string $sheetName): bool
4✔
3858
    {
3859
        return !Preg::isMatch(self::SHEET_NAME_REQUIRES_NO_QUOTES, $sheetName);
4✔
3860
    }
3861

3862
    public function isRowVisible(int $row): bool
124✔
3863
    {
3864
        return !$this->rowDimensionExists($row) || $this->getRowDimension($row)->getVisible();
124✔
3865
    }
3866

3867
    /**
3868
     * Same as Cell->isLocked, but without creating cell if it doesn't exist.
3869
     */
3870
    public function isCellLocked(string $coordinate): bool
1✔
3871
    {
3872
        if ($this->getProtection()->getsheet() !== true) {
1✔
3873
            return false;
1✔
3874
        }
3875
        if ($this->cellExists($coordinate)) {
1✔
3876
            return $this->getCell($coordinate)->isLocked();
1✔
3877
        }
3878
        $spreadsheet = $this->parent;
1✔
3879
        $xfIndex = $this->getXfIndex($coordinate);
1✔
3880
        if ($spreadsheet === null || $xfIndex === null) {
1✔
3881
            return true;
1✔
3882
        }
3883

UNCOV
3884
        return $spreadsheet->getCellXfByIndex($xfIndex)->getProtection()->getLocked() !== StyleProtection::PROTECTION_UNPROTECTED;
×
3885
    }
3886

3887
    /**
3888
     * Same as Cell->isHiddenOnFormulaBar, but without creating cell if it doesn't exist.
3889
     */
3890
    public function isCellHiddenOnFormulaBar(string $coordinate): bool
1✔
3891
    {
3892
        if ($this->cellExists($coordinate)) {
1✔
3893
            return $this->getCell($coordinate)->isHiddenOnFormulaBar();
1✔
3894
        }
3895

3896
        // cell doesn't exist, therefore isn't a formula,
3897
        // therefore isn't hidden on formula bar.
3898
        return false;
1✔
3899
    }
3900

3901
    private function getXfIndex(string $coordinate): ?int
1✔
3902
    {
3903
        [$column, $row] = Coordinate::coordinateFromString($coordinate);
1✔
3904
        $row = (int) $row;
1✔
3905
        $xfIndex = null;
1✔
3906
        if ($this->rowDimensionExists($row)) {
1✔
UNCOV
3907
            $xfIndex = $this->getRowDimension($row)->getXfIndex();
×
3908
        }
3909
        if ($xfIndex === null && $this->ColumnDimensionExists($column)) {
1✔
UNCOV
3910
            $xfIndex = $this->getColumnDimension($column)->getXfIndex();
×
3911
        }
3912

3913
        return $xfIndex;
1✔
3914
    }
3915

3916
    private string $backgroundImage = '';
3917

3918
    private string $backgroundMime = '';
3919

3920
    private string $backgroundExtension = '';
3921

3922
    public function getBackgroundImage(): string
1,049✔
3923
    {
3924
        return $this->backgroundImage;
1,049✔
3925
    }
3926

3927
    public function getBackgroundMime(): string
426✔
3928
    {
3929
        return $this->backgroundMime;
426✔
3930
    }
3931

3932
    public function getBackgroundExtension(): string
426✔
3933
    {
3934
        return $this->backgroundExtension;
426✔
3935
    }
3936

3937
    /**
3938
     * Set background image.
3939
     * Used on read/write for Xlsx.
3940
     * Used on write for Html.
3941
     *
3942
     * @param string $backgroundImage Image represented as a string, e.g. results of file_get_contents
3943
     */
3944
    public function setBackgroundImage(string $backgroundImage): self
4✔
3945
    {
3946
        $imageArray = getimagesizefromstring($backgroundImage) ?: ['mime' => ''];
4✔
3947
        $mime = $imageArray['mime'];
4✔
3948
        if ($mime !== '') {
4✔
3949
            $extension = explode('/', $mime);
3✔
3950
            $extension = $extension[1];
3✔
3951
            $this->backgroundImage = $backgroundImage;
3✔
3952
            $this->backgroundMime = $mime;
3✔
3953
            $this->backgroundExtension = $extension;
3✔
3954
        }
3955

3956
        return $this;
4✔
3957
    }
3958

3959
    /**
3960
     * Copy cells, adjusting relative cell references in formulas.
3961
     * Acts similarly to Excel "fill handle" feature.
3962
     *
3963
     * @param string $fromCell Single source cell, e.g. C3
3964
     * @param string $toCells Single cell or cell range, e.g. C4 or C4:C10
3965
     * @param bool $copyStyle Copy styles as well as values, defaults to true
3966
     */
3967
    public function copyCells(string $fromCell, string $toCells, bool $copyStyle = true): void
1✔
3968
    {
3969
        $toArray = Coordinate::extractAllCellReferencesInRange($toCells);
1✔
3970
        $valueString = $this->getCell($fromCell)->getValueString();
1✔
3971
        /** @var mixed[][] */
3972
        $style = $this->getStyle($fromCell)->exportArray();
1✔
3973
        $fromIndexes = Coordinate::indexesFromString($fromCell);
1✔
3974
        $referenceHelper = ReferenceHelper::getInstance();
1✔
3975
        foreach ($toArray as $destination) {
1✔
3976
            if ($destination !== $fromCell) {
1✔
3977
                $toIndexes = Coordinate::indexesFromString($destination);
1✔
3978
                $this->getCell($destination)->setValue($referenceHelper->updateFormulaReferences($valueString, 'A1', $toIndexes[0] - $fromIndexes[0], $toIndexes[1] - $fromIndexes[1]));
1✔
3979
                if ($copyStyle) {
1✔
3980
                    $this->getCell($destination)->getStyle()->applyFromArray($style);
1✔
3981
                }
3982
            }
3983
        }
3984
    }
3985

3986
    public function calculateArrays(bool $preCalculateFormulas = true): void
1,216✔
3987
    {
3988
        if ($preCalculateFormulas && Calculation::getInstance($this->parent)->getInstanceArrayReturnType() === Calculation::RETURN_ARRAY_AS_ARRAY) {
1,216✔
3989
            $keys = $this->cellCollection->getCoordinates();
46✔
3990
            foreach ($keys as $key) {
46✔
3991
                if ($this->getCell($key)->getDataType() === DataType::TYPE_FORMULA) {
46✔
3992
                    if (!Preg::isMatch(self::FUNCTION_LIKE_GROUPBY, $this->getCell($key)->getValueString())) {
46✔
3993
                        $this->getCell($key)->getCalculatedValue();
45✔
3994
                    }
3995
                }
3996
            }
3997
        }
3998
    }
3999

4000
    public function isCellInSpillRange(string $coordinate): bool
2✔
4001
    {
4002
        if (Calculation::getInstance($this->parent)->getInstanceArrayReturnType() !== Calculation::RETURN_ARRAY_AS_ARRAY) {
2✔
4003
            return false;
1✔
4004
        }
4005
        $this->calculateArrays();
1✔
4006
        $keys = $this->cellCollection->getCoordinates();
1✔
4007
        foreach ($keys as $key) {
1✔
4008
            $attributes = $this->getCell($key)->getFormulaAttributes();
1✔
4009
            if (isset($attributes['ref'])) {
1✔
4010
                if (Coordinate::coordinateIsInsideRange($attributes['ref'], $coordinate)) {
1✔
4011
                    // false for first cell in range, true otherwise
4012
                    return $coordinate !== $key;
1✔
4013
                }
4014
            }
4015
        }
4016

4017
        return false;
1✔
4018
    }
4019

4020
    /** @param mixed[][] $styleArray */
4021
    public function applyStylesFromArray(string $coordinate, array $styleArray): bool
2✔
4022
    {
4023
        $spreadsheet = $this->parent;
2✔
4024
        if ($spreadsheet === null) {
2✔
4025
            return false;
1✔
4026
        }
4027
        $activeSheetIndex = $spreadsheet->getActiveSheetIndex();
1✔
4028
        $originalSelected = $this->selectedCells;
1✔
4029
        $this->getStyle($coordinate)->applyFromArray($styleArray);
1✔
4030
        $this->setSelectedCells($originalSelected);
1✔
4031
        if ($activeSheetIndex >= 0) {
1✔
4032
            $spreadsheet->setActiveSheetIndex($activeSheetIndex);
1✔
4033
        }
4034

4035
        return true;
1✔
4036
    }
4037

4038
    public function copyFormula(string $fromCell, string $toCell): void
1✔
4039
    {
4040
        $formula = $this->getCell($fromCell)->getValue();
1✔
4041
        $newFormula = $formula;
1✔
4042
        if (is_string($formula) && $this->getCell($fromCell)->getDataType() === DataType::TYPE_FORMULA) {
1✔
4043
            [$fromColInt, $fromRow] = Coordinate::indexesFromString($fromCell);
1✔
4044
            [$toColInt, $toRow] = Coordinate::indexesFromString($toCell);
1✔
4045
            $helper = ReferenceHelper::getInstance();
1✔
4046
            $newFormula = $helper->updateFormulaReferences(
1✔
4047
                $formula,
1✔
4048
                'A1',
1✔
4049
                $toColInt - $fromColInt,
1✔
4050
                $toRow - $fromRow
1✔
4051
            );
1✔
4052
        }
4053
        $this->setCellValue($toCell, $newFormula);
1✔
4054
    }
4055
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc