Skip to content

Commit 6925b7f

Browse files
authored
Conditional Formatting Improvements for Xlsx (#3372)
* WIP Conditional Formatting Improvements for Xlsx Fix #3370. Conditional styles are always generated with 5 borders (right, left, top, bottom, diagonal) even though the border style is none in each case. For the spreadsheet in question, top and bottom were inappropriate and interfered with the desired formatting. A new border style, BORDER_OMIT is added which will cause the Xlsx Writer to not generate that style. All conditional borders will be initialized with that value. Any border included in the Xml will, of course, change it to the specified type. Fix #3202. User wants a condition to use "No format set" as you can in Excel. A new boolean property `$noFormatSet`, along with setter and getter, is added to Style/Conditional. It is initialized to false. User can call setter to change it. More importantly for the issue in question, if the Xlsx Reader encounters a `cfRule` tag which does not have a `dxfId` attribute (i.e. no style is associated with the rule), it will set noFormatSet to true. Similarly, the Xlsx writer will not generate a `dfxId` tag when noFormatSet is true. This change is applicable only to Xlsx. Html, Csv, and Ods do not have support for Conditional Formatting. Limited support was added to Xls with PR #2696 in April 2022 and PR #2702 about a month later. However, with the current release code, Xls equivalents of the two new test spreadsheets in this PR are too complicated to be handled correctly by PhpSpreadsheet - loading and then saving them as Xls results in Excel complaining of corruption, and the results don't meet expectations. Since I have no idea how BIFF works, and since the problems with those spreadsheets are not caused by this PR, I am not planning to address those problems at this time. * Update Documentation, Write Alignment and Font Less Often It doesn't cause any particular harm except for small increases in file size and run time, but Alignment tags are written even when (a) all its attributes are null for Conditional Formatting, and (b) when the xml specifically indicates that Alignment should not be applied. Similarly, Font is written even when all its attributes are null for Conditional Formatting. There are some errors in the Conditional Formatting documentation. Specifying a solid fill color in a Conditional Style requires the use of endColor, not StartColor. The discussion of Order of Evaluating is not entirely accurate. I have changed it to what I believe is an accurate explanation of how Excel works; and also added a mention that other spreadsheet programs might not work the same way, adding a couple of illustrations of the difference. The description of the multiple conditions did not quite match the diagram. 'Stop if true' was a blank paragraph; it is now described, and the new 'No format set' option is described in that paragraph since (I think) it would be used most often in conjunction with 'Stop if true'. * Xlsx Writer Allow StartColor for Conditional Solid Fill To set a solid fill in a non-conditional style, you set StartColor (xml will use that value as fgColor and a default value as bgColor). If you instead set EndColor (xml will use that value as bgColor and a default value as fgColor), the styling will not work as expected. However, for conditional styles, if you set StartColor (xml will use that value as fgColor and not specify bgColor), the styling will not work as expected. If you instead set EndColor (xml will use that value as bgColor and not specify fgColor), the styling will work as expected. Together, this means that you need to use different methods for non-conditional style fill than for conditional style fill. This isn't a big problem, but it is a bit weird. This PR changes Xlsx Writer so that if (a) fill is olid and (b) startColor is specified and (c) endColor is null, the xml will be written as bgColor without specifying fgColor. This means that you can set StartColor for both conditional and non-conditional and get the expected styling. You may, of course, continue to specify EndColor instead for conditional. * Fix Some (Not Many) Xls Problems I will open an issue for the (pre-existing) remainder.
1 parent 378beac commit 6925b7f

19 files changed

+357
-92
lines changed

docs/topics/conditional-formatting.md

+27-8
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ $conditional->setOperatorType(\PhpOffice\PhpSpreadsheet\Style\Conditional::OPERA
4343
$conditional->addCondition(80);
4444
$conditional->getStyle()->getFont()->getColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_DARKGREEN);
4545
$conditional->getStyle()->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID);
46-
$conditional->getStyle()->getFill()->getStartColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_GREEN);
46+
$conditional->getStyle()->getFill()->getEndColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_GREEN);
4747

