Skip to content

Commit

Permalink
Add "Priority" Property for Conditional Formatting
Browse files Browse the repository at this point in the history
Fix PHPOffice#4311. Excel applies Conditional Formatting rules according to a priority specified in the xml. The priority must be a natural number; the rules are applied in order from lowest priority number to highest. When reading an Xlsx spreadsheet, PhpSpreadsheet has been ignoring the priority, which can result in differences from Excel's behavior, especially when CF cell ranges overlap (note that overlapping ranges are not supported in Xls format).

If an application uses PhpSpreadsheet to add new Conditional Formatting to a worksheet and does not change its priority from the default (0), the Xlsx Writer will assign a priority with a higher value than any of the CF objects which have been assigned a priority (either from reading it or explicitly assigning it).
  • Loading branch information
oleibman committed Jan 12, 2025
1 parent fb757cf commit 56e7422
Show file tree
Hide file tree
Showing 6 changed files with 157 additions and 5 deletions.
5 changes: 4 additions & 1 deletion src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ private function readConditionalRuleFromExt(SimpleXMLElement $cfRuleXml, SimpleX
{
$conditionType = (string) $attributes->type;
$operatorType = (string) $attributes->operator;
$priority = (int) (string) $attributes->priority;

$operands = [];
foreach ($cfRuleXml->children($this->ns['xm']) as $cfRuleOperandsXml) {
Expand All @@ -134,6 +135,7 @@ private function readConditionalRuleFromExt(SimpleXMLElement $cfRuleXml, SimpleX
$conditional = new Conditional();
$conditional->setConditionType($conditionType);
$conditional->setOperatorType($operatorType);
$conditional->setPriority($priority);
if (
$conditionType === Conditional::CONDITION_CONTAINSTEXT
|| $conditionType === Conditional::CONDITION_NOTCONTAINSTEXT
Expand Down Expand Up @@ -184,7 +186,7 @@ private function readConditionalStyles(SimpleXMLElement $xmlSheet): array
private function setConditionalStyles(Worksheet $worksheet, array $conditionals, SimpleXMLElement $xmlExtLst): void
{
foreach ($conditionals as $cellRangeReference => $cfRules) {
ksort($cfRules);
ksort($cfRules); // no longer needed for Xlsx, but helps Xls
$conditionalStyles = $this->readStyleRules($cfRules, $xmlExtLst);

// Extract all cell references in $cellRangeReference
Expand All @@ -205,6 +207,7 @@ private function readStyleRules(array $cfRules, SimpleXMLElement $extLst): array
$objConditional = new Conditional();
$objConditional->setConditionType((string) $cfRule['type']);
$objConditional->setOperatorType((string) $cfRule['operator']);
$objConditional->setPriority((int) (string) $cfRule['priority']);
$objConditional->setNoFormatSet(!isset($cfRule['dxfId']));

if ((string) $cfRule['text'] != '') {
Expand Down
14 changes: 14 additions & 0 deletions src/PhpSpreadsheet/Style/Conditional.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ class Conditional implements IComparable

private bool $noFormatSet = false;

private int $priority = 0;

/**
* Create a new Conditional.
*/
Expand All @@ -115,6 +117,18 @@ public function __construct()
$this->style = new Style(false, true);
}

public function getPriority(): int
{
return $this->priority;
}

public function setPriority(int $priority): self
{
$this->priority = $priority;

return $this;
}

public function getNoFormatSet(): bool
{
return $this->noFormatSet;
Expand Down
10 changes: 8 additions & 2 deletions src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -861,7 +861,12 @@ private static function writeColorScaleElements(XMLWriter $objWriter, ?Condition
private function writeConditionalFormatting(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksheet): void
{
// Conditional id
$id = 1;
$id = 0;
foreach ($worksheet->getConditionalStylesCollection() as $conditionalStyles) {
foreach ($conditionalStyles as $conditional) {
$id = max($id, $conditional->getPriority());
}
}

// Loop through styles in the current worksheet
foreach ($worksheet->getConditionalStylesCollection() as $cellCoordinate => $conditionalStyles) {
Expand All @@ -888,7 +893,8 @@ private function writeConditionalFormatting(XMLWriter $objWriter, Phpspreadsheet
'dxfId',
(string) $this->getParentWriter()->getStylesConditionalHashTable()->getIndexForHashCode($conditional->getHashCode())
);
$objWriter->writeAttribute('priority', (string) $id++);
$priority = $conditional->getPriority() ?: ++$id;
$objWriter->writeAttribute('priority', (string) $priority);

self::writeAttributeif(
$objWriter,
Expand Down
129 changes: 129 additions & 0 deletions tests/PhpSpreadsheetTests/Reader/Xlsx/ConditionalPriorityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

namespace PhpOffice\PhpSpreadsheetTests\Reader\Xlsx;

use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Conditional;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheetTests\Functional\AbstractFunctional;

class ConditionalPriorityTest extends AbstractFunctional
{
public function testConditionalPriority(): void
{
$filename = 'tests/data/Reader/XLSX/issue.4312c.xlsx';
$reader = IOFactory::createReader('Xlsx');
$spreadsheet = $reader->load($filename);
$reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx');
$spreadsheet->disconnectWorksheets();
$worksheet = $reloadedSpreadsheet->getActiveSheet();
$priorities = [];
foreach ($worksheet->getConditionalStylesCollection() as $conditionalStyles) {
foreach ($conditionalStyles as $conditional) {
$priorities[] = $conditional->getPriority();
}
}
$expected = [27, 2, 3, 4, 1, 22, 14, 5, 6, 7, 20];
self::assertSame($expected, $priorities);
$reloadedSpreadsheet->disconnectWorksheets();
}

public function testZeroPriority(): void
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->fromArray([
[1, 1, 1, 1],
[2, 2, 2, 2],
[3, 3, 3, 3],
[4, 4, 4, 4],
[5, 5, 5, 5],
]);

$range = 'A1:A5';
$styles = [];
$new = new Conditional();
$new->setConditionType(Conditional::CONDITION_CELLIS)
->setOperatorType(Conditional::OPERATOR_EQUAL)
->setPriority(30)
->setConditions(['3'])
->getStyle()
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setArgb('FFC00000');
$styles[] = $new;
$sheet->setConditionalStyles($range, $styles);

$range = 'B1:B5';
$styles = [];
$new = new Conditional();
$new->setConditionType(Conditional::CONDITION_EXPRESSION)
->setConditions('=MOD(A1,2)=0')
->getStyle()
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setArgb('FF00B0F0');
$styles[] = $new;
$new = new Conditional();
$new->setConditionType(Conditional::CONDITION_CELLIS)
->setOperatorType(Conditional::OPERATOR_EQUAL)
->setPriority(40)
->setConditions(['4'])
->getStyle()
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setArgb('FFFFC000');
$styles[] = $new;
$sheet->setConditionalStyles($range, $styles);

$range = 'C1:C5';
$styles = [];
$new = new Conditional();
$new->setConditionType(Conditional::CONDITION_CELLIS)
->setOperatorType(Conditional::OPERATOR_EQUAL)
->setPriority(20)
->setConditions(['2'])
->getStyle()
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setArgb('FFFFFF00');
$styles[] = $new;
$new = new Conditional();
$new->setConditionType(Conditional::CONDITION_CELLIS)
->setOperatorType(Conditional::OPERATOR_EQUAL)
->setConditions(['5'])
->getStyle()
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setArgb('FF008080');
$styles[] = $new;
$sheet->setConditionalStyles($range, $styles);

$reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx');
$spreadsheet->disconnectWorksheets();
$worksheet = $reloadedSpreadsheet->getActiveSheet();
$priorities = [];
foreach ($worksheet->getConditionalStylesCollection() as $conditionalStyles) {
foreach ($conditionalStyles as $conditional) {
$priorities[] = $conditional->getPriority();
}
}
// B1:B5 is written in order 41, 40, but Reader sorts them
$expected = [30, 40, 41, 20, 42];
self::assertSame($expected, $priorities);
$styles = $worksheet->getConditionalStyles('B1:B5');
self::assertSame(Conditional::CONDITION_CELLIS, $styles[0]->getConditionType());
self::assertSame(40, $styles[0]->getPriority());
self::assertSame(Conditional::CONDITION_EXPRESSION, $styles[1]->getConditionType());
self::assertSame(41, $styles[1]->getPriority());
$reloadedSpreadsheet->disconnectWorksheets();
}
}
4 changes: 2 additions & 2 deletions tests/PhpSpreadsheetTests/Reader/Xlsx/Issue4248Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ public function testStyles(): void
$file .= '#xl/worksheets/sheet1.xml';
$data = file_get_contents($file) ?: '';
$expected = '<conditionalFormatting sqref="C16:C38 E17:H18 I17:J37 D18 J23:J38 E38 I38">'
. '<cfRule type="containsText" dxfId="0" priority="1" operator="containsText" text="Oui">'
. '<cfRule type="containsText" dxfId="0" priority="15" operator="containsText" text="Oui">'
. '<formula>NOT(ISERROR(SEARCH(&quot;Oui&quot;,C16)))</formula>'
. '</cfRule>'
. '</conditionalFormatting>';
self::assertStringContainsString($expected, $data, 'first condition for D18');
$expected = '<conditionalFormatting sqref="C16:C38 I17:J37 E17:H18 D18 J23:J38 E38 I38">'
. '<cfRule type="containsText" dxfId="1" priority="2" operator="containsText" text="Non">'
. '<cfRule type="containsText" dxfId="1" priority="14" operator="containsText" text="Non">'
. '<formula>NOT(ISERROR(SEARCH(&quot;Non&quot;,C16)))</formula>'
. '</cfRule>'
. '</conditionalFormatting>';
Expand Down
Binary file added tests/data/Reader/XLSX/issue.4312c.xlsx
Binary file not shown.

0 comments on commit 56e7422

Please sign in to comment.