From a6e992671c98a39fd7bfd27e401052ddaa608dd2 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 21 May 2024 01:04:07 -0700 Subject: [PATCH 1/3] Xls Conditional Border Xls Writer Conditional Border had been creating corrupt spreadsheets. This was mainly because a pack statement that should have specified `V` instead specified `v`. Even changing that, the logic was still slightly wrong on write, and missing altogether on read. This PR corrects the write problems and adds the missing read code. It also adds italic and strikethrough support for Xls Writer Conditional Font italic and strikethrough (read code was already in place). With this, Xls Conditional Writer is completely supported except for NumberFormat. Xls does support that, but I cannot figure out how from the available documentation. --- CHANGELOG.md | 4 +- src/PhpSpreadsheet/Reader/Xls.php | 102 +++++++++++-- src/PhpSpreadsheet/Writer/Xls/Worksheet.php | 80 ++++++++--- .../Reader/Xls/ConditionalBorderTest.php | 134 ++++++++++++++++++ .../Reader/Xls/ConditionalItalicTest.php | 59 ++++++++ 5 files changed, 344 insertions(+), 35 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Xls/ConditionalBorderTest.php create mode 100644 tests/PhpSpreadsheetTests/Reader/Xls/ConditionalItalicTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d89d59c7c4..e568b8a7b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Deprecated -- Nothing +- Writer\Xls\Style\ColorMap is no longer needed. ### Moved @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Incorrect Reader CSV with BOM. [Issue #4028](https://github.com/PHPOffice/PhpSpreadsheet/issues/4028) [PR #4029](https://github.com/PHPOffice/PhpSpreadsheet/pull/4029) - POWER Null/Bool Args. [PR #4031](https://github.com/PHPOffice/PhpSpreadsheet/pull/4031) - Do Not Output Alignment and Protection for Conditional Format. [Issue #4025](https://github.com/PHPOffice/PhpSpreadsheet/issues/4025) [PR #4027](https://github.com/PHPOffice/PhpSpreadsheet/pull/4027) -- Xls Conditional Format Improvements. [PR #4030](https://github.com/PHPOffice/PhpSpreadsheet/pull/4030) +- Xls Conditional Format Improvements. [PR #4030](https://github.com/PHPOffice/PhpSpreadsheet/pull/4030) [PR #4033](https://github.com/PHPOffice/PhpSpreadsheet/pull/4033) ## 2024-05-11 - 2.1.0 diff --git a/src/PhpSpreadsheet/Reader/Xls.php b/src/PhpSpreadsheet/Reader/Xls.php index 01fc8dafa0..47236b692a 100644 --- a/src/PhpSpreadsheet/Reader/Xls.php +++ b/src/PhpSpreadsheet/Reader/Xls.php @@ -23,6 +23,7 @@ use PhpOffice\PhpSpreadsheet\Shared\Xls as SharedXls; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\Alignment; +use PhpOffice\PhpSpreadsheet\Style\Border; use PhpOffice\PhpSpreadsheet\Style\Borders; use PhpOffice\PhpSpreadsheet\Style\Conditional; use PhpOffice\PhpSpreadsheet\Style\Fill; @@ -163,6 +164,26 @@ class Xls extends BaseReader // Size of stream blocks when using RC4 encryption const REKEY_BLOCK = 0x400; + // should be consistent with Writer\Xls\Style\CellBorder + const BORDER_STYLE_MAP = [ + Border::BORDER_NONE, // => 0x00, + Border::BORDER_THIN, // => 0x01, + Border::BORDER_MEDIUM, // => 0x02, + Border::BORDER_DASHED, // => 0x03, + Border::BORDER_DOTTED, // => 0x04, + Border::BORDER_THICK, // => 0x05, + Border::BORDER_DOUBLE, // => 0x06, + Border::BORDER_HAIR, // => 0x07, + Border::BORDER_MEDIUMDASHED, // => 0x08, + Border::BORDER_DASHDOT, // => 0x09, + Border::BORDER_MEDIUMDASHDOT, // => 0x0A, + Border::BORDER_DASHDOTDOT, // => 0x0B, + Border::BORDER_MEDIUMDASHDOTDOT, // => 0x0C, + Border::BORDER_SLANTDASHDOT, // => 0x0D, + Border::BORDER_OMIT, // => 0x0E, + Border::BORDER_OMIT, // => 0x0F, + ]; + /** * Summary Information stream data. */ @@ -1945,12 +1966,9 @@ private function readFont(): void $objFont->colorIndex = $colorIndex; // offset: 6; size: 2; font weight - $weight = self::getUInt2d($recordData, 6); - switch ($weight) { - case 0x02BC: - $objFont->setBold(true); - - break; + $weight = self::getUInt2d($recordData, 6); // regular=400 bold=700 + if ($weight >= 550) { + $objFont->setBold(true); } // offset: 8; size: 2; escapement type @@ -7326,6 +7344,11 @@ public function getMapCellStyleXfIndex(): array return $this->mapCellStyleXfIndex; } + /** + * Parse conditional formatting blocks. + * + * @see https://www.openoffice.org/sc/excelfileformat.pdf Search for CFHEADER followed by CFRULE + */ private function readCFHeader(): array { $length = self::getUInt2d($this->data, $this->pos + 2); @@ -7394,6 +7417,11 @@ private function readCFRule(array $cellRangeAddresses): void $hasBorderRecord = (bool) ((0x10000000 & $options) >> 28); $hasFillRecord = (bool) ((0x20000000 & $options) >> 29); $hasProtectionRecord = (bool) ((0x40000000 & $options) >> 30); + // note unexpected values for following 4 + $hasBorderLeft = !(bool) (0x00000400 & $options); + $hasBorderRight = !(bool) (0x00000800 & $options); + $hasBorderTop = !(bool) (0x00001000 & $options); + $hasBorderBottom = !(bool) (0x00002000 & $options); $offset = 12; @@ -7410,8 +7438,8 @@ private function readCFRule(array $cellRangeAddresses): void } if ($hasBorderRecord === true) { - //$borderStyle = substr($recordData, $offset, 8); - //$this->getCFBorderStyle($borderStyle, $style); + $borderStyle = substr($recordData, $offset, 8); + $this->getCFBorderStyle($borderStyle, $style, $hasBorderLeft, $hasBorderRight, $hasBorderTop, $hasBorderBottom); $offset += 8; } @@ -7459,9 +7487,23 @@ private function getCFFontStyle(string $options, Style $style): void if ($fontSize !== -1) { $style->getFont()->setSize($fontSize / 20); // Convert twips to points } + $options68 = self::getInt4d($options, 68); + $options88 = self::getInt4d($options, 88); - $bold = self::getUInt2d($options, 72) === 700; // 400 = normal, 700 = bold - $style->getFont()->setBold($bold); + if (($options88 & 2) === 0) { + $bold = self::getUInt2d($options, 72); // 400 = normal, 700 = bold + if ($bold !== 0) { + $style->getFont()->setBold($bold >= 550); + } + if (($options68 & 2) !== 0) { + $style->getFont()->setItalic(true); + } + } + if (($options88 & 0x80) === 0) { + if (($options68 & 0x80) !== 0) { + $style->getFont()->setStrikethrough(true); + } + } $color = self::getInt4d($options, 80); @@ -7474,9 +7516,45 @@ private function getCFFontStyle(string $options, Style $style): void { }*/ - /*private function getCFBorderStyle(string $options, Style $style): void + private function getCFBorderStyle(string $options, Style $style, bool $hasBorderLeft, bool $hasBorderRight, bool $hasBorderTop, bool $hasBorderBottom): void { - }*/ + $valueArray = unpack('V', $options); + $value = is_array($valueArray) ? $valueArray[1] : 0; + $left = $value & 15; + $right = ($value >> 4) & 15; + $top = ($value >> 8) & 15; + $bottom = ($value >> 12) & 15; + $leftc = ($value >> 16) & 0x7F; + $rightc = ($value >> 23) & 0x7F; + $valueArray = unpack('V', substr($options, 4)); + $value = is_array($valueArray) ? $valueArray[1] : 0; + $topc = $value & 0x7F; + $bottomc = ($value & 0x3F80) >> 7; + if ($hasBorderLeft) { + $style->getBorders()->getLeft() + ->setBorderStyle(self::BORDER_STYLE_MAP[$left]); + $style->getBorders()->getLeft()->getColor() + ->setRGB(Xls\Color::map($leftc, $this->palette, $this->version)['rgb']); + } + if ($hasBorderRight) { + $style->getBorders()->getRight() + ->setBorderStyle(self::BORDER_STYLE_MAP[$right]); + $style->getBorders()->getRight()->getColor() + ->setRGB(Xls\Color::map($rightc, $this->palette, $this->version)['rgb']); + } + if ($hasBorderTop) { + $style->getBorders()->getTop() + ->setBorderStyle(self::BORDER_STYLE_MAP[$top]); + $style->getBorders()->getTop()->getColor() + ->setRGB(Xls\Color::map($topc, $this->palette, $this->version)['rgb']); + } + if ($hasBorderBottom) { + $style->getBorders()->getBottom() + ->setBorderStyle(self::BORDER_STYLE_MAP[$bottom]); + $style->getBorders()->getBottom()->getColor() + ->setRGB(Xls\Color::map($bottomc, $this->palette, $this->version)['rgb']); + } + } private function getCFFillStyle(string $options, Style $style): void { diff --git a/src/PhpSpreadsheet/Writer/Xls/Worksheet.php b/src/PhpSpreadsheet/Writer/Xls/Worksheet.php index 6d415038f7..21fc4a667e 100644 --- a/src/PhpSpreadsheet/Writer/Xls/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xls/Worksheet.php @@ -12,6 +12,7 @@ use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Shared\Xls; use PhpOffice\PhpSpreadsheet\Style\Border; +use PhpOffice\PhpSpreadsheet\Style\Borders; use PhpOffice\PhpSpreadsheet\Style\Conditional; use PhpOffice\PhpSpreadsheet\Style\Protection; use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup; @@ -2722,6 +2723,8 @@ private function writePageLayoutView(): void /** * Write CFRule Record. + * + * @see https://www.openoffice.org/sc/excelfileformat.pdf Search for CFHEADER followed by CFRULE */ private function writeCFRule( ConditionalHelper $conditionalFormulaHelper, @@ -2824,7 +2827,13 @@ private function writeCFRule( $bBorderRight = ($conditional->getStyle()->getBorders()->getRight()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0; $bBorderTop = ($conditional->getStyle()->getBorders()->getTop()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0; $bBorderBottom = ($conditional->getStyle()->getBorders()->getBottom()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0; - if ($bBorderLeft === 1 || $bBorderRight === 1 || $bBorderTop === 1 || $bBorderBottom === 1) { + $bBorderBottom = ($conditional->getStyle()->getBorders()->getBottom()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0; + $diagonalDirection = $conditional->getStyle()->getBorders()->getDiagonalDirection(); + // Excel does not support conditional diagonal border even for xlsx + $bBorderDiagTop = self::$always0; //$diagonalDirection === Borders::DIAGONAL_DOWN || $diagonalDirection === Borders::DIAGONAL_BOTH; + $bBorderDiagBottom = self::$always0; //$diagonalDirection === Borders::DIAGONAL_UP || $diagonalDirection === Borders::DIAGONAL_BOTH; + + if ($bBorderLeft === 1 || $bBorderRight === 1 || $bBorderTop === 1 || $bBorderBottom === 1 || $bBorderDiagTop === 1 || $bBorderDiagBottom === 1) { $bFormatBorder = 1; } else { $bFormatBorder = 0; @@ -2869,13 +2878,13 @@ private function writeCFRule( // Protection //$flags |= (1 == $bProtLocked ? 0x00000100 : 0); //$flags |= (1 == $bProtHidden ? 0x00000200 : 0); - // Border - $flags |= (1 == $bBorderLeft ? 0x00000400 : 0); - $flags |= (1 == $bBorderRight ? 0x00000800 : 0); - $flags |= (1 == $bBorderTop ? 0x00001000 : 0); - $flags |= (1 == $bBorderBottom ? 0x00002000 : 0); - $flags |= (1 == self::$always1 ? 0x00004000 : 0); // Top left to Bottom right border - $flags |= (1 == self::$always1 ? 0x00008000 : 0); // Bottom left to Top right border + // Border, note that flags are opposite of what you might expect + $flags |= (0 == $bBorderLeft ? 0x00000400 : 0); + $flags |= (0 == $bBorderRight ? 0x00000800 : 0); + $flags |= (0 == $bBorderTop ? 0x00001000 : 0); + $flags |= (0 == $bBorderBottom ? 0x00002000 : 0); + $flags |= (0 === $bBorderDiagTop ? 0x00004000 : 0); // Top left to Bottom right border + $flags |= (0 === $bBorderDiagBottom ? 0x00008000 : 0); // Bottom left to Top right border // Pattern $flags |= (1 == $bFillStyle ? 0x00010000 : 0); $flags |= (1 == $bFillColor ? 0x00020000 : 0); @@ -2915,10 +2924,19 @@ private function writeCFRule( $dataBlockFont .= pack('V', 20 * $conditional->getStyle()->getFont()->getSize()); } // Font Options - $dataBlockFont .= pack('V', 0); + $italicStrike = 0; + if ($conditional->getStyle()->getFont()->getItalic() === true) { + $italicStrike |= 2; + } + if ($conditional->getStyle()->getFont()->getStrikethrough() === true) { + $italicStrike |= 0x80; + } + $dataBlockFont .= pack('V', $italicStrike); // Font weight if ($conditional->getStyle()->getFont()->getBold() === true) { $dataBlockFont .= pack('v', 0x02BC); + } elseif ($conditional->getStyle()->getFont()->getBold() === null) { + $dataBlockFont .= pack('v', 0x0000); } else { $dataBlockFont .= pack('v', 0x0190); } @@ -2975,12 +2993,11 @@ private function writeCFRule( $dataBlockFont .= pack('V', 0x00000000); // Options flags for modified font attributes $optionsFlags = 0; - $optionsFlagsBold = ($conditional->getStyle()->getFont()->getBold() === null ? 1 : 0); - $optionsFlags |= (1 == $optionsFlagsBold ? 0x00000002 : 0); + $optionsFlags |= ($conditional->getStyle()->getFont()->getBold() === null && $conditional->getStyle()->getFont()->getItalic() === null) ? 2 : 0; $optionsFlags |= (1 == self::$always1 ? 0x00000008 : 0); $optionsFlags |= (1 == self::$always1 ? 0x00000010 : 0); $optionsFlags |= (1 == self::$always0 ? 0x00000020 : 0); - $optionsFlags |= (1 == self::$always1 ? 0x00000080 : 0); + $optionsFlags |= ($conditional->getStyle()->getFont()->getStrikethrough() === null) ? 0x80 : 0; $dataBlockFont .= pack('V', $optionsFlags); // Escapement type $dataBlockFont .= pack('V', $fontEscapement); @@ -3025,16 +3042,37 @@ private function writeCFRule( $blockLineStyle |= Style\CellBorder::style($conditional->getStyle()->getBorders()->getTop()) << 8; $blockLineStyle |= Style\CellBorder::style($conditional->getStyle()->getBorders()->getBottom()) << 12; - // TODO writeCFRule() => $blockLineStyle => Index Color for left line - // TODO writeCFRule() => $blockLineStyle => Index Color for right line - // TODO writeCFRule() => $blockLineStyle => Top-left to bottom-right on/off - // TODO writeCFRule() => $blockLineStyle => Bottom-left to top-right on/off + if ($bBorderLeft !== 0) { + $colorIdx = $this->workbookColorIndex($conditional->getStyle()->getBorders()->getLeft()->getColor()->getRgb(), 0); + $blockLineStyle |= $colorIdx << 16; + } + if ($bBorderRight !== 0) { + $colorIdx = $this->workbookColorIndex($conditional->getStyle()->getBorders()->getRight()->getColor()->getRgb(), 0); + $blockLineStyle |= $colorIdx << 23; + } $blockColor = 0; - // TODO writeCFRule() => $blockColor => Index Color for top line - // TODO writeCFRule() => $blockColor => Index Color for bottom line - // TODO writeCFRule() => $blockColor => Index Color for diagonal line - $blockColor |= Style\CellBorder::style($conditional->getStyle()->getBorders()->getDiagonal()) << 21; - $dataBlockBorder = pack('vv', $blockLineStyle, $blockColor); + if ($bBorderTop !== 0) { + $colorIdx = $this->workbookColorIndex($conditional->getStyle()->getBorders()->getTop()->getColor()->getRgb(), 0); + $blockColor |= $colorIdx; + } + if ($bBorderBottom !== 0) { + $colorIdx = $this->workbookColorIndex($conditional->getStyle()->getBorders()->getBottom()->getColor()->getRgb(), 0); + $blockColor |= $colorIdx << 7; + } + /* Excel does not support condtional diagonal borders even for xlsx + if ($bBorderDiagTop !== 0 || $bBorderDiagBottom !== 0) { + $colorIdx = $this->workbookColorIndex($conditional->getStyle()->getBorders()->getDiagonal()->getColor()->getRgb(), 0); + $blockColor |= $colorIdx << 14; + $blockColor |= Style\CellBorder::style($conditional->getStyle()->getBorders()->getDiagonal()) << 21; + if ($bBorderDiagTop !== 0) { + $blockLineStyle |= 1 << 30; + } + if ($bBorderDiagBottom !== 0) { + $blockLineStyle |= 1 << 31; + } + } + */ + $dataBlockBorder = pack('VV', $blockLineStyle, $blockColor); } if ($bFormatFill === 1) { // Fill Pattern Style diff --git a/tests/PhpSpreadsheetTests/Reader/Xls/ConditionalBorderTest.php b/tests/PhpSpreadsheetTests/Reader/Xls/ConditionalBorderTest.php new file mode 100644 index 0000000000..b74091823f --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xls/ConditionalBorderTest.php @@ -0,0 +1,134 @@ +getActiveSheet(); + $sheet->getCell('A1')->setValue(1); + $sheet->getCell('A2')->setValue(9); + $sheet->getCell('A3')->setValue(5); + $sheet->getCell('A4')->setValue(2); + $sheet->getCell('A5')->setValue(8); + + $condition1 = new Conditional(); + $condition1->setConditionType(Conditional::CONDITION_CELLIS); + $condition1->setOperatorType(Conditional::OPERATOR_LESSTHAN); + $condition1->addCondition(5); + $condition1->getStyle()->getBorders()->getRight() + ->setBorderStyle(Border::BORDER_THICK) + ->getColor() + ->setArgb('FFFF0000'); + $condition1->getStyle()->getBorders()->getBottom() + ->setBorderStyle(Border::BORDER_DASHED) + ->getColor() + ->setArgb('FFF08000'); + + $condition2 = new Conditional(); + $condition2->setConditionType(Conditional::CONDITION_CELLIS); + $condition2->setOperatorType(Conditional::OPERATOR_GREATERTHAN); + $condition2->addCondition(5); + $condition2->getStyle()->getBorders()->getRight() + ->setBorderStyle(Border::BORDER_THICK) + ->getColor() + ->setArgb('FF0000FF'); + $condition2->getStyle()->getBorders()->getBottom() + ->setBorderStyle(Border::BORDER_DASHED) + ->getColor() + ->setArgb('FF0080FF'); + + $conditionalStyles = [$condition1, $condition2]; + $sheet->getStyle('A1:A5')->setConditionalStyles($conditionalStyles); + + $sheet->getCell('C6')->setValue(1); + $sheet->getCell('C7')->setValue(9); + $sheet->getCell('C8')->setValue(5); + $sheet->getCell('C9')->setValue(2); + $sheet->getCell('C10')->setValue(8); + + $condition1 = new Conditional(); + $condition1->setConditionType(Conditional::CONDITION_CELLIS); + $condition1->setOperatorType(Conditional::OPERATOR_LESSTHAN); + $condition1->addCondition(5); + $condition1->getStyle()->getBorders()->getLeft() + ->setBorderStyle(Border::BORDER_THICK) + ->getColor() + ->setArgb('FFFF0000'); + $condition1->getStyle()->getBorders()->getTop() + ->setBorderStyle(Border::BORDER_DASHED) + ->getColor() + ->setArgb('FFF08000'); + + $condition2 = new Conditional(); + $condition2->setConditionType(Conditional::CONDITION_CELLIS); + $condition2->setOperatorType(Conditional::OPERATOR_GREATERTHAN); + $condition2->addCondition(5); + $condition2->getStyle()->getBorders()->getLeft() + ->setBorderStyle(Border::BORDER_THICK) + ->getColor() + ->setArgb('FF0000FF'); + $condition2->getStyle()->getBorders()->getTop() + ->setBorderStyle(Border::BORDER_DASHED) + ->getColor() + ->setArgb('FF0080FF'); + + $conditionalStyles = [$condition1, $condition2]; + $sheet->getStyle('C6:C10')->setConditionalStyles($conditionalStyles); + + $newSpreadsheet = $this->writeAndReload($spreadsheet, 'Xls'); + $spreadsheet->disconnectWorksheets(); + $sheet = $newSpreadsheet->getActiveSheet(); + $conditionals = $sheet->getConditionalStylesCollection(); + self::assertCount(2, $conditionals); + + $cond1 = $conditionals['A1:A5']; + self::assertCount(2, $cond1); + + $borders = $cond1[0]->getStyle()->getBorders(); + self::assertSame(Border::BORDER_OMIT, $borders->getLeft()->getBorderStyle()); + self::assertSame(Border::BORDER_THICK, $borders->getRight()->getBorderStyle()); + self::assertSame('FFFF0000', $borders->getRight()->getColor()->getARGB()); + self::assertSame(Border::BORDER_OMIT, $borders->getTop()->getBorderStyle()); + self::assertSame(Border::BORDER_DASHED, $borders->getBottom()->getBorderStyle()); + self::assertSame('FFF08000', $borders->getBottom()->getColor()->getARGB()); + + $borders = $cond1[1]->getStyle()->getBorders(); + self::assertSame(Border::BORDER_OMIT, $borders->getLeft()->getBorderStyle()); + self::assertSame(Border::BORDER_THICK, $borders->getRight()->getBorderStyle()); + self::assertSame('FF0000FF', $borders->getRight()->getColor()->getARGB()); + self::assertSame(Border::BORDER_OMIT, $borders->getTop()->getBorderStyle()); + self::assertSame(Border::BORDER_DASHED, $borders->getBottom()->getBorderStyle()); + self::assertSame('FF0080FF', $borders->getBottom()->getColor()->getARGB()); + + $cond1 = $conditionals['C6:C10']; + self::assertCount(2, $cond1); + + $borders = $cond1[0]->getStyle()->getBorders(); + self::assertSame(Border::BORDER_THICK, $borders->getLeft()->getBorderStyle()); + self::assertSame('FFFF0000', $borders->getLeft()->getColor()->getARGB()); + self::assertSame(Border::BORDER_OMIT, $borders->getRight()->getBorderStyle()); + self::assertSame(Border::BORDER_DASHED, $borders->getTop()->getBorderStyle()); + self::assertSame('FFF08000', $borders->getTop()->getColor()->getARGB()); + self::assertSame(Border::BORDER_OMIT, $borders->getBottom()->getBorderStyle()); + + $borders = $cond1[1]->getStyle()->getBorders(); + self::assertSame(Border::BORDER_THICK, $borders->getLeft()->getBorderStyle()); + self::assertSame('FF0000FF', $borders->getLeft()->getColor()->getARGB()); + self::assertSame(Border::BORDER_OMIT, $borders->getRight()->getBorderStyle()); + self::assertSame(Border::BORDER_DASHED, $borders->getTop()->getBorderStyle()); + self::assertSame('FF0080FF', $borders->getTop()->getColor()->getARGB()); + self::assertSame(Border::BORDER_OMIT, $borders->getBottom()->getBorderStyle()); + + $newSpreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Reader/Xls/ConditionalItalicTest.php b/tests/PhpSpreadsheetTests/Reader/Xls/ConditionalItalicTest.php new file mode 100644 index 0000000000..5ac50193d6 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xls/ConditionalItalicTest.php @@ -0,0 +1,59 @@ +getActiveSheet(); + $sheet->getCell('A1')->setValue(1); + $sheet->getCell('A2')->setValue(9); + $sheet->getCell('A3')->setValue(5); + $sheet->getCell('A4')->setValue(2); + $sheet->getCell('A5')->setValue(8); + + $condition1 = new Conditional(); + $condition1->setConditionType(Conditional::CONDITION_CELLIS); + $condition1->setOperatorType(Conditional::OPERATOR_LESSTHAN); + $condition1->addCondition(5); + $condition1->getStyle()->getFont()->setItalic(true); + + $condition2 = new Conditional(); + $condition2->setConditionType(Conditional::CONDITION_CELLIS); + $condition2->setOperatorType(Conditional::OPERATOR_GREATERTHAN); + $condition2->addCondition(5); + $condition2->getStyle()->getFont()->setStrikeThrough(true)->setBold(true); + + $conditionalStyles = [$condition1, $condition2]; + $sheet->getStyle('A1:A5')->setConditionalStyles($conditionalStyles); + + $newSpreadsheet = $this->writeAndReload($spreadsheet, 'Xls'); + $spreadsheet->disconnectWorksheets(); + $sheet = $newSpreadsheet->getActiveSheet(); + $conditionals = $sheet->getConditionalStylesCollection(); + self::assertCount(1, $conditionals); + + $cond1 = $conditionals['A1:A5']; + self::assertCount(2, $cond1); + + $font = $cond1[0]->getStyle()->getFont(); + self::assertTrue($font->getItalic()); + self::assertNull($font->getBold()); + self::assertNull($font->getStrikethrough()); + + $font = $cond1[1]->getStyle()->getFont(); + self::assertNull($font->getItalic()); + self::assertTrue($font->getBold()); + self::assertTrue($font->getStrikethrough()); + + $newSpreadsheet->disconnectWorksheets(); + } +} From 313dcc537bc9e437ae2eff140cf9778c74f0fade Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 21 May 2024 01:38:32 -0700 Subject: [PATCH 2/3] Scrutinizer Identified Some Dead Code --- src/PhpSpreadsheet/Writer/Xls/Worksheet.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xls/Worksheet.php b/src/PhpSpreadsheet/Writer/Xls/Worksheet.php index 21fc4a667e..6512c6dc9f 100644 --- a/src/PhpSpreadsheet/Writer/Xls/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xls/Worksheet.php @@ -2827,8 +2827,7 @@ private function writeCFRule( $bBorderRight = ($conditional->getStyle()->getBorders()->getRight()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0; $bBorderTop = ($conditional->getStyle()->getBorders()->getTop()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0; $bBorderBottom = ($conditional->getStyle()->getBorders()->getBottom()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0; - $bBorderBottom = ($conditional->getStyle()->getBorders()->getBottom()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0; - $diagonalDirection = $conditional->getStyle()->getBorders()->getDiagonalDirection(); + //$diagonalDirection = $conditional->getStyle()->getBorders()->getDiagonalDirection(); // Excel does not support conditional diagonal border even for xlsx $bBorderDiagTop = self::$always0; //$diagonalDirection === Borders::DIAGONAL_DOWN || $diagonalDirection === Borders::DIAGONAL_BOTH; $bBorderDiagBottom = self::$always0; //$diagonalDirection === Borders::DIAGONAL_UP || $diagonalDirection === Borders::DIAGONAL_BOTH; From 7ce0184f8eb9517ce95be33cf92b0de87762e9b7 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 24 May 2024 07:58:36 -0700 Subject: [PATCH 3/3] Don't Interfere with SelectedCells And Other Changes Xls Reader processing Conditionals interferes with the previously established SelectedCells. Make sure that value is restored. StopIfTrue should always be set for Xls spreadsheet. Set NoFormatSet to true unless any of Font, Fill, or Borders is specified in Conditional Style. In my notes for PR #3372, I mentioned that I could not include some Xls tests because of errors in the software at that time. This PR fixes those errors, so I am adding the missing test, and making the equivalent Xlsx test more comprehensive. --- src/PhpSpreadsheet/Reader/Xls.php | 21 ++++++-- .../Reader/Xls/Issue3202Test.php | 46 ++++++++++++++++++ .../Xlsx/ConditionalNoFormatSetTest.php | 25 ++++++++++ tests/data/Reader/XLS/issue.3202.xls | Bin 0 -> 31744 bytes 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Xls/Issue3202Test.php create mode 100644 tests/data/Reader/XLS/issue.3202.xls diff --git a/src/PhpSpreadsheet/Reader/Xls.php b/src/PhpSpreadsheet/Reader/Xls.php index 47236b692a..58a22835de 100644 --- a/src/PhpSpreadsheet/Reader/Xls.php +++ b/src/PhpSpreadsheet/Reader/Xls.php @@ -703,6 +703,7 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet // Parse the individual sheets $this->activeSheetSet = false; foreach ($this->sheets as $sheet) { + $selectedCells = ''; if ($sheet['sheetType'] != 0x00) { // 0x00: Worksheet, 0x02: Chart, 0x06: Visual Basic module continue; @@ -910,7 +911,7 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet break; case self::XLS_TYPE_SELECTION: - $this->readSelection(); + $selectedCells = $this->readSelection(); break; case self::XLS_TYPE_MERGEDCELLS: @@ -1112,6 +1113,9 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet $this->phpSheet->getComment($cellAddress)->setAuthor($noteDetails['author'])->setText($this->parseRichText($noteDetails['objTextData']['text'])); } } + if ($selectedCells !== '') { + $this->phpSheet->setSelectedCells($selectedCells); + } } if ($this->activeSheetSet === false) { $this->spreadsheet->setActiveSheetIndex(0); @@ -4376,10 +4380,11 @@ private function readPane(): void /** * Read SELECTION record. There is one such record for each pane in the sheet. */ - private function readSelection(): void + private function readSelection(): string { $length = self::getUInt2d($this->data, $this->pos + 2); $recordData = $this->readRecordData($this->data, $this->pos + 4, $length); + $selectedCells = ''; // move stream pointer to next record $this->pos += 4 + $length; @@ -4421,6 +4426,8 @@ private function readSelection(): void $this->phpSheet->setSelectedCells($selectedCells); } + + return $selectedCells; } private function includeCellRangeFiltered(string $cellRangeAddress): bool @@ -7410,6 +7417,7 @@ private function readCFRule(array $cellRangeAddresses): void $options = self::getInt4d($recordData, 6); $style = new Style(false, true); // non-supervisor, conditional + $noFormatSet = true; //$this->getCFStyleOptions($options, $style); $hasFontRecord = (bool) ((0x04000000 & $options) >> 26); @@ -7429,6 +7437,7 @@ private function readCFRule(array $cellRangeAddresses): void $fontStyle = substr($recordData, $offset, 118); $this->getCFFontStyle($fontStyle, $style); $offset += 118; + $noFormatSet = false; } if ($hasAlignmentRecord === true) { @@ -7441,12 +7450,14 @@ private function readCFRule(array $cellRangeAddresses): void $borderStyle = substr($recordData, $offset, 8); $this->getCFBorderStyle($borderStyle, $style, $hasBorderLeft, $hasBorderRight, $hasBorderTop, $hasBorderBottom); $offset += 8; + $noFormatSet = false; } if ($hasFillRecord === true) { $fillStyle = substr($recordData, $offset, 4); $this->getCFFillStyle($fillStyle, $style); $offset += 4; + $noFormatSet = false; } if ($hasProtectionRecord === true) { @@ -7474,7 +7485,7 @@ private function readCFRule(array $cellRangeAddresses): void $offset += $size2; } - $this->setCFRules($cellRangeAddresses, $type, $operator, $formula1, $formula2, $style); + $this->setCFRules($cellRangeAddresses, $type, $operator, $formula1, $formula2, $style, $noFormatSet); } /*private function getCFStyleOptions(int $options, Style $style): void @@ -7604,12 +7615,14 @@ private function readCFFormula(string $recordData, int $offset, int $size): floa } } - private function setCFRules(array $cellRanges, string $type, string $operator, null|float|int|string $formula1, null|float|int|string $formula2, Style $style): void + private function setCFRules(array $cellRanges, string $type, string $operator, null|float|int|string $formula1, null|float|int|string $formula2, Style $style, bool $noFormatSet): void { foreach ($cellRanges as $cellRange) { $conditional = new Conditional(); + $conditional->setNoFormatSet($noFormatSet); $conditional->setConditionType($type); $conditional->setOperatorType($operator); + $conditional->setStopIfTrue(true); if ($formula1 !== null) { $conditional->addCondition($formula1); } diff --git a/tests/PhpSpreadsheetTests/Reader/Xls/Issue3202Test.php b/tests/PhpSpreadsheetTests/Reader/Xls/Issue3202Test.php new file mode 100644 index 0000000000..37436993f8 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xls/Issue3202Test.php @@ -0,0 +1,46 @@ +load($filename); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame('A2', $sheet->getSelectedCells()); + + $collection = $sheet->getConditionalStylesCollection(); + self::assertCount(1, $collection); + $conditionalArray = $collection['A1:A5']; + self::assertCount(3, $conditionalArray); + + $conditions = $conditionalArray[0]->getConditions(); + self::assertCount(1, $conditions); + self::assertSame('$A1=3', $conditions[0]); + self::assertTrue($conditionalArray[0]->getNoFormatSet()); + self::assertTrue($conditionalArray[0]->getStopIfTrue()); + + $conditions = $conditionalArray[1]->getConditions(); + self::assertCount(1, $conditions); + self::assertSame('$A1>5', $conditions[0]); + self::assertFalse($conditionalArray[1]->getNoFormatSet()); + self::assertTrue($conditionalArray[1]->getStopIfTrue()); + + $conditions = $conditionalArray[2]->getConditions(); + self::assertCount(1, $conditions); + self::assertSame('$A1>1', $conditions[0]); + self::assertFalse($conditionalArray[2]->getNoFormatSet()); + self::assertTrue($conditionalArray[2]->getStopIfTrue()); + + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/ConditionalNoFormatSetTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/ConditionalNoFormatSetTest.php index 86729c1c00..7df9b04066 100644 --- a/tests/PhpSpreadsheetTests/Reader/Xlsx/ConditionalNoFormatSetTest.php +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/ConditionalNoFormatSetTest.php @@ -33,6 +33,31 @@ public function testNoFormatTest(): void $reader = new XlsxReader(); $spreadsheet = $reader->load($testfile); + $sheet = $spreadsheet->getactiveSheet(); + + $collection = $sheet->getConditionalStylesCollection(); + self::assertCount(1, $collection); + $conditionalArray = $collection['A1:A5']; + self::assertCount(3, $conditionalArray); + + $conditions = $conditionalArray[0]->getConditions(); + self::assertCount(1, $conditions); + self::assertSame('$A1=3', $conditions[0]); + self::assertTrue($conditionalArray[0]->getNoFormatSet()); + self::assertTrue($conditionalArray[0]->getStopIfTrue()); + + $conditions = $conditionalArray[1]->getConditions(); + self::assertCount(1, $conditions); + self::assertSame('$A1>5', $conditions[0]); + self::assertFalse($conditionalArray[1]->getNoFormatSet()); + self::assertTrue($conditionalArray[1]->getStopIfTrue()); + + $conditions = $conditionalArray[2]->getConditions(); + self::assertCount(1, $conditions); + self::assertSame('$A1>1', $conditions[0]); + self::assertFalse($conditionalArray[2]->getNoFormatSet()); + self::assertFalse($conditionalArray[2]->getStopIfTrue()); + $outfile = File::temporaryFilename(); $writer = new XlsxWriter($spreadsheet); $writer->save($outfile); diff --git a/tests/data/Reader/XLS/issue.3202.xls b/tests/data/Reader/XLS/issue.3202.xls new file mode 100644 index 0000000000000000000000000000000000000000..1c1def7ede5f4a2112994e934e89b60c8bfa4a07 GIT binary patch literal 31744 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 literal 0 HcmV?d00001