4848
$conditionalStyles = $spreadsheet->getActiveSheet()->getStyle('A1:A10')->getConditionalStyles();
4949
$conditionalStyles[] = $conditional;
@@ -63,7 +63,7 @@ $wizard = $wizardFactory->newRule(\PhpOffice\PhpSpreadsheet\Style\ConditionalFor
6363
$wizard->greaterThan(80);
6464
$wizard->getStyle()->getFont()->getColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_DARKGREEN);
6565
$wizard->getStyle()->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID);
66-
$wizard->getStyle()->getFill()->getStartColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_GREEN);
66+
$wizard->getStyle()->getFill()->getEndColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_GREEN);
6767

6868
$conditional = $wizard->getConditional();
6969
```
@@ -84,7 +84,7 @@ $conditional2->setOperatorType(\PhpOffice\PhpSpreadsheet\Style\Conditional::OPER
8484
$conditional2->addCondition(10);
8585
$conditional2->getStyle()->getFont()->getColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_DARKRED);
8686
$conditional2->getStyle()->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID);
87-
$conditional2->getStyle()->getFill()->getStartColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_RED);
87+
$conditional2->getStyle()->getFill()->getEndColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_RED);
8888

8989
$conditionalStyles = $spreadsheet->getActiveSheet()->getStyle('A1:A10')->getConditionalStyles();
9090
$conditionalStyles[] = $conditional2;
@@ -98,17 +98,17 @@ $wizard = $wizardFactory->newRule(\PhpOffice\PhpSpreadsheet\Style\ConditionalFor
9898
$wizard->lessThan(10);
9999
$wizard->getStyle()->getFont()->getColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_DARKGREEN);
100100
$wizard->getStyle()->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID);
101-
$wizard->getStyle()->getFill()->getStartColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_GREEN);
101+
$wizard->getStyle()->getFill()->getEndColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_GREEN);
102102

103103
$conditional = $wizard->getConditional();
104104
```
105105

106106

107107
### Order of Evaluating Multiple Rules/Conditions
108108

109-
`$conditionalStyles` is an array, which not only represents multiple conditions that can be applied to a cell (or range of cells), but also the order in which they are checked. MS Excel will check each of those conditions in turn in the order they are defined; and will stop checking once it finds a first matching rule. This means that the order of checking conditions can be important.
109+
`$conditionalStyles` is an array, which not only represents multiple conditions that can be applied to a cell (or range of cells), but also the order in which they are checked. Some spreadsheet programs stop processing conditions once they find a match. On the other hand, MS Excel will check each of those conditions in turn in the order they are defined. It will stop checking only if it finds a matching rule that specifies 'stop if true'; however, if it finds conflicting matches with conflicting formatting (e.g. both set a background fill color but use different choices), the first match wins. In either case, this means that the order of checking conditions can be important.
110110

111-
Consider the following. We have one condition that checks if a cell value is between -10 and 10, styling the cell in yellow if that condition matches; and a second condition that checks if the cell value is equal to 0, styling the cell in red if that matches.
111+
Consider the following. We have one condition that checks if a cell value is between -2 and 2, styling the fill color of the cell in yellow if that condition matches; and a second condition that checks if the cell value is equal to 0, styling the fill color of the cell in red if that matches.
112112
- Yellow if value is between -2 and 2
113113
- Red if value equals 0
114114

@@ -120,12 +120,22 @@ If the rule order is reversed
120120
- Red if value equals 0
121121
- Yellow if value is between -2 and 2
122122

123-
then the cell containing the value 0 will be rendered in red, because that is the first matching condition; and the between rule will not be assessed for that cell.
123+
then the cell containing the value 0 will be rendered in red, because that is the first matching condition; and the formatting in the other condition conflicts with this, so is discarded.
124124

125125
![11-21-CF-Rule-Order-2.png](./images/11-21-CF-Rule-Order-2.png)
126126

127127
So when you have multiple conditions where the rules might "overlap", the order of these is important.
128128

