diff --git a/samples/ConditionalFormatting/cond09_iconset.php b/samples/ConditionalFormatting/cond09_iconset.php new file mode 100644 index 0000000000..61239b01c7 --- /dev/null +++ b/samples/ConditionalFormatting/cond09_iconset.php @@ -0,0 +1,110 @@ +log('Create new Spreadsheet object'); +$spreadsheet = new Spreadsheet(); +$sheet = $spreadsheet->getActiveSheet(); + +// Set document properties +$helper->log('Set document properties'); +$spreadsheet->getProperties()->setCreator('issakujitsuk') + ->setLastModifiedBy('issakujitsuk') + ->setTitle('PhpSpreadsheet Test Document') + ->setSubject('PhpSpreadsheet Test Document') + ->setDescription('Test document for PhpSpreadsheet, generated using PHP classes.') + ->setKeywords('office PhpSpreadsheet php') + ->setCategory('Test result file'); + +// Create the worksheet +$helper->log('Add data'); +foreach (['A', 'B', 'C'] as $columnIndex) { + $sheet + ->setCellValue("{$columnIndex}1", 1) + ->setCellValue("{$columnIndex}2", 2) + ->setCellValue("{$columnIndex}3", 8) + ->setCellValue("{$columnIndex}4", 4) + ->setCellValue("{$columnIndex}5", 5) + ->setCellValue("{$columnIndex}6", 6) + ->setCellValue("{$columnIndex}7", 7) + ->setCellValue("{$columnIndex}8", 3) + ->setCellValue("{$columnIndex}9", 9) + ->setCellValue("{$columnIndex}10", 10); +} + +// Set conditional formatting rules and styles +$helper->log('Define conditional formatting using Icon Set'); + +// 3 icons +$sheet->getStyle('A1:A10') + ->setConditionalStyles([ + makeConditionalIconSet( + IconSetValues::ThreeSymbols, + [ + new ConditionalFormatValueObject('percent', 0), + new ConditionalFormatValueObject('percent', 33), + new ConditionalFormatValueObject('percent', 67), + ] + ), + ]); + +// 4 icons +$sheet->getStyle('B1:B10') + ->setConditionalStyles([ + makeConditionalIconSet( + IconSetValues::FourArrows, + [ + new ConditionalFormatValueObject('percent', 0), + new ConditionalFormatValueObject('percent', 25), + new ConditionalFormatValueObject('percent', 50), + new ConditionalFormatValueObject('percent', 75), + ] + ), + ]); + +// 5 icons +$sheet->getStyle('C1:C10') + ->setConditionalStyles([ + makeConditionalIconSet( + IconSetValues::FiveQuarters, + [ + new ConditionalFormatValueObject('percent', 0), + new ConditionalFormatValueObject('percent', 20), + new ConditionalFormatValueObject('percent', 40), + new ConditionalFormatValueObject('percent', 60), + new ConditionalFormatValueObject('percent', 80), + ] + ), + ]); + +// Save +$sheet->setSelectedCells('A1'); +$helper->write($spreadsheet, __FILE__, ['Xlsx']); + +/** + * Helper function to create a Conditional object with an IconSet. + * + * @param IconSetValues $type The type of icon set + * @param ConditionalFormatValueObject[] $cfvos The conditional format value objects + */ +function makeConditionalIconSet( + IconSetValues $type, + array $cfvos, +): Conditional { + $condition = new Conditional(); + $condition->setConditionType(Conditional::CONDITION_ICONSET); + $iconSet = new ConditionalIconSet(); + $condition->setIconSet($iconSet); + $iconSet->setIconSetType($type) + ->setCfvos($cfvos); + + return $condition; +} diff --git a/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php b/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php index b09de2ad69..2892dd1225 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php +++ b/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php @@ -9,6 +9,8 @@ use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\ConditionalDataBar; use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\ConditionalFormattingRuleExtension; use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\ConditionalFormatValueObject; +use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\ConditionalIconSet; +use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\IconSetValues; use PhpOffice\PhpSpreadsheet\Style\Style as Style; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use SimpleXMLElement; @@ -265,6 +267,8 @@ private function readStyleRules(array $cfRules, SimpleXMLElement $extLst): array $objConditional->setColorScale( $this->readColorScale($cfRule) ); + } elseif (isset($cfRule->iconSet)) { + $objConditional->setIconSet($this->readIconSet($cfRule)); } elseif (isset($cfRule['dxfId'])) { $objConditional->setStyle(clone $this->dxfs[(int) ($cfRule['dxfId'])]); } @@ -349,6 +353,40 @@ private function readColorScale(SimpleXMLElement|stdClass $cfRule): ConditionalC return $colorScale; } + private function readIconSet(SimpleXMLElement $cfRule): ConditionalIconSet + { + $iconSet = new ConditionalIconSet(); + + if (isset($cfRule->iconSet['iconSet'])) { + $iconSet->setIconSetType(IconSetValues::from($cfRule->iconSet['iconSet'])); + } + if (isset($cfRule->iconSet['reverse'])) { + $iconSet->setReverse('1' === (string) $cfRule->iconSet['reverse']); + } + if (isset($cfRule->iconSet['showValue'])) { + $iconSet->setShowValue('1' === (string) $cfRule->iconSet['showValue']); + } + if (isset($cfRule->iconSet['custom'])) { + $iconSet->setCustom('1' === (string) $cfRule->iconSet['custom']); + } + + $cfvos = []; + foreach ($cfRule->iconSet->cfvo as $cfvoXml) { + $type = (string) $cfvoXml['type']; + $value = (string) ($cfvoXml['val'] ?? ''); + $cfvo = new ConditionalFormatValueObject($type, $value); + if (isset($cfvoXml['gte'])) { + $cfvo->setGreaterThanOrEqual('1' === (string) $cfvoXml['gte']); + } + $cfvos[] = $cfvo; + } + $iconSet->setCfvos($cfvos); + + // TODO: The cfIcon element is not implemented yet. + + return $iconSet; + } + /** @param ConditionalFormattingRuleExtension[] $conditionalFormattingRuleExtensions */ private function readDataBarExtLstOfConditionalRule(ConditionalDataBar $dataBar, SimpleXMLElement $cfRule, array $conditionalFormattingRuleExtensions): void { diff --git a/src/PhpSpreadsheet/Style/Conditional.php b/src/PhpSpreadsheet/Style/Conditional.php index 2ef7cacd79..fedd0aa486 100644 --- a/src/PhpSpreadsheet/Style/Conditional.php +++ b/src/PhpSpreadsheet/Style/Conditional.php @@ -5,6 +5,7 @@ use PhpOffice\PhpSpreadsheet\IComparable; use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\ConditionalColorScale; use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\ConditionalDataBar; +use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\ConditionalIconSet; class Conditional implements IComparable { @@ -25,6 +26,7 @@ class Conditional implements IComparable const CONDITION_TIMEPERIOD = 'timePeriod'; const CONDITION_DUPLICATES = 'duplicateValues'; const CONDITION_UNIQUE = 'uniqueValues'; + const CONDITION_ICONSET = 'iconSet'; private const CONDITION_TYPES = [ self::CONDITION_BEGINSWITH, @@ -43,6 +45,7 @@ class Conditional implements IComparable self::CONDITION_NOTCONTAINSTEXT, self::CONDITION_TIMEPERIOD, self::CONDITION_UNIQUE, + self::CONDITION_ICONSET, ]; // Operator types @@ -102,6 +105,8 @@ class Conditional implements IComparable private ?ConditionalColorScale $colorScale = null; + private ?ConditionalIconSet $iconSet = null; + private Style $style; private bool $noFormatSet = false; @@ -318,6 +323,18 @@ public function setColorScale(ConditionalColorScale $colorScale): static return $this; } + public function getIconSet(): ?ConditionalIconSet + { + return $this->iconSet; + } + + public function setIconSet(ConditionalIconSet $iconSet): static + { + $this->iconSet = $iconSet; + + return $this; + } + /** * Get hash code. * @@ -327,10 +344,10 @@ public function getHashCode(): string { return md5( $this->conditionType - . $this->operatorType - . implode(';', $this->condition) - . $this->style->getHashCode() - . __CLASS__ + . $this->operatorType + . implode(';', $this->condition) + . $this->style->getHashCode() + . __CLASS__ ); } diff --git a/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalFormatValueObject.php b/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalFormatValueObject.php index e6d1035f4e..d1dfb51593 100644 --- a/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalFormatValueObject.php +++ b/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalFormatValueObject.php @@ -10,6 +10,13 @@ class ConditionalFormatValueObject private ?string $cellFormula; + /** + * For icon sets, determines whether this threshold value uses the greater + * than or equal to operator. False indicates 'greater than' is used instead + * of 'greater than or equal to'. + */ + private ?bool $greaterThanOrEqual = null; + public function __construct(string $type, null|float|int|string $value = null, ?string $cellFormula = null) { $this->type = $type; @@ -52,4 +59,16 @@ public function setCellFormula(?string $cellFormula): self return $this; } + + public function getGreaterThanOrEqual(): ?bool + { + return $this->greaterThanOrEqual; + } + + public function setGreaterThanOrEqual(?bool $greaterThanOrEqual): self + { + $this->greaterThanOrEqual = $greaterThanOrEqual; + + return $this; + } } diff --git a/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalIconSet.php b/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalIconSet.php new file mode 100644 index 0000000000..b7c21d869e --- /dev/null +++ b/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalIconSet.php @@ -0,0 +1,96 @@ +iconSetType; + } + + public function setIconSetType(IconSetValues $type): self + { + $this->iconSetType = $type; + + return $this; + } + + public function getReverse(): ?bool + { + return $this->reverse; + } + + public function setReverse(bool $reverse): self + { + $this->reverse = $reverse; + + return $this; + } + + public function getShowValue(): ?bool + { + return $this->showValue; + } + + public function setShowValue(bool $showValue): self + { + $this->showValue = $showValue; + + return $this; + } + + public function getCustom(): ?bool + { + return $this->custom; + } + + public function setCustom(bool $custom): self + { + $this->custom = $custom; + + return $this; + } + + /** + * Get the conditional format value objects. + * + * @return ConditionalFormatValueObject[] + */ + public function getCfvos(): array + { + return $this->cfvos; + } + + /** + * Set the conditional format value objects. + * + * @param ConditionalFormatValueObject[] $cfvos + */ + public function setCfvos(array $cfvos): self + { + $this->cfvos = $cfvos; + + return $this; + } +} diff --git a/src/PhpSpreadsheet/Style/ConditionalFormatting/IconSetValues.php b/src/PhpSpreadsheet/Style/ConditionalFormatting/IconSetValues.php new file mode 100644 index 0000000000..c27d36e6ed --- /dev/null +++ b/src/PhpSpreadsheet/Style/ConditionalFormatting/IconSetValues.php @@ -0,0 +1,28 @@ +startElement('iconSet'); + if ($iconSet->getIconSetType() !== null) { + $objWriter->writeAttribute('iconSet', $iconSet->getIconSetType()->value); + } + foreach ( + [ + 'reverse' => $iconSet->getReverse(), + 'showValue' => $iconSet->getShowValue(), + 'custom' => $iconSet->getCustom(), + ] as $attr => $value + ) { + self::writeAttributeIf($objWriter, $value !== null, $attr, $value ? '1' : '0'); + } + + foreach ($iconSet->getCfvos() as $cfvo) { + $objWriter->startElement('cfvo'); + $objWriter->writeAttribute('type', $cfvo->getType()); + self::writeAttributeIf( + $objWriter, + $cfvo->getValue() !== null, + 'val', + (string) $cfvo->getValue(), + ); + self::writeAttributeIf( + $objWriter, + $cfvo->getGreaterThanOrEqual() !== null, + 'gte', + $cfvo->getGreaterThanOrEqual() ? '1' : '0', + ); + $objWriter->endElement(); // end cfvo + } + + $objWriter->endElement(); // end iconSet + } + /** * Write ConditionalFormatting. */ @@ -897,6 +939,7 @@ private function writeConditionalFormatting(XMLWriter $objWriter, Phpspreadsheet $objWriter, ($conditional->getConditionType() !== Conditional::CONDITION_COLORSCALE && $conditional->getConditionType() !== Conditional::CONDITION_DATABAR + && $conditional->getConditionType() !== Conditional::CONDITION_ICONSET && $conditional->getNoFormatSet() === false), 'dxfId', (string) $this->getParentWriter()->getStylesConditionalHashTable()->getIndexForHashCode($conditional->getHashCode()) @@ -933,6 +976,8 @@ private function writeConditionalFormatting(XMLWriter $objWriter, Phpspreadsheet self::writeTimePeriodCondElements($objWriter, $conditional, $topLeftCell); } elseif ($conditional->getConditionType() === Conditional::CONDITION_COLORSCALE) { self::writeColorScaleElements($objWriter, $conditional->getColorScale()); + } elseif ($conditional->getConditionType() === Conditional::CONDITION_ICONSET) { + self::writeIconSetElements($objWriter, $conditional->getIconSet()); } else { self::writeOtherCondElements($objWriter, $conditional, $topLeftCell); } @@ -1566,8 +1611,8 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell self::writeElementIf( $objWriter, $this->getParentWriter()->getOffice2003Compatibility() === false - && $this->getParentWriter()->getPreCalculateFormulas() - && $calculatedValue !== null, + && $this->getParentWriter()->getPreCalculateFormulas() + && $calculatedValue !== null, 'v', (!is_array($calculatedValue) && !str_starts_with($calculatedValueString, '#')) ? StringHelper::formatNumber($calculatedValueString) : '0' diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/ConditionalIconSetTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/ConditionalIconSetTest.php new file mode 100644 index 0000000000..e3e77d0b40 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/ConditionalIconSetTest.php @@ -0,0 +1,87 @@ +load($filename); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx'); + $spreadsheet->disconnectWorksheets(); + $worksheet = $reloadedSpreadsheet->getActiveSheet(); + + $columnIndex = 'A'; + foreach (IconSetValues::cases() as $iconSetValue) { + // styles + $styles = $worksheet->getConditionalStyles("{$columnIndex}2:{$columnIndex}11"); + self::assertCount(1, $styles); + + // icon set + $iconSet = $styles[0]->getIconSet(); + self::assertNotNull($iconSet); + self::assertSame($iconSetValue, $iconSet->getIconSetType() ?? IconSetValues::ThreeTrafficLights1); + + ++$columnIndex; + } + + // icon set attributes + $columnIndex = 'A'; + foreach ( + [ + ['reverse' => false, 'showValue' => false], + ['reverse' => true, 'showValue' => false], + ['reverse' => false, 'showValue' => true], + ] as $expected + ) { + $styles = $worksheet->getConditionalStyles("{$columnIndex}2:{$columnIndex}11"); + $iconSet = $styles[0]->getIconSet(); + self::assertNotNull($iconSet); + self::assertSame($expected['reverse'], $iconSet->getReverse() ?? false); + self::assertSame($expected['showValue'], $iconSet->getShowValue() ?? true); + self::assertFalse($iconSet->getCustom() ?? false); + + ++$columnIndex; + } + + // cfvos + $columnIndex = 'A'; + foreach ( + [ + [['percent', '0', true], ['percent', '33', false], ['percent', '67', true]], + [['percent', '0', true], ['num', '3', false], ['num', '7', true]], + [['percent', '0', true], ['formula', '10/3', false], ['formula', '10/2', true]], + [['percent', '0', true], ['percentile', '33', false], ['percentile', '67', true]], + ] as $expected + ) { + $styles = $worksheet->getConditionalStyles("{$columnIndex}2:{$columnIndex}11"); + $iconSet = $styles[0]->getIconSet(); + self::assertNotNull($iconSet); + $cfvos = $iconSet->getCfvos(); + self::assertCount(count($expected), $cfvos); + foreach ($expected as $i => [$type, $value, $gte]) { + $cfvo = $cfvos[$i]; + self::assertSame($type, $cfvo->getType()); + self::assertSame($value, $cfvo->getValue()); + self::assertSame($gte, $cfvo->getGreaterThanOrEqual() ?? true); + self::assertNull($cfvo->getCellFormula()); + } + + ++$columnIndex; + } + + // unsupported icon sets + for ($columnIndex = 'R'; $columnIndex <= 'U'; ++$columnIndex) { + $styles = $worksheet->getConditionalStyles("{$columnIndex}2:{$columnIndex}11"); + $iconSet = $styles[0]->getIconSet(); + self::assertNull($iconSet); + } + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ConditionalFormatIconSetTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ConditionalFormatIconSetTest.php new file mode 100644 index 0000000000..9d64b9b8a5 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ConditionalFormatIconSetTest.php @@ -0,0 +1,152 @@ +setConditionType(Conditional::CONDITION_ICONSET); + $iconSet = $condition->setIconSet(new ConditionalIconSet()) + ->getIconSet(); + self::assertNotNull($iconSet); + if ($type !== null) { + $iconSet->setIconSetType($type); + } + $iconSet->setCfvos([ + new ConditionalFormatValueObject('percent', 0), + new ConditionalFormatValueObject('percent', 33), + new ConditionalFormatValueObject('percent', 67), + ]); + if ($reverse !== null) { + $iconSet->setReverse($reverse); + } + if ($showValue !== null) { + $iconSet->setShowValue($showValue); + } + if ($custom !== null) { + $iconSet->setCustom($custom); + } + + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->setConditionalStyles(self::COORDINATE, [$condition]); + + $writer = new Xlsx($spreadsheet); + $writerWorksheet = new Xlsx\Worksheet($writer); + $data = $writerWorksheet->writeWorksheet($worksheet, []); + + $expected = preg_replace(['/^\s+/m', "/\n/"], '', $expected); + self::assertIsString($expected); + self::assertStringContainsString($expected, $data); + } + + public static function iconSetsProvider(): Generator + { + $coordinate = self::COORDINATE; + $cfvos = << + + + XML; + foreach (IconSetValues::cases() as $type) { + yield $type->name => [ + << + + + {$cfvos} + + + + XML, + $type, + ]; + } + + yield 'null' => [ + << + + + {$cfvos} + + + + XML, + null, + ]; + + foreach ([1, 0] as $reverse) { + yield "null/reverse=$reverse" => [ + << + + + {$cfvos} + + + + XML, + null, + $reverse === 1, + ]; + } + + foreach ([1, 0] as $showValue) { + yield "null/showValue=$showValue" => [ + << + + + {$cfvos} + + + + XML, + null, + null, + $showValue === 1, + ]; + } + + foreach ([1, 0] as $custom) { + yield "null/custom=$custom" => [ + << + + + {$cfvos} + + + + XML, + null, + null, + null, + $custom === 1, + ]; + } + } +} diff --git a/tests/data/Reader/XLSX/conditionalFormattingIconSet.xlsx b/tests/data/Reader/XLSX/conditionalFormattingIconSet.xlsx new file mode 100644 index 0000000000..e731b973f8 Binary files /dev/null and b/tests/data/Reader/XLSX/conditionalFormattingIconSet.xlsx differ