129+
If the cell matches multiple conditions, Excel (but not most other spreadsheet programs) will apply non-conflicting styles from each match. So, for the example above, if we wanted a match of 0 to have a different *font* color rather than a different *fill* color, Excel can honor both.
130+
131+
![11-21-CF-Rule-Order-2.pic2.png](./images/11-21-CF-Rule-Order-2.pic2.png)
132+
133+
Here is the same spreadsheet opened in LibreOffice - cell A4 has only the first conditional style applied to it. (You would see the same if you checked 'Stop if True' in Excel.) If you want the spreadsheet to appear the same in both Excel and LibreOffice, you would need to use more complicated conditions.
134+
135+
![11-21-CF-Rule-Order-2.pic2.png](./images/11-21-CF-Rule-Order-2.pic3.png)
136+
137+
PhpSpreadsheet supports the setting of [Stop If True](#stop-if-true-and-no-format-set).
138+
129139

130140
### Reader/Writer Support
131141

@@ -704,8 +714,17 @@ This example can also be found in the [code samples](https://github.com/PHPOffic
704714

705715
## General Notes
706716

707-
### Stop If True
717+
### Stop If True, and No Format Set
708718

719+
Normally, Excel continues to check even after it finds a match. To tell it to stop once a match is found, 'stop if true' should be specified:
720+
```php
721+
$conditional->setStopIfTrue(true);
722+
```
723+
724+
Sometimes you want a matched cell to just show its unconditional format. This is most useful in conjunction with 'stop if true'.
725+
```php
726+
$conditional->setNoFormatSet(true);
727+
```
709728

710729
### Changing the Cell Range
711730

Loading
Loading

src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php

+3-2
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ private function readConditionalStyles(SimpleXMLElement $xmlSheet): array
163163
$conditionals = [];
164164
foreach ($xmlSheet->conditionalFormatting as $conditional) {
165165
foreach ($conditional->cfRule as $cfRule) {
166-
if (Conditional::isValidConditionType((string) $cfRule['type']) && isset($this->dxfs[(int) ($cfRule['dxfId'])])) {
166+
if (Conditional::isValidConditionType((string) $cfRule['type']) && (!isset($cfRule['dxfId']) || isset($this->dxfs[(int) ($cfRule['dxfId'])]))) {
167167
$conditionals[(string) $conditional['sqref']][(int) ($cfRule['priority'])] = $cfRule;
168168
} elseif ((string) $cfRule['type'] == Conditional::CONDITION_DATABAR) {
169169
$conditionals[(string) $conditional['sqref']][(int) ($cfRule['priority'])] = $cfRule;
@@ -197,6 +197,7 @@ private function readStyleRules(array $cfRules, SimpleXMLElement $extLst): array
197197
$objConditional = new Conditional();
198198
$objConditional->setConditionType((string) $cfRule['type']);
199199
$objConditional->setOperatorType((string) $cfRule['operator']);
200+
$objConditional->setNoFormatSet(!isset($cfRule['dxfId']));
200201

201202
if ((string) $cfRule['text'] != '') {
202203
$objConditional->setText((string) $cfRule['text']);
@@ -227,7 +228,7 @@ private function readStyleRules(array $cfRules, SimpleXMLElement $extLst): array
227228
$objConditional->setDataBar(
228229
$this->readDataBarOfConditionalRule($cfRule, $conditionalFormattingRuleExtensions) // @phpstan-ignore-line
229230
);
230-
} else {
231+
} elseif (isset($cfRule['dxfId'])) {
231232
$objConditional->setStyle(clone $this->dxfs[(int) ($cfRule['dxfId'])]);
232233
}
233234

src/PhpSpreadsheet/Reader/Xlsx/Styles.php

+17-5
Original file line numberDiff line numberDiff line change
@@ -206,11 +206,21 @@ public function readBorderStyle(Borders $borderStyle, SimpleXMLElement $borderSt
206206
$borderStyle->setDiagonalDirection(Borders::DIAGONAL_BOTH);
207207
}
208208

209-
$this->readBorder($borderStyle->getLeft(), $borderStyleXml->left);
210-
$this->readBorder($borderStyle->getRight(), $borderStyleXml->right);
211-
$this->readBorder($borderStyle->getTop(), $borderStyleXml->top);
212-
$this->readBorder($borderStyle->getBottom(), $borderStyleXml->bottom);
213-
$this->readBorder($borderStyle->getDiagonal(), $borderStyleXml->diagonal);
209+
if (isset($borderStyleXml->left)) {
210+
$this->readBorder($borderStyle->getLeft(), $borderStyleXml->left);
211+
}
212+
if (isset($borderStyleXml->right)) {
213+
$this->readBorder($borderStyle->getRight(), $borderStyleXml->right);
214+
}
215+
if (isset($borderStyleXml->top)) {
216+
$this->readBorder($borderStyle->getTop(), $borderStyleXml->top);
217+
}
218+
if (isset($borderStyleXml->bottom)) {
219+
$this->readBorder($borderStyle->getBottom(), $borderStyleXml->bottom);
220+
}
221+
if (isset($borderStyleXml->diagonal)) {
222+
$this->readBorder($borderStyle->getDiagonal(), $borderStyleXml->diagonal);
223+
}
214224
}
215225

216226
private function getAttribute(SimpleXMLElement $xml, string $attribute): string
@@ -233,6 +243,8 @@ private function readBorder(Border $border, SimpleXMLElement $borderXml): void
233243
$style = $this->getAttribute($borderXml, 'style');
234244
if ($style !== '') {
235245
$border->setBorderStyle((string) $style);
246+
} else {
247+
$border->setBorderStyle(Border::BORDER_NONE);
236248
}
237249
if (isset($borderXml->color)) {
238250
$border->getColor()->setARGB($this->readColor($borderXml->color));

src/PhpSpreadsheet/Style/Border.php

+5-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Border extends Supervisor
2121
const BORDER_SLANTDASHDOT = 'slantDashDot';
2222
const BORDER_THICK = 'thick';
2323
const BORDER_THIN = 'thin';
24+
const BORDER_OMIT = 'omit'; // should be used only for Conditional
2425

2526
/**
2627
* Border style.
@@ -48,7 +49,7 @@ class Border extends Supervisor
4849
* Leave this value at default unless you understand exactly what
4950
* its ramifications are
5051
*/
51-
public function __construct($isSupervisor = false)
52+
public function __construct($isSupervisor = false, bool $isConditional = false)
5253
{
5354
// Supervisor?
5455
parent::__construct($isSupervisor);
@@ -60,6 +61,9 @@ public function __construct($isSupervisor = false)
6061
if ($isSupervisor) {
6162
$this->color->bindParent($this, 'color');
6263
}
64+
if ($isConditional) {
65+
$this->borderStyle = self::BORDER_OMIT;
66+
}
6367
}
6468

6569
/**

src/PhpSpreadsheet/Style/Borders.php

+11-11
Original file line numberDiff line numberDiff line change
@@ -96,27 +96,27 @@ class Borders extends Supervisor
9696
* Leave this value at default unless you understand exactly what
9797
* its ramifications are
9898
*/
99-
public function __construct($isSupervisor = false)
99+
public function __construct($isSupervisor = false, bool $isConditional = false)
100100
{
101101
// Supervisor?
102102
parent::__construct($isSupervisor);
103103

104104
// Initialise values
105-
$this->left = new Border($isSupervisor);
106-
$this->right = new Border($isSupervisor);
107-
$this->top = new Border($isSupervisor);
108-
$this->bottom = new Border($isSupervisor);
109-
$this->diagonal = new Border($isSupervisor);
105+
$this->left = new Border($isSupervisor, $isConditional);
106+
$this->right = new Border($isSupervisor, $isConditional);
107+
$this->top = new Border($isSupervisor, $isConditional);
108+
$this->bottom = new Border($isSupervisor, $isConditional);
109+
$this->diagonal = new Border($isSupervisor, $isConditional);
110110
$this->diagonalDirection = self::DIAGONAL_NONE;
111111

112112
// Specially for supervisor
113113
if ($isSupervisor) {
114114
// Initialize pseudo-borders
115-
$this->allBorders = new Border(true);
116-
$this->outline = new Border(true);
117-
$this->inside = new Border(true);
118-
$this->vertical = new Border(true);
119-
$this->horizontal = new Border(true);
115+
$this->allBorders = new Border(true, $isConditional);
116+
$this->outline = new Border(true, $isConditional);
117+
$this->inside = new Border(true, $isConditional);
118+
$this->vertical = new Border(true, $isConditional);
119+
$this->horizontal = new Border(true, $isConditional);
120120

121121
// bind parent if we are a supervisor
122122
$this->left->bindParent($this, 'left');

src/PhpSpreadsheet/Style/Conditional.php

+15
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ class Conditional implements IComparable
115115
*/
116116
private $style;
117117

118+
/** @var bool */
119+
private $noFormatSet = false;
120+
118121
/**
119122
* Create a new Conditional.
120123
*/
@@ -124,6 +127,18 @@ public function __construct()
124127
$this->style = new Style(false, true);
125128
}
126129

130+
public function getNoFormatSet(): bool
131+
{
132+
return $this->noFormatSet;
133+
}
134+
135+
public function setNoFormatSet(bool $noFormatSet): self
136+
{
137+
$this->noFormatSet = $noFormatSet;
138+
139+
return $this;
140+
}
141+
127142
/**
128143
* Get Condition type.
129144
*

src/PhpSpreadsheet/Style/Style.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public function __construct($isSupervisor = false, $isConditional = false)
9999
// Initialise values
100100
$this->font = new Font($isSupervisor, $isConditional);
101101
$this->fill = new Fill($isSupervisor, $isConditional);
102-
$this->borders = new Borders($isSupervisor);
102+
$this->borders = new Borders($isSupervisor, $isConditional);
103103
$this->alignment = new Alignment($isSupervisor, $isConditional);
104104
$this->numberFormat = new NumberFormat($isSupervisor, $isConditional);
105105
$this->protection = new Protection($isSupervisor, $isConditional);

src/PhpSpreadsheet/Writer/Xls/Style/CellBorder.php

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class CellBorder
2424
Border::BORDER_DASHDOTDOT => 0x0B,
2525
Border::BORDER_MEDIUMDASHDOTDOT => 0x0C,
2626
Border::BORDER_SLANTDASHDOT => 0x0D,
27+
Border::BORDER_OMIT => 0x00,
2728
];
2829

2930
public static function style(Border $border): int

src/PhpSpreadsheet/Writer/Xls/Worksheet.php

+6-10
Original file line numberDiff line numberDiff line change
@@ -2888,15 +2888,11 @@ private function writeCFRule(
28882888
$bFormatProt = 0;
28892889
}
28902890
// Border
2891-
$bBorderLeft = ($conditional->getStyle()->getBorders()->getLeft()->getColor()->getARGB() == Color::COLOR_BLACK
2892-
&& $conditional->getStyle()->getBorders()->getLeft()->getBorderStyle() == Border::BORDER_NONE ? 1 : 0);
2893-
$bBorderRight = ($conditional->getStyle()->getBorders()->getRight()->getColor()->getARGB() == Color::COLOR_BLACK
2894-
&& $conditional->getStyle()->getBorders()->getRight()->getBorderStyle() == Border::BORDER_NONE ? 1 : 0);
2895-
$bBorderTop = ($conditional->getStyle()->getBorders()->getTop()->getColor()->getARGB() == Color::COLOR_BLACK
2896-
&& $conditional->getStyle()->getBorders()->getTop()->getBorderStyle() == Border::BORDER_NONE ? 1 : 0);
2897-
$bBorderBottom = ($conditional->getStyle()->getBorders()->getBottom()->getColor()->getARGB() == Color::COLOR_BLACK
2898-
&& $conditional->getStyle()->getBorders()->getBottom()->getBorderStyle() == Border::BORDER_NONE ? 1 : 0);
2899-
if ($bBorderLeft == 0 || $bBorderRight == 0 || $bBorderTop == 0 || $bBorderBottom == 0) {
2891+
$bBorderLeft = ($conditional->getStyle()->getBorders()->getLeft()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0;
2892+
$bBorderRight = ($conditional->getStyle()->getBorders()->getRight()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0;
2893+
$bBorderTop = ($conditional->getStyle()->getBorders()->getTop()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0;
2894+
$bBorderBottom = ($conditional->getStyle()->getBorders()->getBottom()->getBorderStyle() !== Border::BORDER_OMIT) ? 1 : 0;
2895+
if ($bBorderLeft === 1 || $bBorderRight === 1 || $bBorderTop === 1 || $bBorderBottom === 1) {
29002896
$bFormatBorder = 1;
29012897
} else {
29022898
$bFormatBorder = 0;
@@ -2905,7 +2901,7 @@ private function writeCFRule(
29052901
$bFillStyle = ($conditional->getStyle()->getFill()->getFillType() === null ? 0 : 1);
29062902
$bFillColor = ($conditional->getStyle()->getFill()->getStartColor()->getARGB() === null ? 0 : 1);
29072903
$bFillColorBg = ($conditional->getStyle()->getFill()->getEndColor()->getARGB() === null ? 0 : 1);
2908-
if ($bFillStyle == 0 || $bFillColor == 0 || $bFillColorBg == 0) {
2904+
if ($bFillStyle == 1 || $bFillColor == 1 || $bFillColorBg == 1) {
29092905
$bFormatFill = 1;
29102906
} else {
29112907
$bFormatFill = 0;

0 commit comments

Comments
 (0)